diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index b96206c1f11..3927f99bbec 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -22,14 +22,13 @@ jobs: npm run build - name: git status run: | - git status + git status - name: working dir run: | cd public pwd ls -la - - name: wtf + - name: Gatsby Publish uses: enriikke/gatsby-gh-pages-action@v2 with: access-token: ${{ secrets.SEKRED2 }} - \ No newline at end of file diff --git a/.gitignore b/.gitignore index e70b488dc23..23db99a1916 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,8 @@ yarn-error.log .pnp.js # Yarn Integrity file .yarn-integrity + + +.nvmrc +.vscode/settings.json +package-lock.json diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000000..b97bf89b923 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v10.18.1 \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000000..2e1fa2d52e1 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +*.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000000..d04b2521a27 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,24 @@ +# Contributing Guidelines + +We really appreciate that you are considering contributing! There are many ways you can help us to improve the course material. The preferred way is to make a pull request, but you can also submit an issue, help with translations or review existing pull requests, for example. + +# Running Full stack open on your environment + +1. Fork the repository +2. Clone your fork +3. Verify that you are running Node version 10 ([NVM](https://github.com/nvm-sh/nvm) recommended for managing node versions) +4. Install dependencies with `npm install` +5. Start the application with `npm start` +6. Application will be available at + +# Setting up the PR + +1. Prettify your code with `npm run format` +2. Create a new branch for your changes +3. Create the PR from that branch to the source branch +4. If your pull request is for a specific part of the course material, please add a corresponding label to your pull request (eg. "Part 3") + +# Contributing with translations + +When translation of a whole new part is completed, remember to update the file src/utils/translationProgress.json +This file tracks the progress of translations, ranging from 0 (part0) to 13 (part13). It is used in the to avoid navigation errors when the user tries to access untranslated parts of the course. At the same time, it is used to automatically redirect the user to the English material (until the part is translated). So, if you have been working on a translation, remember to update this file after completing the translation of a whole part. \ No newline at end of file diff --git a/README.md b/README.md index c73c9978ea8..b52bdd6dc80 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,18 @@ -# Full stack open 2020 +# Full Stack Open - +This repository contains the course material for the Full Stack Open online course. The material is available at . Despite the name of the repository, this is the current course repository. + +# How to Contribute + +Did you find an error in the course material? Is something unclear? We highly value user contributions to improve our course content, and there are many ways you can help. + +For detailed instructions on how to contribute, please read our [CONTRIBUTING.md](CONTRIBUTING.md) page. + +# License Creative Commons -lisenssi -
Materiaali on lisensoitu -Creative Commons BY-NC-SA 3.0 -lisenssillä +
The material is licensed under the +Creative Commons BY-NC-SA 3.0 license diff --git a/gatsby-browser.js b/gatsby-browser.js index 4032ae2ead0..c048b91696a 100644 --- a/gatsby-browser.js +++ b/gatsby-browser.js @@ -1,9 +1,13 @@ import { anchorate } from 'anchorate'; + +export { default as wrapPageElement } from './wrapPageElement'; +export { default as wrapRootElement } from './wrapRootElement'; + require('prismjs/themes/prism-dark.css'); export const onRouteUpdate = () => { anchorate({ - scroller: function(element) { + scroller: function (element) { if (!element) return false; element.scrollIntoView({ behavior: 'smooth' }); return true; diff --git a/gatsby-config.js b/gatsby-config.js index 4f9ebbf5c1f..e42c6a00cd9 100644 --- a/gatsby-config.js +++ b/gatsby-config.js @@ -1,96 +1,147 @@ -module.exports = { - siteMetadata: { - title: 'Full Stack open 2020', - description: '', - author: 'Houston Inc. Consulting oy', - siteUrl: 'https://fullstack-hy2020.github.io/', - }, - plugins: [ - { - resolve: `gatsby-plugin-sitemap`, - }, - { - resolve: 'gatsby-plugin-i18n', - options: { - langKeyDefault: 'fi', - langKeyForNull: 'fi', - prefixDefault: false, - useLangKeyLayout: false, +const IS_DEV = process.env.NODE_ENV === 'development'; + +const createSearchConfig = (indexName, language) => { + return { + resolve: 'gatsby-plugin-local-search', + options: { + name: indexName, + engine: 'flexsearch', + engineOptions: 'speed', + query: ` + { + allMarkdownRemark(filter: {frontmatter: {lang: {eq: "${language}"}}}) { + nodes { + frontmatter { + lang + letter + part + } + id + rawMarkdownBody + } + } + } + `, + ref: 'id', + index: ['body'], + store: ['id', 'part', 'letter', 'lang'], + normalizer: ({ data }) => { + return IS_DEV + ? [] + : data.allMarkdownRemark.nodes.map((node) => ({ + id: node.id, + part: node.frontmatter.part, + letter: node.frontmatter.letter, + lang: node.frontmatter.lang, + body: node.rawMarkdownBody, + })); }, }, - 'gatsby-plugin-react-helmet', - { - resolve: `gatsby-source-filesystem`, - options: { - name: `images`, - path: `${__dirname}/src/images`, - }, + }; +}; + +const plugins = [ + createSearchConfig('finnish', 'fi'), + createSearchConfig('english', 'en'), + createSearchConfig('spanish', 'es'), + createSearchConfig('chinese', 'zh'), + createSearchConfig('portuguese', 'ptbr'), + { + resolve: `gatsby-plugin-sitemap`, + }, + { + resolve: 'gatsby-plugin-i18n', + options: { + langKeyDefault: 'fi', + langKeyForNull: 'fi', + prefixDefault: false, + useLangKeyLayout: false, }, - 'gatsby-transformer-sharp', - 'gatsby-plugin-sharp', - { - resolve: `gatsby-plugin-manifest`, - options: { - name: 'gatsby-starter-default', - short_name: 'starter', - start_url: '/', - background_color: '#e1e1e1', - theme_color: '#e1e1e1', - display: 'minimal-ui', - icon: 'src/images/favicon.png', - }, + }, + 'gatsby-plugin-react-helmet', + { + resolve: `gatsby-source-filesystem`, + options: { + name: `images`, + path: `${__dirname}/src/images`, }, - 'gatsby-plugin-remove-serviceworker', - 'gatsby-plugin-sass', - `gatsby-transformer-json`, - { - resolve: `gatsby-source-filesystem`, - options: { - name: `src`, - path: `${__dirname}/src/content/`, - }, + }, + 'gatsby-transformer-sharp', + 'gatsby-plugin-sharp', + { + resolve: `gatsby-plugin-manifest`, + options: { + name: 'gatsby-starter-default', + short_name: 'starter', + start_url: '/', + background_color: '#e1e1e1', + theme_color: '#e1e1e1', + display: 'minimal-ui', + icon: 'src/images/favicon.png', }, - { - resolve: `gatsby-source-filesystem`, - options: { - path: `${__dirname}/src/content`, - name: 'markdown-pages', - }, + }, + 'gatsby-plugin-remove-serviceworker', + 'gatsby-plugin-sass', + `gatsby-transformer-json`, + { + resolve: `gatsby-source-filesystem`, + options: { + name: `src`, + path: `${__dirname}/src/content/`, + ignore: [`${__dirname}/src/content/pages/*`], }, - { - resolve: 'gatsby-transformer-remark', - options: { - plugins: [ - 'gatsby-remark-unwrap-images', - 'gatsby-remark-picture', - { - resolve: `gatsby-remark-prismjs`, - options: { - classPrefix: 'language-', - inlineCodeMarker: null, - aliases: {}, - showLineNumbers: false, - noInlineHighlight: false, - }, + }, + { + resolve: `gatsby-source-filesystem`, + options: { + path: `${__dirname}/src/content`, + name: 'markdown-pages', + ignore: [`${__dirname}/src/content/pages/*`], + }, + }, + { + resolve: 'gatsby-transformer-remark', + options: { + plugins: [ + 'gatsby-remark-unwrap-images', + 'gatsby-remark-picture', + { + resolve: `gatsby-remark-prismjs`, + options: { + classPrefix: 'language-', + inlineCodeMarker: null, + aliases: {}, + showLineNumbers: false, + noInlineHighlight: false, }, - ], - }, + }, + ], }, - { - resolve: `gatsby-plugin-google-analytics`, - options: { - trackingId: 'UA-135975842-1', - head: false, - respectDNT: true, - exclude: [], - cookieDomain: 'fullstackopen.com', - }, + }, + { + resolve: `gatsby-plugin-google-analytics`, + options: { + trackingId: 'UA-135975842-1', + head: false, + respectDNT: true, + exclude: [], + cookieDomain: 'fullstackopen.com', }, - { - resolve: `gatsby-plugin-canonical-urls`, - options: { - siteUrl: `https://fullstackopen.com`, - }, + }, + { + resolve: `gatsby-plugin-canonical-urls`, + options: { + siteUrl: `https://fullstackopen.com`, }, - ], + }, +]; + +module.exports = { + siteMetadata: { + title: 'Full Stack open 2020', + description: '', + author: 'Houston Inc. Consulting oy', + siteUrl: 'https://fullstack-hy2020.github.io/', + }, + plugins, }; diff --git a/gatsby-node.js b/gatsby-node.js index f72fc0f4404..6e9b509789a 100644 --- a/gatsby-node.js +++ b/gatsby-node.js @@ -26,7 +26,7 @@ exports.createPages = ({ actions, graphql }) => { } } } - `).then(result => { + `).then((result) => { if (result.errors) { return Promise.reject(result.errors); } @@ -35,32 +35,33 @@ exports.createPages = ({ actions, graphql }) => { const { frontmatter } = node; const { part, lang } = frontmatter; - if (!frontmatter.letter) { + const legitPart = part || part === '0' || part === 0; + + if (legitPart && !frontmatter.letter) { createPage({ path: - lang === 'en' - ? `/en/part${part.toString()}` - : lang === 'zh' - ? `/zh/part${part.toString()}` - : `/osa${part.toString()}`, + lang === 'fi' + ? `/osa${part.toString()}` + : `/${lang}/part${part.toString()}`, component: partIntroTemplate, context: { part: part, lang: lang, }, }); - } else if (!isEmpty(navigation[lang][part]) && frontmatter.letter) { + } else if ( + legitPart && + navigation[lang] && + !isEmpty(navigation[lang][part]) && + frontmatter.letter + ) { createPage({ path: - lang === 'en' - ? `/en/part${part}/${snakeCase( - navigation[lang][part][frontmatter.letter] - )}` - : lang === 'zh' - ? `/zh/part${part}/${snakeCase( + lang === 'fi' + ? `/osa${part}/${snakeCase( navigation[lang][part][frontmatter.letter] )}` - : `/osa${part}/${snakeCase( + : `/${lang}/part${part}/${snakeCase( navigation[lang][part][frontmatter.letter] )}`, component: contentTemplate, diff --git a/gatsby-ssr.js b/gatsby-ssr.js index b17b8fc19d6..0035fb68d12 100644 --- a/gatsby-ssr.js +++ b/gatsby-ssr.js @@ -1,7 +1,3 @@ -/** - * Implement Gatsby's SSR (Server Side Rendering) APIs in this file. - * - * See: https://www.gatsbyjs.org/docs/ssr-apis/ - */ - -// You can delete this file if you're not using it +export { default as wrapPageElement } from './wrapPageElement'; +export { default as wrapRootElement } from './wrapRootElement'; +export { default as onRenderBody } from './onRenderBody'; diff --git a/google9ad00ab66f508b0a.html b/google9ad00ab66f508b0a.html new file mode 100644 index 00000000000..1a1d81b8ae1 --- /dev/null +++ b/google9ad00ab66f508b0a.html @@ -0,0 +1 @@ +google-site-verification: google9ad00ab66f508b0a.html diff --git a/license.txt b/license.txt new file mode 100644 index 00000000000..1d1e1d6e885 --- /dev/null +++ b/license.txt @@ -0,0 +1,359 @@ +Creative Commons Legal Code + +Attribution-ShareAlike 3.0 Unported + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS LICENSE DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR + DAMAGES RESULTING FROM ITS USE. + +License + +THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE +COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY +COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS +AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED. + +BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE +TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY +BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS +CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND +CONDITIONS. + +1. Definitions + + a. "Adaptation" means a work based upon the Work, or upon the Work and + other pre-existing works, such as a translation, adaptation, + derivative work, arrangement of music or other alterations of a + literary or artistic work, or phonogram or performance and includes + cinematographic adaptations or any other form in which the Work may be + recast, transformed, or adapted including in any form recognizably + derived from the original, except that a work that constitutes a + Collection will not be considered an Adaptation for the purpose of + this License. For the avoidance of doubt, where the Work is a musical + work, performance or phonogram, the synchronization of the Work in + timed-relation with a moving image ("synching") will be considered an + Adaptation for the purpose of this License. + b. "Collection" means a collection of literary or artistic works, such as + encyclopedias and anthologies, or performances, phonograms or + broadcasts, or other works or subject matter other than works listed + in Section 1(f) below, which, by reason of the selection and + arrangement of their contents, constitute intellectual creations, in + which the Work is included in its entirety in unmodified form along + with one or more other contributions, each constituting separate and + independent works in themselves, which together are assembled into a + collective whole. A work that constitutes a Collection will not be + considered an Adaptation (as defined below) for the purposes of this + License. + c. "Creative Commons Compatible License" means a license that is listed + at https://creativecommons.org/compatiblelicenses that has been + approved by Creative Commons as being essentially equivalent to this + License, including, at a minimum, because that license: (i) contains + terms that have the same purpose, meaning and effect as the License + Elements of this License; and, (ii) explicitly permits the relicensing + of adaptations of works made available under that license under this + License or a Creative Commons jurisdiction license with the same + License Elements as this License. + d. "Distribute" means to make available to the public the original and + copies of the Work or Adaptation, as appropriate, through sale or + other transfer of ownership. + e. "License Elements" means the following high-level license attributes + as selected by Licensor and indicated in the title of this License: + Attribution, ShareAlike. + f. "Licensor" means the individual, individuals, entity or entities that + offer(s) the Work under the terms of this License. + g. "Original Author" means, in the case of a literary or artistic work, + the individual, individuals, entity or entities who created the Work + or if no individual or entity can be identified, the publisher; and in + addition (i) in the case of a performance the actors, singers, + musicians, dancers, and other persons who act, sing, deliver, declaim, + play in, interpret or otherwise perform literary or artistic works or + expressions of folklore; (ii) in the case of a phonogram the producer + being the person or legal entity who first fixes the sounds of a + performance or other sounds; and, (iii) in the case of broadcasts, the + organization that transmits the broadcast. + h. "Work" means the literary and/or artistic work offered under the terms + of this License including without limitation any production in the + literary, scientific and artistic domain, whatever may be the mode or + form of its expression including digital form, such as a book, + pamphlet and other writing; a lecture, address, sermon or other work + of the same nature; a dramatic or dramatico-musical work; a + choreographic work or entertainment in dumb show; a musical + composition with or without words; a cinematographic work to which are + assimilated works expressed by a process analogous to cinematography; + a work of drawing, painting, architecture, sculpture, engraving or + lithography; a photographic work to which are assimilated works + expressed by a process analogous to photography; a work of applied + art; an illustration, map, plan, sketch or three-dimensional work + relative to geography, topography, architecture or science; a + performance; a broadcast; a phonogram; a compilation of data to the + extent it is protected as a copyrightable work; or a work performed by + a variety or circus performer to the extent it is not otherwise + considered a literary or artistic work. + i. "You" means an individual or entity exercising rights under this + License who has not previously violated the terms of this License with + respect to the Work, or who has received express permission from the + Licensor to exercise rights under this License despite a previous + violation. + j. "Publicly Perform" means to perform public recitations of the Work and + to communicate to the public those public recitations, by any means or + process, including by wire or wireless means or public digital + performances; to make available to the public Works in such a way that + members of the public may access these Works from a place and at a + place individually chosen by them; to perform the Work to the public + by any means or process and the communication to the public of the + performances of the Work, including by public digital performance; to + broadcast and rebroadcast the Work by any means including signs, + sounds or images. + k. "Reproduce" means to make copies of the Work by any means including + without limitation by sound or visual recordings and the right of + fixation and reproducing fixations of the Work, including storage of a + protected performance or phonogram in digital form or other electronic + medium. + +2. Fair Dealing Rights. Nothing in this License is intended to reduce, +limit, or restrict any uses free from copyright or rights arising from +limitations or exceptions that are provided for in connection with the +copyright protection under copyright law or other applicable laws. + +3. License Grant. Subject to the terms and conditions of this License, +Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +perpetual (for the duration of the applicable copyright) license to +exercise the rights in the Work as stated below: + + a. to Reproduce the Work, to incorporate the Work into one or more + Collections, and to Reproduce the Work as incorporated in the + Collections; + b. to create and Reproduce Adaptations provided that any such Adaptation, + including any translation in any medium, takes reasonable steps to + clearly label, demarcate or otherwise identify that changes were made + to the original Work. For example, a translation could be marked "The + original work was translated from English to Spanish," or a + modification could indicate "The original work has been modified."; + c. to Distribute and Publicly Perform the Work including as incorporated + in Collections; and, + d. to Distribute and Publicly Perform Adaptations. + e. For the avoidance of doubt: + + i. Non-waivable Compulsory License Schemes. In those jurisdictions in + which the right to collect royalties through any statutory or + compulsory licensing scheme cannot be waived, the Licensor + reserves the exclusive right to collect such royalties for any + exercise by You of the rights granted under this License; + ii. Waivable Compulsory License Schemes. In those jurisdictions in + which the right to collect royalties through any statutory or + compulsory licensing scheme can be waived, the Licensor waives the + exclusive right to collect such royalties for any exercise by You + of the rights granted under this License; and, + iii. Voluntary License Schemes. The Licensor waives the right to + collect royalties, whether individually or, in the event that the + Licensor is a member of a collecting society that administers + voluntary licensing schemes, via that society, from any exercise + by You of the rights granted under this License. + +The above rights may be exercised in all media and formats whether now +known or hereafter devised. The above rights include the right to make +such modifications as are technically necessary to exercise the rights in +other media and formats. Subject to Section 8(f), all rights not expressly +granted by Licensor are hereby reserved. + +4. Restrictions. The license granted in Section 3 above is expressly made +subject to and limited by the following restrictions: + + a. You may Distribute or Publicly Perform the Work only under the terms + of this License. You must include a copy of, or the Uniform Resource + Identifier (URI) for, this License with every copy of the Work You + Distribute or Publicly Perform. You may not offer or impose any terms + on the Work that restrict the terms of this License or the ability of + the recipient of the Work to exercise the rights granted to that + recipient under the terms of the License. You may not sublicense the + Work. You must keep intact all notices that refer to this License and + to the disclaimer of warranties with every copy of the Work You + Distribute or Publicly Perform. When You Distribute or Publicly + Perform the Work, You may not impose any effective technological + measures on the Work that restrict the ability of a recipient of the + Work from You to exercise the rights granted to that recipient under + the terms of the License. This Section 4(a) applies to the Work as + incorporated in a Collection, but this does not require the Collection + apart from the Work itself to be made subject to the terms of this + License. If You create a Collection, upon notice from any Licensor You + must, to the extent practicable, remove from the Collection any credit + as required by Section 4(c), as requested. If You create an + Adaptation, upon notice from any Licensor You must, to the extent + practicable, remove from the Adaptation any credit as required by + Section 4(c), as requested. + b. You may Distribute or Publicly Perform an Adaptation only under the + terms of: (i) this License; (ii) a later version of this License with + the same License Elements as this License; (iii) a Creative Commons + jurisdiction license (either this or a later license version) that + contains the same License Elements as this License (e.g., + Attribution-ShareAlike 3.0 US)); (iv) a Creative Commons Compatible + License. If you license the Adaptation under one of the licenses + mentioned in (iv), you must comply with the terms of that license. If + you license the Adaptation under the terms of any of the licenses + mentioned in (i), (ii) or (iii) (the "Applicable License"), you must + comply with the terms of the Applicable License generally and the + following provisions: (I) You must include a copy of, or the URI for, + the Applicable License with every copy of each Adaptation You + Distribute or Publicly Perform; (II) You may not offer or impose any + terms on the Adaptation that restrict the terms of the Applicable + License or the ability of the recipient of the Adaptation to exercise + the rights granted to that recipient under the terms of the Applicable + License; (III) You must keep intact all notices that refer to the + Applicable License and to the disclaimer of warranties with every copy + of the Work as included in the Adaptation You Distribute or Publicly + Perform; (IV) when You Distribute or Publicly Perform the Adaptation, + You may not impose any effective technological measures on the + Adaptation that restrict the ability of a recipient of the Adaptation + from You to exercise the rights granted to that recipient under the + terms of the Applicable License. This Section 4(b) applies to the + Adaptation as incorporated in a Collection, but this does not require + the Collection apart from the Adaptation itself to be made subject to + the terms of the Applicable License. + c. If You Distribute, or Publicly Perform the Work or any Adaptations or + Collections, You must, unless a request has been made pursuant to + Section 4(a), keep intact all copyright notices for the Work and + provide, reasonable to the medium or means You are utilizing: (i) the + name of the Original Author (or pseudonym, if applicable) if supplied, + and/or if the Original Author and/or Licensor designate another party + or parties (e.g., a sponsor institute, publishing entity, journal) for + attribution ("Attribution Parties") in Licensor's copyright notice, + terms of service or by other reasonable means, the name of such party + or parties; (ii) the title of the Work if supplied; (iii) to the + extent reasonably practicable, the URI, if any, that Licensor + specifies to be associated with the Work, unless such URI does not + refer to the copyright notice or licensing information for the Work; + and (iv) , consistent with Ssection 3(b), in the case of an + Adaptation, a credit identifying the use of the Work in the Adaptation + (e.g., "French translation of the Work by Original Author," or + "Screenplay based on original Work by Original Author"). The credit + required by this Section 4(c) may be implemented in any reasonable + manner; provided, however, that in the case of a Adaptation or + Collection, at a minimum such credit will appear, if a credit for all + contributing authors of the Adaptation or Collection appears, then as + part of these credits and in a manner at least as prominent as the + credits for the other contributing authors. For the avoidance of + doubt, You may only use the credit required by this Section for the + purpose of attribution in the manner set out above and, by exercising + Your rights under this License, You may not implicitly or explicitly + assert or imply any connection with, sponsorship or endorsement by the + Original Author, Licensor and/or Attribution Parties, as appropriate, + of You or Your use of the Work, without the separate, express prior + written permission of the Original Author, Licensor and/or Attribution + Parties. + d. Except as otherwise agreed in writing by the Licensor or as may be + otherwise permitted by applicable law, if You Reproduce, Distribute or + Publicly Perform the Work either by itself or as part of any + Adaptations or Collections, You must not distort, mutilate, modify or + take other derogatory action in relation to the Work which would be + prejudicial to the Original Author's honor or reputation. Licensor + agrees that in those jurisdictions (e.g. Japan), in which any exercise + of the right granted in Section 3(b) of this License (the right to + make Adaptations) would be deemed to be a distortion, mutilation, + modification or other derogatory action prejudicial to the Original + Author's honor and reputation, the Licensor will waive or not assert, + as appropriate, this Section, to the fullest extent permitted by the + applicable national law, to enable You to reasonably exercise Your + right under Section 3(b) of this License (right to make Adaptations) + but not otherwise. + +5. Representations, Warranties and Disclaimer + +UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR +OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY +KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, +INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, +FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF +LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS, +WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION +OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU. + +6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE +LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR +ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES +ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS +BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +7. Termination + + a. This License and the rights granted hereunder will terminate + automatically upon any breach by You of the terms of this License. + Individuals or entities who have received Adaptations or Collections + from You under this License, however, will not have their licenses + terminated provided such individuals or entities remain in full + compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will + survive any termination of this License. + b. Subject to the above terms and conditions, the license granted here is + perpetual (for the duration of the applicable copyright in the Work). + Notwithstanding the above, Licensor reserves the right to release the + Work under different license terms or to stop distributing the Work at + any time; provided, however that any such election will not serve to + withdraw this License (or any other license that has been, or is + required to be, granted under the terms of this License), and this + License will continue in full force and effect unless terminated as + stated above. + +8. Miscellaneous + + a. Each time You Distribute or Publicly Perform the Work or a Collection, + the Licensor offers to the recipient a license to the Work on the same + terms and conditions as the license granted to You under this License. + b. Each time You Distribute or Publicly Perform an Adaptation, Licensor + offers to the recipient a license to the original Work on the same + terms and conditions as the license granted to You under this License. + c. If any provision of this License is invalid or unenforceable under + applicable law, it shall not affect the validity or enforceability of + the remainder of the terms of this License, and without further action + by the parties to this agreement, such provision shall be reformed to + the minimum extent necessary to make such provision valid and + enforceable. + d. No term or provision of this License shall be deemed waived and no + breach consented to unless such waiver or consent shall be in writing + and signed by the party to be charged with such waiver or consent. + e. This License constitutes the entire agreement between the parties with + respect to the Work licensed here. There are no understandings, + agreements or representations with respect to the Work not specified + here. Licensor shall not be bound by any additional provisions that + may appear in any communication from You. This License may not be + modified without the mutual written agreement of the Licensor and You. + f. The rights granted under, and the subject matter referenced, in this + License were drafted utilizing the terminology of the Berne Convention + for the Protection of Literary and Artistic Works (as amended on + September 28, 1979), the Rome Convention of 1961, the WIPO Copyright + Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996 + and the Universal Copyright Convention (as revised on July 24, 1971). + These rights and subject matter take effect in the relevant + jurisdiction in which the License terms are sought to be enforced + according to the corresponding provisions of the implementation of + those treaty provisions in the applicable national law. If the + standard suite of rights granted under applicable copyright law + includes additional rights not granted under this License, such + additional rights are deemed to be included in the License; this + License is not intended to restrict the license of any rights under + applicable law. + + +Creative Commons Notice + + Creative Commons is not a party to this License, and makes no warranty + whatsoever in connection with the Work. Creative Commons will not be + liable to You or any party on any legal theory for any damages + whatsoever, including without limitation any general, special, + incidental or consequential damages arising in connection to this + license. Notwithstanding the foregoing two (2) sentences, if Creative + Commons has expressly identified itself as the Licensor hereunder, it + shall have all rights and obligations of Licensor. + + Except for the limited purpose of indicating to the public that the + Work is licensed under the CCPL, Creative Commons does not authorize + the use by either party of the trademark "Creative Commons" or any + related trademark or logo of Creative Commons without the prior + written consent of Creative Commons. Any permitted use will be in + compliance with Creative Commons' then-current trademark usage + guidelines, as may be published on its website or otherwise made + available upon request from time to time. For the avoidance of doubt, + this trademark restriction does not form part of the License. + + Creative Commons may be contacted at https://creativecommons.org/. \ No newline at end of file diff --git a/onRenderBody.js b/onRenderBody.js new file mode 100644 index 00000000000..b5849ad07db --- /dev/null +++ b/onRenderBody.js @@ -0,0 +1,31 @@ +import React from 'react'; + +const onRenderBody = ({ setHeadComponents }) => { + const initializeTheme = ` + (function() { + try { + const savedTheme = localStorage.getItem('selected_theme'); + + if (savedTheme) { + document.documentElement.dataset.theme = savedTheme; + } else if ( + window.matchMedia && + window.matchMedia('(prefers-color-scheme: dark)').matches + ) { + document.documentElement.dataset.theme = 'dark'; + } + } catch (e) {} + })(); + `; + + setHeadComponents([ + + + +``` + +You can try adding there some HTML to the file. However, when using React, all content that needs to be rendered is usually defined as React components. Let's take a closer look at the code defining the component: @@ -89,7 +130,7 @@ The function is then assigned to a constant variable App: const App = ... ``` -There are a few ways to define functions in JavaScript. Here we will use [arrow functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions), which are described in a newer version of JavaScript known as [ECMAScript 6](http://es6-features.org/#Constants), also called ES6. +There are a few ways to define functions in JavaScript. Here we will use [arrow functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions), which are described in a newer version of JavaScript known as [ECMAScript 6](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const), also called ES6. Because the function consists of only a single expression we have used a shorthand, which represents this piece of code: @@ -105,7 +146,7 @@ const App = () => { In other words, the function returns the value of the expression. -The function defining the component may contain any kind of JavaScript code. Modify your component to be as follows and observe what happens in the console: +The function defining the component may contain any kind of JavaScript code. Modify your component to be as follows: ```js const App = () => { @@ -116,8 +157,20 @@ const App = () => { ) } + +export default App ``` +and observe what happens in the browser console + +![browser console showing console log with arrow to "Hello from component"](../../images/1/30.png) + +The first rule of frontend web development: + +> keep the console open all the time + +Let us repeat this together: I promise to keep the console open all the time during this course, and for the rest of my life when I'm doing web development. + It is also possible to render dynamic content inside of a component. Modify the component as follows: @@ -127,6 +180,7 @@ const App = () => { const now = new Date() const a = 10 const b = 20 + console.log(now, a+b) return (
@@ -141,16 +195,23 @@ const App = () => { Any JavaScript code within the curly braces is evaluated and the result of this evaluation is embedded into the defined place in the HTML produced by the component. +Note that you should not remove the line at the bottom of the component + +```js +export default App +``` + +The export is not shown in most of the examples of the course material. Without the export the component and the whole app breaks down. + +Did you remember your promise to keep the console open? What was printed out there? + ### JSX -It seems like React components are returning HTML markup. However, this is not the case. The layout of React components is mostly written using [JSX](https://reactjs.org/docs/introducing-jsx.html). Although JSX looks like HTML, we are actually dealing with a way to write JavaScript. Under the hood, JSX returned by React components is compiled into JavaScript. +It seems like React components are returning HTML markup. However, this is not the case. The layout of React components is mostly written using [JSX](https://react.dev/learn/writing-markup-with-jsx). Although JSX looks like HTML, we are dealing with a way to write JavaScript. Under the hood, JSX returned by React components is compiled into JavaScript. After compiling, our application looks like this: ```js -import React from 'react' -import ReactDOM from 'react-dom' - const App = () => { const now = new Date() const a = 10 @@ -166,18 +227,13 @@ const App = () => { ) ) } - -ReactDOM.render( - React.createElement(App, null), - document.getElementById('root') -) ``` -The compiling is handled by [Babel](https://babeljs.io/repl/). Projects created with *create-react-app* are configured to compile automatically. We will learn more about this topic in [part 7](/en/part7) of this course. +The compilation is handled by [Babel](https://babeljs.io/repl/). Projects created with *Vite* are configured to compile automatically. We will learn more about this topic in [part 7](/en/part7) of this course. -It is also possible to write React as "pure JavaScript" without using JSX. Although, nobody with a sound mind would actually do so. +It is also possible to write React as "pure JavaScript" without using JSX. Although, nobody with a sound mind would do so. -In practice, JSX is much like HTML with the distinction that with JSX you can easily embed dynamic content by writing appropriate JavaScript within curly braces. The idea of JSX is quite similar to many templating languages, such as Thymeleaf used along Java Spring, which are used on servers. +In practice, JSX is much like HTML with the distinction that with JSX you can easily embed dynamic content by writing appropriate JavaScript within curly braces. The idea of JSX is quite similar to many templating languages, such as Thymeleaf used along with Java Spring, which are used on servers. JSX is "[XML](https://developer.mozilla.org/en-US/docs/Web/XML/XML_introduction)-like", which means that every tag needs to be closed. For example, a newline is an empty element, which in HTML can be written as follows: @@ -193,7 +249,7 @@ but when writing JSX, the tag needs to be closed: ### Multiple components -Let's modify the application as follows (NB: imports at the top of the file are left out in these examples, now and in the future. They are still needed for the code to work): +Let's modify the file App.jsx as follows: ```js // highlight-start @@ -214,8 +270,6 @@ const App = () => {
) } - -ReactDOM.render(, document.getElementById('root')) ``` We have defined a new component Hello and used it inside the component App. Naturally, a component can be used multiple times: @@ -235,15 +289,17 @@ const App = () => { } ``` +**NB**: export at the bottom is left out in these examples, now and in the future. It is still needed for the code to work + Writing components with React is easy, and by combining components, even a more complex application can be kept fairly maintainable. Indeed, a core philosophy of React is composing applications from many specialized reusable components. Another strong convention is the idea of a root component called App at the top of the component tree of the application. Nevertheless, as we will learn in [part 6](/en/part6), there are situations where the component App is not exactly the root, but is wrapped within an appropriate utility component. ### props: passing data to components -It is possible to pass data to components using so called [props](https://reactjs.org/docs/components-and-props.html). +It is possible to pass data to components using so-called [props](https://react.dev/learn/passing-props-to-a-component). -Let's modify the component Hello as follows +Let's modify the component Hello as follows: ```js const Hello = (props) => { // highlight-line @@ -255,7 +311,7 @@ const Hello = (props) => { // highlight-line } ``` -Now the function defining the component has a parameter props. As an argument, the parameter receives an object, which has fields corresponding to all the "props" the user of the component defines. +Now the function defining the component has a parameter props. As an argument, the parameter receives an object, which has fields corresponding to all the "props" the user of the component defines. The props are defined as follows: @@ -264,19 +320,20 @@ const App = () => { return (

Greetings

- // highlight-line - // highlight-line + // highlight-line + // highlight-line
) } ``` -There can be an arbitrary number of props and their values can be "hard coded" strings or results of JavaScript expressions. If the value of the prop is achieved using JavaScript it must be wrapped with curly braces. +There can be an arbitrary number of props and their values can be "hard-coded" strings or the results of JavaScript expressions. If the value of the prop is achieved using JavaScript it must be wrapped with curly braces. Let's modify the code so that the component Hello uses two props: ```js const Hello = (props) => { + console.log(props) // highlight-line return (

@@ -293,7 +350,7 @@ const App = () => { return (

Greetings

- // highlight-line + // highlight-line // highlight-line
) @@ -302,23 +359,77 @@ const App = () => { The props sent by the component App are the values of the variables, the result of the evaluation of the sum expression and a regular string. +Component Hello also logs the value of the object props to the console. + +I really hope your console was open. If it was not, remember what you promised: + +> I promise to keep the console open all the time during this course, and for the rest of my life when I'm doing web development + +Software development is hard. It gets even harder if one is not using all the possible available tools such as the web-console and debug printing with _console.log_. Professionals use both all the time and there is no single reason why a beginner should not adopt the use of these wonderful helper methods that will make their life so much easier. + +### Possible error message + +If your project has React version 18 or earlier installed, you may receive the following error message at this point: + +![screenshot of vs code showing eslint error: "name is missing in props validation"](../../images/1/1-vite5.png) + +It's not an actual error, but a warning caused by the [ESLint](https://eslint.org/) tool. You can silence the warning [react/prop-types](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/prop-types.md) by adding to the file eslint.config.js the next line + +```js +export default [ + { ignores: ['dist'] }, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + settings: { react: { version: '18.3' } }, + plugins: { + react, + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...js.configs.recommended.rules, + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + ...reactHooks.configs.recommended.rules, + 'react/jsx-no-target-blank': 'off', + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + 'react/prop-types': 0, // highlight-line + }, + }, +] +``` + +We will get to know ESLint in more detail [in part 3](/en/part3/validation_and_es_lint#lint). + ### Some notes React has been configured to generate quite clear error messages. Despite this, you should, at least in the beginning, advance in **very small steps** and make sure that every change works as desired. **The console should always be open**. If the browser reports errors, it is not advisable to continue writing more code, hoping for miracles. You should instead try to understand the cause of the error and, for example, go back to the previous working state: -![](../../images/1/2a.png) +![screenshot of undefined prop error](../../images/1/1-vite6.png) -It is good to remember that in React it is possible and worthwhile to write console.log() commands (which print to the console) within your code. +As we already mentioned, when programming with React, it is possible and worthwhile to write console.log() commands (which print to the console) within your code. -Also keep in mind that **React component names must be capitalized**. If you try defining a component as follows +Also, keep in mind that **First letter of React component names must be capitalized**. If you try defining a component as follows: ```js const footer = () => { return (
- greeting app created by mluukkai + greeting app created by mluukkai
) } @@ -331,14 +442,14 @@ const App = () => { return (

Greetings

- +
// highlight-line
) } ``` -the page is not going to display the content defined within the Footer component, and instead React only creates an empty footer element. If you change the first letter of the component name to a capital letter, then React creates a div-element defined in the Footer component, which is rendered on the page. +the page is not going to display the content defined within the footer component, and instead React only creates an empty [footer](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/footer) element, i.e. the built-in HTML element instead of the custom React element of the same name. If you change the first letter of the component name to a capital letter, then React creates a div-element defined in the Footer component, which is rendered on the page. Note that the content of a React component (usually) needs to contain **one root element**. If we, for example, try to define the component App without the outermost div-element: @@ -346,7 +457,7 @@ Note that the content of a React component (usually) needs to contain **one root const App = () => { return (

Greetings

- +
) } @@ -354,7 +465,7 @@ const App = () => { the result is an error message. -![](../../images/1/3e.png) +![multiple root elements error screenshot](../../images/1/1-vite7.png) Using a root element is not the only working option. An array of components is also a valid solution: @@ -362,7 +473,7 @@ Using a root element is not the only working option. An array of componen const App = () => { return [

Greetings

, - , + ,
] } @@ -370,7 +481,7 @@ const App = () => { However, when defining the root component of the application this is not a particularly wise thing to do, and it makes the code look a bit ugly. -Because the root element is stipulated, we have "extra" div-elements in the DOM-tree. This can be avoided by using [fragments](https://reactjs.org/docs/fragments.html#short-syntax), i.e. by wrapping the elements to be returned by the component with an empty element: +Because the root element is stipulated, we have "extra" div elements in the DOM tree. This can be avoided by using [fragments](https://react.dev/reference/react/Fragment), i.e. by wrapping the elements to be returned by the component with an empty element: ```js const App = () => { @@ -380,7 +491,7 @@ const App = () => { return ( <>

Greetings

- +
@@ -388,20 +499,122 @@ const App = () => { } ``` -It now compiles successfully, and the DOM generated by React no longer contains the extra div-element. +It now compiles successfully, and the DOM generated by React no longer contains the extra div element. + +### Do not render objects + +Consider an application that prints the names and ages of our friends on the screen: + +```js +const App = () => { + const friends = [ + { name: 'Peter', age: 4 }, + { name: 'Maya', age: 10 }, + ] + + return ( +
+

{friends[0]}

+

{friends[1]}

+
+ ) +} + +export default App +``` + +However, nothing appears on the screen. I've been trying to find a problem in the code for 15 minutes, but I can't figure out where the problem could be. + +I finally remember the promise we made + +> I promise to keep the console open all the time during this course, and for the rest of my life when I'm doing web development + +The console screams in red: + +![devtools showing error with highlight around "Objects are not valid as a React child"](../../images/1/34new.png) + +The core of the problem is Objects are not valid as a React child, i.e. the application tries to render objects and it fails again. + +The code tries to render the information of one friend as follows + +```js +

{friends[0]}

+``` + +and this causes a problem because the item to be rendered in the braces is an object. + +```js +{ name: 'Peter', age: 4 } +``` + +In React, the individual things rendered in braces must be primitive values, such as numbers or strings. + +The fix is ​​as follows + +```js +const App = () => { + const friends = [ + { name: 'Peter', age: 4 }, + { name: 'Maya', age: 10 }, + ] + + return ( +
+

{friends[0].name} {friends[0].age}

+

{friends[1].name} {friends[1].age}

+
+ ) +} + +export default App +``` + +So now the friend's name is rendered separately inside the curly braces + +```js +{friends[0].name} +``` + +and age + +```js +{friends[0].age} +``` + +After correcting the error, you should clear the console error messages by pressing 🚫 and then reload the page content and make sure that no error messages are displayed. + +A small additional note to the previous one. React also allows arrays to be rendered if the array contains values ​​that are eligible for rendering (such as numbers or strings). So the following program would work, although the result might not be what we want: + +```js +const App = () => { + const friends = [ 'Peter', 'Maya'] + + return ( +
+

{friends}

+
+ ) +} +``` + +In this part, it is not even worth trying to use the direct rendering of the tables, we will come back to it in the next part.

Exercises 1.1.-1.2.

-Exercises are submitted through GitHub and by marking completed exercises in the [submission application](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). +The exercises are submitted via GitHub, and by marking the exercises as done in the "my submissions" tab of the [submission application](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +The exercises are submitted **one part at a time**. When you have submitted the exercises for a part of the course you can no longer submit undone exercises for the same part. + +Note that in this part, there are [more exercises](/en/part1/a_more_complex_state_debugging_react_apps#exercises-1-6-1-14) besides those found below. Do not submit your work until you have completed all of the exercises you want to submit for the part. You may submit all the exercises of this course into the same repository, or use multiple repositories. If you submit exercises of different parts into the same repository, please use a sensible naming scheme for the directories. One very functional file structure for the submission repository is as follows: -``` +```text part0 part1 courseinfo @@ -412,26 +625,31 @@ part2 countries ``` -See [this](https://github.com/fullstack-hy2020/example-submission-repository)! +See this [example submission repository](https://github.com/fullstack-hy2020/example-submission-repository)! -For each part of the course there is a directory, which further branches into directories containing a series of exercises, like "unicafe" for part 1. +For each part of the course, there is a directory, which further branches into directories containing a series of exercises, like "unicafe" for part 1. -For each web application for a series of exercises, it is recommended to submit all files relating to that application, except for the directory node\_modules. +Most of the exercises of the course build a larger application, eg. courseinfo, unicafe and anecdotes in this part, bit by bit. It is enough to submit the completed application. You can make a commit after each exercise, but that is not compulsory. For example the course info app is built in exercises 1.1.-1.5. It is just the end result after 1.5 that you need to submit! -The exercises are submitted **one part at a time**. When you have submitted the exercises for a part of the course you can no longer submit undone exercises for the same part. +For each web application for a series of exercises, it is recommended to submit all files relating to that application, except for the directory node\_modules. -Note that in this part, there are more exercises besides those found below. Do not submit your work until you have completed all of the exercises you want to submit for the part. - -

1.1: course information, step1

+

1.1: Course Information, step 1

The application that we will start working on in this exercise will be further developed in a few of the following exercises. In this and other upcoming exercise sets in this course, it is enough to only submit the final state of the application. If desired, you may also create a commit for each exercise of the series, but this is entirely optional. -Use create-react-app to initialize a new application. Modify index.js to match the following +Use Vite to initialize a new application. Modify main.jsx to match the following ```js -import React from 'react' -import ReactDOM from 'react-dom' +import ReactDOM from 'react-dom/client' + +import App from './App' +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +and App.jsx to match the following + +```js const App = () => { const course = 'Half Stack application development' const part1 = 'Fundamentals of React' @@ -458,13 +676,15 @@ const App = () => { ) } -ReactDOM.render(, document.getElementById('root')) +export default App ``` -and remove extra files (App.js, App.css, App.test.js, logo.svg, setupTests.js, serviceWorker.js). +and remove the extra files App.css and index.css, also remove the directory assets. Unfortunately, the entire application is in the same component. Refactor the code so that it consists of three new components: Header, Content, and Total. All data still resides in the App component, which passes the necessary data to each component using props. Header takes care of rendering the name of the course, Content renders the parts and their number of exercises and Total renders the total number of exercises. +Define the new components in the file App.jsx. + The App component's body will approximately be as follows: ```js @@ -481,11 +701,17 @@ const App = () => { } ``` -**WARNING** create-react-app automatically makes the project a git repository unless the application is created within an already existing repository. Most likely you **do not want** the project becoming a repository, so run the command _rm -rf .git_ in the root of the project. +**WARNING** Don't try to program all the components concurrently, because that will almost certainly break down the whole app. Proceed in small steps, first make e.g. the component Header and only when it works for sure, you could proceed to the next component. + +Careful, small-step progress may seem slow, but it is actually by far the fastest way to progress. Famous software developer Robert "Uncle Bob" Martin has stated + +> "The only way to go fast, is to go well" + +that is, according to Martin, careful progress with small steps is even the only way to be fast. -

1.2: course information, step2

+

1.2: Course Information, step 2

-Refactor the Content component so that it does not render any names of parts or their number of exercises by itself. Instead it only renders three Part components of which each renders the name and number of exercises of one part. +Refactor the Content component so that it does not render any names of parts or their number of exercises by itself. Instead, it only renders three Part components of which each renders the name and number of exercises of one part. ```js const Content = ... { @@ -499,6 +725,6 @@ const Content = ... { } ``` -Our application passes on information in quite a primitive way at the moment, since it is based on individual variables. This situation will improve soon. +Our application passes on information in quite a primitive way at the moment, since it is based on individual variables. We shall fix that in [part 2](/en/part2), but before that, let's go to part1b to learn about JavaScript.
diff --git a/src/content/1/en/part1b.md b/src/content/1/en/part1b.md index fb0167cd8fd..e6003b22435 100644 --- a/src/content/1/en/part1b.md +++ b/src/content/1/en/part1b.md @@ -9,22 +9,21 @@ lang: en During the course, we have a goal and a need to learn a sufficient amount of JavaScript in addition to web development. -JavaScript has advanced rapidly the last few years and in this course we use features from the newer versions. The official name of the JavaScript standard is [ECMAScript](https://en.wikipedia.org/wiki/ECMAScript). At this moment, the latest version is the one released in June of 2019 with the name [ECMAScript® 2019](http://www.ecma-international.org/ecma-262/10.0/index.html), otherwise known as ES10. +JavaScript has advanced rapidly in the last few years and in this course, we use features from the newer versions. The official name of the JavaScript standard is [ECMAScript](https://en.wikipedia.org/wiki/ECMAScript). At this moment, the latest version is the one released in June of 2024 with the name [ECMAScript®2024](https://www.ecma-international.org/ecma-262/), otherwise known as ES15. Browsers do not yet support all of JavaScript's newest features. Due to this fact, a lot of code run in browsers has been transpiled from a newer version of JavaScript to an older, more compatible version. -Today, the most popular way to do the transpiling is using [Babel](https://babeljs.io/). Transpilation is automatically configured in React applications created with create-react-app. We will take a closer look at the configuration of the transpilation in [part 7](/en/part7) of this course. +Today, the most popular way to do transpiling is by using [Babel](https://babeljs.io/). Transpilation is automatically configured in React applications created with Vite. We will take a closer look at the configuration of the transpilation in [part 7](/en/part7) of this course. -[Node.js](https://nodejs.org/en/) is a Javascript runtime environment based on Google's [chrome V8](https://developers.google.com/v8/) Javascript engine and works practically anywhere - from servers to mobile phones. Let's practice writing some Javascript using Node. It is expected that the version of Node.js installed on your machine is at least version 10.18.0. The latest versions of Node already understand the latest versions of Javascript, so the code does not need to be transpiled. +[Node.js](https://nodejs.org/en/) is a JavaScript runtime environment based on Google's [Chrome V8](https://developers.google.com/v8/) JavaScript engine and works practically anywhere - from servers to mobile phones. Let's practice writing some JavaScript using Node. The latest versions of Node already understand the latest versions of JavaScript, so the code does not need to be transpiled. +The code is written into files ending with .js that are run by issuing the command node name\_of\_file.js -The code is written into files ending with .js and are run by issuing the command node name\_of\_file.js - -It is also possible to write JavaScript code into the Node.js console, which is opened by typing _node_ in the command-line, as well as into the browser's developer tool console. The newest revisions of Chrome handle the newer features of JavaScript [pretty well](http://kangax.github.io/compat-table/es2016plus/) without transpiling the code. Alternatively you can use a tool like [JS Bin](https://jsbin.com/?js,console). +It is also possible to write JavaScript code into the Node.js console, which is opened by typing _node_ in the command line, as well as into the browser's developer tool console. [The newest revisions of Chrome handle the newer features of JavaScript pretty well](https://compat-table.github.io/compat-table/es2016plus/) without transpiling the code. Alternatively, you can use a tool like [JS Bin](https://jsbin.com/?js,console). JavaScript is sort of reminiscent, both in name and syntax, to Java. But when it comes to the core mechanism of the language they could not be more different. Coming from a Java background, the behavior of JavaScript can seem a bit alien, especially if one does not make the effort to look up its features. -In certain circles it has also been popular to attempt "simulating" Java features and design patterns in JavaScript. We do not recommend doing this as the languages and respective ecosystems are ultimately very different. +In certain circles, it has also been popular to attempt "simulating" Java features and design patterns in JavaScript. We do not recommend doing this as the languages and respective ecosystems are ultimately very different. ### Variables @@ -34,19 +33,19 @@ In JavaScript there are a few ways to go about defining variables: const x = 1 let y = 5 -console.log(x, y) // 1, 5 are printed +console.log(x, y) // 1 5 are printed y += 10 -console.log(x, y) // 1, 15 are printed +console.log(x, y) // 1 15 are printed y = 'sometext' -console.log(x, y) // 1, sometext are printed +console.log(x, y) // 1 sometext are printed x = 4 // causes an error ``` -[const](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const) does not actually define a variable but a constant for which the value can no longer be changed. On the other hand [let](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let) defines a normal variable. +[const](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const) does not define a variable but a constant for which the value can no longer be changed. On the other hand, [let](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let) defines a normal variable. -In the example above, we also see that the type of the data assigned to the variable can change during execution. At the start _y_ stores an integer and at the end a string. +In the example above, we also see that the variable's data type can change during execution. At the start, _y_ stores an integer; at the end, it stores a string. -It is also possible to define variables in Javascript using the keyword [var](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/var). Var was, for a long time, the only way to define variables. Const and let were only recently added in version ES6. In specific situations, var works in a [different](https://medium.com/craft-academy/javascript-variables-should-you-use-let-var-or-const-394f7645c88f) [way](http://www.jstips.co/en/javascript/keyword-var-vs-let/) compared to variable definitions in most languages. During this course the use of var is ill-advised and you should stick with using const and let! +It is also possible to define variables in JavaScript using the keyword [var](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/var). For a long time, var was the only way to define variables. The keywords const and let were introduced in 2015 with the release of ES6. In specific situations, var works in a different way compared to variable definitions in most languages - see [JavaScript Variables - Should You Use let, var or const? on Medium](https://medium.com/craft-academy/javascript-variables-should-you-use-let-var-or-const-394f7645c88f) or [Keyword: var vs. let on JS Tips](http://www.jstips.co/en/javascript/keyword-var-vs-let/) for more information. During this course the use of var is ill-advised and you should stick with using const and let! You can find more on this topic on YouTube - e.g. [var, let and const - ES6 JavaScript Features](https://youtu.be/sjyJBL5fkp8) ### Arrays @@ -62,11 +61,11 @@ console.log(t.length) // 4 is printed console.log(t[1]) // -1 is printed t.forEach(value => { - console.log(value) // numbers 1, -1, 3, 5 are printed, each to own line + console.log(value) // numbers 1, -1, 3, 5 are printed, each on its own line }) ``` -Notable in this example is the fact that the contents of the array can be modified even though it is defined as a _const_. Because the array is an object the variable always points to the same object. However, the content of the array changes as new items are added to it. +Notable in this example is the fact that although a variable declared with const cannot be reassigned to a different value, the contents of the object it references can still be modified. This is because the const declaration ensures the immutability of the reference itself, not the data it points to. Think of it like changing the furniture inside a house, while the address of the house remains the same. One way of iterating through the items of the array is using _forEach_ as seen in the example. _forEach_ receives a function defined using the arrow syntax as a parameter. @@ -76,14 +75,14 @@ value => { } ``` -forEach calls the function for each of the items in the array, always passing the individual item as a parameter. The function as the parameter of forEach may also receive [other parameters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach). +forEach calls the function for each of the items in the array, always passing the individual item as an argument. The function as the argument of forEach may also receive [other arguments](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach). -In the previous example, a new item was added to the array using the method [push](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push). When using React, techniques from functional programming are often used. One characteristic of the functional programming paradigm is the use of [immutable](https://en.wikipedia.org/wiki/Immutable_object) data structures. In React code, it is preferable to use the method [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat), which does not add the item to the array, but creates a new array in which the content of the old array and the new item are both included. +In the previous example, a new item was added to the array using the method [push](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push). When using React, techniques from functional programming are often used. One characteristic of the functional programming paradigm is the use of [immutable](https://en.wikipedia.org/wiki/Immutable_object) data structures. In React code, it is preferable to use the method [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat), which creates a new array with the added item. This ensures the original array remains unchanged. ```js const t = [1, -1, 3] -const t2 = t.concat(5) +const t2 = t.concat(5) // creates new array console.log(t) // [1, -1, 3] is printed console.log(t2) // [1, -1, 3, 5] is printed @@ -100,7 +99,7 @@ const m1 = t.map(value => value * 2) console.log(m1) // [2, 4, 6] is printed ``` -Based on the old array, map creates a new array, for which the function given as a parameter is used to create the items. In the case of this example the original value is multiplied by two. +Based on the old array, map creates a new array, for which the function given as a parameter is used to create the items. In the case of this example, the original value is multiplied by two. Map can also transform the array into something completely different: @@ -119,11 +118,11 @@ const t = [1, 2, 3, 4, 5] const [first, second, ...rest] = t -console.log(first, second) // 1, 2 is printed -console.log(rest) // [3, 4 ,5] is printed +console.log(first, second) // 1 2 is printed +console.log(rest) // [3, 4, 5] is printed ``` -Thanks to the assignment, the variables _first_ and _second_ will receive the first two integers of the array as their values. The remaining integers are "collected" into an array of their own which is then assigned to the variable _rest_. +Above, the variable _first_ is assigned the first integer of the array and the variable _second_ is assigned the second integer of the array. The variable _rest_ "collects" the remaining integers into its own array. ### Objects @@ -158,7 +157,7 @@ The properties of an object are referenced by using the "dot" notation, or by us ```js console.log(object1.name) // Arto Hellas is printed -const fieldName = 'age' +const fieldName = 'age' console.log(object1[fieldName]) // 35 is printed ``` @@ -169,15 +168,15 @@ object1.address = 'Helsinki' object1['secret number'] = 12341 ``` -The latter of the additions has to be done by using brackets, because when using dot notation, secret number is not a valid property name because of the space character. +The latter of the additions has to be done by using brackets because when using dot notation, secret number is not a valid property name because of the space character. -Naturally, objects in JavaScript can also have methods. However, during this course we do not need to define any objects with methods of their own. This is why they are only discussed briefly during the course. +Naturally, objects in JavaScript can also have methods. However, during this course, we do not need to define any objects with methods of their own. This is why they are only discussed briefly during the course. -Objects can also be defined using so-called constructor functions, which results in a mechanism reminiscent of many other programming languages, e.g. Java's classes. Despite this similarity, Javascript does not have classes in the same sense as object-oriented programming languages. There has been, however, an addition of the class syntax starting from version ES6, which in some cases helps structure object-oriented classes. +Objects can also be defined using so-called constructor functions, which results in a mechanism reminiscent of many other programming languages, e.g. Java's classes. Despite this similarity, JavaScript does not have classes in the same sense as object-oriented programming languages. There has been, however, the addition of the class syntax starting from version ES6, which in some cases helps structure object-oriented classes. ### Functions -We have already become familiar with defining arrow functions. The complete process, without cutting corners, to defining an arrow function is as follows: +We have already become familiar with defining arrow functions. The complete process, without cutting corners, of defining an arrow function is as follows: ```js const sum = (p1, p2) => { @@ -203,7 +202,7 @@ const square = p => { } ``` -If the function only contains a single expression then the braces are not needed. In this case the function only returns the result of its only expression. Now, if we remove console printing, we can further shorten the function definition: +If the function only contains a single expression then the braces are not needed. In this case, the function only returns the result of its only expression. Now, if we remove console printing, we can further shorten the function definition: ```js const square = p => p * p @@ -217,9 +216,9 @@ const tSquared = t.map(p => p * p) // tSquared is now [1, 4, 9] ``` -The arrow function feature was added to JavaScript only a couple of years ago, with version [ES6](http://es6-features.org/). Prior to this the only way to define functions was by using the keyword _function_. +The arrow function feature was added to JavaScript in 2015, with version [ES6](https://rse.github.io/es6-features/). Before this, the only way to define functions was by using the keyword _function_. -There are two ways by which the function can be referenced; one is giving a name in a [function declaration](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function). +There are two ways to reference the function; one is giving a name in a [function declaration](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function). ```js function product(a, b) { @@ -230,7 +229,7 @@ const result = product(2, 6) // result is now 12 ``` -The other way to define the function is using a [function expression](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/function). In this case there is no need to give the function a name and the definition may reside among the rest of the code: +The other way to define the function is by using a [function expression](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/function). In this case, there is no need to give the function a name and the definition may reside among the rest of the code: ```js const average = function(a, b) { @@ -241,14 +240,15 @@ const result = average(2, 5) // result is now 3.5 ``` -During this course we will define all functions using the arrow syntax. +During this course, we will define all functions using the arrow syntax.
+

Exercises 1.3.-1.5.

-We continue building the application that we started working on in the previous exercises. You can write the code into the same project, since we are only interested in the final state of the submitted application. +We continue building the application that we started working on in the previous exercises. You can write the code into the same project since we are only interested in the final state of the submitted application. **Pro-tip:** you may run into issues when it comes to the structure of the props that components receive. A good way to make things more clear is by printing the props to the console, e.g. as follows: @@ -259,7 +259,13 @@ const Header = (props) => { } ``` -

1.3: course information step3

+If and when you encounter an error message + +> Objects are not valid as a React child + +keep in mind the things told [here](/en/part1/introduction_to_react#do-not-render-objects). + +

1.3: Course Information step 3

Let's move forward to using objects in our application. Modify the variable definitions of the App component as follows and also refactor the application so that it still works: @@ -287,9 +293,9 @@ const App = () => { } ``` -

1.4: course information step4

+

1.4: Course Information step 4

-And then place the objects into an array. Modify the variable definitions of App into the following form and modify the other parts of the application accordingly: +Place the objects into an array. Modify the variable definitions of App into the following form and modify the other parts of the application accordingly: ```js const App = () => { @@ -335,7 +341,7 @@ const App = () => { } ``` -

1.5: course information step5

+

1.5: Course Information step 5

Let's take the changes one step further. Change the course and its parts into a single JavaScript object. Fix everything that breaks. @@ -373,7 +379,7 @@ const App = () => { ### Object methods and "this" -Due to the fact that during this course we are using a version of React containing React hooks we have no need for defining objects with methods. **The contents of this chapter are not relevant to the course** but are certainly in many ways good to know. In particular when using older versions of React one must understand the topics of this chapter. +Because this course uses a version of React containing React Hooks we do not need to define objects with methods. **The contents of this chapter are not relevant to the course** but are certainly in many ways good to know. In particular, when using older versions of React one must understand the topics of this chapter. Arrow functions and functions defined using the _function_ keyword vary substantially when it comes to how they behave with respect to the keyword [this](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this), which refers to the object itself. @@ -451,9 +457,9 @@ const referenceToGreet = arto.greet referenceToGreet() // prints "hello, my name is undefined" ``` -When calling the method through a reference the method loses knowledge of what was the original _this_. Contrary to other languages, in Javascript the value of [this](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this) is defined based on how the method is called. When calling the method through a reference the value of _this_ becomes the so-called [global object](https://developer.mozilla.org/en-US/docs/Glossary/Global_object) and the end result is often not what the software developer had originally intended. +When calling the method through a reference, the method loses knowledge of what the original _this_ was. Contrary to other languages, in JavaScript the value of [this](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this) is defined based on how the method is called. When calling the method through a reference, the value of _this_ becomes the so-called [global object](https://developer.mozilla.org/en-US/docs/Glossary/Global_object) and the end result is often not what the software developer had originally intended. -Losing track of _this_ when writing JavaScript code brings forth a few potential issues. Situations often arise where React or Node (or more specifically the JavaScript engine of the web browser) needs to call some method in an object that the developer has defined. However, in this course we avoid these issues by using the "this-less" JavaScript. +Losing track of _this_ when writing JavaScript code brings forth a few potential issues. Situations often arise where React or Node (or more specifically the JavaScript engine of the web browser) needs to call some method in an object that the developer has defined. However, in this course, we avoid these issues by using "this-less" JavaScript. One situation leading to the "disappearance" of _this_ arises when we set a timeout to call the _greet_ function on the _arto_ object, using the [setTimeout](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout) function. @@ -480,11 +486,11 @@ Calling arto.greet.bind(arto) creates a new function where _this_ is bo Using [arrow functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions) it is possible to solve some of the problems related to _this_. They should not, however, be used as methods for objects because then _this_ does not work at all. We will come back later to the behavior of _this_ in relation to arrow functions. -If you want to gain a better understanding of how _this_ works in JavaScript, the internet is full of material about the topic, e.g. the screen cast series [Understand JavaScript's this Keyword in Depth](https://egghead.io/courses/understand-javascript-s-this-keyword-in-depth) by [egghead.io](https://egghead.io) is highly recommended! +If you want to gain a better understanding of how _this_ works in JavaScript, the Internet is full of material about the topic, e.g. the screencast series [Understand JavaScript's this Keyword in Depth](https://egghead.io/courses/understand-javascript-s-this-keyword-in-depth) by [egghead.io](https://egghead.io) is highly recommended! ### Classes -As mentioned previously, there is no class mechanism like the ones in object-oriented programming languages. There are, however, features in JavaScript which make "simulating" object-oriented [classes](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes) possible. +As mentioned previously, there is no class mechanism in JavaScript like the ones in object-oriented programming languages. There are, however, features to make "simulating" object-oriented [classes](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes) possible. Let's take a quick look at the class syntax that was introduced into JavaScript with ES6, which substantially simplifies the definition of classes (or class-like things) in JavaScript. @@ -501,28 +507,32 @@ class Person { } } -const adam = new Person('Adam Ondra', 35) +const adam = new Person('Adam Ondra', 29) adam.greet() -const janja = new Person('Janja Garnbret', 22) +const janja = new Person('Janja Garnbret', 23) janja.greet() ``` -When it comes to syntax, the classes and the objects created from them are very reminiscent of Java classes and objects. Their behavior is also quite similar to Java objects. At the core they are still objects based on JavaScript's [prototypal inheritance](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Inheritance). The type of both objects is actually _Object_, since JavaScript essentially only defines the types [Boolean, Null, Undefined, Number, String, Symbol, and Object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures). +When it comes to syntax, JavaScript classes and the instances created from them are very reminiscent of how classes and objects work in Java. Their behavior is also quite similar to Java objects. At their core, however, they are still plain JavaScript objects built on [prototypal inheritance](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Inheritance). The type of any such class instance is still _Object_, because JavaScript fundamentally defines only a limited set of types: [Boolean, Null, Undefined, Number, String, Symbol, BigInt, and Object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures). -The introduction of the class syntax was a controversial addition. Check out [Not Awesome: ES6 Classes](https://github.com/petsel/not-awesome-es6-classes) or [Is “Class” In ES6 The New “Bad” Part?](https://medium.com/@rajaraodv/is-class-in-es6-the-new-bad-part-6c4e6fe1ee65) for more details. +The introduction of the class syntax was a controversial addition. Check out [Not Awesome: ES6 Classes](https://github.com/petsel/not-awesome-es6-classes) or [Is “Class” In ES6 The New “Bad” Part? on Medium](https://medium.com/@rajaraodv/is-class-in-es6-the-new-bad-part-6c4e6fe1ee65) for more details. -The ES6 class syntax is used a lot in "old" React and also in Node.js, hence an understanding of it being beneficial even in this course. However, since we are using the new [hooks](https://reactjs.org/docs/hooks-intro.html) feature of React throughout this course, we have no concrete use for JavaScript's class syntax. +The ES6 class syntax is used a lot in "old" React and also in Node.js, hence an understanding of it is beneficial even in this course. However, since we are using the new [Hooks](https://react.dev/reference/react/hooks) feature of React throughout this course, we have no concrete use for JavaScript's class syntax. ### JavaScript materials -There exists both good and poor guides for JavaScript on the internet. Most of the links on this page relating to JavaScript features reference [Mozilla's Javascript Guide](https://developer.mozilla.org/en-US/docs/Web/JavaScript). +There exist both good and poor guides for JavaScript on the Internet. Most of the links on this page relating to JavaScript features reference [Mozilla's JavaScript Guide](https://developer.mozilla.org/en-US/docs/Web/JavaScript). -It is highly recommended to immediately read [A re-introduction to JavaScript (JS tutorial)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/A_re-introduction_to_JavaScript) on Mozilla's website. +It is highly recommended to immediately read [JavaScript language overview](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Language_overview) on Mozilla's website. -If you wish to get to know JavaScript deeply there is a great free book series on the internet called [You-Dont-Know-JS](https://github.com/getify/You-Dont-Know-JS). +If you wish to get to know JavaScript deeply there is a great free book series on the Internet called [You-Dont-Know-JS](https://github.com/getify/You-Dont-Know-JS). Another great resource for learning JavaScript is [javascript.info](https://javascript.info). + +The free and highly engaging book [Eloquent JavaScript](https://eloquentjavascript.net) takes you from the basics to interesting stuff quickly. It is a mixture of theory projects and exercises and covers general programming theory as well as the JavaScript language. + +[Namaste 🙏 JavaScript](https://www.youtube.com/playlist?list=PLlasXeu85E9cQ32gLCvAvr9vNaUccPVNP) is another great and highly recommended free JavaScript tutorial in order to understand how JS works under the hood. Namaste JavaScript is a pure in-depth JavaScript course released for free on YouTube. It will cover the core concepts of JavaScript in detail and everything about how JS works behind the scenes inside the JavaScript engine. [egghead.io](https://egghead.io) has plenty of quality screencasts on JavaScript, React, and other interesting topics. Unfortunately, some of the material is behind a paywall. diff --git a/src/content/1/en/part1c.md b/src/content/1/en/part1c.md index c53adb1ba3c..75280009ad9 100644 --- a/src/content/1/en/part1c.md +++ b/src/content/1/en/part1c.md @@ -60,17 +60,17 @@ const Hello = (props) => { } ``` -The logic for guessing the year of birth is separated into its own function that is called when the component is rendered. +The logic for guessing the year of birth is encapsulated within a function of its own, which is invoked when the component is rendered. -The person's age does not have to be passed as a parameter to the function, since it can directly access all props that are passed to the component. +The person's age does not need to be explicitly passed as a parameter to this function because the function can directly access all the props provided to the component. -If we examine our current code closely, we'll notice that the helper function is actually defined inside of another function that defines the behavior of our component. In Java programming, defining a function inside another one is complex and cumbersome, so not all that common. In JavaScript, however, defining functions within functions is a commonly-used technique. +If we examine the current code, we notice that the helper function is defined within another function that determines the component's behavior. In Java programming, defining a function within another function can be complex and is uncommon. However, in JavaScript, defining functions within functions is a common and efficient practice. ### Destructuring Before we move forward, we will take a look at a small but useful feature of the JavaScript language that was added in the ES6 specification, that allows us to [destructure](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) values from objects and arrays upon assignment. -In our previous code, we had to reference the data passed to our component as _props.name_ and _props.age_. Of these two expressions we had to repeat _props.age_ twice in our code. +In our previous code, we had to reference the data passed to our component as _props.name_ and _props.age_. Of these two expressions, we had to repeat _props.age_ twice in our code. Since props is an object @@ -90,11 +90,11 @@ const Hello = (props) => { const age = props.age // highlight-end - const bornYear = () => new Date().getFullYear() - age + const bornYear = () => new Date().getFullYear() - age // highlight-line return (
-

Hello {name}, you are {age} years old

+

Hello {name}, you are {age} years old

// highlight-line

So you were probably born in {bornYear()}

) @@ -104,6 +104,7 @@ const Hello = (props) => { Note that we've also utilized the more compact syntax for arrow functions when defining the _bornYear_ function. As mentioned earlier, if an arrow function consists of a single expression, then the function body does not need to be written inside of curly braces. In this more compact form, the function simply returns the result of the single expression. To recap, the two function definitions shown below are equivalent: + ```js const bornYear = () => new Date().getFullYear() - age @@ -112,7 +113,7 @@ const bornYear = () => { } ``` -Destructuring makes the assignment of variables even easier, since we can use it to extract and gather the values of an object's properties into separate variables: +Destructuring makes the assignment of variables even easier since we can use it to extract and gather the values of an object's properties into separate variables: ```js const Hello = (props) => { @@ -130,8 +131,8 @@ const Hello = (props) => { } ``` - -If the object we are destructuring has the values +When the object that we are destructuring has the values + ```js props = { name: 'Arto Hellas', @@ -142,6 +143,7 @@ props = { the expression const { name, age } = props assigns the values 'Arto Hellas' to _name_ and 35 to _age_. We can take destructuring a step further: + ```js const Hello = ({ name, age }) => { // highlight-line const bornYear = () => new Date().getFullYear() - age @@ -157,9 +159,9 @@ const Hello = ({ name, age }) => { // highlight-line } ``` -The props that are passed to the component are now directly destructured into the variables _name_ and _age_. +The props that are passed to the component are now directly destructured into the variables, _name_ and _age_. -This means that instead of assigning the entire props object into a variable called props and then assigning its properties into the variables _name_ and _age_ +This means that instead of assigning the entire props object into a variable called props and then assigning its properties to the variables _name_ and _age_ ```js const Hello = (props) => { @@ -174,9 +176,9 @@ const Hello = ({ name, age }) => { ### Page re-rendering -So far all of our applications have been such that their appearance remains the same after the initial rendering. What if we wanted to create a counter where the value increased as a function of time or at the click of a button? +Up to this point, our applications have been static — their appearance remains unchanged after the initial rendering. But what if we wanted to create a counter that increases in value, either over time or when a button is clicked? -Let's start with the following: +Let's start with the following. File App.jsx becomes: ```js const App = (props) => { @@ -186,11 +188,20 @@ const App = (props) => { ) } +export default App +``` + +And file main.jsx becomes: + +```js +import ReactDOM from 'react-dom/client' + +import App from './App' + let counter = 1 -ReactDOM.render( - , - document.getElementById('root') +ReactDOM.createRoot(document.getElementById('root')).render( + ) ``` @@ -200,21 +211,17 @@ The App component is given the value of the counter via the _counter_ prop. This counter += 1 ``` -the component won't re-render. We can get the component to re-render by calling the _ReactDOM.render_ method a second time, e.g. in the following way: +the component won't re-render. We can get the component to re-render by calling the _render_ method a second time, e.g. in the following way: ```js -const App = (props) => { - const { counter } = props - return ( -
{counter}
- ) -} - let counter = 1 +const root = ReactDOM.createRoot(document.getElementById('root')) + const refresh = () => { - ReactDOM.render(, - document.getElementById('root')) + root.render( + + ) } refresh() @@ -226,7 +233,7 @@ refresh() The re-rendering command has been wrapped inside of the _refresh_ function to cut down on the amount of copy-pasted code. -Now the component renders three times, first with the value 1, then 2, and finally 3. However, the values 1 and 2 are displayed on the screen for such a short amount of time that they can't be noticed. +Now the component renders three times, first with the value 1, then 2, and finally 3. However, values 1 and 2 are displayed on the screen for such a short amount of time that they can't be noticed. We can implement slightly more interesting functionality by re-rendering and incrementing the counter every second by using [setInterval](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setInterval): @@ -237,19 +244,28 @@ setInterval(() => { }, 1000) ``` -Making repeated calls to the _ReactDOM.render_ method is not the recommended way to re-render components. Next, we'll introduce a better way of accomplishing this effect. +Making repeated calls to the _render_ method is not the recommended way to re-render components. Next, we'll introduce a better way of accomplishing this effect. ### Stateful component All of our components up till now have been simple in the sense that they have not contained any state that could change during the lifecycle of the component. -Next, let's add state to our application's App component with the help of React's [state hook](https://reactjs.org/docs/hooks-state.html). +Next, let's add state to our application's App component with the help of React's [state hook](https://react.dev/learn/state-a-components-memory). -We will change the application to the following: +We will change the application as follows. main.jsx goes back to: ```js -import React, { useState } from 'react' // highlight-line -import ReactDOM from 'react-dom' +import ReactDOM from 'react-dom/client' + +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +and App.jsx changes to the following: + +```js +import { useState } from 'react' // highlight-line const App = () => { const [ counter, setCounter ] = useState(0) // highlight-line @@ -266,16 +282,13 @@ const App = () => { ) } -ReactDOM.render( - , - document.getElementById('root') -) +export default App ``` -In the first row, the application imports the _useState_ function: +In the first row, the file imports the _useState_ function: ```js -import React, { useState } from 'react' +import { useState } from 'react' ``` The function body that defines the component begins with the function call: @@ -284,9 +297,9 @@ The function body that defines the component begins with the function call: const [ counter, setCounter ] = useState(0) ``` -The function call adds state to the component and renders it initialized with the value of zero. The function returns an array that contains two items. We assign the items to the variables _counter_ and _setCounter_ by using the destructuring assignment syntax shown earlier. +The function call adds state to the component and renders it initialized with the value zero. The function returns an array that contains two items. We assign the items to the variables _counter_ and _setCounter_ by using the destructuring assignment syntax shown earlier. -The _counter_ variable is assigned the initial value of state which is zero. The variable _setCounter_ is assigned to a function that will be used to modify the state. +The _counter_ variable is assigned the initial value of state, which is zero. The variable _setCounter_ is assigned a function that will be used to modify the state. The application calls the [setTimeout](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout) function and passes it two parameters: a function to increment the counter state and a timeout of one second: @@ -306,7 +319,7 @@ The function passed as the first parameter to the _setTimeout_ function is invok When the state modifying function _setCounter_ is called, React re-renders the component which means that the function body of the component function gets re-executed: ```js -(props) => { +() => { const [ counter, setCounter ] = useState(0) setTimeout( @@ -320,14 +333,15 @@ When the state modifying function _setCounter_ is called, React re-renders th } ``` -The second time the component function is executed it calls the _useState_ function and returns the new value of the state: 1. Executing the function body again also makes a new function call to _setTimeout_, which executes the one second timeout and increments the _counter_ state again. Because the value of the _counter_ variable is 1, incrementing the value by 1 is essentially the same as an expression setting the value of _counter_ to 2. +The second time the component function is executed it calls the _useState_ function and returns the new value of the state: 1. Executing the function body again also makes a new function call to _setTimeout_, which executes the one-second timeout and increments the _counter_ state again. Because the value of the _counter_ variable is 1, incrementing the value by 1 is essentially the same as an expression setting the value of _counter_ to 2. ```js () => setCounter(2) ``` + Meanwhile, the old value of _counter_ - "1" - is rendered to the screen. -Every time the _setCounter_ modifies the state it causes the component to re-render. The value of the state will be incremented again after one second, and this will continue to repeat for as long as the application is running. +Every time the _setCounter_ modifies the state it causes the component to re-render. The value of the state will be incremented again after one second, and this will continue to repeat for as long as the application is running. If the component doesn't render when you think it should, or if it renders at the "wrong time", you can debug the application by logging the values of the component's variables to the console. If we make the following additions to our code: @@ -348,19 +362,21 @@ const App = () => { } ``` -It's easy to follow and track the calls made to the _render_ function: +It's easy to follow and track the calls made to the App component's render function: -![](../../images/1/4e.png) +![screenshot of rendering log on dev tools](../../images/1/4e.png) + +Was your browser console open? If it wasn't, then promise that this was the last time you need to be reminded about it. ### Event handling -We have already mentioned event handlers a few times in [part 0](/en/part0), that are registered to be called when specific events occur. E.g. a user's interaction with the different elements of a web page can cause a collection of various different kinds of events to be triggered. +We have already mentioned the event handlers that are registered to be called when specific events occur a few times in [part 0](/en/part0). A user's interaction with the different elements of a web page can cause a collection of various kinds of events to be triggered. Let's change the application so that increasing the counter happens when a user clicks a button, which is implemented with the [button](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button) element. -Button elements support so-called [mouse events](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent), of which [click](https://developer.mozilla.org/en-US/docs/Web/Events/click) is the most common event. +Button elements support so-called [mouse events](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent), of which [click](https://developer.mozilla.org/en-US/docs/Web/Events/click) is the most common event. The click event on a button can also be triggered with the keyboard or a touch screen despite the name mouse event. -In React, registering an event handler function to the click event [happens](https://reactjs.org/docs/handling-events.html) like this: +In React, [registering an event handler function](https://react.dev/learn/responding-to-events) to the click event happens like this: ```js const App = () => { @@ -407,6 +423,7 @@ const App = () => { ``` By changing the event handler to the following form + ```js ``` - This would completely break our application: -![](../../images/1/5b.png) +![screenshot of re-renders error](../../images/1/5c.png) - -What's going on? An event handler is supposed to be either a function or a function reference, and when we write +What's going on? An event handler is supposed to be either a function or a function reference, and when we write: ```js ``` - -Now the button's attribute which defines what happens when the button is clicked - onClick - has the value _() => setCounter(counter +1)_. -The setCounter function is called only when a user clicks the button. +Now the button's attribute which defines what happens when the button is clicked - onClick - has the value _() => setCounter(counter + 1)_. +The setCounter function is called only when a user clicks the button. - -Usually defining event handlers within JSX-templates is not a good idea. -Here it's ok, because our event handlers are so simple. +Usually defining event handlers within JSX-templates is not a good idea. +Here it's ok, because our event handlers are so simple. - -Let's separate the event handlers into separate functions anyway: +Let's separate the event handlers into separate functions anyway: ```js const App = () => { @@ -522,8 +527,7 @@ const App = () => { } ``` - -Here the event handlers have been defined correctly. The value of the onClick attribute is a variable containing a reference to a function: +Here, the event handlers have been defined correctly. The value of the onClick attribute is a variable containing a reference to a function: ```js ``` -### Passing state to child components +### Passing state - to child components It's recommended to write React components that are small and reusable across the application and even across projects. Let's refactor our application so that it's composed of three smaller components, one component for displaying the counter and two components for buttons. Let's first implement a Display component that's responsible for displaying the value of the counter. -One best practice in React is to [lift the state up](https://reactjs.org/docs/lifting-state-up.html) in the component hierarchy. The documentation says: +One best practice in React is to [lift the state up](https://react.dev/learn/sharing-state-between-components) in the component hierarchy. The documentation says: > Often, several components need to reflect the same changing data. We recommend lifting the shared state up to their closest common ancestor. @@ -581,7 +585,7 @@ Next, let's make a Button component for the buttons of our application. W ```js const Button = (props) => { return ( - ) @@ -595,7 +599,9 @@ const App = () => { const [ counter, setCounter ] = useState(0) const increaseByOne = () => setCounter(counter + 1) + //highlight-start const decreaseByOne = () => setCounter(counter - 1) + //highlight-end const setToZero = () => setCounter(0) return ( @@ -603,15 +609,15 @@ const App = () => { // highlight-start
+ ) +} +``` + +Let us now see what gets rendered to the console when the buttons plus, zero and minus are pressed: + +![browser showing console with rendering values highlighted](../../images/1/31.png) + +Do not ever try to guess what your code does. It is just better to use _console.log_ and see with your own eyes what it does. + ### Refactoring the components - The component displaying the value of the counter is as follows: ```js @@ -655,8 +698,7 @@ const Display = (props) => { } ``` - -The component only uses the _counter_ field of its props. +The component only uses the _counter_ field of its props. This means we can simplify the component by using [destructuring](/en/part1/component_state_event_handlers#destructuring), like so: ```js @@ -667,36 +709,30 @@ const Display = ({ counter }) => { } ``` - -The method defining the component contains only the return statement, so -we can define the method using the more compact form of arrow functions: +The function defining the component contains only the return statement, so we can define the function using the more compact form of arrow functions: ```js const Display = ({ counter }) =>
{counter}
``` - We can simplify the Button component as well. ```js const Button = (props) => { return ( - ) } ``` - We can use destructuring to get only the required fields from props, and use the more compact form of arrow functions: ```js -const Button = ({ handleClick, text }) => ( - -) +const Button = ({ onClick, text }) => ``` +This approach works because the component contains only a single return statement, making it possible to use the concise arrow function syntax. + diff --git a/src/content/1/en/part1d.md b/src/content/1/en/part1d.md index d4b0f7a5fcc..57ff1550dac 100644 --- a/src/content/1/en/part1d.md +++ b/src/content/1/en/part1d.md @@ -9,29 +9,27 @@ lang: en ### Complex state -In our previous example the application state was simple as it was comprised of a single integer. What if our application requires a more complex state? +In our previous example, the application state was simple as it was comprised of a single integer. What if our application requires a more complex state? -In most cases the easiest and best way to accomplish this is by using the _useState_ function multiple times to create separate "pieces" of state. +In most cases, the easiest and best way to accomplish this is by using the _useState_ function multiple times to create separate "pieces" of state. In the following code we create two pieces of state for the application named _left_ and _right_ that both get the initial value of 0: ```js -const App = (props) => { +const App = () => { const [left, setLeft] = useState(0) const [right, setRight] = useState(0) return (
-
- {left} - - - {right} -
+ {left} + + + {right}
) } @@ -40,6 +38,7 @@ const App = (props) => { The component gets access to the functions _setLeft_ and _setRight_ that it can use to update the two pieces of state. The component's state or a piece of its state can be of any type. We could implement the same functionality by saving the click count of both the left and right buttons into a single object: + ```js { left: 0, @@ -47,10 +46,10 @@ The component's state or a piece of its state can be of any type. We could imple } ``` -In this case the application would look like this: +In this case, the application would look like this: ```js -const App = (props) => { +const App = () => { const [clicks, setClicks] = useState({ left: 0, right: 0 }) @@ -73,12 +72,10 @@ const App = (props) => { return (
-
- {clicks.left} - - - {clicks.right} -
+ {clicks.left} + + + {clicks.right}
) } @@ -87,6 +84,7 @@ const App = (props) => { Now the component only has a single piece of state and the event handlers have to take care of changing the entire application state. The event handler looks a bit messy. When the left button is clicked, the following function is called: + ```js const handleLeftClick = () => { const newClicks = { @@ -98,6 +96,7 @@ const handleLeftClick = () => { ``` The following object is set as the new state of the application: + ```js { left: clicks.left + 1, @@ -105,10 +104,9 @@ The following object is set as the new state of the application: } ``` -The new value of the left property is now the same as the value of left + 1 from the previous state, and the value of the right property is the same as value of the right property from the previous state. +The new value of the left property is now the same as the value of left + 1 from the previous state, and the value of the right property is the same as the value of the right property from the previous state. -We can define the new state object a bit more neatly by using the [object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) -syntax that was added to the language specification in the summer of 2018: +We can define the new state object a bit more neatly by using the [object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) syntax that was added to the language specification in the summer of 2018: ```js const handleLeftClick = () => { @@ -157,18 +155,18 @@ const handleLeftClick = () => { } ``` -The application appears to work. However, it is forbidden in React to mutate state directly, since it can result in unexpected side effects. Changing state has to always be done by setting the state to a new object. If properties from the previous state object are not changed, they need to simply be copied, which is done by copying those properties into a new object, and setting that as the new state. +The application appears to work. However, it is forbidden in React to mutate state directly, since [it can result in unexpected side effects](https://stackoverflow.com/a/40309023). Changing state has to always be done by setting the state to a new object. If properties from the previous state object are not changed, they need to simply be copied, which is done by copying those properties into a new object and setting that as the new state. -Storing all of the state in a single state object is a bad choice for this particular application; there's no apparent benefit and the resulting application is a lot more complex. In this case storing the click counters into separate pieces of state is a far more suitable choice. +Storing all of the state in a single state object is a bad choice for this particular application; there's no apparent benefit and the resulting application is a lot more complex. In this case, storing the click counters into separate pieces of state is a far more suitable choice. -There are situations where it can be beneficial to store a piece of application state in a more complex data structure.[The official React documentation](https://reactjs.org/docs/hooks-faq.html#should-i-use-one-or-many-state-variables) contains some helpful guidance on the topic. +There are situations where it can be beneficial to store a piece of application state in a more complex data structure. [The official React documentation](https://react.dev/learn/choosing-the-state-structure) contains some helpful guidance on the topic. ### Handling arrays Let's add a piece of state to our application containing an array _allClicks_ that remembers every click that has occurred in the application. ```js -const App = (props) => { +const App = () => { const [left, setLeft] = useState(0) const [right, setRight] = useState(0) const [allClicks, setAll] = useState([]) // highlight-line @@ -189,19 +187,17 @@ const App = (props) => { return (
-
- {left} - - - {right} -

{allClicks.join(' ')}

// highlight-line -
+ {left} + + + {right} +

{allClicks.join(' ')}

// highlight-line
) } ``` -Every click is stored into a separate piece of state called _allClicks_ that is initialized as an empty array: +Every click is stored in a separate piece of state called _allClicks_ that is initialized as an empty array: ```js const [allClicks, setAll] = useState([]) @@ -216,7 +212,7 @@ const handleLeftClick = () => { } ``` -The piece of state stored in _allClicks_ is now set to be an array that contains all of the items of the previous state array plus the letter L. Adding the new item to the array is accomplished with the [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat) method, that does not mutate the existing array but rather returns a new copy of the array with the item added to it. +The piece of state stored in _allClicks_ is now set to be an array that contains all of the items of the previous state array plus the letter L. Adding the new item to the array is accomplished with the [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat) method, which does not mutate the existing array but rather returns a new copy of the array with the item added to it. As mentioned previously, it's also possible in JavaScript to add items to an array with the [push](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push) method. If we add the item by pushing it to the _allClicks_ array and then updating the state, the application would still appear to work: @@ -228,35 +224,140 @@ const handleLeftClick = () => { } ``` -However, __don't__ do this. As mentioned previously, the state of React components like _allClicks_ must not be mutated directly. Even if mutating state appears to work in some cases, it can lead to problems that are very hard to debug. +However, __don't__ do this. As mentioned previously, the state of React components, like _allClicks_, must not be mutated directly. Even if mutating state appears to work in some cases, it can lead to problems that are very hard to debug. -Let's take a closer look at how the clicking history is rendered to the page: +Let's take a closer look at how the clicking is rendered to the page: ```js -const App = (props) => { +const App = () => { // ... return (
-
- {left} - - - {right} -

{allClicks.join(' ')}

// highlight-line -
+ {left} + + + {right} +

{allClicks.join(' ')}

// highlight-line
) } ``` -We call the [join](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/join) method on the _allClicks_ array that joins all the items into a single string, separated by the string passed as the function parameter, which in our case is an empty space. +We call the [join](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/join) method on the _allClicks_ array, that joins all the items into a single string, separated by the string passed as the function parameter, which in our case is an empty space. + +### Update of the state is asynchronous + +Let's expand the application so that it keeps track of the total number of button presses in the state _total_, whose value is always updated when the buttons are pressed: + +```js +const App = () => { + const [left, setLeft] = useState(0) + const [right, setRight] = useState(0) + const [allClicks, setAll] = useState([]) + const [total, setTotal] = useState(0) // highlight-line + + const handleLeftClick = () => { + setAll(allClicks.concat('L')) + setLeft(left + 1) + setTotal(left + right) // highlight-line + } + + const handleRightClick = () => { + setAll(allClicks.concat('R')) + setRight(right + 1) + setTotal(left + right) // highlight-line + } + + return ( +
+ {left} + + + {right} +

{allClicks.join(' ')}

+

total {total}

// highlight-line +
+ ) +} +``` + +The solution does not quite work: + +![browser showing 2 left|right 1, RLL total 2](../../images/1/33.png) + +The total number of button presses is consistently one less than the actual amount of presses, for some reason. + +Let us add couple of console.log statements to the event handler: + +```js +const App = () => { + // ... + const handleLeftClick = () => { + setAll(allClicks.concat('L')) + console.log('left before', left) // highlight-line + setLeft(left + 1) + console.log('left after', left) // highlight-line + setTotal(left + right) + } + + // ... +} +``` + +The console reveals the problem + +![devtools console showing left before 4 and left after 4](../../images/1/32.png) + +Even though a new value was set for _left_ by calling _setLeft(left + 1)_, the old value persists despite the update. As a result, the attempt to count button presses produces a result that is too small: + +```js +setTotal(left + right) +``` + +The reason for this is that a state update in React happens [asynchronously](https://react.dev/learn/queueing-a-series-of-state-updates), i.e. not immediately but "at some point" before the component is rendered again. + +We can fix the app as follows: + +```js +const App = () => { + // ... + const handleLeftClick = () => { + setAll(allClicks.concat('L')) + const updatedLeft = left + 1 + setLeft(updatedLeft) + setTotal(updatedLeft + right) + } + + // ... +} +``` + +So now the number of button presses is definitely based on the correct number of left button presses. + +We can also handle asynchronous updates for the right button: + +```js +const App = () => { + // ... + const handleRightClick = () => { + setAll(allClicks.concat('R')); + const updatedRight = right + 1; + setRight(updatedRight); + setTotal(left + updatedRight); + }; + + // ... +} +``` + ### Conditional rendering Let's modify our application so that the rendering of the clicking history is handled by a new History component: ```js +// highlight-start const History = (props) => { if (props.allClicks.length === 0) { return ( @@ -272,19 +373,18 @@ const History = (props) => { ) } +// highlight-end -const App = (props) => { +const App = () => { // ... return (
-
- {left} - - - {right} - // highlight-line -
+ {left} + + + {right} + // highlight-line
) } @@ -306,7 +406,7 @@ And in all other cases, the component renders the clicking history: The History component renders completely different React elements depending on the state of the application. This is called conditional rendering. -React also offers many other ways of doing [conditional rendering](https://reactjs.org/docs/conditional-rendering.html). We will take a closer look at this in [part 2](/en/part2). +React also offers many other ways of doing [conditional rendering](https://react.dev/learn/conditional-rendering). We will take a closer look at this in [part 2](/en/part2). Let's make one last modification to our application by refactoring it to use the _Button_ component that we defined earlier on: @@ -327,15 +427,9 @@ const History = (props) => { ) } -// highlight-start -const Button = ({ onClick, text }) => ( - -) -// highlight-end +const Button = ({ onClick, text }) => // highlight-line -const App = (props) => { +const App = () => { const [left, setLeft] = useState(0) const [right, setRight] = useState(0) const [allClicks, setAll] = useState([]) @@ -352,15 +446,13 @@ const App = (props) => { return (
-
- {left} - // highlight-start -
+ {left} + // highlight-start +
) } @@ -368,15 +460,15 @@ const App = (props) => { ### Old React -In this course we use the [state hook](https://reactjs.org/docs/hooks-state.html) to add state to our React components, which is part of the newer versions of React and is available from version [16.8.0](https://www.npmjs.com/package/react/v/16.8.0) onwards. Before the addition of hooks, there was no way to add state to functional components. Components that required state had to be defined as [class](https://reactjs.org/docs/react-component.html) components, using the JavaScript class syntax. +In this course, we use the [state hook](https://react.dev/learn/state-a-components-memory) to add state to our React components, which is part of the newer versions of React and is available from version [16.8.0](https://www.npmjs.com/package/react/v/16.8.0) onwards. Before the addition of hooks, there was no way to add state to functional components. Components that required state had to be defined as [class](https://react.dev/reference/react/Component) components, using the JavaScript class syntax. -In this course we have made the slightly radical decision to use hooks exclusively from day one, to ensure that we are learning the future style of React. Even though functional components are the future of React, it is still important to learn the class syntax, as there are billions of lines of old React code that you might end up maintaining some day. The same applies to documentation and examples of React that you may stumble across on the internet. +In this course, we have made the slightly radical decision to use hooks exclusively from day one, to ensure that we are learning the current and future variations of React. Even though functional components are the future of React, it is still important to learn the class syntax, as there are billions of lines of legacy React code that you might end up maintaining someday. The same applies to documentation and examples of React that you may stumble across on the internet. We will learn more about React class components later on in the course. ### Debugging React applications -A large part of a typical developer's time is spent on debugging and reading existing code. Every now and then we do get to write a line or two of new code, but a large part of our time is spent on trying to figure out why something is broken or how something works. Good practices and tools for debugging are extremely important for this reason. +A large part of a typical developer's time is spent on debugging and reading existing code. Every now and then we do get to write a line or two of new code, but a large part of our time is spent trying to figure out why something is broken or how something works. Good practices and tools for debugging are extremely important for this reason. Lucky for us, React is an extremely developer-friendly library when it comes to debugging. @@ -384,7 +476,7 @@ Before we move on, let us remind ourselves of one of the most important rules of

The first rule of web development

-> **Keep the browser's developer console open at all times.** +> **Keep the browser's developer console open at all times.** > > The Console tab in particular should always be open, unless there is a specific reason to view another tab. @@ -392,18 +484,14 @@ Keep both your code and the web page open together **at the same time, all the t If and when your code fails to compile and your browser lights up like a Christmas tree: -![](../../images/1/6e.png) +![screenshot of error pointing at the code line where it has been generated](../../images/1/6x.png) don't write more code but rather find and fix the problem **immediately**. There has yet to be a moment in the history of coding where code that fails to compile would miraculously start working after writing large amounts of additional code. I highly doubt that such an event will transpire during this course either. -Old school, print-based debugging is always a good idea. If the component +Old-school, print-based debugging is always a good idea. If the component ```js -const Button = ({ onClick, text }) => ( - -) +const Button = ({ onClick, text }) => ``` is not working as intended, it's useful to start printing its variables out to the console. In order to do this effectively, we must transform our function into the less compact form and receive the entire props object without destructuring it immediately: @@ -422,55 +510,49 @@ const Button = (props) => { This will immediately reveal if, for instance, one of the attributes has been misspelled when using the component. -**NB** When you use _console.log_ for debugging, don't combine _objects_ in a Java-like fashion by using the plus operator. Instead of writing: +**NB** When you use _console.log_ for debugging, don't combine _objects_ in a Java-like fashion by using the plus operator: ```js console.log('props value is ' + props) ``` - -Separate the things you want to log to the console with a comma: + +If you do that, you will end up with a rather uninformative log message: ```js -console.log('props value is', props) +props value is [object Object] ``` -If you use the Java-like way of concatenating a string with an object, you will end up with a rather uninformative log message: +Instead, separate the things you want to log to the console with a comma: ```js -props value is [Object object] +console.log('props value is', props) ``` -Whereas the items separated by a comma will all be available in the browser console for further inspection. +In this way, the separated items will all be available in the browser console for further inspection. -Logging to the console is by no means the only way of debugging our applications. You can pause the execution of your application code in the Chrome developer console's debugger, by writing the command [debugger](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/debugger) anywhere in your code. +Logging output to the console is by no means the only way of debugging our applications. You can pause the execution of your application code in the Chrome developer console's debugger, by writing the command [debugger](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/debugger) anywhere in your code. The execution will pause once it arrives at a point where the _debugger_ command gets executed: -![](../../images/1/7a.png) +![debugger paused in dev tools](../../images/1/7a.png) By going to the Console tab, it is easy to inspect the current state of variables: -![](../../images/1/8a.png) +![console inspection screenshot](../../images/1/8a.png) Once the cause of the bug is discovered you can remove the _debugger_ command and refresh the page. -The debugger also enables us to execute our code line by line with the controls found in the right-hand side of the Source tab. - -You can also access the debugger without the _debugger_ command by adding break points in the Sources tab. Inspecting the values of the component's variables can be done in the _Scope_-section: - -![](../../images/1/9a.png) - -It is highly recommended to add the [React developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) extension to Chrome. It adds a new _React_ tab to the developer tools: +The debugger also enables us to execute our code line by line with the controls found on the right-hand side of the Sources tab. -![](../../images/1/10e.png) +You can also access the debugger without the _debugger_ command by adding breakpoints in the Sources tab. Inspecting the values of the component's variables can be done in the _Scope_-section: -The new _React_ developer tools tab can be used to inspect the different React elements in the application, along with their state and props. +![breakpoint example in devtools](../../images/1/9a.png) -Unfortunately the current version of React developer tools leaves something to be desired when displaying component state created with hooks: +It is highly recommended to add the [React developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) extension to Chrome. It adds a new _Components_ tab to the developer tools. The new developer tools tab can be used to inspect the different React elements in the application, along with their state and props: -![](../../images/1/11e.png) +![screenshot react developer tools extension](../../images/1/10ea.png) -The component state was defined like so: +The _App_ component's state is defined like so: ```js const [left, setLeft] = useState(0) @@ -478,20 +560,24 @@ const [right, setRight] = useState(0) const [allClicks, setAll] = useState([]) ``` -Dev tools shows the state of hooks in the order of their definition: +Dev tools show the state of hooks in the order of their definition: -![](../../images/1/11be.png) +![state of hooks in react dev tools](../../images/1/11ea.png) + +The first State contains the value of the left state, the next contains the value of the right state and the last contains the value of the allClicks state. + +You can also learn about debugging JavaScript in Chrome, for example, with the [Chrome DevTools guide video](https://developer.chrome.com/docs/devtools/javascript). ### Rules of Hooks -There are a few limitations and rules we have to follow to ensure that our application uses hooks-based state functions correctly. +There are a few limitations and [rules](https://react.dev/warnings/invalid-hook-call-warning#breaking-rules-of-hooks) that we have to follow to ensure that our application uses hooks-based state functions correctly. The _useState_ function (as well as the _useEffect_ function introduced later on in the course) must not be called from inside of a loop, a conditional expression, or any place that is not a function defining a component. This must be done to ensure that the hooks are always called in the same order, and if this isn't the case the application will behave erratically. To recap, hooks may only be called from the inside of a function body that defines a React component: ```js -const App = (props) => { +const App = () => { // these are ok const [age, setAge] = useState(0) const [name, setName] = useState('Juha Tauriainen') @@ -521,11 +607,12 @@ const App = (props) => { Event handling has proven to be a difficult topic in previous iterations of this course. -For this reason we will revisit the topic. +For this reason, we will revisit the topic. + +Let's assume that we're developing this simple application with the following component App: -Let's assume that we're developing this simple application: ```js -const App = (props) => { +const App = () => { const [value, setValue] = useState(10) return ( @@ -535,11 +622,6 @@ const App = (props) => { ) } - -ReactDOM.render( - , - document.getElementById('root') -) ``` We want the clicking of the button to reset the state stored in the _value_ variable. @@ -551,7 +633,7 @@ Event handlers must always be a function or a reference to a function. The butto If we were to define the event handler as a string: ```js - + ``` React would warn us about this in the console: @@ -576,6 +658,7 @@ index.js:2178 Warning: Expected `onClick` listener to be a function, instead got ``` This attempt would not work either: + ```js ``` @@ -590,13 +673,14 @@ What about the following: ``` -The message gets printed to the console once but nothing happens when we click the button a second time. Why does this not work even when our event handler contains a function _console.log_? +The message gets printed to the console once when the component is rendered but nothing happens when we click the button. Why does this not work even when our event handler contains a function _console.log_? -The issue here is that our event handler is defined as a function call which means that the event handler is actually assigned the returned value from the function, which in the case of _console.log_ is undefined. +The issue here is that our event handler is defined as a function call which means that the event handler is assigned the returned value from the function, which in the case of _console.log_ is undefined. -The _console.log_ function call gets executed when the component is rendered and for this reason it gets printed once to the console. +The _console.log_ function call gets executed when the component is rendered and for this reason, it gets printed once to the console. The following attempt is flawed as well: + ```js ``` @@ -626,7 +710,7 @@ Defining event handlers directly in the attribute of the button is not necessari You will often see event handlers defined in a separate place. In the following version of our application we define a function that then gets assigned to the _handleClick_ variable in the body of the component function: ```js -const App = (props) => { +const App = () => { const [value, setValue] = useState(10) const handleClick = () => @@ -641,7 +725,7 @@ const App = (props) => { } ``` -The _handleClick_ variable is now assigned to a reference to the function. The reference is passed to the button as the onClick attribute: +The _handleClick_ variable, which references the function definition, is passed to the button as the onClick attribute: ```js @@ -650,7 +734,7 @@ The _handleClick_ variable is now assigned to a reference to the function. The r Naturally, our event handler function can be composed of multiple commands. In these cases we use the longer curly brace syntax for arrow functions: ```js -const App = (props) => { +const App = () => { const [value, setValue] = useState(10) // highlight-start @@ -669,16 +753,16 @@ const App = (props) => { } ``` -### Function that returns a function +### A function that returns a function -Another way to define a event handler is to use function that returns a function. +Another way to define an event handler is to use a function that returns a function. You probably won't need to use functions that return functions in any of the exercises in this course. If the topic seems particularly confusing, you may skip over this section for now and return to it later. Let's make the following changes to our code: ```js -const App = (props) => { +const App = () => { const [value, setValue] = useState(10) // highlight-start @@ -698,7 +782,7 @@ const App = (props) => { } ``` -The code functions correctly even though it looks complicated. +The code functions correctly even though it looks complicated. The event handler is now set to a function call: @@ -706,7 +790,7 @@ The event handler is now set to a function call: ``` -Earlier on we stated that an event handler may not be a call to a function, and that it has to be a function or a reference to a function. Why then does a function call work in this case? +Earlier, we stated that an event handler may not be a function call; rather, it has to either be a function definition or a reference to one. Why then does a function call work in this case? When the component is rendered, the following function gets executed: @@ -741,7 +825,7 @@ What's the point of this concept? Let's change the code a tiny bit: ```js -const App = (props) => { +const App = () => { const [value, setValue] = useState(10) // highlight-start @@ -797,7 +881,7 @@ The function call _hello('react')_ that creates the event handler returns: } ``` -Both buttons get their own individualized event handlers. +Both buttons get their individualized event handlers. Functions returning functions can be utilized in defining generic functionality that can be customized with parameters. The _hello_ function that creates the event handlers can be thought of as a factory that produces customized event handlers meant for greeting users. @@ -843,11 +927,12 @@ const hello = (who) => () => { We can use the same trick to define event handlers that set the state of the component to a given value. Let's make the following changes to our code: ```js -const App = (props) => { +const App = () => { const [value, setValue] = useState(10) // highlight-start const setToValue = (newValue) => () => { + console.log('value now', newValue) // print the new value to console setValue(newValue) } // highlight-end @@ -875,11 +960,12 @@ The event handler is set to the return value of _setToValue(1000)_ which is the ```js () => { + console.log('value now', 1000) setValue(1000) } ``` -The increase button is declared as following: +The increase button is declared as follows: ```js @@ -889,17 +975,19 @@ The event handler is created by the function call _setToValue(value + 1)_ which ```js () => { + console.log('value now', 11) setValue(11) } ``` -Using functions that return functions is not required to achieve this functionality. Let's return the _setToValue_ function that is responsible for updating state, into a normal function: +Using functions that return functions is not required to achieve this functionality. Let's return the _setToValue_ function which is responsible for updating state into a normal function: ```js -const App = (props) => { +const App = () => { const [value, setValue] = useState(10) const setToValue = (newValue) => { + console.log('value now', newValue) setValue(newValue) } @@ -934,36 +1022,51 @@ Let's extract the button into its own component: ```js const Button = (props) => ( - ) ``` -The component gets the event handler function from the _handleClick_ prop, and the text of the button from the _text_ prop. +The component gets the event handler function from the _onClick_ prop, and the text of the button from the _text_ prop. Lets use the new component: + +```js +const App = (props) => { + // ... + return ( +
+ {value} +
+ ) +} +``` Using the Button component is simple, although we have to make sure that we use the correct attribute names when passing props to the component. -![](../../images/1/12e.png) +![using correct attribute names code screenshot](../../images/1/12f.png) ### Do Not Define Components Within Components -Let's start displaying the value of the application into its own Display component. +Let's start displaying the value of the application in its Display component. -We will change the application by defining a new component inside of the App-component. +We will change the application by defining a new component inside of the App component. ```js // This is the right place to define a component const Button = (props) => ( - ) -const App = props => { +const App = () => { const [value, setValue] = useState(10) const setToValue = newValue => { + console.log('value now', newValue) setValue(newValue) } @@ -972,39 +1075,42 @@ const App = props => { return (
- -
) } ``` -The application still appears to work, but **don't implement components like this!** Never define components inside of other components. The method provides no benefits and leads to many unpleasant problems. Let's instead move the Display component function to its correct place, which is outside of the App component function: +The application still appears to work, but **don't implement components like this!** Never define components inside of other components. The method provides no benefits and leads to many unpleasant problems. The biggest problems are because React treats a component defined inside of another component as a new component in every render. This makes it impossible for React to optimize the component. + +Let's instead move the Display component function to its correct place, which is outside of the App component function: ```js const Display = props =>
{props.value}
const Button = (props) => ( - ) -const App = props => { +const App = () => { const [value, setValue] = useState(10) const setToValue = newValue => { + console.log('value now', newValue) setValue(newValue) } return (
-
) } @@ -1012,50 +1118,125 @@ const App = props => { ### Useful Reading -The internet is full of React-related material. However, we use such a new style of React that a large majority of the material found online is outdated for our purposes. +The internet is full of React-related material. However, we use the new style of React for which a large majority of the material found online is outdated. You may find the following links useful: -- The React [official documentation](https://reactjs.org/docs/hello-world.html) is worth checking out at some point, although most of it will become relevant only later on in the course. Also, everything related to class-based components is irrelevant to us; -- Some courses on [Egghead.io](https://egghead.io) like [Start learning React](https://egghead.io/courses/start-learning-react) are of high quality, and recently updated [The Beginner's Guide to React](https://egghead.io/courses/the-beginner-s-guide-to-reactjs) is also relatively good; both courses introduce concepts that will also be introduced later on in this course. **NB** The first one uses class components but the latter uses the new functional ones. +- The [official React documentation](https://react.dev/learn) is worth checking out at some point, although most of it will become relevant only later on in the course. Also, everything related to class-based components is irrelevant to us; +- Some courses on [Egghead.io](https://egghead.io) like [Start learning React](https://egghead.io/courses/start-learning-react) are of high quality, and the recently updated [Beginner's Guide to React](https://egghead.io/courses/the-beginner-s-guide-to-reactjs) is also relatively good; both courses introduce concepts that will also be introduced later on in this course. **NB** The first one uses class components but the latter uses the new functional ones. + +### Web programmers oath + +Programming is hard, that is why I will use all the possible means to make it easier + +- I will have my browser developer console open all the time +- I progress with small steps +- I will write lots of _console.log_ statements to make sure I understand how the code behaves and to help pinpointing problems +- If my code does not work, I will not write more code. Instead I will start deleting the code until it works or just return to a state when everything was still working +- When I ask for help in the course Discord channel or elsewhere I formulate my questions properly, see [here](http://fullstackopen.com/en/part0/general_info#how-to-get-help-in-discord) how to ask for help + +### Utilization of Large language models + +Large language models such as [ChatGPT](https://chat.openai.com/auth/login), [Claude](https://claude.ai/) and [GitHub Copilot](https://github.com/features/copilot) have proven to be very useful in software development. + +Personally, I mainly use GitHub Copilot, which is now [natively integrated into Visual Studio Code](https://code.visualstudio.com/docs/copilot/overview) +As a reminder, if you're a university student, you can access Copilot pro for free through the [GitHub Student Developer Pack](https://education.github.com/pack). + +Copilot is useful in a wide variety of scenarios. Copilot can be asked to generate code for an open file by describing the desired functionality in text: + +![copilot input on vscode](../../images/1/gpt1.png) + +If the code looks good, Copilot adds it to the file: + +![code added by copilot](../../images/1/gpt2.png) + +In the case of our example, Copilot only created a button, the event handler _handleResetClick_ is undefined. + +An event handler may also be generated. By writing the first line of the function, Copilot offers the functionality to be generated: + +![copilot´s code suggestion](../../images/1/gpt3.png) + +In Copilot's chat window, it is possible to ask for an explanation of the function of the painted code area: + +![copilot explaining how the selected code works in the chat window](../../images/1/gpt4.png) + +Copilot is also useful in error situations, by copying the error message into Copilot's chat, you will get an explanation of the problem and a suggested fix: + +![copilot explaining the error and suggesting a fix](../../images/1/gpt5.png) + +Copilot's chat also enables the creation of larger set of functionality + +![copilot creating a login component on request](../../images/1/gpt6.png) + +The degree of usefulness of the hints provided by Copilot and other language models varies. Perhaps the biggest problem with language models is [hallucination](https://en.wikipedia.org/wiki/Hallucination_(artificial_intelligence)), they sometimes generate completely convincing-looking answers, which, however, are completely wrong. When programming, of course, the hallucinated code is often caught quickly if the code does not work. More problematic situations are those where the code generated by the language model seems to work, but it contains more difficult to detect bugs or e.g. security vulnerabilities. + +Another problem in applying language models to software development is that it is difficult for language models to "understand" larger projects, and e.g. to generate functionality that would require changes to several files. Language models are also currently unable to generalize code, i.e. if the code has, for example, existing functions or components that the language model could use with minor changes for the requested functionality, the language model will not bend to this. The result of this can be that the code base deteriorates, as the language models generate a lot of repetition in the code, see more e.g. [here](https://visualstudiomagazine.com/articles/2024/01/25/copilot-research.aspx). + +When using language models, the responsibility always stays with the programmer. + +The rapid development of language models puts the student of programming in a challenging position: is it worth and is it even necessary to learn programming in a detailed level, when you can get almost everything ready-made from language models? + +At this point, it is worth remembering the old wisdom of [Brian Kerningham](https://en.wikipedia.org/wiki/Brian_Kernighan), co-author of *The C Programming Language*: + +![Everyone knows that debugging is twice as hard as writing a program in the first place. So if you're as clever as you can be when you write it, how will you ever debug it? ― Brian Kernighan](../../images/1/kerningham.png) + +In other words, since debugging is twice as difficult as programming, it is not worth programming such code that you can only barely understand. How can debugging be even possible in a situation where programming is outsourced to a language model and the software developer does not understand the debugged code at all? + +So far, the development of language models and artificial intelligence is still at the stage where they are not self-sufficient, and the most difficult problems are left for humans to solve. Because of this, even novice software developers must learn to program really well just in case. It may be that, despite the development of language models, even more in-depth knowledge is needed. Artificial intelligence does the easy things, but a human is needed to sort out the most complicated messes caused by AI. GitHub Copilot is a very well-named product, it's Copilot, a second pilot who helps the main pilot in an aircraft. The programmer is still the main pilot, the captain, and bears the ultimate responsibility. + +It may be in your own interest that you turn off Copilot by default when you do this course and rely on it only in a real emergency.
-

Exercises 1.6.-1.14.

-Submit your solutions to the exercises by first pushing your code to GitHub and then marking the completed exercises into the [exercise submission system](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). +

Exercises 1.6.-1.14.

-Remember, submit **all** the exercises of one part **in a single submission**. Once you have submitted your solutions for one part, **you cannot submit more exercises to that part any more**. +Submit your solutions to the exercises by first pushing your code to GitHub and then marking the completed exercises into the "my submissions" tab of the [submission application](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). -Some of the exercises work on the same application. In these cases, it is sufficient to submit just the final version of the application. If you wish, you can make a commit after every finished exercise, but it is not mandatory. +Remember, submit **all** the exercises of one part **in a single submission**. Once you have submitted your solutions for one part, **you cannot submit more exercises to that part anymore**. -**WARNING** create-react-app will automatically turn your project into a git-repository unless you create your application inside of an existing git repository. **Most likely you do not want each of your projects to be a separate repository**, so simply run the _rm -rf .git_ command at the root of your application. +Some of the exercises work on the same application. In these cases, it is sufficient to submit just the final version of the application. If you wish, you can make a commit after every finished exercise, but it is not mandatory. In some situations you may also have to run the command below from the root of the project: -``` +```bash rm -rf node_modules/ && npm i ``` -

1.6: unicafe step1

+If and when you encounter an error message + +> Objects are not valid as a React child -Like most companies, [Unicafe](https://www.unicafe.fi/#/9/4) collects feedback from its customers. Your task is to implement a web application for collecting customer feedback. There are only three options for feedback: good, neutral, and bad. +keep in mind the things told [here](/en/part1/introduction_to_react#do-not-render-objects). + +

1.6: unicafe step 1

+ +Like most companies, the student restaurant of the University of Helsinki [Unicafe](https://www.unicafe.fi) collects feedback from its customers. Your task is to implement a web application for collecting customer feedback. There are only three options for feedback: good, neutral, and bad. The application must display the total number of collected feedback for each category. Your final application could look like this: -![](../../images/1/13e.png) +![screenshot of feedback options](../../images/1/13e.png) Note that your application needs to work only during a single browser session. Once you refresh the page, the collected feedback is allowed to disappear. -You can implement the application in a single index.js file. You can use the code below as a starting point for your application. +It is advisable to use the same structure that is used in the material and previous exercise. File main.jsx is as follows: ```js -import React, { useState } from 'react' -import ReactDOM from 'react-dom' +import ReactDOM from 'react-dom/client' + +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +You can use the code below as a starting point for the App.jsx file: + +```js +import { useState } from 'react' const App = () => { - // save clicks of each button to own state + // save clicks of each button to its own state const [good, setGood] = useState(0) const [neutral, setNeutral] = useState(0) const [bad, setBad] = useState(0) @@ -1067,18 +1248,16 @@ const App = () => { ) } -ReactDOM.render(, - document.getElementById('root') -) +export default App ``` -

1.7: unicafe step2

+

1.7: unicafe step 2

-Expand your application so that it shows more statistics about the gathered feedback: the total number of collected feedback, the average score (good: 1, neutral: 0, bad: -1) and the percentage of positive feedback. +Expand your application so that it shows more statistics about the gathered feedback: the total number of collected feedback, the average score (the feedback values are: good 1, neutral 0, bad -1) and the percentage of positive feedback. -![](../../images/1/14e.png) +![average and percentage positive screenshot feedback](../../images/1/14e.png) -

1.8: unicafe step3

+

1.8: unicafe step 3

Refactor your application so that displaying the statistics is extracted into its own Statistics component. The state of the application should remain in the App root component. @@ -1106,29 +1285,30 @@ const App = () => { } ``` -

1.9: unicafe step4

+

1.9: unicafe step 4

Change your application to display statistics only once feedback has been gathered. -![](../../images/1/15e.png) +![no feedback given text screenshot](../../images/1/15e.png) -

1.10: unicafe step5

+

1.10: unicafe step 5

Let's continue refactoring the application. Extract the following two components: -- Button for defining the buttons used for submitting feedback -- Statistic for displaying a single statistic, e.g. the average score. +- Button handles the functionality of each feedback submission button. -To be clear: the Statistic component always displays a single statistic, meaning that the application uses multiple components for rendering all of the statistics: +- StatisticLine for displaying a single statistic, e.g. the average score. + +To be clear: the StatisticLine component always displays a single statistic, meaning that the application uses multiple components for rendering all of the statistics: ```js const Statistics = (props) => { /// ... return(
- - - + + + // ...
) @@ -1138,79 +1318,77 @@ const Statistics = (props) => { The application's state should still be kept in the root App component. -

1.11*: unicafe step6

+

1.11*: unicafe step 6

Display the statistics in an HTML [table](https://developer.mozilla.org/en-US/docs/Learn/HTML/Tables/Basics), so that your application looks roughly like this: -![](../../images/1/16e.png) +![screenshot of statistics table](../../images/1/16e.png) Remember to keep your console open at all times. If you see this warning in your console: -![](../../images/1/17a.png) +![console warning](../../images/1/17a.png) -Then perform the necessary actions to make the warning disappear. Try Googling the error message if you get stuck. +Then perform the necessary actions to make the warning disappear. Try pasting the error message into a search engine if you get stuck. -Typical source of an error `Unchecked runtime.lastError: Could not establish connection. Receiving end does not exist.` is Chrome extension. Try going to `chrome://extensions/` and try disabling them one by one and refreshing React app page; the error should eventually disappear. +Typical source of an error _Unchecked runtime.lastError: Could not establish connection. Receiving end does not exist._ is from a Chrome extension. Try going to _chrome://extensions/_ and try disabling them one by one and refreshing React app page; the error should eventually disappear. **Make sure that from now on you don't see any warnings in your console!** -

1.12*: anecdotes step1

+

1.12*: anecdotes step 1

The world of software engineering is filled with [anecdotes](http://www.comp.nus.edu.sg/~damithch/pages/SE-quotes.htm) that distill timeless truths from our field into short one-liners. -Expand the following application by adding a button that can be clicked to display a random anecdote from the field of software engineering: +Expand the following application by adding a button that can be clicked to display a random anecdote from the field of software engineering: ```js -import React, { useState } from 'react' -import ReactDOM from 'react-dom' +import { useState } from 'react' -const App = (props) => { +const App = () => { + const anecdotes = [ + 'If it hurts, do it more often.', + 'Adding manpower to a late software project makes it later!', + 'The first 90 percent of the code accounts for the first 90 percent of the development time...The remaining 10 percent of the code accounts for the other 90 percent of the development time.', + 'Any fool can write code that a computer can understand. Good programmers write code that humans can understand.', + 'Premature optimization is the root of all evil.', + 'Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it.', + 'Programming without an extremely heavy use of console.log is same as if a doctor would refuse to use x-rays or blood tests when diagnosing patients.', + 'The only way to go fast, is to go well.' + ] + const [selected, setSelected] = useState(0) return (
- {props.anecdotes[selected]} + {anecdotes[selected]}
) } -const anecdotes = [ - 'If it hurts, do it more often', - 'Adding manpower to a late software project makes it later!', - 'The first 90 percent of the code accounts for the first 90 percent of the development time...The remaining 10 percent of the code accounts for the other 90 percent of the development time.', - 'Any fool can write code that a computer can understand. Good programmers write code that humans can understand.', - 'Premature optimization is the root of all evil.', - 'Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it.' -] - -ReactDOM.render( - , - document.getElementById('root') -) +export default App ``` -Google will tell you how to generate random numbers in JavaScript. Remember that you can test generating random numbers e.g. straight in the console of your browser. +Content of the file main.jsx is the same as in previous exercises. -Your finished application could look something like this: +Find out how to generate random numbers in JavaScript, eg. via a search engine or on [Mozilla Developer Network](https://developer.mozilla.org). Remember that you can test generating random numbers e.g. straight in the console of your browser. -![](../../images/1/18a.png) +Your finished application could look something like this: -**WARNING** create-react-app will automatically turn your project into a git-repository unless you create your application inside of an existing git repository. **Most likely you do not want each of your project to be a separate repository**, so simply run the _rm -rf .git_ command at the root of your application. +![random anecdote with next button](../../images/1/18a.png) -

1.13*: anecdotes step2

+

1.13*: anecdotes step 2

Expand your application so that you can vote for the displayed anecdote. -![](../../images/1/19a.png) +![anecdote app with votes button added](../../images/1/19a.png) **NB** store the votes of each anecdote into an array or object in the component's state. Remember that the correct way of updating state stored in complex data structures like objects and arrays is to make a copy of the state. You can create a copy of an object like this: ```js -const points = { 0: 1, 1: 3, 2: 4, 3: 2 } +const votes = { 0: 1, 1: 3, 2: 4, 3: 2 } -const copy = { ...points } +const copy = { ...votes } // increment the property 2 value by one copy[2] += 1 ``` @@ -1218,23 +1396,23 @@ copy[2] += 1 OR a copy of an array like this: ```js -const points = [1, 4, 6, 3] +const votes = [1, 4, 6, 3] -const copy = [...points] +const copy = [...votes] // increment the value in position 2 by one copy[2] += 1 ``` -Using an array might be the simpler choice in this case. Googling will provide you with lots of hints on how to create a zero-filled array of a desired length, like [this](https://stackoverflow.com/questions/20222501/how-to-create-a-zero-filled-javascript-array-of-arbitrary-length/22209781). +Using an array might be the simpler choice in this case. Searching the Internet will provide you with lots of hints on how to [create a zero-filled array of the desired length](https://stackoverflow.com/questions/20222501/how-to-create-a-zero-filled-javascript-array-of-arbitrary-length/22209781). -

1.14*: anecdotes step3

+

1.14*: anecdotes step 3

Now implement the final version of the application that displays the anecdote with the largest number of votes: -![](../../images/1/20a.png) +![anecdote with largest number of votes](../../images/1/20a.png) If multiple anecdotes are tied for first place it is sufficient to just show one of them. -This was the last exercise for this part of the course and it's time to push your code to GitHub and mark all of your finished exercises to the [exercise submission system](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). +This was the last exercise for this part of the course and it's time to push your code to GitHub and mark all of your finished exercises to the "my submissions" tab of the [submission application](https://studies.cs.helsinki.fi/stats/courses/fullstackopen).
diff --git a/src/content/1/es/part1.md b/src/content/1/es/part1.md new file mode 100644 index 00000000000..e617c1126f0 --- /dev/null +++ b/src/content/1/es/part1.md @@ -0,0 +1,14 @@ +--- +mainImage: ../../../images/part-1.svg +part: 1 +lang: es +--- + +
+ +En esta parte, nos familiarizaremos con la librería React, que usaremos para escribir el código que se ejecuta en el navegador. También veremos algunas características de JavaScript que son importantes para comprender React. + +Parte actualizada el 21 de Marzo de 2024 +- Acerca de LLMs en el desarrollo de software + +
diff --git a/src/content/1/es/part1a.md b/src/content/1/es/part1a.md new file mode 100644 index 00000000000..bca1bfe8cba --- /dev/null +++ b/src/content/1/es/part1a.md @@ -0,0 +1,742 @@ +--- +mainImage: ../../../images/part-1.svg +part: 1 +letter: a +lang: es +--- + +
+ +Ahora comenzaremos a familiarizarnos con probablemente el tema más importante de este curso, es decir, la librería [React](https://es.react.dev/). Comencemos con la creación de una aplicación React simple y con el conocimiento de los conceptos básicos de React. + +La forma más fácil de empezar es utilizando una herramienta llamada [Vite](https://es.vitejs.dev/). + +Comencemos creando una aplicación llamada part1, naveguemos a su directorio e instalemos las librerías: + +```bash +# npm 6.x (desactualizado, pero aun en uso por algunos): +npm create vite@latest part1 --template react + +# npm 7+, el doble guion adicional es necesario: +npm create vite@latest part1 -- --template react +``` + +```bash +cd part1 +npm install +``` + +La aplicación se inicia de la siguiente manera + +```bash +npm run dev +``` + +La consola indica que la aplicación ha iniciado en localhost, puerto 5173, es decir la dirección : + +![Captura de pantalla de la consola ejecutando vite en localhost 5173](../../images/1/1-vite1.png) + +Vite inicia la aplicación [por defecto](https://es.vitejs.dev/config/server-options.html#server-port) en el puerto 5173. Si este no está libre, Vite utiliza el siguiente numero de puerto libre. + +Abre el navegador y un editor de código para que puedas ver el código y el navegador al mismo tiempo en la pantalla: + +![Captura de pantalla de la pagina inicial de vite y estructura de archivos en vs code](../../images/1/1-vite4.png) + +El código de la aplicación se encuentra en la carpeta src. Simplifiquemos el código predeterminado de tal modo que el archivo main.jsx se vea así: + +```js +import ReactDOM from 'react-dom/client' + +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +y el archivo App.jsx se vea así: + +```js +const App = () => { + return ( +
+

Hello world

+
+ ) +} + +export default App +``` + +Los archivos App.css e index.css, y el directorio assets pueden eliminarse ya que nos son necesarios en nuestra aplicación por ahora. + +### create-react-app + +En lugar de Vite, tu puedes usar la vieja herramienta de generación [create-react-app](https://github.com/facebookincubator/create-react-app) en el curso para inicializar aplicaciones. La diferencia más visible es el nombre del archivo de arranque de la aplicación, el cual es index.js. + +La manera de iniciar la aplicación también es diferente en CRA, en esta se inicia con el comando + +```bash +npm start +``` + +en contraste con Vite + +```bash +npm run dev +``` + +### Componente + +El archivo App.jsx ahora define un [componente](https://es.react.dev/learn/your-first-component) de React con el nombre App. El comando en la línea final del archivo main.jsx + +```js +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +renderiza su contenido dentro del elemento div, definido en el archivo index.html, que tiene el valor 'root' en el atributo id. + +De forma predeterminada, el archivo index.html no contiene ningún marcado HTML que sea visible para nosotros en el navegador: + +```html + + + + + + + Vite + React + + +
+ + + +``` + +Puedes intentar agregar algo de HTML al archivo. Sin embargo, cuando se usa React, todo el contenido que necesita ser renderizado es generalmente definido como componentes de React. + +Echemos un vistazo mas de cerca al código que define el componente: + +```js +const App = () => ( +
+

Hello world

+
+) +``` + +Como probablemente adivinaste, el componente se renderiza como una etiqueta div, que envuelve una etiqueta p que contiene el texto Hello world. + +Técnicamente, el componente se define como una función de JavaScript. La siguiente es una función (que no recibe ningún parámetro): + +```js +() => ( +
+

Hello world

+
+) +``` + +La función luego se asigna a una variable constante App: + +```js +const App = ... +``` + +Hay algunas formas de definir funciones en JavaScript. Aquí utilizaremos [funciones de flecha](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Functions/Arrow_functions), que se describen en una versión más reciente de JavaScript conocida como [ECMAScript 6](http://es6-features.org/#Constants), también llamada ES6. + +Debido a que la función consta de una sola expresión, hemos utilizado una abreviatura, que representa este fragmento de código: + +```js +const App = () => { + return ( +
+

Hello world

+
+ ) +} +``` + +En otras palabras, la función devuelve el valor de la expresión. + +La función que define el componente puede contener cualquier tipo de código JavaScript. Modifica tu componente de la siguiente manera: + +```js +const App = () => { + console.log('Hello from component') + return ( +
+

Hello world

+
+ ) +} + +export default App +``` + +y observa lo que sucede en la consola: + +![Consola del navegador mostrando un registro en consola con flecha a "Hello from component"](../../images/1/30.png) + +La primera regla del desarrollo web frontend: + +> deja la consola abierta todo el tiempo + +Repitamos esto juntos: Prometo dejar la consola abierta todo el tiempo durante este curso, y por el resto de mi vida mientras esté haciendo desarrollo web. + +También es posible renderizar contenido dinámico dentro de un componente. + +Modifica el componente de la siguiente manera: + +```js +const App = () => { + const now = new Date() + const a = 10 + const b = 20 + console.log(now, a+b) + + return ( +
+

Hello world, it is {now.toString()}

+

+ {a} plus {b} is {a + b} +

+
+ ) +} +``` + +Cualquier código de JavaScript entre llaves es evaluado y el resultado de esta evaluación se incrusta en el lugar definido en el HTML producido por el componente. + +Recuerda que no deberías eliminar la línea al final del componente + +```js +export default App +``` + +El export no se muestra en la mayoría de los ejemplos del material de este curso. Sin este export el componente y la aplicación completa se romperían. + +¿Recuerdas que prometiste dejar la consola abierta? ¿Qué se imprimió allí? + +### JSX + +Parece que los componentes de React están devolviendo marcado HTML. Sin embargo, éste no es el caso. El diseño de los componentes de React se escribe principalmente usando [JSX](https://es.react.dev/learn/writing-markup-with-jsx). Aunque JSX se parece a HTML, en realidad estamos tratando con una forma de escribir JavaScript. Bajo el capó, el JSX devuelto por los componentes de React se compila en JavaScript. + +Después de compilar, nuestra aplicación se ve así: + +```js +const App = () => { + const now = new Date() + const a = 10 + const b = 20 + return React.createElement( + 'div', + null, + React.createElement( + 'p', null, 'Hello world, it is ', now.toString() + ), + React.createElement( + 'p', null, a, ' plus ', b, ' is ', a + b + ) + ) +} +``` + +La compilación está a cargo de [Babel](https://babeljs.io/repl/). Los proyectos creados con *create-react-app* o *vite* están configurados para compilarse automáticamente. Aprenderemos más sobre este tema en la [parte 7](/es/part7) de este curso. + +También es posible escribir React como "JavaScript puro" sin usar JSX. Aunque, nadie que este cuerdo lo haría. + +En la práctica, JSX se parece mucho a HTML con la distinción de que con JSX puede incrustar fácilmente contenido dinámico escribiendo JavaScript entre llaves. La idea de JSX es bastante similar a muchos lenguajes de plantillas, como Thymeleaf, utilizado junto con Java Spring, que se utiliza en servidores. + +JSX es similar a [XML](https://developer.mozilla.org/es/docs/Web/XML/XML_introduction), lo que significa que todas las etiquetas deben cerrarse. Por ejemplo, una nueva línea es un elemento vacío, que en HTML se puede escribir de la siguiente manera: + +```html +
+``` + +pero al escribir JSX, la etiqueta debe estar cerrada: + +```html +
+``` + +### Componentes múltiples + +Modifiquemos el archivo App.jsx de la siguiente manera: + +```js +// highlight-start +const Hello = () => { + return ( +
+

Hello world

+
+ ) +} +// highlight-end + +const App = () => { + return ( +
+

Greetings

+ // highlight-line +
+ ) +} +``` + +Hemos definido un nuevo componente Hello y lo usamos dentro del componente App. Naturalmente, un componente se puede usar múltiples veces: + +```js +const App = () => { + return ( +
+

Greetings

+ + // highlight-start + + + // highlight-end +
+ ) +} +``` + +**Nota:** El export al final se omite en estos ejemplos, ahora y en el futuro. Todavía será necesario para que el código funcione. + +Escribir componentes con React es fácil, y al combinar componentes, incluso una aplicación más compleja puede ser bastante fácil de mantener. De hecho, una filosofía central de React es componer aplicaciones a partir de muchos componentes reutilizables especializados. + +Otra fuerte convención es la idea de un componente raíz llamado App en la parte superior del árbol de componentes de la aplicación. Sin embargo, como aprenderemos en la [parte 6](/es/part6), hay situaciones en las que el componente App no es exactamente la raíz, sino que está incluido en un componente de utilidad apropiado. + +### props: pasar datos a componentes + +Es posible pasar datos a componentes usando los llamados [props](https://es.react.dev/learn/passing-props-to-a-component). + +Modifiquemos el componente Hello de la siguiente manera: + +```js +const Hello = (props) => { // highlight-line + return ( +
+

Hello {props.name}

// highlight-line +
+ ) +} +``` + +Ahora la función que define el componente tiene un parámetro props. Como argumento, el parámetro recibe un objeto, que tiene campos correspondientes a todos los "props" ("accesorios") que el usuario del componente define. + +Los props se definen de la siguiente manera: + +```js +const App = () => { + return ( +
+

Greetings

+ // highlight-line + // highlight-line +
+ ) +} +``` + +Puede haber un número arbitrario de props y sus valores pueden ser strings "incrustados en el código" ("hard coded") o resultados de expresiones JavaScript. Si el valor del prop se obtiene usando JavaScript, debe estar envuelto con llaves. + +Modifiquemos el código para que el componente Hello use dos props: + +```js +const Hello = (props) => { + console.log(props) // highlight-line + return ( +
+

+ Hello {props.name}, you are {props.age} years old // highlight-line +

+
+ ) +} + +const App = () => { + const name = 'Peter' // highlight-line + const age = 10 // highlight-line + + return ( +
+

Greetings

+ // highlight-line + // highlight-line +
+ ) +} +``` + +Los props enviados por el componente App son los valores de las variables, el resultado de la evaluación de la expresión de suma y un string regular. + +El componente Hello también imprime en consola el valor del objeto props. + +Yo realmente espero que tu consola esté abierta. Si no es asi, recuerda tu promesa: + +> Prometo dejar la consola abierta todo el tiempo durante este curso, y por el resto de mi vida mientras esté haciendo desarrollo web. + +El desarrollo de software es difícil. Este se vuelve aun más difícil si uno no está usando todas las herramientas disponibles como la consola de desarrollo e imprimiendo la depuración con _console.log_. Los profesionales usan ambas todo el tiempo y no hay una sola razón de porque un principiante no deberías adoptar estos maravillosos métodos de ayuda que le harán la vida mucho más fácil. + +### Posible mensaje de error + +Dependiendo del editor que estés usando, podrías recibir un mensaje de error en este punto: + +![Captura de pantalla de vs code mostrando un error de eslint: "name is missing in props validation"](../../images/1/1-vite5.png) + +Este realmente no es un error, es una advertencia causada por la herramienta [ESLint](https://es.eslint.org/). Puedes silenciar la advertencia [react/prop-types](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/prop-types.md) añadiendo la siguiente línea al archivo eslint.config.js + +```js +export default [ + { ignores: ['dist'] }, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + settings: { react: { version: '18.3' } }, + plugins: { + react, + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...js.configs.recommended.rules, + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + ...reactHooks.configs.recommended.rules, + 'react/jsx-no-target-blank': 'off', + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + 'react/prop-types': 0, // highlight-line + }, + }, +] +``` + +Aprenderemos sobre ESLint más en detalle en la [parte 3](/es/part3/validacion_y_es_lint#lint). + +### Algunas notas + +React se ha configurado para generar mensajes de error bastante claros. A pesar de esto, debes, al menos al principio, avanzar en **pasos muy pequeños** y asegurarte de que cada cambio funcione como deseas. + +**La consola siempre debe estar abierta**. Si el navegador reporta errores, no es recomendable seguir escribiendo más código, esperando milagros. En su lugar, debes intentar comprender la causa del error y, por ejemplo, volver al estado funcional anterior: + +![Captura de pantalla de error de prop: undefined](../../images/1/1-vite6.png) + +Es bueno recordar que en React es posible y vale la pena escribir comandos console.log() (que se imprimen en la consola) dentro de tu código. + +También ten en cuenta que **los nombres de los componentes de React deben comenzar con mayúscula**. Si intentas definir un componente de la siguiente manera: + +```js +const footer = () => { + return ( +
+ greeting app created by mluukkai +
+ ) +} +``` + +y lo usas de esta manera: + +```js +const App = () => { + return ( +
+

Greetings

+ +
// highlight-line +
+ ) +} +``` + +la página no mostrará el contenido definido dentro del componente footer, y en su lugar React solo crea un elemento footer vacío. Si cambias la primera letra del nombre del componente a una letra mayúscula, React crea el elemento div definido en el componente Footer, que se renderiza en la página. + +Ten en cuenta que el contenido de un componente de React (normalmente) debe contener **un elemento raíz**. Si, por ejemplo, intentamos definir el componente App sin el elemento div más externo: + +```js +const App = () => { + return ( +

Greetings

+ +
+ ) +} +``` + +el resultado es un mensaje de error. + +![Captura de pantalla del error multiples elementos de raíz](../../images/1/1-vite7.png) + +Usar un elemento raíz no es la única opción que funciona. Un array de componentes también es una solución válida: + +```js +const App = () => { + return [ +

Greetings

, + , +
+ ] +} +``` + +Sin embargo cuando se define el componente raíz de la aplicación, hacer esto no es algo particularmente sabio, y hace que el código se vea un poco desagradable. + +Debido a que el elemento raíz está estipulado, tenemos elementos div "extra" en el árbol DOM. Esto se puede evitar usando [fragments](https://es.react.dev/reference/react/Fragment), es decir, envolviendo los elementos que el componente devolverá con un elemento vacío: + +```js +const App = () => { + const name = 'Peter' + const age = 10 + + return ( + <> +

Greetings

+ + +
+ + ) +} +``` + +Ahora este se compila con éxito y el DOM generado por React ya no contiene el elemento div adicional. + +### No renderizar objetos + +Considera una aplicación que imprime en pantalla los nombres y edades de nuestros amigos: + +```js +const App = () => { + const friends = [ + { name: 'Peter', age: 4 }, + { name: 'Maya', age: 10 }, + ] + + return ( +
+

{friends[0]}

+

{friends[1]}

+
+ ) +} + +export default App +``` + +Sin embargo, nada aparece en la pantalla. He tratado de buscar el problema en el código por 15 minutos, pero no he podido encontrar cual puede ser el problema. + +Finalmente recordé la promesa que hice + +> Prometo dejar la consola abierta todo el tiempo durante este curso, y por el resto de mi vida mientras esté haciendo desarrollo web. + +La consola grita en rojo: + +![Consola mostrando error resaltado acerca de "Objects are not valid as a React child"](../../images/1/34new.png) + +La raíz del problema es Objects are not valid as a React child (Los objetos no son válidos como elementos hijos de React), es decir, la aplicación intentó renderizar objetos y falló nuevamente. + +El código trató de renderizar la información de un amigo de la siguiente manera: + +```js +

{friends[0]}

+``` + +y esto causó un problema porque el item a ser renderizado en las llaves es un objeto. + +```js +{ name: 'Peter', age: 4 } +``` + +En React, las cosas individuales a ser renderizadas dentro de llaves deben ser valores primitivos, como números o strings. + +La solución es la siguiente: + +```js +const App = () => { + const friends = [ + { name: 'Peter', age: 4 }, + { name: 'Maya', age: 10 }, + ] + + return ( +
+

{friends[0].name} {friends[0].age}

+

{friends[1].name} {friends[1].age}

+
+ ) +} + +export default App +``` + +Ahora el nombre del amigo es renderizado dentro de las llaves de manera separada. + +```js +{friends[0].name} +``` + +y la edad + +```js +{friends[0].age} +``` + +Después de corregir el error, tu deberías limpiar los mensajes de la consola presionando el botón 🚫 y luego recargando el contenido de la página, y asegurarte de que no se están mostrando mensajes de error. + +Una pequeña nota adicional a la anterior. React también permite renderizar arreglos si el arreglo contiene valores que son elegibles para renderizar (como números y cadenas). Así que el siguiente programa funcionaría, aunque el resultado puede que no sea el que queremos: + +```js +const App = () => { + const friends = [ 'Peter', 'Maya'] + + return ( +
+

{friends}

+
+ ) +} +``` + +En esta parte, ni siquiera vale la pena intentar utilizar la renderización directa de las tablas; volveremos a ello en la siguiente parte. + +
+ +
+

Ejercicios 1.1.-1.2.

+ +Los ejercicios se envían a través de GitHub y marcando los ejercicios completados en el [sistema de envío ejercicios](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +Los ejercicios se envían **una parte a la vez**. Cuando hayas enviado los ejercicios para una parte del curso, ya no podrás enviar ejercicios incompletos para la misma parte. + +Ten en cuenta que en esta parte hay [más ejercicios](/es/part1/un_estado_mas_complejo_depurando_aplicaciones_react#exercises-1-6-1-14) además de los que se encuentran a continuación. No envíes tu trabajo hasta que hayas completado todos los ejercicios que deseas enviar para la parte. + +Puedes enviar todos los ejercicios de este curso al mismo repositorio o utilizar varios repositorios. Si envías ejercicios de diferentes partes en el mismo repositorio, utiliza un esquema de nomenclatura razonable para los directorios. + +Una estructura de archivos muy funcional para el repositorio de envíos es la siguiente: + +```text +part0 +part1 + courseinfo + unicafe + anecdotes +part2 + phonebook + countries +``` + +Mira este [repositorio de ejemplo para el envío de ejercicios](https://github.com/fullstack-hy2020/example-submission-repository)! + +Para cada parte del curso hay un directorio, que se ramifica en directorios que contienen una serie de ejercicios, como "unicafe" para la parte 1. + +La mayoría de los ejercicios del curso construyen una aplicación más grande, por ejemplo: courseinfo, unicafe y anecdotes en esta parte, poco a poco. Es suficiente con enviar la aplicación terminada. Puedes hacer un commit después de cada ejercicio, pero no es obligatorio. Por ejemplo, la aplicación de información del curso se construye en los ejercicios 1.1.-1.5. En este caso solo necesitas enviar el resultado final del ejercicio 1.5. + +Por cada aplicación web para una serie de ejercicios, se recomienda enviar todos los archivos relacionados con esa aplicación, excepto para el directorio node\_modules. + +

1.1: Información del Curso, paso 1

+ +La aplicación en la que comenzaremos a trabajar en este ejercicio se continuara desarrollando en algunos de los siguientes ejercicios. En este y otros conjuntos de ejercicios futuros de este curso, es suficiente enviar solo el estado final de la aplicación. Si lo deseas, también puedes crear un commit para cada ejercicio de la serie, pero esto es completamente opcional. + +Usa Vite para inicializar una nueva aplicación. Modifica main.jsx para que coincida con lo siguiente + +```js +import ReactDOM from 'react-dom/client' + +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +y App.jsx para que coincida con lo siguiente + +```js +const App = () => { + const course = 'Half Stack application development' + const part1 = 'Fundamentals of React' + const exercises1 = 10 + const part2 = 'Using props to pass data' + const exercises2 = 7 + const part3 = 'State of a component' + const exercises3 = 14 + + return ( +
+

{course}

+

+ {part1} {exercises1} +

+

+ {part2} {exercises2} +

+

+ {part3} {exercises3} +

+

Number of exercises {exercises1 + exercises2 + exercises3}

+
+ ) +} + +export default App +``` + +y elimina los archivos adicionales App.css, e index.css, también elimina el directorio assets. + +Desafortunadamente, toda la aplicación está en el mismo componente. Refactoriza el código para que conste de tres componentes nuevos: Header, Content y Total. Todos los datos aún residen en el componente App, que pasa los datos necesarios a cada componente mediante props. Header se encarga de mostrar el nombre del curso, Content muestra las partes y su número de ejercicios y Total muestra el número total de ejercicios. + +Define los nuevos componentes en el archivo App.jsx. + +El cuerpo del componente App será aproximadamente como el siguiente: + +```js +const App = () => { + // const-definitions + + return ( +
+
+ + +
+ ) +} +``` + +**ADVERTENCIA** No trates de programar todos los componentes de corrido, porque esto podría ciertamente romper toda la aplicación. Procede en pequeños pasos, primero haz por ejemplo: el componente Header y solo cuando confirmes que funciona, podrás continuar con el siguiente componente. + +El progreso cuidadoso y en pequeños pasos puede parecer lento, pero en realidad es con diferencia la forma más rápida de progresar. El famoso desarrollador de software Robert "Uncle Bob" Martin ha declarado + +> "La única manera de ir rápido, es hacerlo bien" + +es decir, según Martin, avanzar con cuidado y con pequeños pasos es incluso la única manera de ser rápido. + +

1.2: Información del Curso, paso 2

+ +Refactoriza el componente Content para que no muestre ningún nombre de partes o su número de ejercicios por sí mismo. En su lugar, solo representa tres componentes Part de los cuales cada uno representa el nombre y el número de ejercicios de una parte. + +```js +const Content = ... { + return ( +
+ + + +
+ ) +} +``` + +Nuestra aplicación pasa información de una manera bastante primitiva en este momento, ya que se basa en variables individuales. Esta situación mejorará pronto en la [parte 2](/es/part2), pero antes de eso, vamos a la parte 1b para aprender acerca de JavaScript. + +
diff --git a/src/content/1/es/part1b.md b/src/content/1/es/part1b.md new file mode 100644 index 00000000000..269a7772aee --- /dev/null +++ b/src/content/1/es/part1b.md @@ -0,0 +1,539 @@ +--- +mainImage: ../../../images/part-1.svg +part: 1 +letter: b +lang: es +--- + +
+ +Durante el curso, tenemos el objetivo y la necesidad de aprender una cantidad suficiente de JavaScript ademas del desarrollo web. + +JavaScript ha avanzado rápidamente en los últimos años y en este curso usamos características de las versiones más nuevas. El nombre oficial del estándar JavaScript es [ECMAScript](https://es.wikipedia.org/wiki/ECMAScript). En este momento, la última versión es la lanzada en junio de 2024 con el nombre [ECMAScript® 2024](https://www.ecma-international.org/ecma-262/), también conocido como ES15. + +Los navegadores aún no son compatibles con todas las funciones más nuevas de JavaScript. Debido a este hecho, una gran cantidad de código que se ejecuta en los navegadores ha sido transpilado de una versión más nueva de JavaScript a una versión más antigua y compatible. + +Hoy en día, la forma más popular de realizar la transpilación es mediante [Babel](https://babeljs.io/). La transpilación se configura automáticamente en las aplicaciones de React creadas con Vite. Veremos más de cerca la configuración de la transpilación en la [parte 7](/es/part7) de este curso. + +[Node.js](https://nodejs.org/en/) es un entorno de ejecución de JavaScript basado en el motor de JavaScript [Chrome V8](https://developers.google.com/v8/) de Google y funciona prácticamente en cualquier lugar, desde servidores hasta teléfonos móviles. Practiquemos escribir algo de JavaScript usando Node. Las últimas versiones de Node ya comprenden las últimas versiones de JavaScript, por lo que no es necesario transpilar el código. + +El código se escribe en archivos que terminan en .js que se ejecutan emitiendo el comando node nombre\_del\_archivo.js + +También es posible escribir código JavaScript en la consola de Node.js, que se abre escribiendo _node_ en la línea de comandos, así como en la consola de herramientas de desarrollo del navegador. [Las revisiones más recientes de Chrome manejan las características más nuevas de JavaScript bastante bien](https://compat-table.github.io/compat-table/es2016plus/) sin transpilar el código. Alternativamente, puedes utilizar una herramienta como [JS Bin](https://jsbin.com/?js,console). + +JavaScript recuerda, tanto en nombre como en sintaxis, a Java. Pero cuando se trata del mecanismo central del lenguaje, no podrían ser más diferentes. Viniendo de un entorno de Java, el comportamiento de JavaScript puede parecer un poco extraño, especialmente si uno no hace el esfuerzo de buscar sus características. + +En ciertos círculos también ha sido popular intentar "simular" características de Java y patrones de diseño en JavaScript. No recomendamos hacer esto ya que los lenguajes y los ecosistemas respectivos son, en última instancia, muy diferentes. + +### Variables + +En JavaScript, hay algunas formas de definir las variables: + +```js +const x = 1 +let y = 5 + +console.log(x, y) // se imprime 1 5 +y += 10 +console.log(x, y) // se imprime 1 15 +y = 'sometext' +console.log(x, y) // se imprime 1 sometext +x = 4 // provoca un error +``` + +[const](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Statements/const) no define realmente una variable sino una constante para la cual el valor ya no se puede cambiar. Por otra parte [let](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Statements/let) define una variable normal. + +En el ejemplo anterior, también vemos que el tipo de datos asignados a la variable puede cambiar durante la ejecución. Al principio _y_ almacena un número entero y al final un string. + +También es posible definir variables en JavaScript usando la palabra clave [var](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Statements/var). var fue, durante mucho tiempo, la única forma de definir variables. const y let se agregaron recientemente en la versión ES6. En situaciones específicas, var funciona de una manera diferente comparada con las definiciones de variables de la mayoría de los lenguajes - mira [var, let y const: ¿Cuál es la diferencia?](https://www.freecodecamp.org/espanol/news/var-let-y-const-cual-es-la-diferencia/) o [Diferencia entre var y let en JavaScript](https://htmlmasters.tech/diferencia-entre-var-y-let-en-javascript/) para más información. Durante este curso, el uso de var es desaconsejado y deberás usar const y let! +Puedes encontrar más sobre este tema en YouTube, por ejemplo, [var, let y const - Qué, por qué y cómo - Características de JavaScript de ES6](https://youtu.be/sjyJBL5fkp8) + +### Arrays + +Un [array](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array) y un par de ejemplos de su uso: + +```js +const t = [1, -1, 3] + +t.push(5) + +console.log(t.length) // se imprime 4 +console.log(t[1]) // se imprime -1 + +t.forEach(value => { + console.log(value) // se imprimen los números 1, -1, 3, 5 cada uno en su propia línea +}) +``` + +En este ejemplo, cabe destacar el hecho de que el contenido de el array se puede modificar aunque esté definido como _const_. Como el array es un objeto, la variable siempre apunta al mismo objeto. Sin embargo, el contenido del array cambia a medida que se le agregan nuevos elementos. + +Una forma de iterar a través de los elementos del array es usar _forEach_ como se ve en el ejemplo. _forEach_ recibe una función definida usando la sintaxis de flecha como parámetro. + +```js +value => { + console.log(value) +} +``` + +forEach llama a la función para cada uno de los elementos del array, siempre pasando el elemento individual como parámetro. La función como parámetro de forEach también puede recibir [otros parámetros](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach). + +En el ejemplo anterior, se agregó un nuevo elemento al array usando el método [push](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array/push). Cuando se usa React, a menudo se usan técnicas de programación funcional. Una característica del paradigma de programación funcional es el uso de estructuras de datos [inmutables](https://es.wikipedia.org/wiki/Objeto_inmutable). En el código de React, es preferible usar el método [concat](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array/concat), que no agrega el elemento al array, pero crea un nuevo array en la que se incluyen el contenido del array anterior y el nuevo elemento. + +```js +const t = [1, -1, 3] + +const t2 = t.concat(5) // crea un nuevo array + +console.log(t) // se imprime [1, -1, 3] +console.log(t2) // se imprime [1, -1, 3, 5] +``` + +La llamada al método _t.concat(5)_ no agrega un nuevo elemento al array anterior, pero devuelve un nuevo array que, además de contener los elementos del array anterior, también contiene el elemento nuevo. + +Hay muchos métodos útiles definidos para arrays. Veamos un breve ejemplo del uso del método [map](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array/map). + +```js +const t = [1, 2, 3] + +const m1 = t.map(value => value * 2) +console.log(m1) // se imprime [2, 4, 6] +``` + +Basado en el array anterior, map crea un nuevo array, para el cual la función dada como parámetro se usa para crear los elementos. En el caso de este ejemplo, el valor original se multiplica por dos. + +Map también puede transformar el array en algo completamente diferente: + +```js +const m2 = t.map(value => '
  • ' + value + '
  • ') +console.log(m2) +// se imprime [ '
  • 1
  • ', '
  • 2
  • ', '
  • 3
  • ' ] +``` + +Aquí un array lleno de valores enteros se transforma en un array que contiene strings de HTML utilizando el método map. En la [parte 2](/es/part2) de este curso, veremos que map se usa con bastante frecuencia en React. + +Los elementos individuales de un array son fáciles de asignar a variables con la ayuda de la [asignación de desestructuración](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment). + +```js +const t = [1, 2, 3, 4, 5] + +const [first, second, ...rest] = t + +console.log(first, second) // se imprime 1 2 +console.log(rest) // se imprime [3, 4 ,5] +``` + +Gracias a la asignación, las variables _first_ y _second_ recibirán los dos primeros enteros del array como sus valores. Los enteros restantes se "recopilan" en un array propio que luego se asigna a la variable _rest_. + +### Objetos + +Hay algunas formas diferentes de definir objetos en JavaScript. Un método muy común es usar [objetos literales](https://developer.mozilla.org/es/docs/Web/JavaScript/Guide/Grammar_and_types#Object_literals), que sucede al enumerar sus propiedades entre llaves: + +```js +const object1 = { + name: 'Arto Hellas', + age: 35, + education: 'PhD', +} + +const object2 = { + name: 'Full Stack web application development', + level: 'intermediate studies', + size: 5, +} + +const object3 = { + name: { + first: 'Dan', + last: 'Abramov', + }, + grades: [2, 3, 5, 3], + department: 'Stanford University', +} +``` + +Los valores de las propiedades pueden ser de cualquier tipo, como enteros, strings, arrays, objetos... + +Se hace referencia a las propiedades de un objeto usando la notación "de punto", o usando corchetes: + +```js +console.log(object1.name) // se imprime Arto Hellas +const fieldName = 'age' +console.log(object1[fieldName]) // se imprime 35 +``` + +También puedes agregar propiedades a un objeto sobre la marcha usando notación de puntos o corchetes: + +```js +object1.address = 'Helsinki' +object1['secret number'] = 12341 +``` + +La última de las adiciones debe hacerse usando corchetes, porque cuando se usa la notación de puntos, secret number no es un nombre de propiedad válido debido al carácter de espacio. + +Naturalmente, los objetos en JavaScript también pueden tener métodos. Sin embargo, durante este curso no es necesario definir ningún objeto con métodos propios. Es por eso que solo se discuten brevemente durante el curso. + +Los objetos también se pueden definir usando las llamadas funciones de constructor, lo que da como resultado un mecanismo que recuerda a muchos otros lenguajes de programación, por ejemplo, las clases de Java. A pesar de esta similitud, JavaScript no tiene clases en el mismo sentido que los lenguajes de programación orientados a objetos. Sin embargo, ha tenido la adición de la sintaxis de clase a partir de la versión ES6, que en algunos casos ayuda a estructurar clases orientadas a objetos. + +### Funciones + +Ya nos hemos familiarizado con la definición de funciones de flecha. El proceso completo, sin tomar atajos, para definir una función de flecha es el siguiente: + +```js +const sum = (p1, p2) => { + console.log (p1) + console.log (p2) + return p1 + p2 +} +``` + +y la función se llama como se puede esperar: + +```js +const result = sum(1, 5) +console.log (result) +``` + +Si hay un solo parámetro, podemos excluir los paréntesis de la definición: + +```js +const square = p => { + console.log(p) + return p * p +} +``` + +Si la función solo contiene una expresión, entonces las llaves no son necesarias. En este caso, la función solo devuelve el resultado de su única expresión. Ahora, si eliminamos la impresión de la consola, podemos acortar aún más la definición de la función: + +```js +const square = p => p * p +``` + +Esta forma es particularmente útil cuando se manipulan arrays, por ejemplo, cuando se usa el método map: + +```js +const t = [1, 2, 3] +const tSquared = t.map(p => p * p) +// tSquared ahora es [1, 4, 9] +``` + +La característica de la función de flecha se agregó a JavaScript hace solo un par de años, con la versión [ES6](https://rse.github.io/es6-features/). Antes de esto, la única forma de definir funciones era usando la palabra clave _function_. + +Hay dos formas de hacer referencia a la función; uno está dando un nombre en una [declaración de función](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Statements/function). + +```js +function product(a, b) { + return a * b +} + +const result = product(2, 6) +// result ahora es 12 +``` + +La otra forma de definir la función es usando una [expresión de función](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Operators/function). En este caso, no es necesario darle un nombre a la función y la definición puede residir entre el resto del código: + +```js +const average = function(a, b) { + return (a + b) / 2 +} + +const result = average(2, 5) +// result ahora es 3.5 +``` + +Durante este curso definiremos todas las funciones usando la sintaxis de flecha. + +
    + +
    + +

    Ejercicios 1.3.-1.5.

    + +Seguimos construyendo la aplicación en la que empezamos a trabajar en los ejercicios anteriores. Puedes escribir el código en el mismo proyecto, ya que solo estamos interesados en el estado final de la aplicación enviada. + +**Pro-tip:** puedes tener problemas cuando se trata de la estructura de los props que reciben los componentes. Una buena manera de aclarar las cosas es imprimiendo los props en la consola, por ejemplo, de la siguiente manera: + +```js +const Header = (props) => { + console.log(props) // highlight-line + return

    {props.course}

    +} +``` + +Si y cuando recibes un mensaje de error + +> Objects are not valid as a React child + +ten en cuenta las cosas dichas [aquí](/es/part1/introduccion_a_react#no-renderizar-objetos). + +

    1.3: Información del Curso, paso 3

    + +Avancemos para usar objetos en nuestra aplicación. Modifica las definiciones de las variables del componente App de la siguiente manera y también refactoriza la aplicación para que siga funcionando: + +```js +const App = () => { + const course = 'Half Stack application development' + const part1 = { + name: 'Fundamentals of React', + exercises: 10 + } + const part2 = { + name: 'Using props to pass data', + exercises: 7 + } + const part3 = { + name: 'State of a component', + exercises: 14 + } + + return ( +
    + ... +
    + ) +} +``` + +

    1.4: Información del Curso paso 4

    + +Coloca los objetos en un array. Modifica las definiciones de las variables de App de la siguiente forma y modifica las otras partes de la aplicación que sean necesarias para que continue funcionando: + +```js +const App = () => { + const course = 'Half Stack application development' + const parts = [ + { + name: 'Fundamentals of React', + exercises: 10 + }, + { + name: 'Using props to pass data', + exercises: 7 + }, + { + name: 'State of a component', + exercises: 14 + } + ] + + return ( +
    + ... +
    + ) +} +``` + +**Nota:** en este punto puedes asumir que siempre hay tres elementos, por lo que no es necesario pasar por los arrays usando bucles. Volveremos al tema de la renderización de componentes basados en elementos dentro de arrays con una exploración más profunda en la [siguiente parte del curso](../part2). + +Sin embargo, no pases diferentes objetos como props separados del componente App a los componentes Content y Total. En su lugar, pásalos directamente como un array: + +```js +const App = () => { + // definiciones de const + + return ( +
    +
    + + +
    + ) +} +``` + +

    1.5: Información del Curso paso 5

    + +Llevemos los cambios un paso más allá. Cambia el curso y sus partes a un solo objeto JavaScript. Arregla todo lo que se rompa. + +```js +const App = () => { + const course = { + name: 'Half Stack application development', + parts: [ + { + name: 'Fundamentals of React', + exercises: 10 + }, + { + name: 'Using props to pass data', + exercises: 7 + }, + { + name: 'State of a component', + exercises: 14 + } + ] + } + + return ( +
    + ... +
    + ) +} +``` + +
    + +
    + +### Métodos de objeto y "this" + +Debido al hecho de que durante este curso estamos usando una versión de React que contiene React Hooks no tenemos necesidad de definir objetos con métodos. **El contenido de este capítulo no es relevante para el curso** pero ciertamente, en muchos sentidos, es bueno conocerlo. En particular, cuando se utilizan versiones anteriores de React, se deben comprender los temas de este capítulo. + +Las funciones de flecha y las funciones definidas usando la palabra clave _function_ varían sustancialmente cuando se trata de cómo se comportan con respecto a la palabra clave [this](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Operators/this), que se refiere al objeto en sí. + +Podemos asignar métodos a un objeto definiendo propiedades que son funciones: + +```js +const arto = { + name: 'Arto Hellas', + age: 35, + education: 'PhD', + // highlight-start + greet: function() { + console.log('hello, my name is ' + this.name) + }, + // highlight-end +} + +arto.greet() // se imprime "hello, my name is Arto Hellas" +``` + +Los métodos se pueden asignar a los objetos incluso después de la creación del objeto: + +```js +const arto = { + name: 'Arto Hellas', + age: 35, + education: 'PhD', + greet: function() { + console.log('hello, my name is ' + this.name) + }, +} + +// highlight-start +arto.growOlder = function() { + this.age += 1 +} +// highlight-end + +console.log(arto.age) // se imprime 35 +arto.growOlder() +console.log(arto.age) // se imprime 36 +``` + +Modifiquemos ligeramente el objeto: + +```js +const arto = { + name: 'Arto Hellas', + age: 35, + education: 'PhD', + greet: function() { + console.log('hello, my name is ' + this.name) + }, + // highlight-start + doAddition: function(a, b) { + console.log(a + b) + }, + // highlight-end +} + +arto.doAddition(1, 4) // se imprime 5 + +const referenceToAddition = arto.doAddition +referenceToAddition(10, 15) // se imprime 25 +``` + +Ahora el objeto tiene el método _doAddition_ que calcula la suma de números que se le da como parámetros. El método se llama de la forma habitual, utilizando el objeto arto.doAddition(1, 4) o almacenando una referencia de método en una variable y llamando al método a través de la variable: referenceToAddition(10, 15). + +Si intentamos hacer lo mismo con el método _greet_ nos encontramos con un problema: + +```js +arto.greet() // se imprime "hello, my name is Arto Hellas" + +const referenceToGreet = arto.greet +referenceToGreet() // se imprime "hello, my name is undefined" +``` + +Al llamar al método a través de una referencia, el método pierde el conocimiento de cuál era el _this_ original. A diferencia de otros lenguajes, en JavaScript el valor de [this](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Operators/this) se define en función de cómo se llama al método. Cuando se llama al método a través de una referencia, el valor de _this_ se convierte en el llamado [objeto global](https://developer.mozilla.org/en-US/docs/Glossary/Global_object) y el resultado final a menudo no es lo que el desarrollador de software había previsto originalmente. + +Perder la pista de _this_ al escribir código JavaScript genera algunos problemas potenciales. A menudo surgen situaciones en las que React o Node (o más específicamente el motor JavaScript del navegador web) necesita llamar a algún método en un objeto que el desarrollador ha definido. Sin embargo, en este curso evitamos estos problemas mediante el uso de JavaScript "this-less". + +Una situación que lleva a la "desaparición" de _this_ surge cuando establecemos un tiempo de espera para llamar a la función _greet_ en el objeto _arto_, usando la función [setTimeout](https://developer.mozilla.org/es/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout). + +```js +const arto = { + name: 'Arto Hellas', + greet: function() { + console.log('hello, my name is ' + this.name) + }, +} + +setTimeout(arto.greet, 1000) // highlight-line +``` + +Como se mencionó, el valor de _this_ en JavaScript se define en función de cómo se llama al método. Cuando setTimeout llama al método, es el motor JavaScript el que realmente llama al método y, en ese punto, _this_ se refiere al objeto global. + +Existen varios mecanismos mediante los cuales se puede conservar el _this_ original. Uno de ellos es usar un método llamado [bind](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Function/bind): + +```js +setTimeout(arto.greet.bind(arto), 1000) +``` + +Al llamar a arto.greet.bind(arto) se crea una nueva función donde _this_ está obligado a apuntar a Arto, independientemente de dónde y cómo se llame al método. + +Usando [funciones de flecha](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Functions/Arrow_functions) es posible resolver algunos de los problemas relacionados con _this_. Sin embargo, no deben usarse como métodos para objetos porque entonces _this_ no funciona en absoluto. Más adelante volveremos al comportamiento de _this_ en relación con las funciones de flecha. + +Si deseas obtener una mejor comprensión de cómo funciona _this_ en JavaScript, Internet está lleno de material sobre el tema, por ejemplo, la serie de screencasts [Comprender la palabra clave this de JavaScript en profundidad](https://egghead.io/courses/understand-javascript-s-this-keyword-in-depth) de [egghead.io](https://egghead.io) es muy recomendable. + +### Clases + +Como se mencionó anteriormente, no existe un mecanismo de clase como los de los lenguajes de programación orientados a objetos. Sin embargo, hay características en JavaScript que hacen posible "simular" [clases](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Classes) orientadas a objetos. + +Echemos un vistazo rápido a la sintaxis de clase que se introdujo en JavaScript con ES6, que simplifica sustancialmente la definición de clases (o cosas similares a clases) en JavaScript. + +En el siguiente ejemplo definimos una "clase" llamada Person y dos objetos Person: + +```js +class Person { + constructor(name, age) { + this.name = name + this.age = age + } + greet() { + console.log('hello, my name is ' + this.name) + } +} + +const adam = new Person('Adam Ondra', 29) +adam.greet() + +const janja = new Person('Janja Garnbret', 23) +janja.greet() +``` + +Cuando se trata de sintaxis, las clases y los objetos creados a partir de ellos recuerdan mucho a las clases y objetos de Java. Su comportamiento también es bastante similar al de los objetos Java. En el núcleo, siguen siendo objetos basados en la [herencia prototípica](https://developer.mozilla.org/es/docs/Learn/JavaScript/Objects/Inheritance) de JavaScript. El tipo de ambos objetos es en realidad _Object_, ya que JavaScript esencialmente solo define los tipos [Boolean, Null, Undefined, Number, String, Symbol, BigInt y Object](https://developer.mozilla.org/es/docs/Web/JavaScript/Data_structures). + +La introducción de la sintaxis de clases fue una adición controvertida. Consulta [No es impresionante: clases de ES6](https://github.com/petsel/not-awesome-es6-classes) o [¿Es la "clase" en ES6 la nueva parte "mala"?](Https://medium.com/@rajaraodv/is-class-in-es6-the-new-bad-part-6c4e6fe1ee65) para obtener más detalles. + +La sintaxis de la clase ES6 se usa mucho en React "antiguo" y también en Node.js, por lo que comprenderlo es beneficioso incluso en este curso. Sin embargo, dado que estamos usando la nueva funcionalidad [Hooks](https://es.react.dev/reference/react) de React a lo largo de este curso, no tenemos un uso concreto para la sintaxis de clases de JavaScript. + +### Materiales JavaScript + +Existen guías buenas y malas para JavaScript en Internet. La mayoría de los enlaces en esta página relacionados con características de JavaScript se refieren a la [Guía de JavaScript de Mozilla](https://developer.mozilla.org/es/docs/Web/JavaScript). + +Te recomendamos leer inmediatamente [Una re-introducción a JavaScript (tutorial de JS)](https://developer.mozilla.org/es/docs/Web/JavaScript/A_re-introduction_to_JavaScript) en el sitio web de Mozilla. + +Si deseas conocer JavaScript en profundidad, hay una gran serie de libros gratuitos en Internet llamada [You-Dont-Know-JS](https://github.com/getify/You-Dont-Know-JS). + +Otro gran recurso para aprender JavaScript es [javascript.info](https://es.javascript.info/). + +El libro gratuito [Eloquent JavaScript](https://eloquentjavascript.net) te lleva desde los conceptos básicos hasta temas interesantes rápidamente. Es una mezcla de teoría, proyectos y ejercicios, y abarca tanto la teoría general de programación como el lenguaje JavaScript. + +[Namaste 🙏 JavaScript](https://www.youtube.com/playlist?list=PLlasXeu85E9cQ32gLCvAvr9vNaUccPVNP) es otro excelente y altamente recomendado tutorial gratuito de JavaScript para entender cómo funciona JS bajo el capó. Namaste JavaScript es un curso puro y en profundidad de JavaScript lanzado de forma gratuita en YouTube. Cubrirá en detalle los conceptos fundamentales de JavaScript y todo acerca de cómo JS funciona detrás de escena dentro del motor de JavaScript. + +[egghead.io](https://egghead.io) tiene muchos screencasts de calidad sobre JavaScript, React y otros temas interesantes. Desafortunadamente, parte del material está detrás de un muro de pago. + +
    diff --git a/src/content/1/es/part1c.md b/src/content/1/es/part1c.md new file mode 100644 index 00000000000..e7aa04e78ba --- /dev/null +++ b/src/content/1/es/part1c.md @@ -0,0 +1,761 @@ +--- +mainImage: ../../../images/part-1.svg +part: 1 +letter: c +lang: es +--- + +
    + +Volvamos a trabajar con React. + +Comenzamos con un nuevo ejemplo: + +```js +const Hello = (props) => { + return ( +
    +

    + Hello {props.name}, you are {props.age} years old +

    +
    + ) +} + +const App = () => { + const name = 'Peter' + const age = 10 + + return ( +
    +

    Greetings

    + + +
    + ) +} +``` + +### Funciones auxiliares del componente + +Vamos a expandir nuestro componente Hello para que adivine el año de nacimiento de la persona que recibe la bienvenida: + +```js +const Hello = (props) => { + // highlight-start + const bornYear = () => { + const yearNow = new Date().getFullYear() + return yearNow - props.age + } + // highlight-end + + return ( +
    +

    + Hello {props.name}, you are {props.age} years old +

    +

    So you were probably born in {bornYear()}

    // highlight-line +
    + ) +} +``` + +La lógica para adivinar el año de nacimiento se divide en su propia función que se llama cuando se renderiza el componente. + +La edad de la persona no tiene que pasarse como parámetro a la función, ya que puede acceder directamente a todos los props que se pasan al componente. + +Si examinamos nuestro código actual de cerca, notaremos que la función auxiliar está realmente definida dentro de otra función que define el comportamiento de nuestro componente. En la programación Java, definir una función dentro de otra es complejo y engorroso, por lo que no es tan común. En JavaScript, sin embargo, definir funciones dentro de funciones es una técnica de uso común. + +### Desestructuración + +Antes de seguir adelante, veremos una característica pequeña pero útil del lenguaje JavaScript que se agregó en la especificación ES6, que nos permite [desestructurar](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) valores de objetos y matrices en la asignación. + +En nuestro código anterior, teníamos que hacer referencia a los datos pasados ​​a nuestro componente como _props.name_ y _props.age_. De estas dos expresiones, tuvimos que repetir _props.age_ dos veces en nuestro código. + +Dado que props es un objeto + +```js +props = { + name: 'Arto Hellas', + age: 35, +} +``` + +podemos optimizar nuestro componente asignando los valores de las propiedades directamente en dos variables _name_ y _age_ que luego podemos usar en nuestro código: + +```js +const Hello = (props) => { + // highlight-start + const name = props.name + const age = props.age + // highlight-end + + const bornYear = () => new Date().getFullYear() - age // highlight-line + + return ( +
    +

    Hello {name}, you are {age} years old

    // highlight-line +

    So you were probably born in {bornYear()}

    +
    + ) +} +``` + +Ten en cuenta que también hemos utilizado la sintaxis más compacta para las funciones de flecha al definir la función _bornYear_. Como se mencionó anteriormente, si una función de flecha consta de una sola expresión, entonces no es necesario escribir el cuerpo de la función entre llaves. En esta forma más compacta, la función simplemente devuelve el resultado de la expresión única. + +En resumen, las dos definiciones de función que se muestran a continuación son equivalentes: + +```js +const bornYear = () => new Date().getFullYear() - age + +const bornYear = () => { + return new Date().getFullYear() - age +} +``` + +La desestructuración facilita aún más la asignación de variables, ya que podemos usarla para extraer y reunir los valores de las propiedades de un objeto en variables separadas: + +```js +const Hello = (props) => { + // highlight-start + const { name, age } = props + // highlight-end + const bornYear = () => new Date().getFullYear() - age + + return ( +
    +

    Hello {name}, you are {age} years old

    +

    So you were probably born in {bornYear()}

    +
    + ) +} +``` + +Cuando el objeto que estamos desestructurando tiene los valores + +```js +props = { + name: 'Arto Hellas', + age: 35, +} +``` + +la expresión const { name, age } = props asigna los valores 'Arto Hellas' a _name_ y 35 a _age_. + +Podemos llevar la desestructuración un paso más allá: + +```js +const Hello = ({ name, age }) => { // highlight-line + const bornYear = () => new Date().getFullYear() - age + + return ( +
    +

    + Hello {name}, you are {age} years old +

    +

    So you were probably born in {bornYear()}

    +
    + ) +} +``` + +Los props que se pasan al componente ahora se desestructuran directamente en las variables _name_ y _age_. + +Esto significa que en lugar de asignar todo el objeto props a una variable llamada props y luego asignar sus propiedades a las variables _name_ y _age_ + +```js +const Hello = (props) => { + const { name, age } = props +``` + +asignamos los valores de las propiedades directamente a las variables al desestructurar el objeto props que se pasa a la función del componente como parámetro: + +```js +const Hello = ({ name, age }) => { +``` + +### Re-renderizado de la página + +Hasta ahora, la apariencia de todas nuestras aplicaciones sigue siendo la misma después de la renderización inicial. ¿Qué pasaría si quisiéramos crear un contador donde el valor aumentara en función del tiempo o con el clic de un botón? + +Comencemos con lo siguiente. El archivo App.jsx se convierte en: + +```js +const App = (props) => { + const {counter} = props + return ( +
    {counter}
    + ) +} + +export default App +``` + +Y el archivo main.jsx se convierte en: + +```js +import ReactDOM from 'react-dom/client' + +import App from './App' + +let counter = 1 + +ReactDOM.createRoot(document.getElementById('root')).render( + +) +``` + +El componente de la aplicación recibe el valor del contador a través del prop _counter_. Este componente muestra el valor en la pantalla. ¿Qué sucede cuando cambia el valor de _counter_? Incluso si tuviéramos que agregar lo siguiente + +```js +counter += 1 +``` + +el componente no volverá a renderizar. Podemos hacer que el componente se vuelva a renderizar llamando al método _ReactDOM.render_ por segunda vez, por ejemplo, de la siguiente manera: + +```js +let counter = 1 + +const refresh = () => { + ReactDOM.createRoot(document.getElementById('root')).render( + + ) +} + +refresh() +counter += 1 +refresh() +counter += 1 +refresh() +``` + +El comando de re-renderizado se ha envuelto dentro de la función _refresh_ para reducir la cantidad de código copiado y pegado. + +Ahora el componente se renderiza tres veces, primero con el valor 1, luego 2 y finalmente 3. Sin embargo, los valores 1 y 2 se muestran en la pantalla durante un período de tiempo tan corto que pueden no ser notados. + +Podemos implementar una funcionalidad un poco más interesante volviendo a renderizar e incrementando el contador cada segundo usando [setInterval](https://developer.mozilla.org/es/docs/Web/API/WindowOrWorkerGlobalScope/setInterval): + +```js +setInterval(() => { + refresh() + counter += 1 +}, 1000) +``` + +Hacer llamadas repetidas al método _ReactDOM.render_ no es la forma recomendada de volver a renderizar componentes. A continuación, presentaremos una mejor forma de lograr este efecto. + +### Componente con estado + +Todos nuestros componentes hasta ahora han sido simples en el sentido de que no contienen ningún estado que pueda cambiar durante el ciclo de vida del componente. + +A continuación, agreguemos estado al componente App de nuestra aplicación con la ayuda del [hook de estado](https://es.react.dev/learn/state-a-components-memory) de React. + +Cambiaremos la aplicación a lo siguiente. main.jsx vuelve a: + +```js +import ReactDOM from 'react-dom/client' + +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +y App.jsx cambia a lo siguiente: + +```js +import { useState } from 'react' // highlight-line + +const App = () => { + const [ counter, setCounter ] = useState(0) // highlight-line + +// highlight-start + setTimeout( + () => setCounter(counter + 1), + 1000 + ) + // highlight-end + + return ( +
    {counter}
    + ) +} + +export default App +``` + +En la primera fila, la aplicación importa la función _useState_: + +```js +import { useState } from 'react' +``` + +El cuerpo de la función que define el componente comienza con la llamada a la función: + +```js +const [ counter, setCounter ] = useState(0) +``` + +La llamada a la función agrega state al componente y lo renderiza inicializado con el valor cero. La función devuelve un array que contiene dos elementos. Asignamos los elementos a las variables _counter_ y _setCounter_ usando la sintaxis de asignación por desestructuración mostrada anteriormente. + +A la variable _counter_ se le asigna el valor inicial de state, que es cero. La variable _setCounter_ se asigna a una función que se utilizará para modificar el estado. + +La aplicación llama a la función [setTimeout](https://developer.mozilla.org/es/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout) y le pasa dos parámetros: una función para incrementar el estado del contador y un tiempo de espera de un segundo: + +```js +setTimeout( + () => setCounter(counter + 1), + 1000 +) +``` + +La función pasada como primer parámetro a la función _setTimeout_ se invoca un segundo después de llamar a la función _setTimeout_ + +```js +() => setCounter(counter + 1) +``` + +Cuando se llama a la función de modificación de estado _setCounter_, React vuelve a renderizar el componente, lo que significa que el cuerpo de la función del componente se vuelve a ejecutar: + +```js +() => { + const [ counter, setCounter ] = useState(0) + + setTimeout( + () => setCounter(counter + 1), + 1000 + ) + + return ( +
    {counter}
    + ) +} +``` + +La segunda vez que la función del componente es ejecutado, llama a la función _useState_ y devuelve el nuevo valor del estado: 1. Al ejecutar el cuerpo de la función nuevamente, también se realiza una nueva llamada de función a _setTimeout_, que ejecuta el tiempo de espera de un segundo e incrementa el estado _counter_ nuevamente. Debido a que el valor de la variable _counter_ es 1, incrementar el valor en 1 es esencialmente lo mismo que una expresión que establece el valor de _counter_ en 2. + +```js +() => setCounter(2) +``` + +Mientras tanto, el antiguo valor de _counter_ - "1" - se muestra en la pantalla. + +Cada vez que _setCounter_ modifica el estado, hace que el componente se vuelva a renderizar. El valor del estado se incrementará nuevamente después de un segundo y esto continuará repitiéndose mientras la aplicación esté en ejecución. + +Si el componente no se renderiza cuando tu crees que debería, o si se renderiza en el "momento incorrecto", puedes depurar la aplicación registrando los valores de las variables del componente en la consola. Si agregamos lo siguiente a nuestro código: + +```js +const App = () => { + const [ counter, setCounter ] = useState(0) + + setTimeout( + () => setCounter(counter + 1), + 1000 + ) + + console.log('rendering...', counter) // highlight-line + + return ( +
    {counter}
    + ) +} +``` + +Es fácil de seguir y rastrear las llamadas realizadas a la función de renderizado del componente App: + +![Captura de pantalla de rendering log en herramientas de desarrollo](../../images/1/4e.png) + +¿Estaba la consola de tu navegador abierta? Si no lo estaba, entonces promete que esta sera la ultima vez que necesitas que te lo recuerden. + +### Control de eventos + +Ya hemos mencionado a los controladores de eventos algunas veces en la [parte 0](/es/part0), que están registrados para ser llamados cuando ocurren eventos específicos. Por ejemplo, la interacción de un usuario con los diferentes elementos de una página web puede provocar que se active una colección de diferentes tipos de eventos. + +Cambiemos la aplicación para que aumente el contador cuando un usuario haga clic en un botón, que se implementa con el elemento [botón](https://developer.mozilla.org/es/docs/Web/HTML/Element/button). + +Los elementos de botón admiten los llamados [eventos de mouse](https://developer.mozilla.org/es/docs/Web/API/MouseEvent), de los cuales [click](https://developer.mozilla.org/es/docs/Web/Events/click) es el evento más común. El evento de click en un botón también puede ser disparado por el teclado o por una pantalla táctil a pesar del nombre mouse event. + +En React, [registrar una función de controlador de eventos](https://es.react.dev/learn/responding-to-events) en el evento click ocurre así: + +```js +const App = () => { + const [ counter, setCounter ] = useState(0) + + // highlight-start + const handleClick = () => { + console.log('clicked') + } + // highlight-end + + return ( +
    +
    {counter}
    + // highlight-start + + // highlight-end +
    + ) +} +``` + +Establecemos el valor del atributo onClick del botón para que sea una referencia a la función _handleClick_ definida en el código. + +Ahora, cada clic del botón plus hace que se llame a la función _handleClick_, lo que significa que cada evento de clic registrará un mensaje de clicked en la consola del navegador. + +La función del controlador de eventos también se puede definir directamente en la asignación de valor del atributo onClick: + +```js +const App = () => { + const [ counter, setCounter ] = useState(0) + + return ( +
    +
    {counter}
    + +
    + ) +} +``` + +Al cambiar el controlador de eventos a la siguiente forma + +```js + +``` + +logramos el comportamiento deseado, lo que significa que el valor de _counter_ aumenta en uno y el componente se vuelve a renderizar. + +Agreguemos también un botón para restablecer el contador: + +```js +const App = () => { + const [ counter, setCounter ] = useState(0) + + return ( +
    +
    {counter}
    + + // highlight-start + + // highlight-end +
    + ) +} +``` + +Nuestra aplicación ya está lista! + +### Un controlador de eventos es una función + +Definimos los controladores de eventos para nuestros botones donde declaramos sus atributos onClick: + +```js + +``` + +¿Qué pasaría si intentáramos definir los controladores de eventos de una forma más simple? + +```js + +``` + +Esto rompería completamente nuestra aplicación: + +![Captura de pantalla de un error de re-renderizado](../../images/1/5c.png) + +¿Qué está pasando? Se supone que un controlador de eventos es una función o una referencia de función, y cuando escribimos + +```js + +``` + +Ahora el atributo del botón que define lo que sucede cuando se hace clic en el botón - onClick - tiene el valor _() => setCounter (counter + 1)_. +La función setCounter se llama solo cuando un usuario hace clic en el botón. + +Por lo general, definir controladores de eventos dentro de las plantillas JSX no es una buena idea. +Aquí está bien, porque nuestros controladores de eventos son muy simples. + +Vamos a separar a los controladores de eventos en funciones separadas de todas formas: + +```js +const App = () => { + const [ counter, setCounter ] = useState(0) + +// highlight-start + const increaseByOne = () => setCounter(counter + 1) + + const setToZero = () => setCounter(0) + // highlight-end + + return ( +
    +
    {counter}
    + + +
    + ) +} +``` + +Aquí los controladores de eventos se han definido correctamente. El valor del atributo onClick es una variable que contiene una referencia a una función: + +```js + +``` + +### Pasando el estado a componentes hijos + +Se recomienda escribir componentes de React que sean pequeños y reutilizables en toda la aplicación e incluso en diferentes proyectos. Refactorizemos nuestra aplicación para que esté compuesta por tres componentes más pequeños, un componente para mostrar el contador y dos componentes para los botones. + +Primero implementemos un componente Display que sea responsable de mostrar el valor del contador. + +Una de las mejores prácticas en React es [levantar el estado](https://es.react.dev/learn/sharing-state-between-components) en la jerarquía de componentes. La documentación dice: + +> A menudo, varios componentes deben reflejar los mismos datos cambiantes. Recomendamos elevar el estado compartido a su ancestro común más cercano. + +Así que coloquemos el estado de la aplicación en el componente App y pasémoslo al componente Display a través de props: + +```js +const Display = (props) => { + return ( +
    {props.counter}
    + ) +} +``` + +Usar el componente es sencillo, ya que solo necesitamos pasarle el estado del _counter_: + +```js +const App = () => { + const [ counter, setCounter ] = useState(0) + + const increaseByOne = () => setCounter(counter + 1) + const setToZero = () => setCounter(0) + + return ( +
    + // highlight-line + + +
    + ) +} +``` + +Todo sigue funcionando. Cuando se hace clic en los botones y App se vuelve a renderizar, todos sus elementos secundarios, incluido el componente Display, también se vuelven a renderizar. + +A continuación, creemos un componente Button para los botones de nuestra aplicación. Tenemos que pasar el controlador de eventos, así como el título del botón a través de las props del componente: + +```js +const Button = (props) => { + return ( + + ) +} +``` + +Nuestro componente App ahora se ve así: + +```js +const App = () => { + const [ counter, setCounter ] = useState(0) + + const increaseByOne = () => setCounter(counter + 1) + //highlight-start + const decreaseByOne = () => setCounter(counter - 1) + //highlight-end + const setToZero = () => setCounter(0) + + return ( +
    + + // highlight-start +
    + ) +} +``` + +Dado que ahora tenemos un componente Button fácilmente reutilizable, también hemos implementado una nueva funcionalidad en nuestra aplicación agregando un botón que se puede usar para disminuir el contador. + +El controlador de eventos se pasa al componente Button a través de la propiedad _onClick_. El nombre del prop en sí no es tan significativo, pero nuestra elección de nombre no fue completamente aleatoria. + +El propio [tutorial](https://es.react.dev/learn/tutorial-tic-tac-toe) oficial de React sugiere: +"En React, es convencional usar nombres onSomething para props que representan eventos y handleSomething para las definiciones de funciones que controlan los eventos." + +### Los cambios en el estado provocan re-renderizado + +Repasemos los principios fundamentales de cómo funciona una aplicación una vez más. + +Cuando se inicia la aplicación, se ejecuta el código en _App_. Este código usa un hook [useState](https://es.react.dev/reference/react/useState) para crear el estado de la aplicación, estableciendo un valor inicial de la variable _counter_. +Este componente contiene el componente _Display_, que muestra el valor del contador, 0, y tres componentes _Button_. Todos los botones tienen controladores de eventos, que se utilizan para cambiar el estado del contador. + +Cuando se hace clic en uno de los botones, se ejecuta el controlador de eventos. El controlador de eventos cambia el estado del componente _App_ con la función _setCounter_. +**Llamar a una función que cambia el estado hace que el componente se vuelva a renderizar.** + +Entonces, si un usuario hace clic en el botón plus, el controlador de eventos del botón cambia el valor de _counter_ a 1, y el componente _App_ se vuelve a renderizar. +Esto hace que sus subcomponentes _Display_ y _Button_ también se vuelvan a renderizar. +_Display_ recibe el nuevo valor del contador, 1, como prop. Los componentes _Button_ reciben controladores de eventos que pueden usarse para cambiar el estado del contador. + +Para asegurarnos de entender como funciona el programa, vamos a agregarle algunos _console.log_ + +```js +const App = () => { + const [counter, setCounter] = useState(0) + console.log('rendering with counter value', counter) // highlight-line + + const increaseByOne = () => { + console.log('increasing, value before', counter) // highlight-line + setCounter(counter + 1) + } + + const decreaseByOne = () => {  + console.log('decreasing, value before', counter) // highlight-line + setCounter(counter - 1) + } + + const setToZero = () => { + console.log('resetting to zero, value before', counter) // highlight-line + setCounter(0) + } + + return ( +
    + +
    + ) +} +``` + +Ahora veamos que se imprime en la consola cuando se hace clic en los botones plus, zero y minus: + +![Navegador mostrando la consola con los valores impresos resaltados](../../images/1/31.png) + +No intentes siempre adivinar lo que tu código hace. Justamente lo mejor es usar _console.log_ y ver con tus propios ojos lo que este hace. + +### Refactorización de los componentes + +El componente que muestra el valor del contador es el siguiente: + +```js +const Display = (props) => { + return ( +
    {props.counter}
    + ) +} +``` + +El componente solo usa el campo _counter_ de sus props. +Esto significa que podemos simplificar el componente usando [desestructuración](/es/part1/estado_del_componente_controladores_de_eventos#desestructuracion), así: + +```js +const Display = ({ counter }) => { + return ( +
    {counter}
    + ) +} +``` + +La función que define el componente contiene solo la declaración return, por lo que podemos definir la función usando la forma más compacta de funciones de flecha: + +```js +const Display = ({ counter }) =>
    {counter}
    +``` + +También podemos simplificar el componente Button. + +```js +const Button = (props) => { + return ( + + ) +} +``` + +Podemos usar la desestructuración para obtener solo los campos requeridos de props, y usar la forma más compacta de funciones de flecha: + +**Nota:** Cuando estas construyendo tus propios componentes, puedes nombrar sus controladores de eventos de la manera que quieras, para esto puedes referirte a la documentación de React en [Nombrar props de controladores de eventos](https://es.react.dev/learn/responding-to-events#naming-event-handler-props). Que dice lo siguiente: + +> Por convención, las props de los controladores de eventos deberían iniciar con `on`, seguido de una letra mayúscula. +Por ejemplo, el prop `onClick` del componente Button pudo haberse llamado `onSmash`: + +```js +const Button = ({ onClick, text }) => ( + +) +``` + +podría también ser llamado de la siguiente manera: + +```js +const Button = ({ onSmash, text }) => ( + +) +``` + +Podríamos también simplificar el componente Button un poco más definiendo la declaración return en una sola línea: + +```js +const Button = ({ onSmash, text }) => +``` + +**Nota:** Sin embargo, debes ser cuidadoso de no sobresimplificar tus componentes, ya que esto hace que agregar complejidad sea una tarea más tediosa en el futuro. + +
    diff --git a/src/content/1/es/part1d.md b/src/content/1/es/part1d.md new file mode 100644 index 00000000000..2a04c0702d6 --- /dev/null +++ b/src/content/1/es/part1d.md @@ -0,0 +1,1409 @@ +--- +mainImage: ../../../images/part-1.svg +part: 1 +letter: d +lang: es +--- + +
    + +### Estado complejo + +En nuestro ejemplo anterior el estado de la aplicación era simple ya que estaba compuesto por un solo entero. ¿Y si nuestra aplicación requiere un estado más complejo? + +En la mayoría de los casos, la mejor, y más fácil, manera de lograr esto es usando la función _useState_ varias veces para crear "partes" de estado separadas. + +En el siguiente código creamos dos partes de estado para la aplicación llamada _left_ y _right_ que obtienen el valor inicial de 0: + +```js +const App = () => { + const [left, setLeft] = useState(0) + const [right, setRight] = useState(0) + + return ( +
    + {left} + + + {right} +
    + ) +} +``` + +El componente obtiene acceso a las funciones _setLeft_ y _setRight_ que puede usar para actualizar las dos partes del estado. + +El estado del componente o una parte de su estado puede ser de cualquier tipo. Podríamos implementar la misma funcionalidad guardando el recuento de clics de los botones left y right en un solo objeto: + +```js +{ + left: 0, + right: 0 +} +``` + +En este caso, la aplicación se vería así: + +```js +const App = () => { + const [clicks, setClicks] = useState({ + left: 0, right: 0 + }) + + const handleLeftClick = () => { + const newClicks = { + left: clicks.left + 1, + right: clicks.right + } + setClicks(newClicks) + } + + const handleRightClick = () => { + const newClicks = { + left: clicks.left, + right: clicks.right + 1 + } + setClicks(newClicks) + } + + return ( +
    + {clicks.left} + + + {clicks.right} +
    + ) +} +``` + +Ahora el componente solo tiene una parte de estado y los controladores de eventos deben encargarse de cambiar el estado completo de la aplicación. + +El controlador de eventos se ve un poco desordenado. Cuando se hace clic en el botón izquierdo, se llama a la siguiente función: + +```js +const handleLeftClick = () => { + const newClicks = { + left: clicks.left + 1, + right: clicks.right + } + setClicks(newClicks) +} +``` + +El siguiente objeto se establece como el nuevo estado de la aplicación: + +```js +{ + left: clicks.left + 1, + right: clicks.right +} +``` + +El nuevo valor de la propiedad left ahora es el mismo que el valor de left + 1 del estado anterior, y el valor de la propiedad right es el mismo que el valor de la propiedad right del estado anterior. + +Podemos definir el nuevo objeto de estado de una manera más clara utilizando la sintaxis de [object spread](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Operators/Spread_syntax) que se agregó a la especificación del lenguaje en el verano de 2018: + +```js +const handleLeftClick = () => { + const newClicks = { + ...clicks, + left: clicks.left + 1 + } + setClicks(newClicks) +} + +const handleRightClick = () => { + const newClicks = { + ...clicks, + right: clicks.right + 1 + } + setClicks(newClicks) +} +``` + +La sintaxis puede parecer un poco extraña al principio. En la práctica, { ...clicks } crea un nuevo objeto que tiene copias de todas las propiedades del objeto _clicks_. Cuando especificamos una propiedad en particular, por ejemplo, right en { ...clicks, right: 1 }, el valor de la propiedad _right_ en el nuevo objeto será 1. + +En el ejemplo anterior, esto: + +```js +{ ...clicks, right: clicks.right + 1 } +``` + +crea una copia del objeto _clicks_ donde el valor de la propiedad _right_ aumenta en uno. + +No es necesario asignar el objeto a una variable en los controladores de eventos y podemos simplificar las funciones a la siguiente forma: + +```js +const handleLeftClick = () => + setClicks({ ...clicks, left: clicks.left + 1 }) + +const handleRightClick = () => + setClicks({ ...clicks, right: clicks.right + 1 }) +``` + +Algunos lectores podrían preguntarse por qué no actualizamos el estado directamente, así: + +```js +const handleLeftClick = () => { + clicks.left++ + setClicks(clicks) +} +``` + +La aplicación parece funcionar. Sin embargo, está prohibido en React mutar el estado directamente, ya que [puede provocar efectos secundarios inesperados](https://stackoverflow.com/a/40309023). El cambio de estado siempre debe realizarse estableciendo el estado en un nuevo objeto. Si las propiedades del objeto de estado anterior no se modifican, simplemente deben copiarse, lo que se hace copiando esas propiedades en un nuevo objeto y estableciendo eso como el nuevo estado. + +Almacenar todo el estado en un solo objeto de estado es una mala elección para esta aplicación en particular; no hay ningún beneficio aparente y la aplicación resultante es mucho más compleja. En este caso, almacenar los contadores de clics en estados separados es una opción mucho más adecuada. + +Hay situaciones en las que puede resultar beneficioso almacenar una parte del estado de la aplicación en una estructura de datos más compleja. [La documentación oficial de React](https://es.react.dev/learn/choosing-the-state-structure) contiene una guía útil sobre el tema. + +### Manejo de arrays + +Agreguemos un fragmento de estado a nuestra aplicación que contenga un array _allClicks_ que recuerda cada clic que ha ocurrido en la aplicación. + +```js +const App = () => { + const [left, setLeft] = useState(0) + const [right, setRight] = useState(0) + const [allClicks, setAll] = useState([]) // highlight-line + +// highlight-start + const handleLeftClick = () => { + setAll(allClicks.concat('L')) + setLeft(left + 1) + } +// highlight-end + +// highlight-start + const handleRightClick = () => { + setAll(allClicks.concat('R')) + setRight(right + 1) + } +// highlight-end + + return ( +
    + {left} + + + {right} +

    {allClicks.join(' ')}

    // highlight-line +
    + ) +} +``` + +Cada clic se almacena en una pieza de estado separada llamada _allClicks_ que se inicializa como un array vacío: + +```js +const [allClicks, setAll] = useState([]) +``` + +Cuando se hace clic en el botón left, agregamos la letra L al array _allClicks_: + +```js +const handleLeftClick = () => { + setAll(allClicks.concat('L')) + setLeft(left + 1) +} +``` + +La parte del estado almacenada en _allClicks_ ahora está configurada para ser un array que contiene todos los elementos del array de estado anterior más la letra L. La adición del nuevo elemento al array se logra con el método [concat](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array/concat), que no muta el array existente, sino que devuelve una nueva copia del array con el elemento agregado. + +Como se mencionó anteriormente, también es posible en JavaScript agregar elementos a un array con el método [push](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array/push). Si agregamos el elemento empujándolo al array _allClicks_ y luego actualizando el estado, la aplicación aún parecería funcionar: + +```js +const handleLeftClick = () => { + allClicks.push('L') + setAll(allClicks) + setLeft(left + 1) +} +``` + +Sin embargo, __no__ hagas esto. Como se mencionó anteriormente, el estado de los componentes de React, como _allClicks_, no debe modificarse directamente. Incluso si el estado mutado parece funcionar en algunos casos, puede provocar problemas que son muy difíciles de depurar. + +Echemos un vistazo más de cerca a cómo se muestra el historial de clics en la página: + +```js +const App = () => { + // ... + + return ( +
    + {left} + + + {right} +

    {allClicks.join(' ')}

    // highlight-line +
    + ) +} +``` + +Llamamos al método [join](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array/join) en el array _allClicks_, que une todos los elementos en un solo string, separados por el string pasado como parámetro de la función, que en nuestro caso es un espacio vacío. + +### La actualización del estado es asíncrona + +Ampliemos la aplicación para que realice un seguimiento del número _total_ de veces que los botones son presionados, cuyo valor siempre se actualiza cuando se presionan los botones: + +```js +const App = () => { + const [left, setLeft] = useState(0) + const [right, setRight] = useState(0) + const [allClicks, setAll] = useState([]) + const [total, setTotal] = useState(0) // highlight-line + + const handleLeftClick = () => { + setAll(allClicks.concat('L')) + setLeft(left + 1) + setTotal(left + right) // highlight-line + } + + const handleRightClick = () => { + setAll(allClicks.concat('R')) + setRight(right + 1) + setTotal(left + right) // highlight-line + } + + return ( +
    + {left} + + + {right} +

    {allClicks.join(' ')}

    +

    total {total}

    // highlight-line +
    + ) +} +``` + +La solución no funciona del todo: + +![Navegador mostrando 2 left|right 1, RLL total 2](../../images/1/33.png) + +El número total de pulsaciones de botones es constantemente uno menos que la cantidad real de pulsaciones, por alguna razón. + +Agreguemos un par de _console.log_ al controlador de eventos: + +```js +const App = () => { + // ... + const handleLeftClick = () => { + setAll(allClicks.concat('L')) + console.log('left before', left) // highlight-line + setLeft(left + 1) + console.log('left after', left) // highlight-line + setTotal(left + right) + } + + // ... +} +``` + +La consola revela el problema + +![La consola mostrándonos left before 4 y left after 4](../../images/1/32.png) + +Aunque se estableció un nuevo valor para _left_ llamando a _setLeft(left + 1)_, el valor anterior persiste a pesar de la actualización. Como resultado, el intento de contar las pulsaciones de botones produce un resultado demasiado pequeño: + +```js +setTotal(left + right) +``` + +La razón de esto es que una actualización de estado en React ocurre [asíncronamente](https://es.react.dev/learn/queueing-a-series-of-state-updates), es decir, no inmediatamente sino "en algún momento" antes de que el componente se renderice nuevamente. + +Podemos arreglar la aplicación de la siguiente manera: + +```js +const App = () => { + // ... + const handleLeftClick = () => { + setAll(allClicks.concat('L')) + const updatedLeft = left + 1 + setLeft(updatedLeft) + setTotal(updatedLeft + right) + } + + // ... +} +``` + +Así que ahora el número de pulsaciones de botones se basa definitivamente en el número correcto de pulsaciones del botón izquierdo. + +### Renderizado condicional + +Modifiquemos nuestra aplicación para que el renderizado del historial de clics sea manejado por un nuevo componente History: + +```js +// highlight-start +const History = (props) => { + if (props.allClicks.length === 0) { + return ( +
    + the app is used by pressing the buttons +
    + ) + } + + return ( +
    + button press history: {props.allClicks.join(' ')} +
    + ) +} +// highlight-end + +const App = () => { + // ... + + return ( +
    + {left} + + + {right} + // highlight-line +
    + ) +} +``` + +Ahora el comportamiento del componente depende de si se ha hecho clic en cualquier botón. Si no es así, lo que significa que el array allClicks está vacío, el componente muestra un elemento div con algunas instrucciones en su lugar: + +```js +
    the app is used by pressing the buttons
    +``` + +Y en todos los demás casos, el componente muestra el historial de clics: + +```js +
    + button press history: {props.allClicks.join(' ')} +
    +``` + +El componente History representa elementos React completamente diferentes según el estado de la aplicación. Esto se llama renderizado condicional. + +React también ofrece muchas otras formas de hacer [renderizado condicional](https://es.react.dev/learn/conditional-rendering). Veremos esto más de cerca en la [parte 2](/es/part2). + +Hagamos una última modificación a nuestra aplicación, para usar el componente _Button_ que definimos anteriormente: + +```js +const History = (props) => { + if (props.allClicks.length === 0) { + return ( +
    + the app is used by pressing the buttons +
    + ) + } + + return ( +
    + button press history: {props.allClicks.join(' ')} +
    + ) +} + +// highlight-start +const Button = ({ handleClick, text }) => ( + +) +// highlight-end + +const App = () => { + const [left, setLeft] = useState(0) + const [right, setRight] = useState(0) + const [allClicks, setAll] = useState([]) + + const handleLeftClick = () => { + setAll(allClicks.concat('L')) + setLeft(left + 1) + } + + const handleRightClick = () => { + setAll(allClicks.concat('R')) + setRight(right + 1) + } + + return ( +
    + {left} + // highlight-start +
    + ) +} +``` + +### React Antiguo + +En este curso usamos el [state hook](https://es.react.dev/learn/state-a-components-memory) para agregar estado a nuestros componentes de React, que es parte de las versiones más nuevas de React y está disponible desde la versión [16.8.0](https://www.npmjs.com/package/react/v/16.8.0) en adelante. Antes de la adición de hooks, no había forma de agregar estado a los componentes funcionales. Los componentes que requerían el estado tenían que definirse como componentes de [clase](https://es.react.dev/reference/react/Component), utilizando la sintaxis de clase de JavaScript. + +En este curso hemos tomado la decisión un poco radical de utilizar hooks exclusivamente desde el primer día, para asegurarnos de que estamos aprendiendo las actuales y futuras versiones de React. Aunque los componentes funcionales son el futuro de React, sigue siendo importante aprender la sintaxis de clase, ya que hay miles de millones de líneas de código antiguo de React que podrías terminar manteniendo algún día. Lo mismo se aplica a la documentación y los ejemplos de React con los que puedes tropezar en Internet. + +Aprenderemos más sobre los componentes de clase de React más adelante en el curso. + +### Depuración de aplicaciones React + +Una gran parte del tiempo de un desarrollador típico se dedica a depurar y leer el código existente. De vez en cuando podemos escribir una línea o dos de código nuevo, pero una gran parte de nuestro tiempo se dedica a tratar de averiguar por qué algo está roto o cómo funciona algo. Las buenas prácticas y herramientas para depurar son extremadamente importantes por este motivo. + +Por suerte para nosotros, React es una librería extremadamente amigable para los desarrolladores cuando se trata de depurar. + +Antes de continuar, recordemos una de las reglas más importantes del desarrollo web. + +

    La primera regla de desarrollo web

    + +> **Mantén la consola de desarrollador del navegador abierta en todo momento.** +> +> La Consola, en particular, debería estar siempre abierta, a menos que haya una razón específica para ver otra pestaña. + +Mantén tu código y la página web abiertos juntos **al mismo tiempo, todo el tiempo**. + +Si tu código falla al compilarse y tu navegador se ilumina como un árbol de Navidad: + +![Captura de pantalla de un error, apuntando a la linea de código en donde se ha generado ](../../images/1/6x.png) + +no escribas más código, sino busca y soluciona el problema **inmediatamente**. Aún no ha habido un momento en la historia de la codificación en el que el código que no se compila comience a funcionar milagrosamente después de escribir grandes cantidades de código adicional. Dudo mucho que tal evento ocurra durante este curso. + +La depuración de la vieja escuela basada en impresión siempre es una buena idea. Si el componente + +```js +const Button = ({ onClick, text }) => ( + +) +``` + +no funciona como se esperaba, es útil comenzar a imprimir sus variables en la consola. Para hacer esto de manera efectiva, debemos transformar nuestra función en la forma menos compacta y recibir el objeto props completo sin desestructurarlo inmediatamente: + +```js +const Button = (props) => { + console.log(props) // highlight-line + const { handleClick, text } = props + return ( + + ) +} +``` + +Esto revelará inmediatamente si, por ejemplo, uno de los atributos se ha escrito mal al usar el componente. + +**Nota:** Cuando use _console.log_ para depurar, no combines _objetos_ como en Java utilizando el operador de adición: + +```js +console.log('props value is ' + props) +``` + +Si haces eso, terminará mostrándote un mensaje poco informativo: + +```js +props value is [Object object] +``` + +En su lugar, separa las cosas que deseas imprimir en la consola con una coma: + +```js +console.log('props value is', props) +``` + +De esta forma, los elementos separados por una coma estarán disponibles en la consola del navegador para una inspección más detallada. + +Imprimir en la consola no es de ninguna manera la única forma de depurar nuestras aplicaciones. Puedes pausar la ejecución del código de tu aplicación en el depurador de la consola de desarrollador de Chrome, escribiendo el comando [debugger](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Statements/debugger) en cualquier parte de tu código. + +La ejecución se detendrá una vez que llegue a un punto donde se ejecuta el comando _debugger_: + +![debugger pausado en dev tools](../../images/1/7a.png) + +Al ir a la pestaña Console (consola), Es fácil inspeccionar el estado actual de las variables: + +![screenshot de la consola](../../images/1/8a.png) + +Una vez que se descubre la causa del error, puedes eliminar el comando _debugger_ y actualizar la página. + +El depurador también nos permite ejecutar nuestro código línea por línea con los controles que se encuentran en el lado derecho de la pestaña Sources. + +También puedes acceder al depurador sin el comando _debugger_ agregando puntos de interrupción en la pestaña Sources. La inspección de los valores de las variables del componente se puede hacer en la sección _Scope_: + +![ejemplo de breakpoint en devtools](../../images/1/9a.png) + +Es muy recomendable instalar la extensión [React developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) para Chrome. Agregará una nueva pestaña _Components_ a las herramientas de desarrollo. La nueva pestaña de herramientas de desarrollador se puede usar para inspeccionar los diferentes elementos de React en la aplicación, junto con su estado y props: + +![screenshot de herramientas de desarrollo de react](../../images/1/10ea.png) + +El estado del componente _App_ se define así: + +```js +const [left, setLeft] = useState(0) +const [right, setRight] = useState(0) +const [allClicks, setAll] = useState([]) +``` + +Las herramientas de desarrollo muestran el estado de los hooks en el orden de su definición: + +![estado de hooks en react dev tools](../../images/1/11ea.png) + +El primer State contiene el valor del estado left, el siguiente contiene el valor del estado right y el último contiene el valor del estado allClicks. + +### Reglas de los Hooks + +Hay algunas limitaciones y [reglas](https://es.react.dev/warnings/invalid-hook-call-warning#breaking-rules-of-hooks) que debemos seguir para asegurarnos de que nuestra aplicación utilice correctamente las funciones de estado basadas en hooks. + +La función _useState_ (así como la función _useEffect_ presentada más adelante en el curso) no se debe llamar desde dentro de un loop, una expresión condicional o cualquier lugar que no sea una función que defina a un componente. Esto debe hacerse para garantizar que los hooks siempre se llamen en el mismo orden o, si este no es el caso, la aplicación se comportará de manera errática. + +En resumen, los hooks solo se pueden llamar desde el interior de un cuerpo de la función que define un componente de React: + +```js +const App = () => { + // estos están bien + const [age, setAge] = useState(0) + const [name, setName] = useState('Juha Tauriainen') + + if ( age > 10 ) { + // esto no funciona! + const [foobar, setFoobar] = useState(null) + } + + for ( let i = 0; i < age; i++ ) { + // esto tampoco está bien + const [rightWay, setRightWay] = useState(false) + } + + const notGood = () => { + // y esto también es ilegal + const [x, setX] = useState(-1000) + } + + return ( + //... + ) +} +``` + +### Revision de los Controladores de Eventos + +El control de eventos ha demostrado ser un tema difícil en iteraciones anteriores de este curso. + +Por esta razón volveremos a tratar el tema. + +Supongamos que estamos desarrollando esta sencilla aplicación con el siguiente componente App: + +```js +const App = () => { + const [value, setValue] = useState(10) + + return ( +
    + {value} + +
    + ) +} +``` + +Queremos hacer clic en el botón para restablecer el estado almacenado en la variable _value_. + +Para que el botón reaccione a un evento de clic, tenemos que agregarle un controlador de eventos. + +Los controladores de eventos siempre deben ser una función o una referencia a una función. El botón no funcionará si el controlador de eventos se establece en una variable de cualquier otro tipo. + +Si definiéramos el controlador de eventos como un string: + +```js + +``` + +React nos advertiría sobre esto en la consola: + +```js +index.js:2178 Warning: Expected `onClick` listener to be a function, instead got a value of `string` type. + in button (at index.js:20) + in div (at index.js:18) + in App (at index.js:27) +``` + +El siguiente intento tampoco funcionaría: + +```js + +``` + +Hemos intentado establecer el controlador de eventos en _value + 1_ que simplemente devuelve el resultado de la operación. React nos advertirá amablemente sobre esto en la consola: + +```js +index.js:2178 Warning: Expected `onClick` listener to be a function, instead got a value of `number` type. +``` + +Este intento tampoco funcionaría: + +```js + +``` + +El controlador de eventos no es una función sino una asignación de variable, y React volverá a emitir una advertencia para la consola. Este intento también tiene fallas en el sentido de que nunca debemos mutar el estado directamente en React. + +¿Qué pasa con lo siguiente?: + +```js + +``` + +El mensaje se imprime en la consola una vez cuando se renderiza el componente, pero no sucede nada cuando hacemos clic en el botón. ¿Por qué esto no funciona incluso cuando nuestro controlador de eventos contiene una función _console.log_? + +El problema aquí es que nuestro controlador de eventos está definido como una llamada a una función, lo que significa que al controlador de eventos se le asigna realmente el valor devuelto por la función, que en el caso de _console.log_ es undefined. + +La llamada a la función _console.log_ se ejecuta cuando se renderiza el componente y por esta razón se imprime una vez en la consola. + +El siguiente intento también tiene fallas: + +```js + +``` + +Una vez más, hemos intentado establecer una llamada a una función como controlador de eventos. Esto no funciona. Este intento en particular también causa otro problema. Cuando se renderiza el componente, se ejecuta la función _setValue(0)_, lo que a su vez hace que el componente se vuelva a renderizar. La renderización a su vez llama a _setValue(0)_ de nuevo, lo que da como resultado una recursion infinita. + +La ejecución de una llamada de función en particular cuando se hace clic en el botón se puede lograr así: + +```js + +``` + +Ahora el controlador de eventos es una función definida con la sintaxis de función de flecha _() => console.log('clicked the button')_. Cuando el componente se renderiza, no se llama a ninguna función y solo la referencia a la función de flecha se establece en el controlador de eventos. La llamada a la función ocurre solo una vez que se hace clic en el botón. + +Podemos implementar el reseteo del estado en nuestra aplicación con esta misma técnica: + +```js + +``` + +El controlador de eventos ahora es la función _() => setValue (0)_. + +Definir controladores de eventos directamente en el atributo del botón no es necesariamente la mejor idea. + +A menudo verás los controladores de eventos definidos en un lugar separado. En la siguiente versión de nuestra aplicación, definimos una función que luego se asigna a la variable _handleClick_ en el cuerpo de la función del componente: + +```js +const App = () => { + const [value, setValue] = useState(10) + + const handleClick = () => + console.log('clicked the button') + + return ( +
    + {value} + +
    + ) +} +``` + +La variable _handleClick_ ahora está asignada a una referencia de una función. La referencia se pasa al botón como el atributo onClick: + +```js + +``` + +Naturalmente, nuestra función de controlador de eventos puede estar compuesta por varios comandos. En estos casos, usamos la sintaxis de llaves más largas para las funciones de flecha: + +```js +const App = () => { + const [value, setValue] = useState(10) + + // highlight-start + const handleClick = () => { + console.log('clicked the button') + setValue(0) + } + // highlight-end + + return ( +
    + {value} + +
    + ) +} +``` + +### Una función que devuelve una función + +Otra forma de definir un controlador de eventos es utilizar una función que devuelve una función. + +Probablemente no necesites utilizar funciones que devuelvan funciones en ninguno de los ejercicios de este curso. Si el tema parece particularmente confuso, puedes omitir esta sección por ahora y volver a ella más tarde. + +Hagamos los siguientes cambios en nuestro código: + +```js +const App = () => { + const [value, setValue] = useState(10) + + // highlight-start + const hello = () => { + const handler = () => console.log('hello world') + + return handler + } + // highlight-end + + return ( +
    + {value} + +
    + ) +} +``` + +El código funciona correctamente aunque parece complicado. + +El controlador de eventos ahora está configurado para una llamada de función: + +```js + +``` + +Anteriormente dijimos que un controlador de eventos puede no ser una llamada a una función, y que tiene que ser una función o una referencia a una función. Entonces, ¿por qué funciona una llamada a función en este caso? + +Cuando se renderiza el componente, se ejecuta la siguiente función: + +```js +const hello = () => { + const handler = () => console.log('hello world') + + return handler +} +``` + +El valor de retorno de la función es otra función que se asigna a la variable _handler_. + +Cuando React renderiza la línea: + +```js + +``` + +Asigna el valor de retorno de _hello()_ al atributo onClick. Básicamente, la línea se transforma en: + +```js + +``` + +Dado que la función _hello_ devuelve una función, el controlador de eventos ahora es una función. + +¿Qué sentido tiene este concepto? + +Cambiemos el código un poquito: + +```js +const App = () => { + const [value, setValue] = useState(10) + + // highlight-start + const hello = (who) => { + const handler = () => { + console.log('hello', who) + } + + return handler + } + // highlight-end + + return ( +
    + {value} + // highlight-start + + + + // highlight-end +
    + ) +} +``` + +Ahora la aplicación tiene tres botones con controladores de eventos definidos por la función _hello_ que acepta un parámetro. + +El primer botón se define como + +```js + +``` + +El controlador de eventos se crea ejecutando la llamada de función _hello('world')_. La llamada a la función devuelve la función: + +```js +() => { + console.log('hello', 'world') +} +``` + +El segundo botón se define como: + +```js + +``` + +La llamada de función _hello('react')_ que crea el controlador de eventos devuelve: + +```js +() => { + console.log('hello', 'react') +} +``` + +Ambos botones tienen sus propios controladores de eventos individualizados. + +Las funciones que devuelven funciones se pueden utilizar para definir funciones genéricas que se pueden personalizar con parámetros. La función _hello_ que crea los controladores de eventos se puede considerar como una fábrica que produce controladores de eventos personalizados destinados a saludar a los usuarios. + +Nuestra definición actual es un poco verbosa: + +```js +const hello = (who) => { + const handler = () => { + console.log('hello', who) + } + + return handler +} +``` + +Eliminemos las variables auxiliares y devolvamos directamente la función creada: + +```js +const hello = (who) => { + return () => { + console.log('hello', who) + } +} +``` + +Dado que nuestra función _hello_ se compone de un solo comando de retorno, podemos omitir las llaves y usar la sintaxis más compacta para las funciones de flecha: + +```js +const hello = (who) => + () => { + console.log('hello', who) + } +``` + +Por último, escribamos todas las flechas en la misma línea: + +```js +const hello = (who) => () => { + console.log('hello', who) +} +``` + +Podemos usar el mismo truco para definir controladores de eventos que establecen el estado del componente en un valor dado. Hagamos los siguientes cambios en nuestro código: + +```js +const App = () => { + const [value, setValue] = useState(10) + + // highlight-start + const setToValue = (newValue) => () => { + console.log('value now', newValue) // imprime el nuevo valor en la consola + setValue(newValue) + } + // highlight-end + + return ( +
    + {value} + // highlight-start + + + + // highlight-end +
    + ) +} +``` + +Cuando se renderiza el componente, se crea el botón thousand: + +```js + +``` + +El controlador de eventos se establece en el valor de retorno de _setToValue(1000)_ que es la siguiente función: + +```js +() => { + console.log('value now', 1000) + setValue(1000) +} +``` + +El botón de aumento se declara de la siguiente manera: + +```js + +``` + +El controlador de eventos es creado por la llamada de función _setToValue(value + 1)_ que recibe como parámetro el valor actual de la variable de estado _value_ aumentado en uno. Si el valor de _value_ fuera 10, entonces el controlador de eventos creado sería la función: + +```js +() => { + console.log('value now', 11) + setValue(11) +} +``` + +No es necesario utilizar funciones que devuelvan funciones para lograr esta funcionalidad. Regresemos la función _setToValue_ que es responsable de actualizar el estado, a una función normal: + +```js +const App = () => { + const [value, setValue] = useState(10) + + const setToValue = (newValue) => { + console.log('value now', newValue) + setValue(newValue) + } + + return ( +
    + {value} + + + +
    + ) +} +``` + +Ahora podemos definir el controlador de eventos como una función que llama a la función _setToValue_ con un parámetro apropiado. El controlador de eventos para resetear el estado de la aplicación sería: + +```js + +``` + +Elegir entre las dos formas presentadas de definir tus controladores de eventos es sobre todo una cuestión de gustos. + +### Pasando controladores de eventos a componentes hijos + +Extraigamos el botón en su propio componente: + +```js +const Button = (props) => ( + +) +``` + +El componente obtiene la función de controlador de eventos de la propiedad _handleClick_, y el texto del botón de la propiedad _text_. Usemos el nuevo componente: + +```js +const App = (props) => { + // ... + return ( +
    + {value} +
    + ) +} +``` + +Usar el componente Button es simple, aunque debemos asegurarnos de que usamos los nombres de atributos correctos al pasar props al componente. + +![Captura de pantalla del uso correcto de los nombres de atributos](../../images/1/12e.png) + +### No definir componentes dentro de los componentes + +Empecemos a mostrar el valor de la aplicación en su propio componente Display. + +Cambiaremos la aplicación definiendo un nuevo componente dentro del componente App. + +```js +// Este es lugar correcto para definir un componente +const Button = (props) => ( + +) + +const App = () => { + const [value, setValue] = useState(10) + + const setToValue = newValue => { + console.log('value now', newValue) + setValue(newValue) + } + + // No definas componentes adentro de otro componente + const Display = props =>
    {props.value}
    // highlight-line + + return ( +
    + // highlight-line +
    + ) +} +``` + +La aplicación todavía parece funcionar, pero **¡no implementes componentes como este!** Nunca definas componentes dentro de otros componentes. El método no proporciona beneficios y da lugar a muchos problemas desagradables. Los mayores problemas se deben al hecho de que React trata un componente definido dentro de otro componente como un nuevo componente en cada render. Esto hace imposible que React optimice el componente. + +En su lugar, movamos la función del componente Display a su lugar correcto, que está fuera de la función del componente App: + +```js +const Display = props =>
    {props.value}
    + +const Button = (props) => ( + +) + +const App = () => { + const [value, setValue] = useState(10) + + const setToValue = newValue => { + console.log('value now', newValue) + setValue(newValue) + } + + return ( +
    + +
    + ) +} +``` + +### Lectura útil + +Internet está lleno de material relacionado con React. Sin embargo, utilizamos un estilo de React tan nuevo que una gran mayoría del material que se encuentra en línea está desactualizado para nuestros propósitos. + +Puedes encontrar útiles los siguientes enlaces: + +- Vale la pena echarle un vistazo a la [documentación oficial](https://es.react.dev/learn) de React en algún momento, aunque la mayor parte será relevante solo más adelante en el curso. Además, todo lo relacionado con los componentes basados en clases es irrelevante para nosotros; +- Algunos cursos en [Egghead.io](https://egghead.io) como [Start learning React](https://egghead.io/courses/start-learning-react) son de alta calidad y la recientemente actualizada [Guía para principiantes de React](https://egghead.io/courses/the-beginner-s-guide-to-reactjs) también es relativamente buena; Ambos cursos introducen conceptos que también se presentarán más adelante en este curso. **Nota:** El primero usa componentes de clase pero el segundo usa los nuevos componentes funcionales. + +### Juramento de los programadores web + +Programar es difícil, por eso usaré todos los medios posibles para hacerlo más fácil. + +- Tendré la consola de desarrollador de mi navegador abierta todo el tiempo. +- Progreso con pequeños pasos. +- Escribiré muchas declaraciones _console.log_ para asegurarme de que entiendo cómo se comporta el código y para ayudar a identificar problemas. +- Si mi código no funciona, no escribiré más código. En lugar de eso, empiezo a eliminar el código hasta que funcione o simplemente vuelvo a un estado en el que todo seguía funcionando. +- Cuando pido ayuda en el canal de Discord del curso o en otro lugar, formulo mis preguntas correctamente, consulta [aquí](http://fullstackopen.com/en/part0/general_info#how-to-get-help-in-discord) como pedir ayuda. + +### Utilización de grandes modelos de lenguaje (LLM) + +Modelos de lenguaje grandes como [ChatGPT](https://chat.openai.com/auth/login), [Claude](https://claude.ai/) y [GitHub Copilot](https://github.com/features/copilot) han demostrado ser muy útiles en el desarrollo de software. + +Personalmente, principalmente uso Copilot, el cual se integra a la perfección con VS Code gracias al [plugin](https://visualstudio.microsoft.com/github-copilot/). + +Copilot es útil en una amplia variedad de escenarios. A Copilot se le puede pedir que genere código para un archivo abierto describiendo la funcionalidad deseada en texto: + +![input de copilot en vscode](../../images/1/gpt1.png) + +Si el código parece bueno, Copilot lo añade al archivo: + +![código agregado por copilot](../../images/1/gpt2.png) + +En el caso de nuestro ejemplo, Copilot solo creó un botón, el controlador de eventos _handleResetClick_ no está definido. + +También se puede generar un controlador de eventos. Al escribir la primera línea de la función, Copilot ofrece la funcionalidad a generar: + +![sugerencia de código de copilot](../../images/1/gpt3.png) + +En la ventana de chat de Copilot, es posible pedir una explicación de la función del área de código seleccionada: + +![copilot explicando como funciona el código seleccionado en la ventana de chat](../../images/1/gpt4.png) + +Copilot también es útil en situaciones de error, copiando el mensaje de error en el chat de Copilot, obtendrás una explicación del problema y una solución sugerida: + +![copilot explicando el error y sugiriendo una solución](../../images/1/gpt5.png) + +El chat de Copilot también permite la creación de un conjunto más grande de funcionalidades + +![copilot creando un componente de login a demanda](../../images/1/gpt6.png) + +El grado de utilidad de las sugerencias proporcionadas por Copilot y otros modelos de lenguaje varía. Quizás el problema más grande con los modelos de lenguaje es la [alucinación](https://es.wikipedia.org/wiki/Alucinaci%C3%B3n_(inteligencia_artificial)), a veces generan respuestas que parecen completamente convincentes, pero que, sin embargo, son completamente incorrectas. Al programar, por supuesto, el código alucinado a menudo se detecta rápidamente si el código no funciona. Situaciones más problemáticas son aquellas donde el código generado por el modelo de lenguaje parece funcionar, pero contiene errores más difíciles de detectar o, por ejemplo, vulnerabilidades de seguridad. + +Otro problema al aplicar modelos de lenguaje al desarrollo de software es que es difícil para los modelos de lenguaje "entender" proyectos más grandes, y, por ejemplo, generar funcionalidad que requeriría cambios en varios archivos. Los modelos de lenguaje también son actualmente incapaces de generalizar código, es decir, si el código tiene, por ejemplo, funciones o componentes existentes que el modelo de lenguaje podría usar con cambios menores para la funcionalidad solicitada, el modelo de lenguaje no se adaptará a esto. El resultado de esto puede ser que la base de código se deteriore, ya que los modelos de lenguaje generan mucha repetición en el código, ver más por ejemplo [aquí](https://visualstudiomagazine.com/articles/2024/01/25/copilot-research.aspx). + +Al usar modelos de lenguaje, la responsabilidad siempre queda con el programador. + +El rápido desarrollo de los modelos de lenguaje pone al estudiante de programación en una posición desafiante: ¿vale la pena y es incluso necesario aprender a programar a un nivel detallado, cuando casi todo se puede obtener ya hecho de los modelos de lenguaje? + +En este punto, vale la pena recordar la antigua sabiduría de [Brian Kernighan](https://es.wikipedia.org/wiki/Brian_Kernighan), el desarrollador del lenguaje de programación C: + +![Todo el mundo sabe que depurar es dos veces más difícil que escribir un programa en primer lugar. Entonces, si eres tan inteligente como puedes ser cuando lo escribes, ¿cómo podrás depurarlo? - Brian Kernighan](../../images/1/kerningham.png) + +En otras palabras, dado que depurar es dos veces más difícil que programar, no vale la pena programar tal código que apenas puedes entender. ¿Cómo puede ser posible la depuración en una situación donde la programación se externaliza a un modelo de lenguaje y el desarrollador de software no entiende el código depurado en absoluto? + +Hasta ahora, el desarrollo de modelos de lenguaje e inteligencia artificial aún está en una etapa donde no son autosuficientes, y los problemas más difíciles quedan para que los humanos los resuelvan. Por esto, incluso los desarrolladores de software novatos deben aprender a programar realmente bien por si acaso. Puede ser que, a pesar del desarrollo de modelos de lenguaje, se necesite aún más conocimiento en profundidad. La inteligencia artificial hace las cosas fáciles, pero se necesita un humano para resolver los líos más complicados causados por la IA. GitHub Copilot es un producto muy bien nombrado, es Copilot, un segundo piloto que ayuda al piloto principal en una aeronave. El programador sigue siendo el piloto principal, el capitán y lleva la máxima responsabilidad. + +Puede ser de tu interés que desactives Copilot por defecto cuando hagas este curso y confíes en él solo en una emergencia real. + +
    + +
    + +

    Ejercicios 1.6.-1.14.

    + +Envía tus soluciones a los ejercicios enviando primero tu código a GitHub y luego marcando los ejercicios completados en el [sistema de envío de ejercicios](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +Recuerda, envía **todos** los ejercicios de una parte **en una sola presentación**. Una vez que hayas enviado tus soluciones para una parte, **ya no podrás enviar más ejercicios a esa parte**. + +Algunos de los ejercicios funcionan en la misma aplicación. En estos casos, es suficiente enviar solo la versión final de la aplicación. Si lo deseas, puedes realizar un commit después de cada ejercicio terminado, pero no es obligatorio. + +En algunas situaciones, es posible que también debas ejecutar el siguiente comando desde la raíz del proyecto: + +```bash +rm -rf node_modules/ && npm i +``` + +Si y cuándo encuentras un mensaje de error + +> Los objetos no son válidos como hijos de React + +ten en cuenta las cosas que se cuentan [aquí](/es/part1/introduccion_a_react#no-renderizar-objetos). + +

    1.6: unicafe, paso 1

    + +Como la mayoría de las empresas, [Unicafe](https://www.unicafe.fi) recopila comentarios de sus clientes. Tu tarea es implementar una aplicación web para recopilar comentarios de los clientes. Solo hay tres opciones para los comentarios: good (bueno), neutral y bad(malo). + +La aplicación debe mostrar el número total de comentarios recopilados para cada categoría. Tu aplicación final podría verse así: + +![Captura de pantalla de las opciones de comentarios](../../images/1/13e.png) + +Ten en cuenta que tu aplicación debe funcionar solo durante una única sesión del navegador. Una vez que se actualice la página, los comentarios recopilados pueden desaparecer. + +Te recomendamos usar la misma estructura usada en el material y en el anterior ejercicio. El archivo main.jsx sería asi: + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' + +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +Podrías usar el siguiente código como punto de partida para el archivo App.jsx: + +```js +import { useState } from 'react' + +const App = () => { + // guarda los clics de cada botón en su propio estado + const [good, setGood] = useState(0) + const [neutral, setNeutral] = useState(0) + const [bad, setBad] = useState(0) + + return ( +
    + code here +
    + ) +} + +export default App +``` + +

    1.7: unicafe, paso 2

    + +Amplía tu aplicación para que muestre más estadísticas sobre los comentarios recopilados: el número total de comentarios recopilados, la puntuación promedio (buena: 1, neutral: 0, mala: -1) y el porcentaje de comentarios positivos. + +![Captura de pantalla del promedio y el porcentaje de comentarios positivos](../../images/1/14e.png) + +

    1.8: unicafe, paso 3

    + +Refactoriza tu aplicación para que la visualización de las estadísticas se extraiga en su propio componente Statistics. El estado de la aplicación debe permanecer en el componente raíz App. + +Recuerda que los componentes no deben definirse dentro de otros componentes: + +```js +// un lugar adecuado para definir un componente +const Statistics = (props) => { + // ... +} + +const App = () => { + const [good, setGood] = useState(0) + const [neutral, setNeutral] = useState(0) + const [bad, setBad] = useState(0) + + // no definas componentes adentro de otro componente + const Statistics = (props) => { + // ... + } + + return ( + // ... + ) +} +``` + +

    1.9: unicafe paso 4

    + +Cambia tu aplicación para mostrar estadísticas solo una vez que se hayan recopilado los comentarios. + +![Captura de pantalla con texto que indica que no se han dejado comentarios](../../images/1/15e.png) + +

    1.10: unicafe paso 5

    + +Continuemos refactorizando la aplicación. Extrae los siguiente dos componentes: + +- Button para definir los botones utilizados para enviar comentarios + +- StatisticLine para mostrar una única estadística, por ejemplo, la puntuación media. + +Para ser claros: el componente StatisticLine siempre muestra una única estadística, lo que significa que la aplicación utiliza varios componentes para representar todas las estadísticas: + +```js +const Statistics = (props) => { + /// ... + return( +
    + + + + // ... +
    + ) +} + +``` + +El estado de la aplicación aún debe mantenerse en el componente raíz App. + +

    1.11*: unicafe, paso 6

    + +Muestra las estadísticas en una [tabla](https://developer.mozilla.org/es/docs/Learn/HTML/Tables/Basics) HTML, de modo que tu aplicación se vea más o menos así: + +![Captura de pantalla de la tabla de estadísticas](../../images/1/16e.png) + +Recuerda mantener la consola abierta en todo momento. Si ves esta advertencia en tu consola: + +![Advertencia en la consola](../../images/1/17a.png) + +Entonces realiza las acciones necesarias para que la advertencia desaparezca. Intenta buscar en Google el mensaje de error si te quedas atascado. + +Una fuente típica de un error `Unchecked runtime.lastError: Could not establish connection. Receiving end does not exist.` es la extensión de Chrome. Intenta ir a _chrome://extensions_ y deshabilitarlas una por una y luego actualizar la página de la aplicación React; el error debería desaparecer eventualmente. + +**¡Asegúrate de que a partir de ahora no veas ninguna advertencia en tu consola!** + +

    1.12*: anecdotes, paso 1

    + +El mundo de la ingeniería de software está lleno de [anécdotas](http://www.comp.nus.edu.sg/~damithch/pages/SE-quotes.htm) que destilan verdades atemporales de nuestro campo en breves frases. + +Expande la siguiente aplicación agregando un botón en el que se pueda hacer clic para mostrar una anécdota aleatoria del campo de la ingeniería de software: + +```js +import { useState } from 'react' + +const App = () => { + const anecdotes = [ + 'If it hurts, do it more often.', + 'Adding manpower to a late software project makes it later!', + 'The first 90 percent of the code accounts for the first 10 percent of the development time...The remaining 10 percent of the code accounts for the other 90 percent of the development time.', + 'Any fool can write code that a computer can understand. Good programmers write code that humans can understand.', + 'Premature optimization is the root of all evil.', + 'Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it.', + 'Programming without an extremely heavy use of console.log is same as if a doctor would refuse to use x-rays or blood tests when diagnosing patients.', + 'The only way to go fast, is to go well.' + ] + + const [selected, setSelected] = useState(0) + + return ( +
    + {anecdotes[selected]} +
    + ) +} + +export default App +``` + +El contenido del archivo main.jsx es el mismo de los ejercicios anteriores. + +Busca como generar números aleatorios en JavaScript, por ejemplo, en un buscador o en [Mozilla Developer Network](https://developer.mozilla.org). Recuerda que puedes probar la generación de números aleatorios, por ejemplo, directamente en la consola de tu navegador. + +Tu aplicación finalizada podría verse así + +![anécdota aleatoria con el botón next](../../images/1/18a.png) + +

    1.13*: anecdotes, paso 2

    + +Expande tu aplicación para que puedas votar por la anécdota mostrada. + +![Aplicación de anécdotas con un botón para votar](../../images/1/19a.png) + +**Nota:** almacena los votos de cada anécdota en un array u objeto en el estado del componente. Recuerda que la forma correcta de actualizar el estado almacenado en estructuras de datos complejas como objetos y arrays es hacer una copia del estado. + +Puedes crear una copia de un objeto de esta forma: + +```js +const votes = { 0: 1, 1: 3, 2: 4, 3: 2 } + +const copy = { ...votes } +// incrementa en uno el valor de la propiedad 2 +copy[2] += 1 +``` + +O una copia de un array de esta forma: + +```js +const votes = [1, 4, 6, 3] + +const copy = [...votes] +// incrementa en uno el valor de la posición 2 +copy[2] += 1 +``` + +El uso de un array podría ser la opción más sencilla en este caso. Buscar en internet te proporcionará muchos consejos sobre cómo [crear un array lleno de ceros de la longitud deseada](https://stackoverflow.com/questions/20222501/how-to-create-a-zero-filled-javascript-array-of-arbitrary-length/22209781). + +

    1.14*: anecdotes, paso 3

    + +Ahora implementa la versión final de la aplicación que muestra la anécdota con el mayor número de votos + +![Anécdota con la mayor cantidad de votos](../../images/1/20a.png) + +Si varias anécdotas empatan en el primer lugar, es suficiente con solo mostrar una de ellas. + +Este fue el último ejercicio de esta parte del curso y es hora de enviar tu código a GitHub y marcar todos tus ejercicios terminados en el [sistema de envío de ejercicios](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    diff --git a/src/content/1/fi/osa1.md b/src/content/1/fi/osa1.md index b756add5b75..2e6265475df 100644 --- a/src/content/1/fi/osa1.md +++ b/src/content/1/fi/osa1.md @@ -8,4 +8,8 @@ lang: fi Alamme tässä osassa tutustua React-kirjastoon, jolla siis teemme sovelluksen selaimessa suoritettavan koodin. Teemme samalla myös nopean katsauksen Javascriptin Reactin kannalta oleellisimpiin ominaisuuksiin. +Osa päivitetty 17.1.2025 +- Node päivitetty versioon v22.3.0 +- Eslint-configuraatio siirtynyt eslint.config.js-tiedostoon + diff --git a/src/content/1/fi/osa1a.md b/src/content/1/fi/osa1a.md index 353f9130bda..25f57b98b29 100644 --- a/src/content/1/fi/osa1a.md +++ b/src/content/1/fi/osa1a.md @@ -7,59 +7,100 @@ lang: fi
    -Alamme nyt tutustua kurssin ehkä tärkeimpään teemaan, [React](https://reactjs.org/)-kirjastoon. Tehdään heti yksinkertainen React-sovellus ja tutustutaan samalla Reactin peruskäsitteistöön. +Alamme nyt tutustua kurssin ehkä tärkeimpään teemaan, [React](https://react.dev/)-kirjastoon. Tehdään heti yksinkertainen React-sovellus ja tutustutaan samalla Reactin peruskäsitteistöön. -Ehdottomasti helpoin tapa päästä alkuun on [create-react-app](https://github.com/facebookincubator/create-react-app)-nimisen työkalun käyttö. create-react-app on mahdollista asentaa omalle koneelle, mutta asennukseen ei ole tarvetta jos Noden mukana asentunut npm-työkalu on versioltaan vähintään 5.3. Tällöin npm:n mukana asentuu komento npx, joka mahdollistaa create-react-app:in käytön asentamatta sitä erikseen. Npm:n version saa selville komennolla npm -v. +Helpoin tapa päästä alkuun on [Vite](https://vitejs.dev/)-nimisen työkalun käyttö. -Luodaan sovellus nimeltään part1 ja mennään sovelluksen sisältämään hakemistoon: +Luodaan uusi sovellus create-vite-työkalun avulla: ```bash -$ npx create-react-app part1 -$ cd part1 +npm create vite@latest ``` -Kaikki tässä (ja jatkossa) annettavat merkillä $ alkavat komennot on kirjoitettu terminaaliin eli komentoriville. Merkkiä $ ei tule kirjoittaa, sillä se edustaa komentokehotetta. +Vastaillaan työkalun esittämiin kysymyksiin seuraavasti: -Sovellus käynnistetään seuraavasti +![create-vite-työkalun valintanäkymä, jossa valitaan projektin nimeksi part1, frameworkiksi React, variantiksi JavaScript ja muihin kysymyksiin vastataan kieltävästi](../../images/1/1-create-vite.png) + +Loimme siis part1-nimisen sovelluksen. Työkalu olisi voinut myös asentaa tarvittavat riippuvuudet ja käynnistää sen jälkeen sovelluksen automaattisesti, jos olisimme vastanneet myöntävästi kysymykseen "Install with npm and start now?" Teemme vaiheet nyt kuitenkin manuaalisesti, jotta pääsemme näkemään, miten ne tehdään. + +Siirrytään sovelluksen sisältämään hakemistoon ja asennetaan sovelluksen käyttämät kirjastot: + +```bash +cd part1 +npm install +``` + +Sovellus käynnistetään seuraavasti: ```bash -$ npm start +npm run dev ``` -Sovellus käynnistyy oletusarvoisesti localhostin porttiin 3000, eli osoitteeseen +Konsoli kertoo että sovellus on käynnistynyt localhostin porttiin 5173, eli osoitteeseen : + +![](../../images/1/1-vite1.png) + +Vite käynnistää sovelluksen [oletusarvoisesti](https://vitejs.dev/config/server-options.html#server-port) porttiin 5173. Jos se ei ole vapaana, käyttää Vite seuraavaa vapaata porttinumeroa. -Chromen pitäisi aueta automaattisesti. Avaa konsoli **välittömästi**. Avaa myös tekstieditori siten, että näet koodin ja web-sivun samaan aikaan ruudulla: +Avataan selain sekä tekstieditori siten, että näet koodin ja web-sivun samaan aikaan ruudulla: -![](../../images/1/1e.png) +![](../../images/1/1-vite4.png) -Sovelluksen koodi on hakemistossa src. Yksinkertaistetaan valmiina olevaa koodia siten, että tiedoston index.js sisällöksi tulee: +Sovelluksen koodi on hakemistossa src. Yksinkertaistetaan valmiina olevaa koodia siten, että tiedoston main.jsx sisällöksi tulee: ```js -import React from 'react' -import ReactDOM from 'react-dom' +import ReactDOM from 'react-dom/client' +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() + +``` + +ja tiedoston App.jsx sisällöksi + +```js const App = () => (

    Hello world

    ) -ReactDOM.render(, document.getElementById('root')) +export default App ``` -Tiedostot App.js, App.css, App.test.js, logo.svg ja serviceWorker.js voi poistaa sillä niitä emme sovelluksessamme nyt tarvitse. +Tiedostot App.css ja index.css sekä hakemiston assets voi poistaa, sillä emme tarvitse niitä. ### Komponentti -Tiedosto index.js määrittelee nyt React-[komponentin](https://reactjs.org/docs/components-and-props.html) nimeltään App ja viimeisen rivin komento +Tiedosto App.jsx määrittelee nyt React-[komponentin](https://react.dev/learn/your-first-component) nimeltään App. Tiedoston main.jsx viimeisen rivin komento ```js -ReactDOM.render(, document.getElementById('root')) +ReactDOM.createRoot(document.getElementById('root')).render() ``` -renderöi komponentin sisällön tiedoston public/index.html määrittelemään div-elementtiin, jonka id:n arvona on 'root'. +renderöi komponentin sisällön tiedoston index.html määrittelemään div-elementtiin, jonka id:n arvona on 'root'. + +Tiedosto index.html on headerin määrittelyjä lukuun ottamatta oleellisesti ottaen tyhjä: -Tiedosto public/index.html on oleellisesti ottaen tyhjä, voit kokeilla lisätä sinne HTML:ää. Reactilla ohjelmoitaessa yleensä kuitenkin kaikki renderöitävä sisältö määritellään Reactin komponenttien avulla. +```html + + + + + + + part1 + + +
    + + + + +``` + +Voit kokeilla lisätä tiedostoon HTML:ää. Reactilla ohjelmoitaessa yleensä kuitenkin kaikki renderöitävä sisältö määritellään Reactin komponenttien avulla. Tarkastellaan vielä tarkemmin komponentin määrittelevää koodia: @@ -73,7 +114,7 @@ const App = () => ( Kuten arvata saattaa, komponentti renderöityy div-tagina, jonka sisällä on p-tagin sisällä oleva teksti Hello world. -Teknisesti ottaen komponentti on määritelty Javascript-funktiona. Seuraava siis on funktio (joka ei saa yhtään parametria): +Teknisesti ottaen komponentti on määritelty JavaScript-funktiona. Seuraava on siis funktio (joka ei saa yhtään parametria): ```js () => ( @@ -89,9 +130,9 @@ joka sijoitetaan vakioarvoiseen muuttujaan App const App = ... ``` -Javascriptissa on muutama tapa määritellä funktioita. Käytämme nyt Javascriptin hieman uudemman version [EcmaScript 6:n](http://es6-features.org/#Constants) eli ES6:n [nuolifunktiota](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions) (arrow functions). +JavaScriptissa on muutama tapa määritellä funktioita. Käytämme nyt JavaScriptin hieman uudemman version [ECMAScript 6:n](http://es6-features.org/#Constants) eli ES6:n [nuolifunktiota](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions) (arrow functions). -Koska funktio koostuu vain yhdestä lausekkeesta, on käytössämme lyhennysmerkintä, joka vastaa oikeasti seuraavaa koodia: +Koska funktio koostuu vain yhdestä lausekkeesta, käytössämme on lyhennysmerkintä, joka vastaa seuraavaa koodia: ```js const App = () => { @@ -105,7 +146,7 @@ const App = () => { eli funktio palauttaa sisältämänsä lausekkeen arvon. -Komponentin määrittelevä funktio voi sisältää mitä tahansa Javascript-koodia. Muuta komponenttisi seuraavaan muotoon ja katso mitä konsolissa tapahtuu: +Komponentin määrittelevä funktio voi sisältää mitä tahansa JavaScript-koodia. Muuta komponenttisi seuraavaan muotoon: ```js const App = () => { @@ -116,8 +157,20 @@ const App = () => {
    ) } + +export default App ``` +ja katso mitä selaimen konsolissa tapahtuu: + +![](../../images/1/30.png) + +Web-sovelluskehityksen sääntö numero yksi on + +> pidä konsoli koko ajan auki + +Toistetaan tämä vielä yhdessä: pidän konsolin koko ajan auki tämän kurssin ja koko loppuelämäni ajan tehdessäni web-sovelluskehitystä. + Komponenttien sisällä on mahdollista renderöidä myös dynaamista sisältöä. Muuta komponentti muotoon: @@ -127,6 +180,7 @@ const App = () => { const now = new Date() const a = 10 const b = 20 + console.log(now, a+b) return (
    @@ -139,18 +193,25 @@ const App = () => { } ``` -Aaltosulkeiden sisällä oleva Javascript-koodi evaluoidaan ja evaluoinnin tulos upotetaan määriteltyyn kohtaan komponentin tuottamaa HTML-koodia. +Aaltosulkeiden sisällä oleva JavaScript-koodi evaluoidaan ja evaluoinnin tulos upotetaan määriteltyyn kohtaan komponentin tuottamaa HTML-koodia. + +Huom: älä poista tiedoston lopusta riviä + +```js +export default App +``` + +Kyseistä riviä ei useimmiten näytetä materiaalin esimerkeissä mutta ilman sitä komponentti ja koko ohjelma hajoaa. + +Muistitko pitää konsolin auki? Mitä sinne tulostui? ### JSX -Näyttää siltä, että React-komponentti palauttaa HTML-koodia. Näin ei kuitenkaan ole. React-komponenttien ulkoasu kirjoitetaan yleensä [JSX](https://reactjs.org/docs/introducing-jsx.html):ää käyttäen. Vaikka JSX näyttää HTML:ltä, kyseessä on kuitenkin tapa kirjoittaa Javascriptiä. React komponenttien palauttama JSX käännetään konepellin alla Javascriptiksi. +Näyttää siltä, että React-komponentti palauttaa HTML-koodia. Näin ei kuitenkaan ole. React-komponenttien ulkoasu kirjoitetaan yleensä [JSX](https://react.dev/learn/writing-markup-with-jsx):ää käyttäen. Vaikka JSX näyttää HTML:ltä, kyseessä on kuitenkin tapa kirjoittaa JavaScriptia. React-komponenttien palauttama JSX käännetään konepellin alla JavaScriptiksi. -Käännösvaiheen jälkeen ohjelmamme näyttää seuraavalta: +Käännösvaiheen jälkeen komponentin määrittelevä koodi näyttää seuraavalta: ```js -import React from 'react' -import ReactDOM from 'react-dom' - const App = () => { const now = new Date() const a = 10 @@ -166,20 +227,15 @@ const App = () => { ) ) } - -ReactDOM.render( - React.createElement(App, null), - document.getElementById('root') -) ``` -Käännöksen hoitaa [Babel](https://babeljs.io/repl/). Create-react-app:illa luoduissa projekteissa käännös on konfiguroitu tapahtumaan automaattisesti. Tulemme tutustumaan aiheeseen tarkemmin kurssin [osassa 7](/osa7). +Käännöksen hoitaa [Babel](https://babeljs.io/repl/). Vitellä luoduissa projekteissa käännös on konfiguroitu tapahtumaan automaattisesti. Tulemme tutustumaan aiheeseen tarkemmin kurssin [osassa 7](/osa7). -Reactia olisi myös mahdollista kirjoittaa "suoraan Javascriptinä" käyttämättä JSX:ää. Kukaan täysijärkinen ei kuitenkaan niin tee. +Reactia olisi mahdollista kirjoittaa myös "suoraan JavaScriptinä" käyttämättä JSX:ää, mutta tämä ei ole järkevää. -Käytännössä JSX on melkein kuin HTML:ää sillä erotuksella, että mukaan voi upottaa helposti dynaamista sisältöä kirjoittamalla sopivaa Javascriptiä aaltosulkeiden sisälle. Idealtaan JSX on melko lähellä monia palvelimella käytettäviä templating-kieliä kuten Java Springin yhteydessä käytettävää thymeleafia. +Käytännössä JSX on melkein kuin HTML:ää sillä erotuksella, että mukaan voi upottaa helposti dynaamista sisältöä kirjoittamalla sopivaa JavaScriptia aaltosulkeiden sisälle. Idealtaan JSX on melko lähellä monia palvelimella käytettäviä templating-kieliä kuten Java Springin yhteydessä käytettävää Thymeleafia. -JSX on "XML:n kaltainen", eli jokainen tagi tulee sulkea. Esimerkiksi rivinvaihto on tyhjä elementti, joka voidaan kirjottaa HTML:ssä seuraavasti +JSX on "XML:n kaltainen", eli jokainen tagi tulee sulkea. Esimerkiksi rivinvaihto on tyhjä elementti, joka voidaan kirjoittaa HTML:ssä seuraavasti ```html
    @@ -193,7 +249,7 @@ mutta JSX:ää kirjoittaessa tagi on pakko sulkea: ### Monta komponenttia -Muutetaan sovellusta seuraavasti (yläreunan importit jätetään esimerkeistä nyt ja jatkossa pois, niiden on kuitenkin oltava koodissa jotta ohjelma toimisi): +Muutetaan tiedostoa App.jsx seuraavasti (muista, että alimman rivin export jätetään esimerkeistä nyt ja jatkossa pois, niiden on kuitenkin oltava koodissa jotta ohjelma toimisi): ```js // highlight-start @@ -214,8 +270,6 @@ const App = () => {
    ) } - -ReactDOM.render(, document.getElementById('root')) ``` Olemme määritelleet uuden komponentin Hello, jota käytetään komponentista App. Komponenttia voidaan luonnollisesti käyttää monta kertaa: @@ -237,13 +291,13 @@ const App = () => { Komponenttien tekeminen Reactissa on helppoa ja komponentteja yhdistelemällä monimutkaisempikin sovellus on mahdollista pitää kohtuullisesti ylläpidettävänä. Reactissa filosofiana onkin koostaa sovellus useista, pieneen asiaan keskittyvistä uudelleenkäytettävistä komponenteista. -Vahva konventio on myös se, että sovelluksen ylimpänä oleva juurikomponentti on nimeltään App. Tosin kuten [osassa 6](/osa6) tulemme näkemään on tilanteita, joissa komponentti App ei ole suoraan juuressa, vaan se kääritään sopivan apukomponentin sisään. +Vahva konventio on myös se, että sovelluksen ylimpänä oleva juurikomponentti on nimeltään App. Tosin kuten [osassa 6](/osa6) tulemme näkemään, on tilanteita, joissa komponentti App ei ole suoraan juuressa, vaan se kääritään sopivan apukomponentin sisään. ### props: tiedonvälitys komponenttien välillä -Komponenteille on mahdollista välittää dataa [propsien](https://reactjs.org/docs/components-and-props.html) avulla. +Komponenteille on mahdollista välittää dataa [propsien](https://react.dev/learn/passing-props-to-a-component) avulla. -Muutetaan komponenttia Hello seuraavasti +Muutetaan komponenttia Hello seuraavasti: ```js const Hello = (props) => { // highlight-line @@ -255,7 +309,7 @@ const Hello = (props) => { // highlight-line } ``` -komponentin määrittelevällä funktiolla on nyt parametri props. Parametri saa arvokseen olion, jonka kenttinä ovat kaikki eri "propsit", jotka komponentin käyttäjä määrittelee. +Komponentin määrittelevällä funktiolla on nyt parametri props. Parametri saa arvokseen olion, jonka kenttinä ovat kaikki eri "propsit", jotka komponentin käyttäjä määrittelee. Propsit määritellään seuraavasti: @@ -271,12 +325,13 @@ const App = () => { } ``` -Propseja voi olla mielivaltainen määrä ja niiden arvot voivat olla "kovakoodattuja" merkkijonoja tai Javascript-lausekkeiden tuloksia. Jos propsin arvo muodostetaan Javascriptillä, tulee se olla aaltosulkeissa. +Propseja voi olla mielivaltainen määrä ja niiden arvot voivat olla "kovakoodattuja" merkkijonoja tai JavaScript-lausekkeiden tuloksia. Jos propsin arvo muodostetaan JavaScriptillä, sen tulee olla aaltosulkeissa. Muutetaan koodia siten, että komponentti Hello käyttää kahta propsia: ```js const Hello = (props) => { + console.log(props) // highlight-line return (

    @@ -302,15 +357,70 @@ const App = () => { Komponentti App lähettää propseina muuttujan arvoja, summalausekkeen evaluoinnin tuloksen ja normaalin merkkijonon. +Komponentti Hello myös tulostaa props-olion arvon konsoliin. + +Toivottavasti konsolisi on auki, jos ei ole, muista yhteinen lupauksemme: + +> pidän konsolin koko ajan auki tämän kurssin ja koko loppuelämäni ajan tehdessäni web-sovelluskehitystä + +Ohjemistokehitys on haastavaa, ja erityisen haastavaksi se muuttuu, jos jokainen mahdollinen apukeino kuten web-konsoli sekä komennolla _console.log_ tehtävät aputulostukset eivät ole käytössä. Ammattilaiset käyttävät näitä aina. Ei ole yhtään syytä miksi aloittelijan pitäisi jättää nämä fantastiset apuvälineet hyödyntämättä. + +### Mahdollinen virheilmoitus + +Jos projektiisi on asennettuna Reactin versio 18 tai aiempi, saatat saada tässä vaiheessa seuraavan virheilmoituksen: + +![](../../images/1/1-vite5.png) + +Kyse ei ole varsinaisesta virheestä vaan [ESLint](https://eslint.org/)-työkalun aiheuttamasta varoituksesta. Saat hiljennettyä varoituksen [react/prop-types](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/prop-types.md) lisäämällä tiedostoon eslint.config.js seuraavan rivin + +```js +export default [ + { ignores: ['dist'] }, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + settings: { react: { version: '18.3' } }, + plugins: { + react, + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...js.configs.recommended.rules, + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + ...reactHooks.configs.recommended.rules, + 'react/jsx-no-target-blank': 'off', + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + 'react/prop-types': 0, // highlight-line + }, + }, +] +``` + +Tutustumme ESLintiin tarkemmin [osassa 3](/osa3/validointi_ja_es_lint#lint). + + ### Muutamia huomioita React on konfiguroitu antamaan varsin hyviä virheilmoituksia. Kannattaa kuitenkin edetä ainakin alussa **todella pienin askelin** ja varmistaa, että jokainen muutos toimii halutulla tavalla. -**Konsolin tulee olla koko ajan auki**. Jos selain ilmoittaa virheestä, ei kannata kirjoittaa sokeasti lisää koodia ja toivoa ihmettä tapahtuvaksi, vaan tulee yrittää ymmärtää virheen syy ja esim. palata edelliseen toimivaan tilaan: +**Konsolin tulee olla koko ajan auki**. Jos selain ilmoittaa virheestä, ei kannata kirjoittaa sokeasti lisää koodia ja toivoa ihmettä tapahtuvaksi, vaan tulee yrittää ymmärtää virheen syy ja esim. palata edelliseen toimivaan tilaan: -![](../../images/1/2a.png) +![](../../images/1/1-vite6.png) -Kannattaa myös muistaa, että React-koodissakin on mahdollista ja kannattavaa lisätä koodin sekaan sopivia konsoliin tulostavia console.log()-komentoja. Tulemme hieman [myöhemmin](#react-sovellusten-debuggaus) tutustumaan muutamiin muihinkin tapoihin debugata Reactia. +Kuten jo todettiin, myös React-koodissa on mahdollista ja kannattavaa lisätä koodin sekaan sopivia konsoliin tulostavia console.log()-komentoja. Tulemme hieman [myöhemmin](#react-sovellusten-debuggaus) tutustumaan muutamiin muihinkin tapoihin debugata Reactia. Kannattaa pitää mielessä, että **React-komponenttien nimien tulee alkaa isolla kirjaimella**. Jos yrität määritellä komponentin seuraavasti: @@ -325,7 +435,7 @@ const footer = () => { } ``` -ja ottaa se käyttöön +ja ottaa sen käyttöön ```js const App = () => { @@ -339,9 +449,9 @@ const App = () => { } ``` -sivulle ei kuitenkaan ilmesty näkyviin Footer-komponentissa määriteltyä sisältöä, vaan React luo sivulle ainoastaan tyhjän footer-elementin. Jos muutat komponentin nimen alkamaan isolla kirjaimella, React luo sivulle div-elementin, joka määriteltiin Footer-komponentissa. +sivulle ei ilmestykään näkyviin Footer-komponentissa määriteltyä sisältöä, vaan React luo sivulle ainoastaan tyhjän footer-elementin. Jos muutat komponentin nimen alkamaan isolla kirjaimella, React luo sivulle div-elementin, joka määriteltiin Footer-komponentissa. -Kannattaa myös pitää mielessä, että React-komponentin sisällön tulee (yleensä) sisältää **yksi juurielementti**. Eli jos yrittäisimme määritellä komponentin App ilman uloimmaista div-elementtiä: +Kannattaa pitää mielessä myös, että React-komponentin sisällön tulee (yleensä) sisältää **yksi juurielementti**. Eli jos yrittäisimme määritellä komponentin App ilman uloimmaista div-elementtiä ```js const App = () => { @@ -355,7 +465,7 @@ const App = () => { seurauksena on virheilmoitus: -![](../../images/1/3a.png) +![](../../images/1/1-vite7.png) Juurielementin käyttö ei ole ainoa toimiva vaihtoehto, myös taulukollinen komponentteja on validi tapa: @@ -369,9 +479,9 @@ const App = () => { } ``` -Määritellessä sovelluksen juurikomponenttia, tämä ei kuitenkaan ole järkevää ja näyttää koodissakin pahalta. +Määriteltäessä sovelluksen juurikomponenttia tämä ei kuitenkaan ole järkevää, ja taulukko näyttää koodissakin pahalta. -Juurielementin pakollisesta käytöstä on se seuraus, että sovelluksen DOM-puuhun tulee "ylimääräisiä" div-elementtejä. Tämä on mahdollista välttää käyttämällä [fragmentteja](https://reactjs.org/docs/fragments.html#short-syntax), eli ympäröimällä komponentin palauttamat elementit tyhjällä elementillä: +Juurielementin pakollisesta käytöstä on se seuraus, että sovelluksen DOM-puuhun tulee "ylimääräisiä" div-elementtejä. Tämä on mahdollista välttää käyttämällä [fragmentteja](https://react.dev/reference/react/Fragment), eli ympäröimällä komponentin palauttamat elementit tyhjällä elementillä: ```js const App = () => { @@ -389,7 +499,105 @@ const App = () => { } ``` -Nyt käännös menee läpi ja Reactin generoimaan DOM:iin ei tule ylimääräistä div-elementtiä. +Nyt käännös menee läpi, ja Reactin generoimaan DOM:iin ei tule ylimääräistä div-elementtiä. + +### Älä renderöi olioita + +Tarkastellaan sovellusta, joka tulostaa ruudulle ystäviemme nimet ja iät: + +```js +const App = () => { + const friends = [ + { name: 'Leevi', age: 4 }, + { name: 'Venla', age: 10 }, + ] + + return ( +

    +

    {friends[0]}

    +

    {friends[1]}

    +
    + ) +} + +export default App +``` + +Mitään ei kuitenkaan tule ruudulle. Yritän etsiä koodista 15 minuutin ajan ongelmaa, mutta en keksi missä vika voisi olla. + +Vihdoin mieleeni palaa antamamme lupaus + +> pidän konsolin koko ajan auki tämän kurssin ja koko loppuelämäni ajan tehdessäni web-sovelluskehitystä + +Konsoli huutaakin punaisena: + +![](../../images/1/34new.png) + +Ongelman ydin on Objects are not valid as a React child eli sovellus yrittää renderöidä olioita ja se taas ei onnistu. + +Koodissa yhden ystävän tiedot yritetään renderöidä seuraavasti + +```js +

    {friends[0]}

    +``` + +ja tämä aiheuttaa ongelman sillä aaltosulkeissa oleva renderöitävä asia on olio + +```js +{ name: 'Leevi', age: 4 } +``` + +Yksittäisten aaltosulkeissa renderöitävien asioiden tulee Reactissa olla primitiivisiä arvoja, kuten lukuja tai merkkijonoja. + +Korjaus on seuraava + +```js +const App = () => { + const friends = [ + { name: 'Leevi', age: 4 }, + { name: 'Venla', age: 10 }, + ] + + return ( +
    +

    {friends[0].name} {friends[0].age}

    +

    {friends[1].name} {friends[1].age}

    +
    + ) +} + +export default App +``` + +Eli nyt aaltosulkeiden sisällä renderöidään erikseen ystävän nimi + +```js +{friends[0].name} +``` + +ja ikä + +```js +{friends[0].age} +``` + +Virheen korjauksen jälkeen kannattaa konsolin virheilmoitukset tyhjentää painamalla Ø, uudelleenladata tämän jälkeen sivun sisältö ja varmistua että virheilmoituksia ei näy. + +Pieni lisähuomio edelliseen. React sallii myös taulukoiden renderöimisen jos taulukko sisältää arvoja, jotka kelpaavat renderöitäviksi (kuten numeroita tai merkkijonoja). Eli seuraava ohjelma kyllä toimisi, vaikka tulos ei ole kenties se mitä haluamme: + +```js +const App = () => { + const friends = [ 'Leevi', 'Venla'] + + return ( +
    +

    {friends}

    +
    + ) +} +``` + +Tässä osassa ei kannata edes yrittää hyödyntää taulukoiden suoraa renderöintiä. Palaamme siihen seuraavassa osassa.
    @@ -398,9 +606,9 @@ Nyt käännös menee läpi ja Reactin generoimaan DOM:iin ei tule ylimääräist Tehtävät palautetaan GitHubin kautta ja merkitsemällä tehdyt tehtävät [palautussovellukseen](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). -Voit palauttaa kurssin kaikki tehtävät samaan repositorioon, tai käyttää useita repositorioita. Jos palautat eri osien tehtäviä samaan repositorioon, käytä järkevää hakemistojen nimentää. Jos käytät privaattirepositorioa tehtävien palautukseen liitä repositoriolle collaboratoriksi mluukkai +Voit palauttaa kurssin kaikki tehtävät samaan repositorioon tai käyttää useita repositorioita. Jos palautat eri osien tehtäviä samaan repositorioon, nimeä hakemistot järkevästi. Jos käytät yksityistä (private) repositoriota tehtävien palautukseen, liitä repositoriolle collaboratoriksi mluukkai. -Eräs varsin toimiva hakemistorakenne palautusrepositoriolle on [tässä esimerkkirepositoriossa käytetty tapa](https://github.com/fullstack-hy2020/palauitusrepositorio), jossa kutakin osaa kohti on oma hakemistonsa, joka vielä jakautuu tehtäväsarjat (esim. osan 1 kurssitiedot) sisältäviin hakemistoihin: +Eräs varsin toimiva hakemistorakenne palautusrepositoriolle on [tässä esimerkkirepositoriossa käytetty tapa](https://github.com/FullStack-HY2020/palauitusrepositorio), jossa kutakin osaa kohti on oma hakemistonsa, joka vielä jakautuu tehtäväsarjat (esim. osan 1 kurssitiedot) sisältäviin hakemistoihin: ``` osa0 @@ -417,24 +625,31 @@ Kunkin tehtäväsarjan ohjelmasta kannattaa palauttaa kaikki sovelluksen sisält Tehtävät palautetaan **yksi osa kerrallaan**. Kun olet palauttanut osan tehtävät, et voi enää palauttaa saman osan tekemättä jättämiäsi tehtäviä. -Huomaa, että tässä osassa on muitakin tehtäviä kuin allaolevat, eli älä tee palautusta ennen kun olet tehnyt osan tehtävistä kaikki mitkä haluat palauttaa. +Huomaa, että tässä osassa on muitakin tehtäviä kuin alla olevat. Älä siis tee palautusta ennen kun olet tehnyt osan tehtävistä kaikki, jotka haluat palauttaa. -**Vinkki:** Kun olet avaamassa tehtävääsi Visual Studio Codella, huomaathan avata koko projektin kansion editoriin. Tämä mahdollistaa editorissa helpomman tiedostojen välillä siirtymisen ja paremmat automaattiset täydennykset. Saat tämän tehtyä siirtymällä terminaalissa projektin kansioon ja suorittamalla komennon: +**Vinkki:** Kun olet avaamassa tehtävääsi Visual Studio Codella, huomaathan avata koko projektin kansion editoriin. Tämä mahdollistaa editorissa helpomman tiedostojen välillä siirtymisen ja paremmat automaattiset täydennykset. Tämä onnistuu siirtymällä terminaalissa projektin kansioon ja komentamalla: ```bash -$ code . +code . ```

    1.1: kurssitiedot, step1

    -Tässä tehtävässä aloitettavaa ohjelmaa kehitellään eteenpäin muutamassa seuraavassa tehtävässä. Tässä ja kurssin aikana muissakin vastaantulevissa tehtäväsarjoissa ohjelman lopullisen version palauttaminen riittää, voit toki halutessasi tehdä commitin jokaisen tehtävän jälkeisestä tilanteesta, mutta se ei ole välttämätöntä. +Tässä tehtävässä aloitettavaa ohjelmaa kehitellään eteenpäin muutamassa seuraavassa tehtävässä. Tässä ja kurssin aikana muissakin vastaan tulevissa tehtäväsarjoissa ohjelman lopullisen version palauttaminen riittää. Voit toki halutessasi tehdä commitin jokaisen tehtävän jälkeisestä tilanteesta, mutta se ei ole välttämätöntä. -Luo create-react-app:illa uusi sovellus. Muuta index.js muotoon +Luo Vitellä uusi sovellus. Muuta main.jsx muotoon ```js -import React from 'react' -import ReactDOM from 'react-dom' +import ReactDOM from 'react-dom/client' +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +ja tiedosto App.jsx muotoon + +```js const App = () => { const course = 'Half Stack application development' const part1 = 'Fundamentals of React' @@ -461,13 +676,15 @@ const App = () => { ) } -ReactDOM.render(, document.getElementById('root')) +export default App ``` -ja poista ylimääräiset tiedostot (App.js, App.css, App.test.js, logo.svg, serviceWorker.js). +ja poista ylimääräiset tiedostot App.css ja index.css ja hakemisto assets. Koko sovellus on nyt ikävästi yhdessä komponentissa. Refaktoroi sovelluksen koodi siten, että se koostuu kolmesta uudesta komponentista: Header, Content ja Total. Kaikki data pidetään edelleen komponentissa App, joka välittää tarpeelliset tiedot kullekin komponentille props:ien avulla. Header huolehtii kurssin nimen renderöimisestä, Content osista ja niiden tehtävämääristä ja Total tehtävien yhteismäärästä. +Tee uudet komponentit tiedostoon App.jsx. + Komponentin App runko tulee olemaan suunnilleen seuraavanlainen: ```js @@ -484,7 +701,13 @@ const App = () => { } ``` -**VAROITUS** create-react-app tekee projektista automaattisesti git-repositorion, ellei sovellusta luoda jo olemassaolevan repositorion sisälle. Todennäköisesti **et halua** että projektista tulee repositorio, joten suorita projektin juuressa komento _rm -rf .git_. +**VAROITUS** älä yritä tehdä ohjelmassasi kaikkia komponentteja yhtä aikaa, sillä se johtaa lähes varmasti siihen että ohjelma ei toimi. Etene pieni askel kerrallaan, tee aluksi esim. komponentti Header ja vasta kun se toimii 100% varmasti, kannattaa edetä seuraavaan komponenttiin. + +Huolellinen, pienin askelin eteneminen saattaa tuntua hitaalta, mutta se on itse asiassa ylivoimaisesti nopein tapa edetä. Kuuluisa ohjelmistokehittäjä Robert "Uncle Bob" Martin on todennut + +> "The only way to go fast, is to go well" + +eli Martinin mukaan pienin askelin tapahtuva huolellinen eteneminen on jopa ainoa tapa olla nopea.

    1.2: kurssitiedot, step2

    @@ -502,6 +725,6 @@ const Content = ... { } ``` -Sovelluksemme tiedonvälitys on tällä hetkellä todella alkukantaista, sillä se perustuu yksittäisiin muuttujiin. Tilanne paranee pian. + Sovelluksemme tiedonvälitys on tällä hetkellä todella arkaaista, sillä se perustuu yksittäisiin muuttujiin. Tilanne paranee pian. diff --git a/src/content/1/fi/osa1b.md b/src/content/1/fi/osa1b.md index 418615ddb93..b8196d6da25 100644 --- a/src/content/1/fi/osa1b.md +++ b/src/content/1/fi/osa1b.md @@ -7,66 +7,68 @@ lang: fi
    -Kurssin aikana on websovelluskehityksen rinnalla tavoite ja tarve oppia riittävässä määrin Javascriptiä. +Kurssin aikana on web-sovelluskehityksen rinnalla tavoite ja tarve oppia riittävässä määrin JavaScriptia. -Javascript on kehittynyt viime vuosina nopeaan tahtiin, ja käytämme kurssilla kielen uusimpien versioiden piirteitä. Javascript-standardin virallinen nimi on [ECMAScript](https://en.wikipedia.org/wiki/ECMAScript). Tämän hetken tuorein versio on kesäkuussa 2019 julkaistu [ES10](https://www.ecma-international.org/ecma-262/10.0/index.html), toiselta nimeltään ECMAScript 2019. +JavaScript on kehittynyt viime vuosina nopeaan tahtiin, ja käytämme kurssilla kielen uusimpien versioiden piirteitä. JavaScript-standardin virallinen nimi on [ECMAScript](https://en.wikipedia.org/wiki/ECMAScript). Tämän hetken tuorein versio on kesäkuussa 2024 julkaistu [ES15](https://www.ecma-international.org/ecma-262/), toiselta nimeltään ECMAScript 2024 . -Selaimet eivät vielä osaa kaikkia Javascriptin uusimpien versioiden ominaisuuksia. Tämän takia selaimessa suoritetaan useimmiten koodia joka on käännetty (englanniksi transpiled) uudemmasta Javascriptin versiosta johonkin vanhempaan, laajemmin tuettuun versioon. +Selaimet eivät vielä osaa kaikkia JavaScriptin uusimpien versioiden ominaisuuksia. Tämän takia selaimessa suoritetaan useimmiten koodia, joka on käännetty (englanniksi transpiled) uudemmasta JavaScriptin versiosta johonkin vanhempaan, laajemmin tuettuun versioon. -Tällä hetkellä johtava tapa tehdä transpilointi on [Babel](https://babeljs.io/). Create-react-app:in avulla luoduissa React-sovelluksissa on valmiiksi konfiguroitu automaattinen transpilaus. Katsomme kurssin [osassa 7](/osa7) tarkemmin miten transpiloinnin konfigurointi tapahtuu. +Tällä hetkellä johtava tapa tehdä transpilointi on [Babel](https://babeljs.io/). Viten avulla luoduissa React-sovelluksissa on valmiiksi konfiguroitu automaattinen transpilaus. Katsomme kurssin [osassa 7](/osa7) tarkemmin miten transpiloinnin konfigurointi tapahtuu. -[Node.js](https://nodejs.org/en/) on melkein missä vaan, mm. palvelimilla toimiva, Googlen [chrome V8](https://developers.google.com/v8/)-javascriptmoottoriin perustuva Javascript-suoritusympäristö. Harjoitellaan hieman Javascriptiä Nodella. Tässä oletetaan, että koneellasi on Node.js:stä vähintään versio 10.18.0. Noden tuoreet versiot osaavat suoraan Javascriptin kohtuullisen uusia versioita, joten koodin transpilaus ei ole tarpeen. +[Node.js](https://nodejs.org/en/) on melkein missä vaan (mm. palvelimilla) toimiva, Googlen [Chrome V8](https://developers.google.com/v8/)-JavaScript-moottoriin perustuva JavaScript-suoritusympäristö. Harjoitellaan hieman JavaScriptia Nodella. Noden tuoreet versiot osaavat suoraan JavaScriptin kohtuullisen uusia versioita, joten koodin transpilaus ei ole tarpeen. -Koodi kirjoitetaan .js-päätteiseen tiedostoon, ja suoritetaan komennolla node tiedosto.js +Koodi kirjoitetaan .js-päätteiseen tiedostoon ja suoritetaan komennolla node tiedosto.js -Koodia on mahdollisuus kirjoittaa myös Node.js-konsoliin, joka aukeaa kun kirjoitat komentorivillä _node_ tai myös selaimen developer toolin konsoliin. Chromen uusimmat versiot osaavat suoraan transpiloimatta [melko hyvin](http://kangax.github.io/compat-table/es2016plus/) Javascriptin uusiakin piirteitä. +Koodia on mahdollista kirjoittaa myös Node.js-konsoliin, joka aukeaa kun kirjoitat komentorivillä _node_, tai myös selaimen developer toolin konsoliin. Chromen uusimmat versiot osaavat suoraan transpiloimatta [melko hyvin](https://compat-table.github.io/compat-table/es2016plus/) JavaScriptin uusiakin piirteitä. -Javascript muistuttaa nimensä ja syntaksinsa puolesta läheisesti Javaa. Perusmekanismeiltaan kielet kuitenkin poikkeavat radikaalisti. Java-taustalta tultaessa Javascriptin käyttäytyminen saattaa aiheuttaa hämmennystä, varsinkin jos kielen piirteistä ei viitsitä ottaa selvää. +JavaScript muistuttaa nimensä ja syntaksinsa puolesta läheisesti Javaa. Perusmekanismeiltaan kielet kuitenkin poikkeavat radikaalisti. Java-ohjelmoijalle JavaScriptin käyttäytyminen saattaa aiheuttaa hämmennystä, erityisesti jos kielen piirteistä ei viitsi ottaa selvää. -Tietyissä piireissä on myös ollut suosittua yrittää "simuloida" Javascriptilla eräitä Javan piirteitä ja ohjelmointitapoja. En suosittele. +Tietyissä piireissä on myös ollut suosittua yrittää "simuloida" JavaScriptilla eräitä Javan, Pythonin tai muiden tavanomaisten olio-ohjelmointikielien piirteitä ja ohjelmointitapoja, mutta se ei ole suositeltavaa. ### Muuttujat -Javascriptissä on muutama tapa määritellä muuttujia: +JavaScriptissä on muutama tapa määritellä muuttujia: ```js const x = 1 let y = 5 -console.log(x, y) // tulostuu 1, 5 +console.log(x, y) // tulostuu 1 5 y += 10 -console.log(x, y) // tulostuu 1, 15 +console.log(x, y) // tulostuu 1 15 y = 'teksti' -console.log(x, y) // tulostuu 1, teksti +console.log(x, y) // tulostuu 1 teksti x = 4 // aiheuttaa virheen ``` [const](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const) ei oikeastaan määrittele muuttujaa vaan vakion, jonka arvoa ei voi enää muuttaa. [let](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let) taas määrittelee normaalin muuttujan. -Esimerkistä näemme myös, että muuttujan tallettaman tiedon tyyppi voi vaihtua suorituksen aikana, _y_ tallettaa aluksi luvun ja lopulta merkkijonon. +Muuttujan tallettaman tiedon tyyppi voi vaihtua suorituksen aikana, _y_ tallettaa aluksi luvun ja lopulta merkkijonon. -Javascriptissa on myös mahdollista määritellä muuttujia avainsanan [var](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/var) avulla. Var oli pitkään ainoa tapa muuttujien määrittelyyn, const ja let tulivat kieleen mukaan vasta versiossa ES6. Var toimii tietyissä tilanteissa [eri](https://medium.com/craft-academy/javascript-variables-should-you-use-let-var-or-const-394f7645c88f) [tavalla](http://www.jstips.co/en/javascript/keyword-var-vs-let/) kuin useimpien muiden kielien muuttujien määrittely. Tällä kurssilla varin käyttö ei ole suositeltavaa eli käytä aina const:ia tai let:iä! +JavaScriptissa on myös mahdollista määritellä muuttujia avainsanan [var](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/var) avulla. Var oli pitkään ainoa tapa muuttujien määrittelyyn, const ja let tulivat kieleen mukaan vasta vuonna 2015 Javascriptin versiossa ES6. Var toimii tietyissä tilanteissa [eri](https://medium.com/craft-academy/javascript-variables-should-you-use-let-var-or-const-394f7645c88f) [tavalla](http://www.jstips.co/en/javascript/keyword-var-vs-let/) kuin useimpien muiden kielien muuttujien määrittely. Tällä kurssilla var:in käyttö ei ole suositeltavaa, eli käytä aina const:ia tai let:iä! -Lisää aiheesta esim. youtubessa [var, let and const - ES6 JavaScript Features](https://youtu.be/sjyJBL5fkp8) +Lisää aiheesta on esim. YouTubessa: [var, let and const - ES6 JavaScript Features](https://youtu.be/sjyJBL5fkp8) ### Taulukot -[Taulukko](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array) ja muutama esimerkki sen käytöstä +[Taulukko](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array) ja muutama esimerkki sen käytöstä: ```js const t = [1, -1, 3] -t.push(5) +console.log(t.length) // tulostuu 3 +console.log(t[1]) // tulostuu -1 + +t.push(5) // lisätään taulukkoon luku 5 console.log(t.length) // tulostuu 4 -console.log(t[1]) // tulostuu -1 t.forEach(value => { console.log(value) // tulostuu 1, -1, 3, 5 omille riveilleen }) ``` -Huomattavaa esimerkissä on se, että taulukon sisältöä voi muuttaa, vaikka se on määritelty _const_:ksi. Koska taulukko on olio, viittaa muuttuja koko ajan samaan olioon, jonka sisältö muuttuu sitä mukaa kuin taulukkoon lisätään uusia alkioita. +Huomaa, että taulukon sisältöä voi muuttaa, vaikka taulukko on määritelty _const_:ksi. Tämä johtuu siitä, että taulukko on olio. Muuttuja _t_ viittaa koko ajan samaan olioon, vaikka olion sisältö muuttuukin, kun taulukkoon lisätään uusia alkioita. Eräs tapa käydä taulukon alkiot läpi on esimerkissä käytetty _forEach_, joka saa parametrikseen nuolisyntaksilla määritellyn funktion @@ -76,9 +78,9 @@ value => { } ``` -forEach kutsuu funktiota jokaiselle taulukon alkiolle, antaen taulukon yksittäisen alkion yksi kerrallaan funktiolle parametrina. forEachin parametrina oleva funktio voi saada myös [muita parametreja](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach). +forEach kutsuu funktiota jokaiselle taulukon alkiolle antaen kunkin taulukon yksittäisen alkion yksi kerrallaan funktiolle parametrina. forEachin parametrina oleva funktio voi saada myös [muita parametreja](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach). -Edellisessä esimerkissä taulukkoon lisättiin uusi alkio metodilla [push](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push). Reactin yhteydessä sovelletaan usein funktionaalisen ohjelmoinnin tekniikoita, jonka eräs piirre on käyttää muuttumattomia (engl. [immutable](https://en.wikipedia.org/wiki/Immutable_object)) tietorakenteita. React-koodissa kannattaakin mieluummin käyttää metodia [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat), joka ei lisää alkiota taulukkoon vaan luo uuden taulukon, jossa on lisättävä alkio sekä vanhan taulukon sisältö: +Edellisessä esimerkissä taulukkoon lisättiin uusi alkio metodilla [push](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push). Reactin yhteydessä sovelletaan usein funktionaalisen ohjelmoinnin tekniikoita, ja eräs piirre on käyttää muuttumattomia (engl. [immutable](https://en.wikipedia.org/wiki/Immutable_object)) tietorakenteita. React-koodissa kannattaakin mieluummin käyttää metodia [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat), joka ei lisää alkiota taulukkoon vaan luo uuden taulukon, jossa on lisättävä alkio sekä vanhan taulukon sisältö: ```js const t = [1, -1, 3] @@ -91,7 +93,7 @@ console.log(t2) // tulostuu [1, -1, 3, 5] Metodikutsu _t.concat(5)_ ei siis lisää uutta alkiota vanhaan taulukkoon, vaan palauttaa uuden taulukon, joka sisältää vanhan taulukon alkioiden lisäksi uuden alkion. -Taulukoille on määritelty runsaasti hyödyllisiä operaatioita. Katsotaan pieni esimerkki metodin [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) käytöstä. +Taulukoille on määritelty runsaasti hyödyllisiä operaatioita. Katsotaan pieni esimerkki metodin [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) käytöstä: ```js const t = [1, 2, 3] @@ -110,7 +112,7 @@ console.log(m2) // tulostuu [ '
  • 1
  • ', '
  • 2
  • ', '
  • 3
  • ' ] ``` -Eli lukuja sisältävästä taulukosta tehdään map-metodin avulla HTML-koodia sisältävä taulukko. Tulemmekin kurssin [osassa2](/osa2) näkemään, että mapia käytetään Reactissa todella usein. +Yllä lukuja sisältävästä taulukosta tehdään map-metodin avulla HTML-koodia sisältävä taulukko. Tulemmekin kurssin [osassa 2](/osa2) näkemään, että mapia käytetään Reactissa todella usein. Taulukon yksittäisiä alkioita on helppo sijoittaa muuttujiin [destrukturoivan](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) sijoituslauseen avulla: @@ -119,15 +121,15 @@ const t = [1, 2, 3, 4, 5] const [first, second, ...rest] = t -console.log(first, second) // tulostuu 1, 2 +console.log(first, second) // tulostuu 1 2 console.log(rest) // tulostuu [3, 4 ,5] ``` -Eli muuttujiin _first_ ja _second_ tulee sijoituksen ansiosta taulukon kaksi ensimmäistä lukua. Muuttujaan _rest_ "kerätään" sijoituksesta jäljellejääneet luvut omaksi taulukoksi. +Yllä muuttujaan _first_ sijoitetaan taulukon ensimmäinen luku ja muuttujaan _second_ taulukon toinen luku. Muuttujaan _rest_ "kerätään" sijoituksesta jäljelle jääneet luvut omaksi taulukoksi. ### Oliot -Javascriptissä on muutama tapa määritellä olioita. Erittäin yleisesti käytetään [olioliteraaleja](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Grammar_and_types#Object_literals), eli määritellään olio luettelemalla sen kentät (englanniksi property) aaltosulkeiden sisällä: +JavaScriptissä on muutama tapa määritellä olioita. Erittäin yleisesti käytetään [olioliteraaleja](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Grammar_and_types#Object_literals), eli määritellään olio luettelemalla sen kentät (englanniksi property) aaltosulkeiden sisällä: ```js const object1 = { @@ -136,7 +138,7 @@ const object1 = { education: 'Filosofian tohtori', } -const object12 = { +const object2 = { name: 'Full Stack -websovelluskehitys', level: 'aineopinto', size: 5, @@ -152,9 +154,9 @@ const object3 = { } ``` -Kenttien arvot voivat olla tyypiltään mitä vaan, lukuja, merkkijonoja, taulukoita, olioita... +Kenttien arvot voivat olla tyypiltään mitä vaan: lukuja, merkkijonoja, taulukoita, olioita... -Olioiden kenttiin viitataan pistenotaatiolla, tai hakasulkeilla: +Olioiden kenttiin viitataan pistenotaatiolla tai hakasulkeilla: ```js console.log(object1.name) // tulostuu Arto Hellas @@ -169,11 +171,11 @@ object1.address = 'Tapiola' object1['secret number'] = 12341 ``` -Jälkimmäinen lisäyksistä on pakko tehdä hakasulkeiden avulla, sillä pistenotaatiota käytettäessä secret number ei kelpaa kentän nimeksi. +Jälkimmäinen lisäyksistä on pakko tehdä hakasulkeiden avulla, sillä pistenotaatiota käytettäessä välilyönnin sisältävä merkkijono secret number ei kelpaa kentän nimeksi. -Javascriptissä olioilla voi luonnollisesti olla myös metodeja. Emme kuitenkaan tarvitse tällä kurssilla ollenkaan itse määriteltyjä metodillisia olioita, joten asiaa ei tällä kurssilla käsitellä kuin lyhyesti. +JavaScriptissä olioilla voi olla myös metodeja. Tällä kurssilla emme kuitenkaan tarvitse itse määriteltyjä metodillisia olioita, joten asiaa ei käsitellä kuin lyhyesti. -Olioita on myös mahdollista määritellä ns. konstruktorifunktioiden avulla, jolloin saadaan aikaan hieman monien ohjelmointikielten, esim. Javan luokkia (class) muistuttava mekanismi. Javascriptissä ei kuitenkaan ole luokkia samassa mielessä kuin olio-ohjelmointikielissä. Kieleen on kuitenkin lisätty versiosta ES6 alkaen luokkasyntaksi, joka helpottaa tietyissä tilanteissa olio-ohjelmointikielimäisten luokkien esittämistä. +Olioita on mahdollista määritellä myös ns. konstruktorifunktioiden avulla, jolloin saadaan aikaan hieman monien muiden ohjelmointikielten, esim. Javan tai Pythonin, luokkia (class) muistuttava mekanismi. JavaScriptissä ei kuitenkaan ole luokkia samassa mielessä kuin olio-ohjelmointikielissä. Kieleen on kuitenkin lisätty versiosta ES6 alkaen luokkasyntaksi, joka helpottaa tietyissä tilanteissa olio-ohjelmointikielimäisten luokkien esittämistä. ### Funktiot @@ -187,14 +189,14 @@ const sum = (p1, p2) => { } ``` -ja funktiota kutsutaan kuten olettaa saattaa +ja funktiota kutsutaan kuten olettaa saattaa: ```js const result = sum(1, 5) console.log(result) ``` -Jos parametreja on vain yksi, voidaan sulut jättää määrittelystä pois: +Jos parametreja on vain yksi, sulut voidaan jättää määrittelystä pois: ```js const square = p => { @@ -217,9 +219,9 @@ const tSquared = t.map(p => p * p) // tSquared on nyt [1, 4, 9] ``` -Nuolifunktio on tullut Javascriptiin vasta muutama vuosi sitten version [ES6](http://es6-features.org/) myötä. Tätä ennen ainoa tapa funktioiden määrittelyyn oli avainsanan _function_ käyttö. +Nuolifunktio on tullut JavaScriptiin vuonna 2015 version [ES6](https://rse.github.io/es6-features/) myötä. Tätä ennen ainoa tapa funktioiden määrittelyyn oli avainsanan _function_ käyttö. -Määrittelytapoja on kaksi, funktiolle voidaan antaa [function declaration](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function) -tyyppisessä määrittelyssä nimi, jonka avulla funktioon voidaan viitata: +Määrittelytapoja on kaksi, funktiolle voidaan antaa [function declaration](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function) ‑tyyppisessä määrittelyssä nimi, jonka avulla funktioon voidaan viitata: ```js function product(a, b) { @@ -239,16 +241,16 @@ const average = function(a, b) { const vastaus = average(2, 5) ``` -Määrittelemme tällä kurssilla kaikki funktiot nuolisyntaksin avulla. +Määrittelemme tällä kurssilla muutamaa poikkeusta lukuun ottamatta kaikki funktiot nuolisyntaksin avulla.

    Tehtävät 1.3-1.5

    -Jatkamme edellisissä tehtävissä aloitetun ohjelman rakentamista, voit siis tehdä koodin samaan projektiin, palautuksessa ollaan kiinnostuneita ainoastaan ohjelman lopullisesta versiosta. +Jatkamme edellisissä tehtävissä aloitetun ohjelman rakentamista. Voit siis tehdä koodin samaan projektiin, koska palautuksessa ollaan kiinnostuneita ainoastaan ohjelman lopullisesta versiosta. -**Protip:** voit kohdata ohjelmoidessasi ongelmia sen suhteen missä muodossa komponentin saamat propsit ovat. Hyvä keino varmistua asiasta on tulostaa propsit konsoliin, esim. seuraavasti: +**Protip:** voit kohdata ohjelmoidessasi ongelmia sen suhteen missä muodossa komponentin saamat propsit ovat. Hyvä keino varmistua asiasta on tulostaa propsit konsoliin esim. seuraavasti: ```js const Header = (props) => { @@ -257,9 +259,16 @@ const Header = (props) => { } ``` +Jos ja kun törmäät virheilmoitukseen + +> Objects are not valid as a React child + +pidä mielessä [täällä](/osa1/reactin_alkeet#ala-renderoi-olioita) kerrotut asiat. + +

    1.3: kurssitiedot step3

    -Siirrytään käyttämään sovelluksessamme oliota. Muuta komponentin App muuttujamäärittelyt seuraavaan muotoon ja muuta sovelluksen kaikkia osia niin, että se taas toimii: +Siirrytään käyttämään sovelluksessamme olioita. Muuta komponentin App muuttujamäärittelyt seuraavaan muotoon ja muuta sovelluksen kaikkia osia niin, että sovellus edelleen toimii: ```js const App = () => { @@ -288,7 +297,7 @@ const App = () => {

    1.4: kurssitiedot step4

    -Ja laitetaan oliot taulukkoon, eli muuta App :in muuttujamäärittelyt seuraavaan muotoon ja muuta sovelluksen kaikki osat vastaavasti: +Seuraavaksi laitetaan oliot taulukkoon, eli muuta App :in muuttujamäärittelyt seuraavaan muotoon ja muuta sovelluksen kaikki osat vastaavasti: ```js const App = () => { @@ -316,7 +325,7 @@ const App = () => { } ``` -**HUOM:** tässä vaiheessa voit olettaa, että taulukossa on aina kolme alkiota, eli taulukkoa ei ole pakko käydä läpi looppaamalla. Palataan taulukossa olevien olioiden perusteella tapahtuvaan komponenttien renderöintiin asiaan tarkemmin kurssin [seuraavassa osassa](../osa2). +**HUOM:** tässä vaiheessa voit olettaa, että taulukossa on aina kolme alkiota, eli taulukkoa ei ole pakko käydä läpi silmukalla. Palataan taulukossa olevien olioiden perusteella tapahtuvaan komponenttien renderöintiin myöhemmin kurssin [seuraavassa osassa](../osa2). Älä kuitenkaan välitä eri olioita komponentista App sen sisältämiin komponentteihin Content ja Total erillisinä propseina, vaan suoraan taulukkona: @@ -336,7 +345,7 @@ const App = () => {

    1.5: kurssitiedot step5

    -Viedään muutos vielä yhtä askelta pidemmälle, eli tehdään kurssista ja sen osista yksi Javascript-olio. Korjaa kaikki mikä menee rikki. +Viedään muutos vielä yhtä askelta pidemmälle, eli tehdään kurssista ja sen osista yksi JavaScript-olio. Korjaa kaikki mikä menee rikki. ```js const App = () => { @@ -372,11 +381,11 @@ const App = () => { ### Olioiden metodit ja this -Koska käytämme tällä kurssilla Reactin hookit sisältävää versiota, meidän ei kurssin aikana tarvitse määritellä ollenkaan olioita, joilla on metodeja. **Tämän luvun asiat siis eivät ole kurssin kannalta relevantteja**, mutta varmasti monella tapaa hyödyllisiä tietää. Käytettäessä "vanhempaa Reactia", tämän luvun asiat on hallittava. +Koska käytämme tällä kurssilla Reactin hookit sisältävää versiota, meidän ei kurssin aikana tarvitse määritellä ollenkaan olioita, joilla on metodeja. **Tämän luvun asiat eivät siis ole kurssin kannalta relevantteja**, mutta varmasti monella tapaa hyödyllisiä tietää. Käytettäessä "vanhempaa Reactia" tämän luvun asiat on hallittava. -Nuolifunktiot ja avainsanan _function_ avulla määritellyt funktiot poikkeavat radikaalisti siitä miten ne käyttäytyvät olioon itseensä viittaavan avainsanan [this](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this) suhteen. +Nuolifunktiot ja avainsanan _function_ avulla määritellyt funktiot poikkeavat radikaalisti siinä, miten ne käyttäytyvät olioon itseensä viittaavan avainsanan [this](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this) suhteen. -Voimme liittää oliolle metodeja määrittelemällä niille kenttiä, jotka ovat funktioita: +Voimme liittää olioihin metodeja määrittelemällä niille kenttiä, jotka ovat funktioita: ```js const arto = { @@ -391,7 +400,9 @@ const arto = { arto.greet() // tulostuu hello, my name is Arto Hellas ``` -metodeja voidaan liittää olioille myös niiden luomisen jälkeen: +Metodin sisällä voidaan siis viitata olion kenttien arvoihin avainsanan this avulla vastaavasti kuin Javassa. Pythonissa saman asian ajaa avainsana self. + +Metodeja voi lisätä myös olion luomisen jälkeen: ```js const arto = { @@ -414,7 +425,7 @@ arto.growOlder() console.log(arto.age) // tulostuu 36 ``` -Muutetaan olioa hiukan +Muutetaan oliota hiukan: ```js const arto = { @@ -448,11 +459,11 @@ const referenceToGreet = arto.greet referenceToGreet() // tulostuu ainoastaan hello, my name is ``` -Kutsuttaessa metodia viitteen kautta, on metodi kadottanut tiedon siitä mikä oli alkuperäinen _this_. Toisin kuin melkein kaikissa muissa kielissä, Javascriptissa [this](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this):n arvo määrittyy sen mukaan miten metodia on kutsuttu. Kutsuttaessa metodia viitteen kautta, _this_:in arvoksi tulee ns. [globaali objekti](https://developer.mozilla.org/en-US/docs/Glossary/Global_object) ja lopputulos ei ole yleensä ollenkaan se, mitä sovelluskehittäjä olettaa. +Kun metodia kutsutaan viitteen kautta, metodi on kadottanut tiedon siitä, mikä alkuperäinen _this_ oli. Toisin kuin melkein kaikissa muissa kielissä, JavaScriptissa [this](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this):n arvo määrittyy sen mukaan miten metodia on kutsuttu. Kutsuttaessa metodia viitteen kautta, _this_:in arvoksi tulee ns. [globaali objekti](https://developer.mozilla.org/en-US/docs/Glossary/Global_object), ja lopputulos ei ole yleensä ollenkaan se, mitä sovelluskehittäjä olettaa. -This:in kadottaminen aiheuttaa Javascriptillä ohjelmoidessa monia potentiaalisia ongelmia. Eteen tulee erittäin usein tilanteita, missä Reactin/Noden (oikeammin ilmaistuna selaimen Javascript-moottorin) tulee kutsua joitain ohjelmoijan määrittelemien olioiden metodeja. Tällä kurssilla kuitenkin säästymme näiltä ongelmilta, sillä käytämme ainoastaan "thissitöntä" Javascriptia. +This:in kadottaminen saattaa aiheuttaa ongelmia. Eteen tulee usein tilanteita, joissa Reactin/Noden (tai oikeammin ilmaistuna selaimen JavaScript-moottorin) tulee kutsua joitain ohjelmoijan määrittelemien olioiden metodeja. Tällä kurssilla kuitenkin säästymme näiltä ongelmilta, sillä käytämme ainoastaan "thissitöntä" JavaScriptia. -Eräs this:in katoamiseen johtava tilanne tulee esim. jos pyydetään Artoa tervehtimään sekunnin kuluttua metodia [setTimeout](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout) hyväksikäyttäen. +_this_ katoaa esimerkiksi, jos pyydetään Artoa tervehtimään sekunnin kuluttua metodia [setTimeout](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout) käyttäen: ```js const arto = { @@ -466,28 +477,28 @@ setTimeout(arto.greet, 1000) // highlight-line // sekunnin päästä tulostuu hello, my name is ``` -Javascriptissa this:in arvo siis määräytyy siitä miten metodia on kutsuttu. setTimeoutia käytettäessä metodia kutsuu Javascript-moottori ja this viittaa Timeout-olioon. +JavaScriptissa this:in arvo siis määräytyy siitä, miten metodia on kutsuttu. setTimeoutia käytettäessä metodia kutsuu JavaScript-moottori, jolloin this viittaa Timeout-olioon. -On useita mekanismeja, joiden avulla alkuperäinen _this_ voidaan säilyttää, eräs näistä on metodin [bind](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind) käyttö: +On useita mekanismeja, joiden avulla alkuperäinen _this_ voidaan säilyttää. Eräs näistä on metodin [bind](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind) käyttö: ```js setTimeout(arto.greet.bind(arto), 1000) // sekunnin päästä tulostuu hello, my name is Arto Hellas ``` -Komento arto.greet.bind(arto) luo uuden funktion, missä se on sitonut _this_:in tarkoittamaan Artoa riippumatta siitä missä ja miten metodia kutsutaan. +Komento arto.greet.bind(arto) luo uuden funktion, jossa _this_ on sidottu tarkoittamaan Artoa riippumatta siitä, missä ja miten metodia kutsutaan. [Nuolifunktioiden](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions) avulla on mahdollista ratkaista eräitä this:iin liittyviä ongelmia. Olioiden metodeina niitä ei kuitenkaan kannata käyttää, sillä silloin _this_ ei toimi ollenkaan. -Jos haluat ymmärtää paremmin javascriptin _this_:in toimintaa, löytyy internetistä runsaasti materiaalia aiheesta. Esim. [egghead.io](https://egghead.io):n 20 minuutin screencastsarja [Understand JavaScript's this Keyword in Depth](https://egghead.io/courses/understand-javascript-s-this-keyword-in-depth) on erittäin suositeltava! +Jos haluat ymmärtää paremmin JavaScriptin _this_:in toimintaa, Internetissä on runsaasti materiaalia aiheesta. Esim. [egghead.io](https://egghead.io):n 20 minuutin screencast-sarja [Understand JavaScript's this Keyword in Depth](https://egghead.io/courses/understand-javascript-s-this-keyword-in-depth) on erittäin suositeltava! ### Luokat -Kuten aiemmin mainittiin, Javascriptissä ei ole olemassa olio-ohjelmointikielten luokkamekanismia. Javascriptissa on kuitenkin ominaisuuksia, jotka mahdollistavat olio-ohjelmoinnin [luokkien](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes) "simuloinnin". Emme mene nyt sen tarkemmin Javascriptin olioiden taustalla olevaan [prototyyppiperintämekanismiin](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Inheritance_and_the_prototype_chain). +Kuten aiemmin mainittiin, JavaScriptissä ei ole olemassa olio-ohjelmointikielten luokkamekanismia. JavaScriptissa on kuitenkin ominaisuuksia, jotka mahdollistavat olio-ohjelmoinnin [luokkien](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes) "simuloinnin". Emme mene nyt sen tarkemmin JavaScriptin olioiden taustalla olevaan [prototyyppiperintämekanismiin](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Inheritance_and_the_prototype_chain). -Tutustutaan nyt pikaisesti ES6:n myötä Javascriptiin tulleeseen luokkasyntaksiin, joka helpottaa oleellisesti luokkien (tai luokan kaltaisten asioiden) määrittelyä Javascriptissa. +Tutustutaan nyt pikaisesti ES6:n myötä JavaScriptiin tulleeseen luokkasyntaksiin, joka helpottaa oleellisesti luokkien (tai luokan kaltaisten asioiden) määrittelyä JavaScriptissa. -Seuraavassa on määritelty "luokka" Person ja sille kaksi Person-oliota: +Seuraavassa on määritelty "luokka" Person ja kaksi Person-oliota: ```js class Person { @@ -507,22 +518,22 @@ const juhq = new Person('Juha Tauriainen', 48) juhq.greet() ``` -Syntaksin osalta luokat ja niistä luodut oliot muistuttavat erittäin paljon esim. Javan luokkia ja olioita. Käyttäytymiseltäänkin ne ovat aika lähellä Javan olioita. Perimmiltään kyseessä on kuitenkin edelleen Javascriptin [prototyyppiperintään](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Inheritance) perustuvista olioista. Molempien olioiden todellinen tyyppi on _Object_ sillä Javascriptissä ei perimmiltään ole muita tyyppejä kuin [Boolean, Null, Undefined, Number, String, Symbol ja Object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures) +Syntaksin osalta luokat ja niistä luodut oliot muistuttavat erittäin paljon esim. Javan tai Pythonin luokkia ja olioita. Käyttäytymiseltäänkin ne ovat aika lähellä tavanomaisten oliokielten olioita. Kyse on kuitenkin edelleen JavaScriptin [prototyyppiperintään](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Inheritance) perustuvista olioista. Molempien olioiden todellinen tyyppi on _Object_, sillä JavaScriptissä ei ole muita tyyppejä kuin [Boolean, Null, Undefined, Number, String, Symbol, BigInt ja Object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures). -Luokkasyntaksin tuominen Javascriptiin on osin kiistelty lisäys, ks. esim. [Not Awesome: ES6 Classes](https://awesomeopensource.com/project/joshburgess/not-awesome-es6-classes) tai [Is “Class” In ES6 The New “Bad” Part?](https://medium.com/@rajaraodv/is-class-in-es6-the-new-bad-part-6c4e6fe1ee65) +Luokkasyntaksin tuominen JavaScriptiin on osin kiistelty lisäys, kts. esim. [Not Awesome: ES6 Classes](https://github.com/petsel/not-awesome-es6-classes) tai [Is “Class” In ES6 The New “Bad” Part?](https://medium.com/@rajaraodv/is-class-in-es6-the-new-bad-part-6c4e6fe1ee65). -ES6:n luokkasyntaksia käytetään paljon "vanhassa" Reactissa ja Node.js:ssä ja siksi sen tunteminen on tälläkin kurssilla paikallaan. Koska käytämme kurssilla Reactin uutta [hook](https://reactjs.org/docs/hooks-intro.html)-ominaisuutta, meidän ei ole tarvetta käyttää kurssilla ollenkaan Javascriptin luokkasyntaksia. +ES6:n luokkasyntaksia käytetään paljon "vanhassa" Reactissa ja Node.js:ssä ja siksi sen tunteminen on tälläkin kurssilla paikallaan. Koska käytämme kurssilla Reactiin vuonna 2019 lisättyä [hook](https://react.dev/reference/react/hooks)-ominaisuutta, meidän ei ole tarvetta käyttää kurssilla ollenkaan JavaScriptin luokkasyntaksia. -### Javascript-materiaalia +### JavaScript-materiaalia -Javascriptistä löytyy verkosta suuret määrät sekä hyvää että huonoa materiaalia. Tällä sivulla lähes kaikki Javascriptin ominaisuuksia käsittelevät linkit ovat [Mozillan Javascript -materiaaliin](https://developer.mozilla.org/en-US/docs/Web/JavaScript). +JavaScriptistä löytyy verkosta suuret määrät sekä hyvää että huonoa materiaalia. Tällä sivulla lähes kaikki JavaScriptin ominaisuuksia käsittelevät linkit ovat [Mozillan JavaScript-materiaaliin](https://developer.mozilla.org/en-US/docs/Web/JavaScript). Mozillan sivuilta kannattaa lukea oikeastaan välittömästi [A re-introduction to JavaScript (JS tutorial)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/A_re-introduction_to_JavaScript). -Jos haluat tutustua todella syvällisesti Javascriptiin, löytyy internetistä ilmaiseksi mainio kirjasarja [You-Dont-Know-JS](https://github.com/getify/You-Dont-Know-JS). +Jos haluat tutustua JavaScriptiin syvällisesti, Internetistä on ladattavissa ilmaiseksi mainio kirjasarja [You-Dont-Know-JS](https://github.com/getify/You-Dont-Know-JS). -Toinen hieno resurssi Javascriptin oppimiseen on [javascript.info] (https://javascript.info). +Toinen hieno sivusto JavaScriptin oppimiseen on [javascript.info](https://javascript.info). -[egghead.io](https://egghead.io):lla on tarjolla runsaasti laadukkaita screencasteja Javascriptista, Reactista ym. kiinnostavasta. Valitettavasti materiaali on osittain maksullista. +[egghead.io](https://egghead.io):ssa on tarjolla laadukkaita screencasteja JavaScriptista, Reactista ym. kiinnostavasta. Valitettavasti materiaali on osittain maksullista.
    diff --git a/src/content/1/fi/osa1c.md b/src/content/1/fi/osa1c.md index 7cf1fa9d9ff..84544b15281 100644 --- a/src/content/1/fi/osa1c.md +++ b/src/content/1/fi/osa1c.md @@ -62,13 +62,13 @@ const Hello = (props) => { Syntymävuoden arvauksen tekevä logiikka on erotettu omaksi funktiokseen, jota kutsutaan komponentin renderöinnin yhteydessä. -Tervehdittävän henkilön ikää ei metodille tarvitse välittää parametrina, sillä funktio näkee sen sisältävälle komponentille välitettävät propsit. +Tervehdittävän henkilön ikää ei tarvitse välittää funktiolle parametrina, sillä funktio näkee sen sisältävälle komponentille välitettävät propsit. -Teknisesti ajatellen syntymävuoden selvittävä funktio on määritelty komponentin toiminnan määrittelevän funktion sisällä. Esim. Javalla ohjelmoitaessa metodien määrittely toisen metodin sisällä ei onnistu. Javascriptissa taas funktioiden sisällä määritellyt funktiot on hyvin yleisesti käytetty tekniikka. +Syntymävuoden selvittävä funktio on määritelty komponentin toiminnan määrittelevän funktion sisällä. Esim. Javalla ohjelmoitaessa metodien määrittely toisen metodin sisällä ei onnistu. JavaScriptissa taas funktioiden sisällä määritellyt funktiot on hyvin yleisesti käytetty tekniikka. ### Destrukturointi -Ennen kuin siirrymme eteenpäin, tarkastellaan erästä pientä, mutta käyttökelpoista ES6:n mukanaan tuomaa uutta piirrettä Javascriptissä, eli muuttujaan sijoittamisen yhteydessä tapahtuvaa [destrukturointia](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment). +Tarkastellaan erästä pientä mutta käyttökelpoista ES6:n mukanaan tuomaa uutta piirrettä JavaScriptissa, eli muuttujaan sijoittamisen yhteydessä tapahtuvaa [destrukturointia](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment). Jouduimme äskeisessä koodissa viittaamaan propseina välitettyyn dataan hieman ikävästi muodossa _props.name_ ja _props.age_. Näistä _props.age_ pitää toistaa komponentissa kahteen kertaan. @@ -94,16 +94,16 @@ const Hello = (props) => { return (
    -

    Hello {name}, you are {age} years old

    +

    Hello {name}, you are {age} years old

    // highlight-line

    So you were probably born {bornYear()}

    ) } ``` -Huomaa, että olemme myös hyödyntäneet nuolifunktion kompaktimpaa kirjoitustapaa metodin _bornYear_ määrittelyssä. Kuten aiemmin totesimme, jos nuolifunktio koostuu ainoastaan yhdestä komennosta, ei funktion runkoa tarvitse kirjoittaa aaltosulkeiden sisään ja funktio palauttaa ainoan komentonsa arvon. +Huomaa, että olemme hyödyntäneet myös nuolifunktion kompaktimpaa kirjoitustapaa funktion _bornYear_ määrittelyssä. Kuten aiemmin totesimme, jos nuolifunktio koostuu ainoastaan yhdestä komennosta, ei funktion runkoa tarvitse kirjoittaa aaltosulkeiden sisään ja funktio palauttaa ainoan komentonsa arvon. -Seuraavat ovat siis vaihtoehtoiset tavat määritellä sama funktio: +Seuraavat ovat siis vaihtoehtoisia tapoja määritellä sama funktio: ```js const bornYear = () => new Date().getFullYear() - age @@ -113,7 +113,7 @@ const bornYear = () => { } ``` -[Destrukturointi](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) tekee apumuuttujien määrittelyn vielä helpommaksi, sen avulla voimme "kerätä" olion oliomuuttujien arvot suoraan omiin yksittäisiin muuttujiin: +[Destrukturointi](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) tekee apumuuttujien määrittelyn vielä helpommaksi. Sen avulla voimme "kerätä" olion oliomuuttujien arvot suoraan omiin yksittäisiin muuttujiin: ```js const Hello = (props) => { @@ -142,7 +142,7 @@ props = { saa const { name, age } = props aikaan sen, että muuttuja _name_ saa arvon 'Maya' ja muuttuja _age_ arvon 36. -Voimme viedä destrukturoinnin vielä askeleen verran pidemmälle +Voimme viedä destrukturoinnin vielä askeleen verran pidemmälle: ```js const Hello = ({ name, age }) => { // highlight-line @@ -161,14 +161,14 @@ const Hello = ({ name, age }) => { // highlight-line Destrukturointi tehdään nyt suoraan sijoittamalla komponentin saamat propsit muuttujiin _name_ ja _age_. -Eli sensijaan että props-olio otettaisiin vastaan muuttujaan props ja sen kentät sijoitettaisiin tämän jälkeen muuttujiin _name_ ja _age_ +Eli sen sijaan, että props-olio otettaisiin vastaan muuttujaan props ja sen kentät sijoitettaisiin tämän jälkeen muuttujiin _name_ ja _age_ ```js const Hello = (props) => { const { name, age } = props ``` -sijoitamme destrukturoinnin avulla propsin kentät suoraan muuttujiin kun määrittelemme komponettifunktion saaman parametrin: +sijoitamme destrukturoinnin avulla propsin kentät suoraan muuttujiin kun määrittelemme komponenttifunktion saaman parametrin: ```js const Hello = ({ name, age }) => { @@ -176,9 +176,9 @@ const Hello = ({ name, age }) => { ### Sivun uudelleenrenderöinti -Toistaiseksi tekemämme sovellukset ovat olleet sellaisia, että kun niiden komponentit on kerran renderöity, niiden ulkoasua ei ole enää voinut muuttaa. Entä jos haluaisimme toteuttaa laskurin, jonka arvo kasvaa esim. ajan kuluessa tai nappien painallusten yhteydessä? +Toistaiseksi tekemämme sovellukset ovat olleet sellaisia, että kun niiden komponentit on kerran renderöity, niiden ulkoasua ei ole enää voinut muuttaa. Entä jos haluaisimme toteuttaa laskurin, jonka arvo kasvaa ajan kuluessa tai nappeja painettaessa? -Aloitetaan seuraavasta rungosta: +Aloitetaan seuraavasta rungosta. Tiedostoon App.jsx tulee: ```js const App = (props) => { @@ -188,11 +188,20 @@ const App = (props) => { ) } +export default App +``` + +Tiedoston main.jsx sisältö on: + +```js +import ReactDOM from 'react-dom/client' + +import App from './App' + let counter = 1 -ReactDOM.render( - , - document.getElementById('root') +ReactDOM.createRoot(document.getElementById('root')).render( + ) ``` @@ -202,21 +211,15 @@ Sovelluksen juurikomponentille siis annetaan propsiksi laskurin _counter_ arvo. counter += 1 ``` -ei komponenttia kuitenkaan renderöidä uudelleen. Voimme saada komponentin uudelleenrenderöitymään kutsumalla uudelleen metodia _ReactDOM.render_, esim. seuraavasti +ei komponenttia kuitenkaan renderöidä uudelleen. Voimme saada komponentin renderöitymään uudelleen kutsumalla funktiota _render_ uudelleen esim. seuraavasti ```js -const App = (props) => { - const { counter } = props - return ( -
    {counter}
    - ) -} - let counter = 1 +const root = ReactDOM.createRoot(document.getElementById("root")) + const refresh = () => { - ReactDOM.render(, - document.getElementById('root')) + root.render() } refresh() @@ -226,11 +229,11 @@ counter += 1 refresh() ``` -Copypasten vähentämisen takia on komponentin renderöinti kääritty funktioon _refresh_. +Copy-pasten vähentämiseksi komponentin renderöinti on refaktoroitu funktioon _refresh_. Nyt komponentti renderöityy kolme kertaa, saaden ensin arvon 1, sitten 2 ja lopulta 3. Luvut 1 ja 2 tosin ovat ruudulla niin vähän aikaa, että niitä ei ehdi havaita. -Hieman mielenkiintoisempaan toiminnallisuuteen pääsemme tekemällä renderöinnin ja laskurin kasvatuksen toistuvasti sekunnin välein käyttäen [SetInterval](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setInterval): +Hieman mielenkiintoisempaan toiminnallisuuteen pääsemme tekemällä renderöinnin ja laskurin kasvatuksen toistuvasti sekunnin välein käyttäen [SetInterval](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setInterval)-metodia: ```js setInterval(() => { @@ -239,21 +242,30 @@ setInterval(() => { }, 1000) ``` -_ReactDOM.render_-metodin toistuva kutsuminen ei kuitenkaan ole suositeltu tapa päivittää komponentteja. Tutustutaan seuraavaksi järkevämpään tapaan. +_render_-funktion toistuva kutsuminen ei kuitenkaan ole hyvä tapa päivittää komponentteja, joten tutustutaan seuraavaksi järkevämpään tapaan. ### Tilallinen komponentti Tähänastiset komponenttimme ovat olleet siinä mielessä yksinkertaisia, että niillä ei ole ollut ollenkaan omaa tilaa, joka voisi muuttua komponentin elinaikana. -Määritellään nyt sovelluksemme komponentille App tila Reactin [state hookin](https://reactjs.org/docs/hooks-state.html) avulla. +Määritellään nyt sovelluksemme komponentille App tila Reactin [state hookin](https://react.dev/learn/state-a-components-memory#meet-your-first-hook) avulla. -Muutetaan ohjelmaa seuraavasti +Palautetaan main.jsx muotoon ```js -import React, { useState } from 'react' // highlight-line -import ReactDOM from 'react-dom' +import ReactDOM from 'react-dom/client' -const App = (props) => { +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +ja muutetaan App.jsx muotoon: + +```js +import { useState } from 'react' // highlight-line + +const App = () => { const [ counter, setCounter ] = useState(0) // highlight-line // highlight-start @@ -268,25 +280,22 @@ const App = (props) => { ) } -ReactDOM.render( - , - document.getElementById('root') -) +export default App ``` -Sovellus importaa nyt heti ensimmäisellä rivillä _useState_-funktion: +Tiedosto importtaa nyt heti ensimmäisellä rivillä _useState_-funktion: ```js -import React, { useState } from 'react' +import { useState } from 'react' ``` -Komponentin määrittelevä funktio alkaa metodikutsulla +Komponentin määrittelevä funktio alkaa funktiokutsulla: ```js const [ counter, setCounter ] = useState(0) ``` -Kutsu saa aikaan sen, että komponentille luodaan tila, joka saa alkuarvokseen nollan. Metodi palauttaa taulukon, jossa on kaksi alkiota. Alkiot otetaan taulukon destrukturointisyntaksilla talteen muuttujiin _counter_ ja _setCounter_. +Kutsu saa aikaan sen, että komponentille luodaan tila, joka saa alkuarvokseen nollan. Funktio palauttaa taulukon, jossa on kaksi alkiota. Alkiot otetaan taulukon destrukturointisyntaksilla talteen muuttujiin _counter_ ja _setCounter_. Muuttuja _counter_ pitää sisällään tilan arvon joka on siis aluksi nolla. Muuttuja _setCounter_ taas on viite funktioon, jonka avulla tilaa voidaan muuttaa. @@ -302,7 +311,7 @@ setTimeout( Kun tilaa muuttavaa funktiota _setCounter_ kutsutaan, renderöi React komponentin uudelleen, eli käytännössä suorittaa uudelleen komponentin määrittelevän koodin ```js -(props) => { +() => { const [ counter, setCounter ] = useState(0) setTimeout( @@ -332,10 +341,10 @@ ja koska muuttujan _counter_ arvo on 1, on koodi oleellisesti sama kuin tilan _c Ja tämä saa jälleen aikaan sen, että komponentti renderöidään uudelleen. Tilan arvo kasvaa sekunnin päästä yhdellä ja sama jatkuu niin kauan kun sovellus on toiminnassa. -Jos komponentti ei renderöidy vaikka sen omasta mielestä pitäisi, tai se renderöityy "väärään aikaan", debuggaamista auttaa joskus komponentin määrittelevään funktioon lisätty konsoliin tulostus. Esim. jos lisäämme koodiin seuraavan tulostuksen +Jos komponenttia ei saa renderöitymään tai komponentti renderöityy "väärään" aikaan, debuggaamista saattaa auttaa komponentin määrittelevään funktioon lisätty konsoliin tulostus. Esim. näin ```js -const App = (props) => { +const App = () => { const [ counter, setCounter ] = useState(0) setTimeout( @@ -351,22 +360,24 @@ const App = (props) => { } ``` -voidaan konsolista seurata metodin _render_ kutsuja: +voidaan konsolista seurata komponentin renderöitymistä: ![](../../images/1/4e.png) +Olihan selaimesi konsoli auki? Jos ei ollut, niin lupaa että tämä oli viimeinen kerta kun asiasta pitää muistuttaa. + ### Tapahtumankäsittely -Mainitsimme jo [osassa 0](/osa0) muutamaan kertaan tapahtumankäsittelijät, eli funktiot, jotka on rekisteröity kutsuttavaksi tiettyjen tapahtumien eli eventien yhteydessä. Esim. käyttäjän interaktio sivun elementtien kanssa aiheuttaa joukon erinäisiä tapahtumia. +Jo [osassa 0](/osa0) mainitsimme tapahtumankäsittelijät eli funktiot, jotka on rekisteröity kutsuttavaksi tiettyjen tapahtumien eli eventien yhteydessä. Esim. käyttäjän interaktio sivun elementtien kanssa voi aiheuttaa joukon erilaisia tapahtumia. Muutetaan sovellusta siten, että laskurin kasvaminen tapahtuukin käyttäjän painaessa [button](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button)-elementin avulla toteutettua nappia. Button-elementit tukevat mm. [hiiritapahtumia](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent) (mouse events), joista yleisin on [click](https://developer.mozilla.org/en-US/docs/Web/Events/click). -Reactissa funktion rekisteröiminen tapahtumankäsittelijäksi tapahtumalle click [tapahtuu](https://reactjs.org/docs/handling-events.html) seuraavasti: +Reactissa funktion rekisteröiminen tapahtumankäsittelijäksi tapahtumalle click [tapahtuu](https://react.dev/learn/responding-to-events) seuraavasti: ```js -const App = (props) => { +const App = () => { const [ counter, setCounter ] = useState(0) // highlight-start @@ -388,14 +399,14 @@ const App = (props) => { } ``` -Eli laitetaan buttonin onClick-attribuutin arvoksi aaltosulkeissa oleva viite koodissa määriteltyyn funktioon _handleClick_. +Yllä asetetaan buttonin onClick-attribuutin arvoksi aaltosulkeissa oleva viite koodissa määriteltyyn funktioon _handleClick_. -Nyt jokainen napin plus painallus saa aikaan sen että funktiota _handleClick_ kutsutaan, eli klikatessa konsoliin tulostuu clicked. +Nyt jokainen napin plus painallus saa aikaan sen, että funktiota _handleClick_ kutsutaan, eli klikatessa konsoliin tulostuu clicked. Tapahtumankäsittelijäfunktio voidaan määritellä myös suoraan onClick-määrittelyn yhteydessä: ```js -const App = (props) => { +const App = () => { const [ counter, setCounter ] = useState(0) return ( @@ -409,7 +420,7 @@ const App = (props) => { } ``` -Muuttamalla tapahtumankäsittelijä seuraavaan muotoon +Muuttamalla tapahtumankäsittelijä muotoon ```js ``` -Entä jos yritämme määritellä tapahtumankäsittelijän hieman yksinkertaisemmassa muodossa: +Entä jos yritämme määritellä tapahtumankäsittelijän yksinkertaisemmin: ```js ``` -Tämä muutos kuitenkin hajottaa sovelluksemme täysin: +Tämä ei kuitenkaan toimi: -![](../../images/1/5b.png) +![](../../images/1/5c.png) Mistä on kyse? Tapahtumankäsittelijäksi on tarkoitus määritellä joko funktio tai viite funktioon. Kun koodissa on @@ -471,9 +482,9 @@ Mistä on kyse? Tapahtumankäsittelijäksi on tarkoitus määritellä joko fu ``` -Nyt napin tapahtumankäsittelijän määrittelevä attribuutti onClick saa arvokseen funktion _() => setCounter(counter + 1)_, ja funktiota kutsutaan siinä vaiheessa kun sovelluksen käyttäjä painaa nappia. +Nyt napin tapahtumankäsittelijän määrittelevä attribuutti onClick saa arvokseen funktion _() => setCounter(counter + 1)_, ja funktiota kutsutaan siinä vaiheessa kun sovelluksen käyttäjä painaa nappia. -Tapahtumankäsittelijöiden määrittely suoraan JSX-templatejen sisällä ei useimmiten ole kovin viisasta. Tässä tapauksessa se tosin on ok, koska tapahtumankäsittelijät ovat niin yksinkertaisia. +Tapahtumankäsittelijöiden määrittely suoraan JSX-templatejen sisällä ei useimmiten ole kovin viisasta. Tässä tapauksessa se tosin on ok, koska tapahtumankäsittelijät ovat niin yksinkertaisia. -Eriytetään kuitenkin nappien tapahtumankäsittelijät omiksi komponentin sisäisiksi apufunktioikseen: +Eriytetään kuitenkin nappien tapahtumankäsittelijät omiksi komponentin sisäisiksi apufunktioiksi: ```js -const App = (props) => { +const App = () => { const [ counter, setCounter ] = useState(0) // highlight-start @@ -511,7 +522,7 @@ const App = (props) => { } ``` -Tälläkin kertaa tapahtumankäsittelijät on määritelty oikein, sillä onClick-attribuutit saavat arvokseen muuttujan, joka tallettaa viitteen funktioon: +Nytkin tapahtumankäsittelijät on määritelty oikein, sillä onClick-attribuutit saavat arvokseen muuttujan, joka tallettaa viitteen funktioon: ```js ) @@ -579,11 +590,11 @@ const Button = (props) => { Komponentti App muuttuu nyt muotoon: ```js -const App = (props) => { +const App = () => { const [ counter, setCounter ] = useState(0) const increaseByOne = () => setCounter(counter + 1) - const decreaseByOne = () => setCounter(counter - 1) + const decreaseByOne = () => setCounter(counter - 1) // highlight-line const setToZero = () => setCounter(0) return ( @@ -591,15 +602,15 @@ const App = (props) => { // highlight-start ) } ``` -Eli destrukturoidaan props:ista tarpeelliset kentät ja käytetään nuolifunktioiden tiiviimpää muotoa +Destrukturoidaan props:ista tarpeelliset kentät ja käytetään nuolifunktioiden tiiviimpää muotoa: ```js -const Button = ({ handleClick, text }) => ( - -) +const Button = ({ onClick, text }) => ``` +Koska komponentti sisältää vain _return_-lauseen, on tiiviin nuolifunktiosyntaksin käyttö mahdollista. + diff --git a/src/content/1/fi/osa1d.md b/src/content/1/fi/osa1d.md index 0fde0c2e009..afd5bb0204a 100644 --- a/src/content/1/fi/osa1d.md +++ b/src/content/1/fi/osa1d.md @@ -9,14 +9,14 @@ lang: fi ### Monimutkaisempi tila -Edellisessä esimerkissä sovelluksen tila oli yksinkertainen, se koostui ainoastaan yhdestä kokonaisluvusta. Entä jos sovellus tarvitsee monimutkaisemman tilan? +Edellisessä esimerkissä sovelluksen tila oli yksinkertainen, sillä se koostui ainoastaan yhdestä kokonaisluvusta. Entä jos sovellus tarvitsee monimutkaisemman tilan? Helpoin ja useimmiten paras tapa on luoda sovellukselle useita erillisiä tiloja tai tilan "osia" kutsumalla funktiota _useState_ useampaan kertaan. Seuraavassa sovellukselle luodaan kaksi alkuarvon 0 saavaa tilaa _left_ ja _right_: ```js -const App = (props) => { +const App = () => { const [left, setLeft] = useState(0) const [right, setRight] = useState(0) @@ -37,7 +37,7 @@ const App = (props) => { } ``` -Komponentti saa käyttöönsä tilan alustuksen yhteydessä funktiot _setLeft_ ja _setRight_ joiden avulla se voi päivittää tilan osia. +Komponentti saa käyttöönsä tilan alustuksen yhteydessä funktiot _setLeft_ ja _setRight_, joiden avulla se voi päivittää tilan osia. Komponentin tila tai yksittäinen tilan pala voi olla minkä tahansa tyyppinen. Voisimme toteuttaa saman toiminnallisuuden tallentamalla nappien left ja right painallukset yhteen olioon @@ -48,10 +48,10 @@ Komponentin tila tai yksittäinen tilan pala voi olla minkä tahansa tyyppinen. } ``` -sovellus muuttuisi seuraavasti: +jolloin sovellus muuttuisi seuraavasti: ```js -const App = (props) => { +const App = () => { const [clicks, setClicks] = useState({ left: 0, right: 0 }) @@ -87,7 +87,7 @@ const App = (props) => { Nyt komponentilla on siis ainoastaan yksi tila. Näppäinten painallusten yhteydessä on nyt huolehdittava koko tilan muutoksesta. -Tapahtumankäsittelijä vaikuttaa hieman sotkuiselta. Kun nappia left painetaan, suoritetaan seuraava funktio: +Tapahtumankäsittelijä vaikuttaa hieman sotkuiselta. Kun nappia left painetaan, suoritetaan seuraava funktio ```js const handleLeftClick = () => { @@ -99,7 +99,7 @@ const handleLeftClick = () => { } ``` -uudeksi tilaksi siis asetetaan seuraava olio +ja uudeksi tilaksi asetetaan siis seuraava olio ```js { @@ -108,9 +108,9 @@ uudeksi tilaksi siis asetetaan seuraava olio } ``` -eli kentän left arvo on sama kuin alkuperäisen tilan kentän left + 1 ja kentän right arvo on sama kuin alkuperäisen tilan kenttä right. +jolloin kentän left arvo on sama kuin alkuperäisen tilan kentän left + 1 ja kentän right arvo on sama kuin alkuperäisen tilan kentän right. -Uuden tilan määrittelevän olion muodostaminen onnistuu hieman tyylikkäämmin hyödyntämällä kesällä 2018 kieleen tuotua [object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) -syntaksia: +Uuden tilan määrittelevän olion muodostaminen onnistuu hieman tyylikkäämmin hyödyntämällä kesällä 2018 kieleen tuotua [object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) ‑syntaksia: ```js const handleLeftClick = () => { @@ -138,7 +138,7 @@ Esimerkissä siis { ...clicks, right: clicks.right + 1 } ``` -luo oliosta _clicks_ kopion, missä kentän _right_ arvoa kasvatetaan yhdellä. +luo oliosta _clicks_ kopion, jossa kentän _right_ arvoa kasvatetaan yhdellä. Apumuuttujat ovat oikeastaan turhat, ja tapahtumankäsittelijät voidaan määritellä seuraavasti: @@ -150,7 +150,7 @@ const handleRightClick = () => setClicks({ ...clicks, right: clicks.right + 1 }) ``` -Lukijalle voi tässä vaiheessa herätä kysymys miksi emme hoitaneet tilan päivitystä seuraavalla tavalla +Miksi emme hoitaneet tilan päivitystä seuraavasti? ```js const handleLeftClick = () => { @@ -159,18 +159,18 @@ const handleLeftClick = () => { } ``` -Sovellus näyttää toimivan. Reactissa ei kuitenkaan ole sallittua muuttaa tilaa suoraan, sillä voi olla arvaamattomat seuraukset. Tilan muutos tulee aina tehdä asettamalla uudeksi tilaksi vanhan perusteella tehty kopio! +Sovellus näyttää toimivan. Reactissa ei kuitenkaan ole sallittua muuttaa tilaa suoraan (kuten komento _clicks.left_ nyt tekee), koska sillä voi olla arvaamattomat seuraukset. Tilan muutos tulee aina tehdä asettamalla uudeksi tilaksi vanhan perusteella tehty kopio! Kaiken tilan pitäminen yhdessä oliossa on tämän sovelluksen kannalta huono ratkaisu; etuja siinä ei juuri ole, mutta sovellus monimutkaistuu merkittävästi. Onkin ehdottomasti parempi ratkaisu tallettaa nappien klikkaukset erillisiin tilan paloihin. -On kuitenkin tilanteita, joissa jokin osa tilaa kannattaa pitää monimutkaisemman tietorakenteen sisällä. [Reactin dokumentaatiossa](https://reactjs.org/docs/hooks-faq.html#should-i-use-one-or-many-state-variables) on hieman ohjeistusta aiheeseen liityen. +On kuitenkin tilanteita, joissa jokin osa tilaa kannattaa pitää monimutkaisemman tietorakenteen sisällä. [Reactin dokumentaatiossa](https://react.dev/learn/choosing-the-state-structure) on hieman ohjeistusta aiheeseen liittyen. ### Taulukon käsittelyä -Tehdään sovellukseen vielä laajennus, lisätään sovelluksen tilaan taulukko _allClicks_, joka muistaa kaikki näppäimenpainallukset. +Tehdään sovellukseen laajennus lisäämällä sovelluksen tilaan taulukko _allClicks_, joka muistaa kaikki näppäimenpainallukset: ```js -const App = (props) => { +const App = () => { const [left, setLeft] = useState(0) const [right, setRight] = useState(0) const [allClicks, setAll] = useState([]) // highlight-line @@ -203,13 +203,13 @@ const App = (props) => { } ``` -Kaikki klikkaukset siis talletetaan omaan tilaan _allClicks_, joka alustetaan tyhjäksi taulukoksi +Kaikki painallukset siis talletetaan omaan tilaan _allClicks_, joka alustetaan tyhjäksi taulukoksi: ```js const [allClicks, setAll] = useState([]) ``` -Kun esim. nappia left painetaan, lisätään tilan taulukkoon _allClicks_ kirjain L: +Kun esim. nappia left painetaan, tilan taulukkoon _allClicks_ lisätään kirjain L: ```js const handleLeftClick = () => { @@ -218,9 +218,9 @@ const handleLeftClick = () => { } ``` -Tilaa _allClicks_ saa nyt arvokseen taulukon, missä on entisen taulukon alkiot ja L. Uuden alkion liittäminen on tehty metodilla [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat), joka toimii siten, että se ei muuta olemassaolevaa taulukkoa vaan luo uuden taulukon, mihin uusi alkio on lisätty. +Tila _allClicks_ saa nyt arvokseen taulukon, jossa ovat entisen taulukon alkiot ja L. Uuden alkion liittäminen on tehty metodilla [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat), joka toimii siten, että se ei muuta olemassa olevaa taulukkoa vaan luo uuden taulukon, johon uusi alkio on lisätty. -Kuten jo aiemmin mainittiin, Javascriptissa on myös mahdollista lisätä taulukkoon metodilla [push](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push) ja sovellus näyttäisi tässä tilanteessa toimivan myös jos lisäys hoidettaisiin siten että _allClicks_-tilaa muuteaan pushaamalla siihen alkio ja sitten päivitetään tila: +Kuten jo aiemmin mainittiin, JavaScriptissa on mahdollista lisätä taulukkoon myös metodilla [push](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push). Sovelluksemme näyttäisikin toimivan myös silloin, kun lisäys hoidetaan muuttamalla _allClicks_-tilaa pushaamalla siihen alkio ja päivittämällä sitten tila: ```js const handleLeftClick = () => { @@ -230,12 +230,12 @@ const handleLeftClick = () => { } ``` -Älä kuitenkaan tee näin. Kuten jo mainitsimme, React-komponentin tilaa, eli esimerkiksi muuttujaa _allClicks_ ei saa muuttaa. Vaikka tilan muuttaminen näyttääkin toimivan joissaikin tilanteissa, voi seurauksena olla hankalasti havaittavia ongelmia. +Älä kuitenkaan tee näin. Kuten jo mainitsimme, React-komponentin tilaa, eli esimerkiksi muuttujaa _allClicks_, ei saa muuttaa. Vaikka tilan muuttaminen näyttääkin toimivan joissakin tilanteissa, voi seurauksena olla hankalasti havaittavia ongelmia. Katsotaan vielä tarkemmin, miten kaikkien painallusten historia renderöidään ruudulle: ```js -const App = (props) => { +const App = () => { // ... return ( @@ -252,13 +252,104 @@ const App = (props) => { } ``` -Taulukolle _allClicks_ kutsutaan metodia [join](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/join), joka muodostaa taulukosta merkkijonon, joka sisältää taulukon alkiot erotettuina parametrina olevalla merkillä, eli välilyönnillä. +Taulukolle _allClicks_ kutsutaan metodia [join](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/join), joka muodostaa taulukosta merkkijonon, joka sisältää taulukon alkiot erotettuina parametrina olevalla merkillä eli välilyönnillä. + + +### Tilan päivitys tapahtuu asynkronisesti + +Laajennetaan sovellusta siten, että se pitää kirjaa nappien painallusten yhteenlasketusta määrästä tilassa _total_, jonka arvoa päivitetään aina nappien painalluksen yhteydessä: + +```js +const App = () => { + const [left, setLeft] = useState(0) + const [right, setRight] = useState(0) + const [allClicks, setAll] = useState([]) + const [total, setTotal] = useState(0) // highlight-line + + const handleLeftClick = () => { + setAll(allClicks.concat('L')) + setLeft(left + 1) + setTotal(left + right) // highlight-line + } + + const handleRightClick = () => { + setAll(allClicks.concat('R')) + setRight(right + 1) + setTotal(left + right) // highlight-line + } + + return ( +
    + {left} + + + {right} +

    {allClicks.join(' ')}

    +

    total {total}

    // highlight-line +
    + ) +} +``` + +Ratkaisu toimii melkein: + +![](../../images/1/33.png) + +Jostain syystä nappien painallusten yhteenlaskettu määrä näyttää koko ajan yhtä liian vähän. + +Lisätään tapahtumankäsittelijään muutama console.log: + +```js +const App = () => { + // ... + const handleLeftClick = () => { + setAll(allClicks.concat('L')) + console.log('left before', left) // highlight-line + setLeft(left + 1) + console.log('left after', left) // highlight-line + setTotal(left + right) + } + + // ... +} +``` + +Konsoli paljastaa ongelman + +![](../../images/1/32.png) + +Vaikka tilalle _left_ asetettiin uusi arvo kutsumalla _setLeft(left + 1)_ on tilalla siis tapahtumankäsittelijän sisällä edelleen vanha arvo päivityksestä huolimatta! Tämän takia seuraava nappien painallusten laskuyritys tuottaa aina yhtä liian pienen tuloksen: + +```js +setTotal(left + right) +``` + +Syynä ilmiöön on se, että tilan päivitys tapahtuu Reactissa [asynkronisesti](https://react.dev/learn/queueing-a-series-of-state-updates#react-batches-state-updates), eli "jossain vaiheessa" ennen kuin komponentti renderöidään uudelleen, ei kuitenkaan välittömästi. + +Saamme korjattua sovelluksen seuraavasti: + +```js +const App = () => { + // ... + const handleLeftClick = () => { + setAll(allClicks.concat('L')) + const updatedLeft = left + 1 + setLeft(updatedLeft) + setTotal(updatedLeft + right) + } + + // ... +} +``` + +Eli nyt nappien määrän summa perustuu varmasti oikeaan määrään vasemman napin painalluksia. ### Ehdollinen renderöinti -Muutetaan sovellusta siten, että näppäilyhistorian renderöinnistä vastaa komponentti _History_: +Muutetaan sovellusta siten, että painallushistorian renderöinnistä vastaa komponentti _History_: ```js +// highlight-start const History = (props) => { if (props.allClicks.length === 0) { return ( @@ -274,8 +365,9 @@ const History = (props) => { ) } +// highlight-end -const App = (props) => { +const App = () => { // ... return ( @@ -292,7 +384,7 @@ const App = (props) => { } ``` -Nyt komponentin toiminta riippuu siitä, onko näppäimiä jo painettu. Jos ei, eli taulukko allClicks on tyhjä, renderöi komponentti "käyttöohjeen" sisältävän divin. +Nyt komponentin toiminta riippuu siitä, onko näppäimiä jo painettu. Jos ei, eli taulukko allClicks on tyhjä, komponentti renderöi "käyttöohjeen" sisältävän divin. ```js
    the app is used by pressing the buttons
    @@ -306,9 +398,9 @@ ja muussa tapauksessa näppäilyhistorian: ``` -Komponentin _History_ ulkoasun muodostamat React-elementit siis ovat erilaisia riippuen sovelluksen tilasta, eli komponentissa on ehdollista renderöintiä. +Komponentti _History_ renderöi siis eri React-elementit riippuen sovelluksen tilasta, eli komponentissa on ehdollista renderöintiä. -Reactissa on monia muitakin tapoja [ehdolliseen renderöintiin](https://reactjs.org/docs/conditional-rendering.html). Katsotaan niitä tarkemmin [seuraavassa osassa](/osa2). +Reactissa on monia muitakin tapoja [ehdolliseen renderöintiin](https://react.dev/learn/conditional-rendering). Katsotaan niitä tarkemmin [seuraavassa osassa](/osa2). Muutetaan vielä sovellusta siten, että se käyttää aiemmin määrittelemäämme komponenttia _Button_ painikkeiden muodostamiseen: @@ -329,15 +421,9 @@ const History = (props) => { ) } -// highlight-start -const Button = ({ onClick, text }) => ( - -) -// highlight-end +const Button = ({ onClick, text }) => // highlight-line -const App = (props) => { +const App = () => { const [left, setLeft] = useState(0) const [right, setRight] = useState(0) const [allClicks, setAll] = useState([]) @@ -354,15 +440,13 @@ const App = (props) => { return (
    -
    - {left} - // highlight-start -
    + {left} + // highlight-start +
    ) } @@ -370,15 +454,15 @@ const App = (props) => { ### Vanha React -Tällä kurssilla käyttämämme tapa React-komponenttien tilan määrittelyyn, eli [state hook](https://reactjs.org/docs/hooks-state.html) on siis uutta Reactia ja käytettävissä alkuvuodesta 2019 ilmestyneestä versiosta [16.8.0](https://www.npmjs.com/package/react/v/16.8.0) lähtien. Ennen hookeja Javascript-funktioina määriteltyihin React-komponentteihin ei ollut mahdollista saada tilaa ollenkaan, tilaa edellyttävät komponentit oli pakko määritellä [Class](https://reactjs.org/docs/react-component.html)-komponentteina Javascriptin luokkasyntaksia hyödyntäen. +Tällä kurssilla käyttämämme tapa React-komponenttien tilan määrittelyyn, eli [state hook](https://react.dev/learn/state-a-components-memory), on siis "uutta" Reactia ja käytettävissä alkuvuodesta 2019 ilmestyneestä versiosta [16.8.0](https://www.npmjs.com/package/react/v/16.8.0) lähtien. Ennen hookeja JavaScript-funktioina määriteltyihin React-komponentteihin ei ollut mahdollista saada tilaa ollenkaan, ja tilaa edellyttävät komponentit oli pakko määritellä [class](https://react.dev/reference/react/Component)-komponentteina JavaScriptin luokkasyntaksia hyödyntäen. -Olemme tällä kurssilla tehneet hieman radikaalinkin ratkaisun käyttää pelkästään hookeja ja näin ollen opetella heti alusta asti ohjelmoimaan "huomisen" Reactia. Luokkasyntaksin hallitseminen on kuitenkin sikäli tärkeää, että vaikka funktiona määriteltävät komponentit ovat Reactin tulevaisuus, on maailmassa miljardeja rivejä vanhaa Reactia, jota kenties sinäkin joudut jonain päivänä ylläpitämään. Dokumentaation ja internetistä löytyvien esimerkkien suhteen tilanne on sama, törmäät class-komponentteihin välittömästi. +Olemme tällä kurssilla tehneet hieman radikaalinkin ratkaisun käyttää pelkästään hookeja ja näin ollen opetella heti alusta asti ohjelmoimaan modernia Reactia. Luokkasyntaksin hallitseminen on kuitenkin sikäli tärkeää, että vaikka funktiona määriteltävät komponentit ovat modernia Reactia, maailmassa on miljardeja rivejä vanhaa Reactia, jota kenties sinäkin joudut jonain päivänä ylläpitämään. Dokumentaation ja Internetistä löytyvien esimerkkien suhteen tilanne on sama; tulet törmäämään myös class-komponentteihin. Tutustummekin riittävällä tasolla class-komponentteihin kurssin [seitsemännessä](/osa7) osassa. ### React-sovellusten debuggaus -Ohjelmistokehittäjän elämä koostuu pääosin debuggaamisesta (ja olemassaolevan koodin lukemisesta). Silloin tällöin syntyy toki muutama rivi uuttakin koodia, mutta suuri osa ajasta ihmetellään miksi joku on rikki tai miksi joku asia ylipäätään toimii. Hyvät debuggauskäytänteet ja työkalut ovatkin todella tärkeitä. +Ohjelmistokehittäjän työ sisältää monesti debuggaamista ja olemassa olevan koodin lukemista. Silloin tällöin syntyy toki muutama rivi uuttakin koodia, mutta suuri osa ajasta ihmetellään, miksi joku on rikki tai miksi joku asia ylipäätään toimii. Hyvät debuggauskäytännöt ja ‑työkalut ovatkin todella tärkeitä. Onneksi React on debuggauksen suhteen jopa harvinaisen kehittäjäystävällinen kirjasto. @@ -390,39 +474,35 @@ Muistutetaan vielä tärkeimmästä web-sovelluskehitykseen liittyvästä asiast > > Välilehdistä tulee olla auki nimenomaan Console, jollei ole erityistä syytä käyttää jotain muuta välilehteä. -Pidä myös koodi ja web-sivu **koko ajan** molemmat yhtä aikaa näkyvillä. +Pidä myös koodi ja web-sivu **koko ajan** yhtä aikaa näkyvillä. -Jos ja kun koodi ei käänny, eli selaimessa alkaa näkyä punaista +Jos ja kun koodi ei käänny eli selaimessa alkaa näkyä punaista -![](../../images/1/6e.png) +![](../../images/1/6x.png) -älä kirjota enää lisää koodia vaan selvitä ongelma **välittömästi**. Koodauksen historia ei tunne tilannetta, missä kääntymätön koodi alkaisi ihmeenomaisesti toimimaan kirjoittamalla suurta määrää lisää koodia, enkä usko että sellaista ihmettä nähdään tälläkään kurssilla. +älä kirjoita lisää koodia, vaan selvitä ongelma. Koodauksen historia ei tunne tilannetta, jossa kääntymätön koodi alkaa ihmeen voimalla toimimaan kirjoittamalla suuri määrää lisää koodia, emmekä usko tällaista ihmettä nähtävän tälläkään kurssilla. -Vanha kunnon printtaukseen perustuva debuggaus kannattaa aina. Eli jos esim. komponentissa +Vanha kunnon printtaukseen perustuva debuggaus on monesti toimiva tapa. Eli jos esim. komponentissa ```js -const Button = ({ handleClick, text }) => ( - -) +const Button = ({ onClick, text }) => ``` -olisi jotain ongelmia, kannattaa komponentista alkaa printtailla konsoliin. Pystyäksemme printtaamaan, tulee funktio muuttaa pitempään muotoon ja propsit kannattaa kenties vastaanottaa ilman destrukturointia: +olisi ongelma, kannattaa komponentista alkaa printtailla konsoliin. Pystyäksemme printtaamaan tulee funktio muuttaa pitempään muotoon ja propsit kannattaa kenties vastaanottaa ilman destrukturointia: ```js const Button = (props) => { console.log(props) // highlight-line - const { handleClick, text } = props + const { onClick, text } = props return ( - ) } ``` -näin selviää heti onko esim. joku propsia vastaava attribuutti nimetty väärin komponenttia käytettäessä. +näin selviää heti, onko esim. joku propsia vastaava attribuutti nimetty väärin komponenttia käytettäessä. **HUOM** kun käytät komentoa _console.log_ debuggaukseen, älä yhdistele asioita "javamaisesti" plussalla, eli sen sijaan että kirjoittaisit @@ -436,33 +516,33 @@ erottele tulostettavat asiat pilkulla: console.log('props value is', props) ``` -Jos yhdistät merkkijonoon olion, tuloksena on suhteellisen hyödytön tulostusmuoto +Jos yhdistät plussaa käyttäen merkkijonoon olion, tuloksena on suhteellisen hyödytön tulostusmuoto ```js props value is [Object object] ``` -kun taas pilkulla tulostettavat asiat erotellessa saat developer-konsoliin olion, jonka sisältöä on mahdollista tarkastella. +kun taas erotellessasi tulostettavat asiat pilkulla saat developer-konsoliin olion, jonka sisältöä on mahdollista tarkastella. -Konsoliin tulostus ei ole suinkaan ainoa keino debuggaamiseen. Koodin suorituksen voi pysäyttää Chromen developer konsolin debuggeriin kirjoittamalla mihin tahansa kohtaa koodia komennon [debugger](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/debugger). +Konsoliin tulostus ei ole suinkaan ainoa keino debuggaamiseen. Voit pysäyttää koodin suorituksen Chromen developer-konsolin debuggeriin kirjoittamalla omassa tekstieditorissasi olevaan lähdekoodiin mihin tahansa kohtaan koodia komennon [debugger](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/debugger). -Koodi pysähtyy kun suoritus etenee sellaiseen pisteeseen, missä komento _debugger_ suoritetaan: +Koodi pysähtyy, kun suoritus etenee sellaiseen pisteeseen, jossa komento _debugger_ suoritetaan: ![](../../images/1/7a.png) -Menemällä välilehdelle Console on helppo tutkia muuttujien tilaa: +Muuttujien tilaa voi tutkia Console-välilehdellä: ![](../../images/1/8a.png) -Kun bugi selviää, voi komennon _debugger_ poistaa ja uudelleenladata sivun. +Kun bugi selviää, _debugger_-komennon voi poistaa ja ladata sivun uudelleen. Debuggerissa on mahdollista suorittaa koodia tarvittaessa rivi riviltä Sources-välilehden oikealta laidalta. -Debuggeriin pääsee myös ilman komentoa _debugger_, lisäämällä Sources-välilehdellä sopiviin kohtiin koodia breakpointeja. Komponentin muuttujien arvojen tarkkailu on mahdollista _Scope_-osassa: +Debuggeriin pääsee myös ilman komentoa _debugger_ lisäämällä Sources-välilehdellä sopiviin kohtiin koodia breakpointeja. Komponentin muuttujien arvojen tarkkailu on mahdollista _Scope_-osassa: ![](../../images/1/9a.png) -Chromeen kannattaa ehdottomasti asentaa [React developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) -lisäosa, joka tuo konsoliin uuden tabin _Components_. Uuden konsolitabin avulla voidaan tarkkailla sovelluksen React-komponentteja ja niiden tilaa ja propseja: +Chromeen kannattaa ehdottomasti asentaa [React Developer Tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) ‑lisäosa, joka tuo konsoliin uuden välilehden _Components_. Uuden välilehden avulla voidaan tarkkailla sovelluksen React-komponentteja ja niiden tilaa ja propseja: ![](../../images/1/10ea.png) @@ -474,17 +554,19 @@ const [right, setRight] = useState(0) const [allClicks, setAll] = useState([]) ``` -React dev tools näyttää hookeilla luodut tilan osat siinä järjestyksessä kun ne on määritelty koodissa: +React Developer Tools näyttää hookeilla luodut tilan osat siinä järjestyksessä kuin ne on määritelty koodissa: ![](../../images/1/11ea.png) -Ylimpänä oleva State siis vastaa tilan left arvoa, seuraava tilan right arvoa ja alimpana on taulukko allClicks. +Ylimpänä oleva State vastaa siis tilan left arvoa, seuraava tilan right arvoa ja alimpana on taulukko allClicks. + +Chromella tapahtuvaan JavaScriptin debuggaukseen voi tutustua myös esim. [Chromen DevTools-ohjeen videolla](https://developer.chrome.com/docs/devtools/javascript). ### Hookien säännöt -Jotta hookeilla muodostettu sovelluksen tila toimisi oikein, on hookeja käytettävä tiettyjä [rajoituksia](https://reactjs.org/docs/hooks-rules.html) noudattaen. +Jotta hookeilla muodostettu sovelluksen tila toimisi oikein, on hookeja käytettävä tiettyjä [rajoituksia](https://react.dev/learn/state-a-components-memory#meet-your-first-hook) noudattaen. -Funktiota _useState_ (eikä seuraavassa osassa esiteltävää funktiota _useEffect_) ei saa kutsua loopissa, ehtolausekkeiden sisältä tai muista kun komponentin määrittelevästä funktioista. Tämä takaa sen, että hookeja kutsutaan aina samassa järjestyksessä, jos näin ei ole, sovellus toimii miten sattuu. +Funktiota _useState_ ei saa kutsua silmukassa (sama koskee seuraavassa osassa esiteltävää funktiota _useEffect_), ehtolausekkeiden sisältä tai muista kuin komponentin määrittelevästä funktiosta. Tämä takaa sen, että hookeja kutsutaan aina samassa järjestyksessä. Jos näin ei ole, sovellus saattaa toimia miten sattuu. Hookeja siis kuuluu kutsua ainoastaan React-komponentin määrittelevän funktion rungosta: @@ -517,11 +599,11 @@ const App = (props) => { ### Tapahtumankäsittely revisited -Edellisten vuosien kurssin perusteella tapahtumankäsittely on osoittautunut monelle tässä vaiheessa haastavaksi aiheeksi. +Tapahtumankäsittely on osoittautunut aiempien vuosien kursseilla haastavaksi aiheeksi. Tarkastellaan asiaa vielä uudelleen. -Oletetaan, että käytössä on äärimmäisen yksinkertainen sovellus: +Oletetaan, että käytössä on yksinkertainen sovellus, jonka komponentti App on määritelty seuraavasti: ```js const App = (props) => { @@ -534,26 +616,21 @@ const App = (props) => { ) } - -ReactDOM.render( - , - document.getElementById('root') -) ``` -Haluamme, että napin avulla tilan tallettava muuttuja _value_ saadaan nollattua. +Haluamme, että napin avulla saadaan nollattua tilan tallettava muuttuja _value_. -Jotta saamme napin reagoimaan, on sille lisättävä tapahtumankäsittelijä. +Jotta saamme napin reagoimaan, on napille lisättävä tapahtumankäsittelijä. -Tapahtumankäsittelijän tulee aina olla funktio tai viite funktioon. Jos tapahtumankäsittelijän paikalle yritetään laittaa jotain muuta, ei nappi toimi. +Tapahtumankäsittelijän tulee aina olla funktio tai viite funktioon. Jos tapahtumankäsittelijän paikalle yritetään laittaa jotain muuta, nappi ei toimi. -Jos esim. antaisimme tapahtumankäsittelijäksi merkkijonon: +Jos annamme tapahtumankäsittelijäksi esimerkiksi merkkijonon ```js ``` -React varoittaa asiasta konsolissa +React varoittaa asiasta konsolissa: ```js index.js:2178 Warning: Expected `onClick` listener to be a function, instead got a value of `string` type. @@ -562,13 +639,13 @@ index.js:2178 Warning: Expected `onClick` listener to be a function, instead got in App (at index.js:27) ``` -myös seuraavanlainen yritys olisi tuhoon tuomittu +Myös seuraavanlainen yritys olisi tuhoon tuomittu: ```js ``` -nyt tapahtumankäsittelijäksi on yritetty laittaa _value + 1_ mikä tarkoittaa laskuoperaation tulosta. React varoittaa tästäkin konsolissa +Nyt tapahtumankäsittelijäksi on yritetty laittaa _value + 1_, joka tarkoittaa laskuoperaation tulosta. React varoittaa tästäkin konsolissa: ```js index.js:2178 Warning: Expected `onClick` listener to be a function, instead got a value of `number` type. @@ -580,7 +657,7 @@ Myöskään seuraava ei toimi ``` -taaskaan tapahtumankäsittelijänä ei ole funktio vaan sijoitusoperaatio. Konsoliin tulee valitus. Tämä tapa on myös toisella tavalla väärin. Tilan muuttaminen ei onnistu suoraan tilan arvon tallentavaa muuttujaa muuttamalla. +sillä taaskaan tapahtumankäsittelijänä ei ole funktio vaan sijoitusoperaatio. Konsoliin tulee valitus. Tämä tapa on myös toisella tavalla väärin: tilan muuttaminen ei onnistu suoraan tilan arvon tallentavaa muuttujaa muuttamalla. Entä seuraava: @@ -590,21 +667,21 @@ Entä seuraava: ``` -konsoliin tulostuu kertaalleen clicked the button, mutta nappia painellessa ei tapahdu mitään. Miksi tämä ei toimi vaikka tapahtumankäsittelijänä on nyt funktio _console.log_? +Konsoliin tulostuu kertaalleen clicked the button, mutta nappia painellessa ei tapahdu mitään. Miksi tämä ei toimi vaikka tapahtumankäsittelijänä on nyt funktio _console.log_? -Ongelma on nyt siinä, että tapahtumankäsittelijänä on funktion kutsu, eli varsinaiseksi tapahtumankäsittelijäksi tulee funktion kutsun paluuarvo, joka on tässä tapauksessa määrittelemätön arvo undefined. +Ongelma on siinä, että tapahtumankäsittelijänä on funktion kutsu, eli varsinaiseksi tapahtumankäsittelijäksi tulee funktion kutsun paluuarvo, joka on tässä tapauksessa määrittelemätön arvo undefined. -Funktiokutsu _console.log('clicked the button')_ suoritetaan siinä vaiheessa kun komponentti renderöidään, ja tämän takia konsoliin tulee tulostus kertalleen. +Funktiokutsu _console.log('clicked the button')_ suoritetaan siinä vaiheessa kun komponentti renderöidään, minkä takia konsoliin tulee kuitenkin yksi tulostus. -Myös seuraava yritys on virheellinen +Myös seuraava yritys on virheellinen: ```js ``` -jälleen olemme yrittäneet laittaa tapahtumankäsittelijäksi funktiokutsun. Ei toimi. Tämä yritys aiheuttaa myös toisen ongelman. Kun komponenttia renderöidään, suoritetaan tapahtumankäsittelijänä oleva funktiokutsu _setValue(0)_ joka taas saa aikaan komponentin uudelleenrenderöinnin. Ja uudelleenrenderöinnin yhteydessä funktiota kutsutaan uudelleen käynnistäen jälleen uusi uudelleenrenderöinti, ja joudutaan päättymättömään rekursioon. +Jälleen olemme yrittäneet laittaa tapahtumankäsittelijäksi funktiokutsun. Ei toimi. Tämä yritys aiheuttaa myös toisen ongelman: kun komponenttia renderöidään, suoritetaan tapahtumankäsittelijänä oleva funktiokutsu _setValue(0)_ mikä taas saa aikaan komponentin uudelleenrenderöinnin. Ja uudelleenrenderöinnin yhteydessä funktiota kutsutaan uudelleen, mikä käynnistää jälleen uuden uudelleenrenderöinnin, ja näin joudutaan päättymättömään rekursioon. -Jos haluamme suorittaa tietyn funktiokutsun tapahtuvan nappia painettaessa, toimii seuraava +Jos haluamme suorittaa tietyn funktiokutsun nappia painettaessa, seuraava toimii: ```js ``` -Nyt tapahtumankäsittelijä on nuolisyntaksilla määritelty funktio _() => console.log('clicked the button')_. Kun komponentti renderöidään, ei suoriteta mitään, ainoastaan talletetaan funktioviite tapahtumankäsittelijäksi. Itse funktion suoritus tapahtuu vasta napin painallusten yhteydessä. +Nyt tapahtumankäsittelijä on nuolisyntaksilla määritelty funktio _() => console.log('clicked the button')_. Kun komponentti renderöidään, ei suoriteta mitään, ainoastaan talletetaan funktioviite tapahtumankäsittelijäksi. Itse funktion suoritus tapahtuu vasta napin painalluksen yhteydessä. Saamme myös nollauksen toimimaan samalla tekniikalla @@ -622,7 +699,7 @@ Saamme myös nollauksen toimimaan samalla tekniikalla eli nyt tapahtumankäsittelijä on funktio _() => setValue(0)_. -Tapahtumakäsittelijäfunktioiden määrittely suoraan napin määrittelyn yhteydessä ei välttämättä ole paras mahdollinen idea. +Tapahtumankäsittelijäfunktioiden määrittely suoraan napin määrittelyn yhteydessä ei ole välttämättä paras mahdollinen tapa. Usein tapahtumankäsittelijä määritelläänkin jossain muualla. Seuraavassa määritellään funktio ja sijoitetaan se muuttujaan _handleClick_ komponentin rungossa: @@ -642,13 +719,13 @@ const App = (props) => { } ``` -Muuttujassa _handleClick_ on nyt talletettuna viite itse funktioon. Viite annetaan napin määrittelyn yhteydessä attribuutin onClick: +Muuttujassa _handleClick_ on nyt talletettuna viite itse funktioon. Viite annetaan napin määrittelyn yhteydessä attribuuttiin onClick: ```js ``` -Tapahtumankäsittelijäfunktio voi luonnollisesti koostua useista komennoista, tällöin käytetään nuolifunktion aaltosulullista muotoa: +Tapahtumankäsittelijäfunktio voi luonnollisesti koostua useista komennoista, jolloin käytetään nuolifunktion aaltosulullista muotoa: ```js const App = (props) => { @@ -670,12 +747,12 @@ const App = (props) => { } ``` -### Funktio joka palauttaa funktion +### Funktion palauttava funktio Näytetään vielä eräs tapa määritellä tapahtumankäsittelijöitä: funktion palauttava funktio. Tällä kurssilla ei tätä tyyliä tulla käyttämään, joten **voit huoletta hypätä seuraavan ohi** jos asia tuntuu nyt hankalalta. Funktioita palauttavat funktiot ovat kuitenkin melko yleisiä funktionaalista ohjelmointityyliä käytettäessä, joten tarkastellaan tekniikkaa hieman vaikka selviämmekin kurssilla ilman sitä. -Muutetaan koodia seuraavasti +Muutetaan koodia seuraavasti: ```js const App = (props) => { @@ -698,15 +775,15 @@ const App = (props) => { } ``` -Koodi näyttää hankalalta mutta se ihme kyllä toimii. +Koodi näyttää hankalalta, mutta se toimii. -Tapahtumankäsittelijäksi on nyt "rekisteröity" funktiokutsu: +Tapahtumankäsittelijäksi on nyt asetettu funktiokutsu: ```js ``` -Aiemmin varoteltiin, että tapahtumankäsittelijä ei saa olla funktiokutsu vaan sen on oltava funktio tai viite funktioon. Miksi funktiokutsu kuitenkin toimii nyt? +Aiemmin varoiteltiin, että tapahtumankäsittelijä ei saa olla funktiokutsu vaan sen on oltava funktio tai viite funktioon. Miksi funktiokutsu kuitenkin toimii nyt? Kun komponenttia renderöidään suoritetaan seuraava funktio: @@ -718,15 +795,15 @@ const hello = () => { } ``` -funktion paluuarvona on nyt toinen, muuttujaan _handler_ määritelty funktio. +Funktion paluuarvona on nyt toinen, muuttujaan _handler_ määritelty funktio. -eli kun react renderöi seuraavan rivin +Eli kun React renderöi rivin ```js ``` -sijoittaa se onClick-käsittelijäksi funktiokutsun _hello()_ paluuarvon. Eli oleellisesti ottaen rivi "muuttuu" seuraavaksi +se sijoittaa onClick-käsittelijäksi funktiokutsun _hello()_ paluuarvon. Eli oleellisesti ottaen rivi "muuttuu" seuraavaksi: ```js ``` -koska funktio _hello_ palautti funktion, on tapahtumankäsittelijä nyt funktio. +Koska funktio _hello_ palautti funktion, tapahtumankäsittelijäkin on nyt funktio. -Mitä järkeä tässä konseptissa on? +Mitä hyötyä tällaisesta on? Muutetaan koodia hiukan: @@ -767,15 +844,15 @@ const App = (props) => { } ``` -Nyt meillä on kolme nappia joiden tapahtumankäsittelijät määritellään parametrin saavan funktion _button_ avulla. +Nyt meillä on kolme nappia, joiden tapahtumankäsittelijät määritellään parametrin saavan funktion _hello_ avulla. -Ensimmäinen nappi määritellään seuraavasti +Ensimmäinen nappi määritellään seuraavasti: ```js ``` -Tapahtumankäsittelijä siis saadaan suorittamalla funktiokutsu _hello('world')_. Funktiokutsu palauttaa funktion +Tapahtumankäsittelijä siis saadaan suorittamalla funktiokutsu _hello('world')_. Funktiokutsu palauttaa funktion: ```js () => { @@ -783,7 +860,7 @@ Tapahtumankäsittelijä siis saadaan suorittamalla funktiokutsu _hello('w } ``` -Toinen nappi määritellään seuraavasti +Toinen nappi määritellään seuraavasti: ```js @@ -797,9 +874,9 @@ Tapahtumankäsittelijän määrittelevä funktiokutsu _hello('react')_ palauttaa } ``` -eli molemmat napit saavat oman, yksilöllisen tapahtumankäsittelijänsä. +eli molemmat napit saavat omat, yksilölliset tapahtumankäsittelijänsä. -Funktioita palauttavia funktioita voikin hyödyntää määrittelemään geneeristä toiminnallisuutta, jota voi tarkentaa parametrien avulla. Tapahtumankäsittelijöitä luovan funktion _hello_ voikin ajatella olevan eräänlainen tehdas, jota voi pyytää valmistamaan sopivia tervehtimiseen tarkoitettuja tapahtumankäsittelijäfunktioita. +Funktioita palauttavia funktioita voikin hyödyntää määrittelemään geneeristä toiminnallisuutta, jota voi tarkentaa parametrien avulla. Tapahtumankäsittelijöitä luovan funktion _hello_ voikin ajatella olevan eräänlainen tehdas, jota voi pyytää valmistamaan sopivia tervehtimiseen käytettäviä tapahtumankäsittelijäfunktioita. Käyttämämme määrittelytapa @@ -813,7 +890,7 @@ const hello = (who) => { } ``` -on hieman verboosi. Eliminoidaan apumuuttuja, ja määritellään palautettava funktio suoraan returnin yhteydessä: +on hieman verboosi. Eliminoidaan apumuuttuja ja määritellään palautettava funktio suoraan returnin yhteydessä: ```js const hello = (who) => { @@ -823,7 +900,7 @@ const hello = (who) => { } ``` -ja koska funktio _hello_ sisältää ainoastaan yhden komennon, eli returnin, voidaan käyttää aaltosulutonta muotoa +Koska funktio _hello_ sisältää ainoastaan yhden komennon, returnin, voimme käyttää aaltosulutonta muotoa ```js const hello = (who) => @@ -832,7 +909,7 @@ const hello = (who) => } ``` -ja tuodaan vielä "kaikki nuolet" samalle riville +ja tuoda vielä "kaikki nuolet" samalle riville: ```js const hello = (who) => () => { @@ -840,13 +917,14 @@ const hello = (who) => () => { } ``` -Voimme käyttää samaa kikkaa myös muodostamaan tapahtumankäsittelijöitä, jotka asettavat komponentin tilalle halutun arvon. Muutetaan koodi muotoon: +Voimme käyttää samaa kikkaa myös muodostamaan tapahtumankäsittelijöitä, jotka asettavat komponentin tilan halutuksi. Muutetaan koodi muotoon: ```js const App = (props) => { const [value, setValue] = useState(10) const setToValue = (newValue) => () => { + console.log('value now', newValue) // tulostetaan uusi arvo konsoliin setValue(newValue) } @@ -867,24 +945,25 @@ Kun komponentti renderöidään, ja tehdään nappia thousand ``` -tulee tapahtumankäsittelijäksi funktiokutsun _setToValue(1000)_ paluuarvo eli seuraava funktio +tulee tapahtumankäsittelijäksi funktiokutsun _setToValue(1000)_ paluuarvo eli seuraava funktio: ```js () => { - setValue(1000) + console.log('value now', 1000) + setValue(1000) } ``` -Kasvatusnapin generoima rivi on seuraava +Kasvatusnapin generoima rivi on seuraava: ```js ``` -Tapahtumankäsittelijän muodostaa funktiokutsu _setToValue(value + 1)_, joka saa parametrikseen tilan tallettavan muuttujan _value_ nykyisen arvon kasvatettuna yhdellä. Jos _value_ olisi 10, tulisi tapahtumankäsittelijäksi funktio - +Tapahtumankäsittelijän muodostaa funktiokutsu _setToValue(value + 1)_, joka saa parametrikseen tilan tallettavan muuttujan _value_ nykyisen arvon kasvatettuna yhdellä. Jos _value_ olisi 10, tulisi tapahtumankäsittelijäksi funktio: ```js () => { + console.log('value now', 11) setValue(11) } ``` @@ -896,6 +975,7 @@ const App = (props) => { const [value, setValue] = useState(10) const setToValue = (newValue) => { + console.log('value now', newValue) setValue(newValue) } @@ -916,50 +996,65 @@ const App = (props) => { } ``` -Voimme nyt määritellä tapahtumankäsittelijän funktioksi, joka kutsuu funktiota _setToValue_ sopivalla parametrilla, esim. nollaamisen tapahtumankäsittelijä: +Voimme nyt määritellä tapahtumankäsittelijän funktioksi, joka kutsuu funktiota _setToValue_ sopivalla parametrilla. Esim. nollaamisen tapahtumankäsittelijä voidaan kirjoittaa muotoon: ```js ``` -On makuasia käyttääkö tapahtumankäsittelijänä funktioita palauttavia funktioita vai nuolifunktioita. Tällä kurssilla emme kuitenkaan selvyyden vuoksi käytä funktioita palauttavia funktioita. +On makuasia käyttääkö tapahtumankäsittelijöinä funktioita palauttavia funktioita vai nuolifunktioita. Tällä kurssilla emme kuitenkaan selvyyden vuoksi käytä funktioita palauttavia funktioita. ### Tapahtumankäsittelijän vieminen alikomponenttiin -Eriytetään vielä painike omaksi komponentikseen +Eriytetään vielä painike omaksi komponentikseen: ```js const Button = (props) => ( - ) ``` -Komponentti saa siis propsina _handleClick_ tapahtumankäsittelijän ja propsina _text_ merkkijonon, jonka se renderöi painikkeen tekstiksi. +Komponentti saa siis propsina _onClick_ tapahtumankäsittelijän ja propsina _text_ merkkijonon, jonka se renderöi painikkeen tekstiksi. Komponenttia käytetään seuraavasti: -Komponentin Button käyttö on helppoa, on toki pidettävä huolta siitä, että komponentille annettavat propsit on nimetty niin kuin komponentti olettaa: +```js +const App = (props) => { + // ... + return ( +
    + {value} +
    + ) +} +``` -![](../../images/1/12e.png) +Komponentin Button käyttö on helppoa, mutta on toki pidettävä huolta siitä, että komponentille annettavat propsit on nimetty niin kuin komponentti olettaa: + +![](../../images/1/12f.png) ### Älä määrittele komponenttia komponentin sisällä -Eriytetään vielä sovelluksestamme luvun näyttäminen omaan komponenttiin Display. +Eriytetään vielä sovelluksestamme luvun näyttäminen omaan komponenttiinsa Display. -Muutetaan ohjelmaa seuraavasti, eli määritelläänkin uusi komponentti App-komponentin sisällä: +Määritellään uusi komponentti App-komponentin sisällä: ```js // tämä on oikea paikka määritellä komponentti! const Button = (props) => ( - ) -const App = props => { +const App = (props) => { const [value, setValue] = useState(10) const setToValue = newValue => { + console.log('value now', newValue) setValue(newValue) } @@ -968,39 +1063,42 @@ const App = props => { return (
    - -
    ) } ``` -Kaikki näyttää toimivan. Mutta **älä tee koskaan näin**, eli määrittele komponenttia toisen komponentin sisällä. Tapa on hyödytön ja johtaa useissa tilanteissa ikäviin ongelmiin. Siirretäänkin komponentin Display määrittely oikeaan paikkaan, eli komponentin App määrittelevän funktion ulkopuolelle: +Kaikki näyttää toimivan, mutta **älä koskaan määrittele komponenttia toisen komponentin sisällä**. Tapa on hyödytön ja johtaa usein ongelmiin. Suurimmat ongelmat johtuvat siitä, että toisen komponentin sisällä määritelty komponentti on Reactin näkökulmasta jokaisen renderöinnin yhteydessä aina uusi komponentti. Tämä tekee komponentin optimoinnista Reactille mahdotonta. + +Siirretäänkin komponentin Display määrittely oikeaan paikkaan eli komponentin App määrittelevän funktion ulkopuolelle: ```js const Display = props =>
    {props.value}
    const Button = (props) => ( - ) -const App = props => { +const App = () => { const [value, setValue] = useState(10) const setToValue = newValue => { + console.log('value now', newValue) setValue(newValue) } return (
    -
    ) } @@ -1008,13 +1106,73 @@ const App = props => { ### Hyödyllistä materiaalia -Internetissä on todella paljon Reactiin liittyvää materiaalia. Tällä hetkellä ongelman muodostaa kuitenkin se, että käytämme kurssilla niin uutta Reactia, että melko suuri osa internetistä löytyvästä tavarasta on meidän kannaltamme vanhentunutta ja käyttää Class-syntaksia komponenttien määrittelyyn. +Internetissä on todella paljon Reactiin liittyvää materiaalia. Välillä ongelman muodostaa kuitenkin se, että käytämme kurssilla uutta Reactia, ja edelleen aika suuri osa Internetistä löytyvästä materiaalista on meidän kannaltamme vanhentunutta ja käyttää Class-syntaksia komponenttien määrittelyyn. + +Linkkejä: + +- Reactin [dokumentaatio](https://react.dev/learn) kannattaa ehdottomasti käydä jossain vaiheessa läpi, ei välttämättä kaikkea nyt, osa on ajankohtaista vasta kurssin myöhemmissä osissa ja kaikki Class-komponentteihin liittyvä on kurssin kannalta epärelevanttia. +- Reactin sivuilla oleva [tutoriaali](https://react.dev/learn/tutorial-tic-tac-toe) sen sijaan on aika huono. +- [Egghead.io](https://egghead.io):n kursseista [Start learning React](https://egghead.io/courses/start-learning-react) on laadukas, ja hieman uudempi [The Beginner's guide to React](https://egghead.io/courses/the-beginner-s-guide-to-reactjs) on myös kohtuullisen hyvä; molemmat sisältävät myös asioita, jotka tulevat tällä kurssilla vasta myöhemmissä osissa. Molemmissa on toki se ongelma, että ne käyttävät Class-komponentteja. + +### Webohjelmoijan vala + +Ohjelmointi on hankalaa, ja sen takia lupaan hyödyntää kaikkia ohjelmointia helpottavia keinoja: + +- pidän selaimeni konsolin koko ajan auki +- etenen pienin askelin +- käytän koodissani runsaasti _console.log_-komentoja sekä varmistamaan sen, että varmasti ymmärrän jokaisen kirjoittamani koodirivin, että etsiessäni koodistani mahdollisia bugin aiheuttajia +- jos koodini ei toimi, en kirjoita enää yhtään lisää koodia, vaan alan poistaa toiminnan rikkoneita rivejä tai palaan suosiolla tilanteeseen, missä koodini vielä toimi +- kun kysyn apua kurssin Discord-kanavalla, tai muualla internetissä, muotoilen kysymyksen järkevästi, esim. [täällä](/en/part0/general_info#how-to-get-help-in-discord) esiteltyyn tapaan + +### Kielimallien hyödyntäminen -Seuraavassa muutamia linkkejä: +Suuret kielimallit, kuten [ChatGPT](https://chat.openai.com/auth/login), [Claude](https://claude.ai/) ja [GitHub Copilot](https://github.com/features/copilot) ovat osoittautuneet erittäin hyödyllisiksi ohjelmistokehityksessä. -- Reactin [dokumentaatio](https://reactjs.org/docs/getting-started.html) kannattaa ehdottomasti käydä jossain vaiheessa läpi, ei välttämättä kaikkea nyt, osa on ajankohtaista vasta kurssin myöhemmissä osissa ja kaikki Class-komponentteihin liittyvä on kurssin kannalta epärelevanttia -- Reactin sivuilla oleva [tutoriaali](https://reactjs.org/tutorial/tutorial.html) sen sijaan on aika huono -- [Egghead.io](https://egghead.io):n kursseista [Start learning React](https://egghead.io/courses/start-learning-react) on laadukas, ja hieman uudempi [The Beginner's guide to React](https://egghead.io/courses/the-beginner-s-guide-to-reactjs) on myös kohtuullisen hyvä; molemmat sisältävät myös asioita jotka tulevat tällä kurssilla vasta myöhemmissä osissa. Molemmissa toki se ongelma, että ne käyttävät Class-komponentteja +Itse käytän pääasiassa Copilottia, joka on nykyään [natiivisti integroitu VS Codeen](https://code.visualstudio.com/docs/copilot/overview). Lisäksi yliopisto-opiskelijat saavat Copilot Pro -version käyttöönsä ilmaiseksi [GitHub Student Developer Packin](https://education.github.com/pack) kautta. + +Copilot on hyödyllinen monenlaisissa skenaarioissa. Copilotia voi pyytää generoimaan koodia avoinna olevaan tiedostoon kuvailemalla halutun toiminnallisuuden teksinä: + +![](../../images/1/gpt1.png) + +Jos koodi vaikuttaa hyvältä, Copilot lisää sen tiedostoon: + +![](../../images/1/gpt2.png) + +Esimerkkimme tapauksessa Copilot loi ainoastaan painikkeen, tapahtumankäsittelijä _handleResetClick_ on määrittelemättä. + +Myös tapahtumankäsittelijän saa generoitua. Funktion ensimmäisen rivin kirjoittamalla Copilot tarjoaa generoimaansa toiminnallisuutta: + +![](../../images/1/gpt3.png) + +Copilotin chat-ikkunassa on mahdollista kysyä selitystä maalatun koodialueen toiminnalle: + +![](../../images/1/gpt4.png) + +Copilot on hyödyllinen myös virhetilanteissa, kopioimalla virheviesti Copilotin chatiin, tulee selitys ongelmasta ja korjausehdotus: + +![](../../images/1/gpt5.png) + +Copilotin chat mahdollistaa myös suurempien kokonaisuuksien luomisen + +![](../../images/1/gpt6.png) + +Copilotin ja muiden kielimallien antamien vihjeiden hyödyllisyyden aste vaihtelee. Kielimallien ehkä suurin ongelma on [hallusinointi](https://en.wikipedia.org/wiki/Hallucination_(artificial_intelligence)), ne generoivat välillä täysin vakuuttavan näköisiä vastauksia mitkä kuitenkin ovat täysin päättömiä. Ohjelmoidessa toki hallusinoitu koodi jää usein nopeasti kiinni jos koodi ei toimi. Ongelmallisempia tilanteita ovat ne, missä kielimallin generoima koodi näyttää toimivan, mutta se sisältää vaikeammin havaittavia bugeja tai esim. tietoturvahaavoittuvuuksia. + +Toinen ongelma kielimallien soveltamisessa ohjelmistokehitykseen on se, että kielimallien on vaikea "hahmottaa" isompia projekteja, ja esim. generoida toiminnallisuutta, joka edellyttäisi muutoksia useisiin tiedostoihin. Kielimallit eivät myöskään nykyisellään osaa yleistää koodia, eli jos koodissa on esim. olemassaolevia funktioita tai komponentteja, joita kielimalli pystyisi pienin muutoksin hyödyntämään siltä pyydettyyn toiminnallisuuteen, ei kielimalli tähän taivu. Tästä voi olla seurauksena se, että koodikanta rapistuu sillä kielimallit generoivat koodiin paljon toisteisuutta, ks. lisää esim. [täältä](https://visualstudiomagazine.com/articles/2024/01/25/copilot-research.aspx). + +Kielimalleja käytettäessä vastuu siis jää aina ohjelmoijalle. + +Kielimallien nopea kehitys asettaa ohjelmointia opiskelevan haastavaan asemaan: kannattaako ja tarvitseeko enää ylipäätään opetella ohjelmointia vanhan liiton tyyliin, kun lähes kaiken saa kielimalleilta valmiina? + +Tässä kohtaa kannattaa muistaa C-kielen kehittäjän [Brian Kerninghamin](https://en.wikipedia.org/wiki/Brian_Kernighan) vanha viisaus: + +![](../../images/1/kerningham.png) + +Eli koska ongelmien selvittely on kaksi kertaa vaikeampaa kuin ohjelmointi, ei kannata ohjelmoida sellaista koodia minkä vain juuri ja juuri itse ymmärtää. Miten debuggaus mahtaakaan onnistua tilanteessa missä ohjelmointi on ulkoistettu kielimallille ja ohjelmistokehittäjä ei ymmärrä debugattavaa koodia ollenkaan? + +Toistaiseksi kielimallien ja tekoälyn kehitys on vielä siinä vaiheessa, että ne eivät ole itseriittoisia, ja vaikeimmat ongelmat jäävät ihmisten selvitettäväksi. Tämän takia aloittelevienkin ohjelmistokehittäjien on kaiken varalta opeteltava ohjelmoimaan todella hyvin. Voi olla, että kielimallien kehityksestä huolimatta tarvitaankin entistä syvällisempää osaamista. Tekoäly tekee ne helpot asiat, mutta ihmistä tarvitaan kaikkein kiperimpien tekoälyn aiheuttamien sotkujen selvittelyyn. GitHub Copilot onkin varsin hyvin nimetty tuote, kyseessä on Copilot eli lentoperämies/nainen. Ohjelmoija on edelleen kapteeni ja kantaa lopullisen vastuun. + +Voikin olla oman etusi mukaista, että kytket oletusarvoisesti Copilotin pois päältä kun teet tätä kurssia ja turvaudut siihen ainoastaan todellisella hädän hetkellä. @@ -1027,23 +1185,35 @@ Tehtävät palautetaan **yksi osa kerrallaan**. Kun olet palauttanut osan tehtä Samaa ohjelmaa kehittelevissä tehtäväsarjoissa ohjelman lopullisen version palauttaminen riittää, voit toki halutessasi tehdä commitin jokaisen tehtävän jälkeisestä tilanteesta, mutta se ei ole välttämätöntä. -**VAROITUS** create-react-app tekee projektista automaattisesti git-repositorion, ellei sovellusta luoda jo olemassaolevan repositorion sisälle. Todennäköisesti **et halua** että projektista tulee repositorio, joten suorita projektin juuressa komento _rm -rf .git_. +Jos, ja kun törmäät virheilmoitukseen + +> Objects are not valid as a React child -

    1.6: unicafe step1

    +pidä mielessä [täällä](/osa1/reactin_alkeet#ala-renderoi-olioita) kerrotut asiat. -Monien firmojen tapaan nykyään myös [Unicafe](https://www.unicafe.fi/#/9/4) kerää asiakaspalautetta. Tee Unicafelle verkossa toimiva palautesovellus. Vastausvaihtoehtoja olkoon vain kolme: hyvä, neutraali ja huono. +

    1.6: unicafe step1

    + +Monien firmojen tapaan nykyään myös Helsingin yliopiston opiskelijaruokala [Unicafe](https://www.unicafe.fi) kerää asiakaspalautetta. Tee Unicafelle verkossa toimiva palautesovellus. Vastausvaihtoehtoja olkoon vain kolme: hyvä, neutraali ja huono. Sovelluksen tulee näyttää jokaisen palautteen lukumäärä. Sovellus voi näyttää esim. seuraavalta: ![](../../images/1/13e.png) -Huomaa, että sovelluksen tarvitsee toimia vain yhden selaimen käyttökerran ajan, esim. kun selain refreshataan, tilastot saavat hävitä. +Huomaa, että sovelluksen tarvitsee toimia vain yhden selaimen käyttökerran ajan. Esim. kun sivu refreshataan, tilastot saavat hävitä. + +Kannattaa noudattaa samaa rakennetta kuin materiaalissa ja edellisessä tehtävässä, eli tiedoston main.jsx sisältö on seuraava: + +```js +import ReactDOM from 'react-dom/client' +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() +``` -Voit tehdä koko sovelluksen tiedostoon index.js. Tiedoston sisältö voi olla aluksi seuraava +Muun sovelluksen voi tehdä tiedostoon App.jsx. Tiedoston sisältö voi olla aluksi seuraava: ```js -import React, { useState } from 'react' -import ReactDOM from 'react-dom' +import { useState } from 'react' const App = () => { // tallenna napit omaan tilaansa @@ -1058,9 +1228,7 @@ const App = () => { ) } -ReactDOM.render(, - document.getElementById('root') -) +export default App ```

    1.7: unicafe step2

    @@ -1100,7 +1268,7 @@ const App = () => {

    1.9: unicafe step4

    -Muuta sovellusta siten, että numeeriset tilastot näytetään ainoastaan jos palautteita on jo annettu: +Muuta sovellusta siten, että numeeriset tilastot näytetään ainoastaan, jos palautteita on jo annettu: ![](../../images/1/15e.png) @@ -1111,7 +1279,7 @@ Jatketaan sovelluksen refaktorointia. Eriytä seuraavat kaksi komponentti - Button vastaa yksittäistä palautteenantonappia - StatisticLine huolehtii tilastorivien, esim. keskiarvon näyttämisestä -Tarkennuksena: komponentti StatisticLine näyttää aina yhden tilastorivin, joten sovellus käyttää montaa komponenttia kaikkien tilastorivien renderöintiin +Tarkennuksena: komponentti StatisticLine näyttää aina yhden tilastorivin, joten sovellus käyttää komponenttia useaan kertaan renderöidäkseen kaikki tilastorivit ```js const Statistics = (props) => { @@ -1125,7 +1293,6 @@ const Statistics = (props) => { ) } - ``` Sovelluksen tila säilytetään edelleen juurikomponentissa App. @@ -1140,7 +1307,7 @@ Muista pitää konsoli koko ajan auki. Jos saat konsoliin seuraavan warningin: ![](../../images/1/17a.png) -tee tarvittavat toimenpiteet jotta saat warningin katoamaan. Googlaa tarvittaessa virheilmoituksella. +tee tarvittavat toimenpiteet, jotta saat warningin katoamaan. Googlaa tarvittaessa virheilmoituksella. **Huolehdi nyt ja jatkossa, että konsolissa ei näy mitään warningeja!** @@ -1151,42 +1318,40 @@ Ohjelmistotuotannossa tunnetaan lukematon määrä [anekdootteja](http://www.com Laajenna seuraavaa sovellusta siten, että siihen tulee nappi, jota painamalla sovellus näyttää satunnaisen ohjelmistotuotantoon liittyvän anekdootin: ```js -import React, { useState } from 'react' -import ReactDOM from 'react-dom' +import { useState } from 'react' -const App = (props) => { +const App = () => { + const anecdotes = [ + 'If it hurts, do it more often.', + 'Adding manpower to a late software project makes it later!', + 'The first 90 percent of the code accounts for the first 90 percent of the development time...The remaining 10 percent of the code accounts for the other 90 percent of the development time.', + 'Any fool can write code that a computer can understand. Good programmers write code that humans can understand.', + 'Premature optimization is the root of all evil.', + 'Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it.', + 'Programming without an extremely heavy use of console.log is same as if a doctor would refuse to use x-rays or blood tests when dianosing patients.', + 'The only way to go fast, is to go well.' + ] + const [selected, setSelected] = useState(0) return (
    - {props.anecdotes[selected]} + {anecdotes[selected]}
    ) } -const anecdotes = [ - 'If it hurts, do it more often', - 'Adding manpower to a late software project makes it later!', - 'The first 90 percent of the code accounts for the first 90 percent of the development time...The remaining 10 percent of the code accounts for the other 90 percent of the development time.', - 'Any fool can write code that a computer can understand. Good programmers write code that humans can understand.', - 'Premature optimization is the root of all evil.', - 'Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it.' -] - -ReactDOM.render( - , - document.getElementById('root') -) +export default App ``` -Google kertoo, miten voit generoida Javascriptilla sopivia satunnaisia lukuja. Muista, että voit testata esim. satunnaislukujen generointia konsolissa. +Tiedoston main.jsx sisältö on sama kuin edellisissä tehtävissä. + +Google kertoo, miten voit generoida JavaScriptilla sopivia satunnaisia lukuja. Muista, että voit testata esim. satunnaislukujen generointia konsolissa. Sovellus voi näyttää esim. seuraavalta: ![](../../images/1/18a.png) -**VAROITUS** create-react-app tekee projektista automaattisesti git-repositorion, ellei sovellusta luoda jo olemassaolevan repositorion sisälle. Todennäköisesti **et halua** että projektista tulee repositorio, joten suorita projektin juuressa komento _rm -rf .git_. -

    1.13*: anekdootit step2

    Laajenna sovellusta siten, että näytettävää anekdoottia on mahdollista äänestää: @@ -1195,12 +1360,12 @@ Laajenna sovellusta siten, että näytettävää anekdoottia on mahdollista ää **Huom:** kunkin anekdootin äänet kannattanee tallettaa komponentin tilassa olevan olion kenttiin tai taulukkoon. Muista, että tilan oikeaoppinen päivittäminen edellyttää olion tai taulukon kopioimista. -Olio voidaan kopioida esim. seuraavasti: +Olio voidaan kopioida esim. seuraavasti ```js -const points = { 0: 1, 1: 3, 2: 4, 3: 2 } +const votes = { 0: 1, 1: 3, 2: 4, 3: 2 } -const copy = { ...points } +const copy = { ...votes } // kasvatetaan olion kentän 2 arvoa yhdellä copy[2] += 1 ``` @@ -1208,14 +1373,14 @@ copy[2] += 1 ja taulukko esim. seuraavasti: ```js -const points = [1, 4, 6, 3] +const votes = [1, 4, 6, 3] -const copy = [...points] +const copy = [...votes] // kasvatetaan taulukon paikan 2 arvoa yhdellä copy[2] += 1 ``` -Yksinkertaisempi ratkaisu lienee nyt taulukon käyttö. Googlaamalla löydät paljon vihjeitä sille, miten kannattaa luoda halutun mittainen taulukko, joka on täytetty nollilla esim. [tämän](https://stackoverflow.com/questions/20222501/how-to-create-a-zero-filled-javascript-array-of-arbitrary-length/22209781). +Yksinkertaisempi ratkaisu lienee nyt taulukon käyttö. Googlaamalla löydät paljon vihjeitä sille, miten kannattaa luoda halutun mittainen taulukko, joka on täytetty nollilla, esim. [tämän](https://stackoverflow.com/questions/20222501/how-to-create-a-zero-filled-javascript-array-of-arbitrary-length/22209781).

    1.14*: anekdootit step3

    @@ -1225,6 +1390,6 @@ Ja sitten vielä lopullinen versio, joka näyttää eniten ääniä saaneen anek Jos suurimman äänimäärän saaneita anekdootteja on useita, riittää että niistä näytetään yksi. -Tämä oli osan viimeinen tehtävä ja on aika pushata koodi githubiin sekä merkata tehdyt tehtävät [palautussovellukseen](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). +Tämä oli osan viimeinen tehtävä, ja on aika pushata koodi GitHubiin ja merkata tehdyt tehtävät [palautussovellukseen](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). diff --git a/src/content/1/fr/part1.md b/src/content/1/fr/part1.md new file mode 100644 index 00000000000..086c28bf710 --- /dev/null +++ b/src/content/1/fr/part1.md @@ -0,0 +1,14 @@ +--- +mainImage: ../../../images/part-1.svg +part: 1 +lang: fr +--- + +
    + +Dans cette partie, nous nous familiariserons avec la bibliothèque React, que nous utiliserons pour écrire le code qui s'exécute dans le navigateur. Nous examinerons également certaines fonctionnalités de JavaScript qui sont importantes pour comprendre React. + +Partie mise à jour le 11 août 2023 +- Create React App a été remplacé par Vite + +
    diff --git a/src/content/1/fr/part1a.md b/src/content/1/fr/part1a.md new file mode 100644 index 00000000000..afed733fe00 --- /dev/null +++ b/src/content/1/fr/part1a.md @@ -0,0 +1,733 @@ +--- +mainImage: ../../../images/part-1.svg +part: 1 +letter: a +lang: fr +--- + +
    + +Nous allons maintenant commencer à nous familiariser avec probablement le sujet le plus important de ce cours, à savoir la bibliothèque [React](https://react.dev/). Commençons par créer une application React simple et découvrir les concepts de base de React. + +Le moyen le plus simple de commencer de loin est d'utiliser un outil appelé [Vite](https://vitejs.dev/). + +Nous allons créer une application appelée part1, accédons à son répertoire et installons les bibliothèques : + +```bash +# npm 6.x (obsolète, mais encore utilisé par certains) : +npm create vite@latest part1 --template react + +# npm 7+, un double tiret supplémentaire est nécessaire : +npm create vite@latest part1 -- --template react +``` +```bash +cd part1 +npm install +``` +L'application est exécutée comme suit + +```bash +npm run dev +``` + +Le terminal affiche que l'application a démarré sur le port localhost 5173, c'est-à-dire à l'adresse : + +![Image](../../images/1/1-vite1.png) + +Par défaut, Vite démarre l'application sur le port 5173. Si ce port n'est pas disponible, Vite utilisera le numéro de port suivant disponible. + +Ouvrez le navigateur et un éditeur de texte pour pouvoir afficher le code ainsi que la page Web en même temps à l'écran : + +![Image](../../images/1/1-vite4.png) + +Le code de l'application se trouve dans le dossier src. Simplifions le code par défaut de telle sorte que le contenu du fichier main.jsx ressemble à ceci : + +```js +import ReactDOM from 'react-dom/client' + +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +et le fichier App.jsx ressemble à ceci + +```js +const App = () => ( +
    +

    Hello world

    +
    +) + +export default App +``` + +Les fichiers App.css et index.css, ainsi que le répertoire assets, peuvent être supprimés car ils ne sont pas nécessaires dans notre application pour le moment. + +### create-react-app +Au lieu de Vite, vous pouvez également utiliser l'outil de la génération précédente create-react-app dans le cours pour configurer les applications. La différence la plus visible par rapport à Vite est le nom du fichier de démarrage de l'application, qui est index.js. + +La manière de démarrer l'application est également différente dans CRA, elle est lancée avec la commande + +```bash +npm start +``` +contrairement à Vite qui utilise + +```bash +npm run dev +``` +Le cours est actuellement (15 novembre 2024) en cours de mise à jour pour utiliser Vite. Certaines marques peuvent toujours utiliser la base d'application créée avec create-react-app. + +### Composant + +Le fichier App.jsx définit maintenant un [composant React](https://react.dev/learn/your-first-component) avec le nom App. La commande sur la dernière ligne du fichier main.jsx + +```js +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +rend son contenu dans l'élément div, défini dans le fichier public/index.html, ayant la valeur id 'root'. + +Par défaut, le fichier index.html ne contient aucune balise HTML visible pour nous dans le navigateur : + +```html + + + + + + + Vite + React + + +
    + + + + +``` +Vous pouvez essayer d'ajouter du HTML dans le fichier. Cependant, lors de l'utilisation de React, tout le contenu qui doit être rendu est généralement défini sous forme de composants React. + +Jetons un coup d'oeil plus attentif au code qui définit le composant : + +```js +const App = () => ( +
    +

    Hello world

    +
    +) +``` + +Comme vous l'avez probablement deviné, le composant sera rendu sous la forme d'une balise div, qui enveloppe une balise p contenant le texte Hello world. + +Techniquement, le composant est défini comme une fonction JavaScript. Voici une fonction (qui ne reçoit aucun paramètre) : + +```js +() => ( +
    +

    Hello world

    +
    +) +``` + +La fonction est alors affectée à une variable constante App : + +```js +const App = ... +``` + +Il existe plusieurs façons de définir des fonctions en JavaScript. Ici, nous utiliserons les [fonctions fléchées](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions), qui sont décrites dans une version plus récente de JavaScript connue sous le nom de [ECMAScript 6 ](https://262.ecma-international.org/6.0/#sec-built-in-function), également appelé ES6. + +Parce que la fonction se compose d'une seule expression, nous avons utilisé un raccourci, qui représente ce morceau de code : + +```js +const App = () => { + return ( +
    +

    Hello world

    +
    + ) +} +``` + +En d'autres termes, la fonction renvoie la valeur de l'expression. + +La fonction définissant le composant peut contenir n'importe quel type de code JavaScript. Modifiez votre composant pour qu'il soit comme suit + +```js +const App = () => { + console.log('Hello from component') + return ( +
    +

    Hello world

    +
    + ) +} +export default App +``` + +et observez ce qui se passe dans la console : + +![console du navigateur affichant la console avec une flèche pointant vers "Hello from component"](../../images/1/30.png) + +La première règle du développement web côté client : + +> gardez la console ouverte en permanence + +Répétons ceci ensemble : je promets de garder la console ouverte en permanence pendant ce cours, et pour le reste de ma vie lorsque je fais du développement web. + +Il est également possible de rendre du contenu dynamique à l'intérieur d'un composant. + +Modifiez le composant comme suit : + +```js +const App = () => { + const now = new Date() + const a = 10 + const b = 20 + + return ( +
    +

    Hello world, it is {now.toString()}

    +

    + {a} plus {b} is {a + b} +

    +
    + ) +} +``` + +Tout code JavaScript à l'intérieur des accolades est évalué et le résultat de cette évaluation est intégré à l'emplacement défini dans le code HTML produit par le composant. + +Notez que vous ne devez pas supprimer la ligne en bas du composant + +```js +export default App +``` + +L'exportation n'est pas affichée dans la plupart des exemples du matériel du cours. Sans l'exportation, le composant et toute l'application ne fonctionnent pas. + +Vous souvenez-vous de votre promesse de garder la console ouverte ? Qu'y a-t-il été imprimé ? + +### JSX + +Il semble que les composants React renvoient le balisage HTML. Cependant, ce n'est pas le cas. La disposition des composants React est principalement écrite à l'aide de [JSX](https://react.dev/learn/writing-markup-with-jsx). Bien que JSX ressemble à du HTML, nous avons en fait affaire à un moyen d'écrire du JavaScript. Sous le capot, le JSX renvoyé par les composants React est compilé en JavaScript. + +Après compilation, notre application ressemble à ceci : + +```js +const App = () => { + const now = new Date() + const a = 10 + const b = 20 + return React.createElement( + 'div', + null, + React.createElement( + 'p', null, 'Hello world, it is ', now.toString() + ), + React.createElement( + 'p', null, a, ' plus ', b, ' is ', a + b + ) + ) +} +``` + +La compilation est gérée par [Babel](https://babeljs.io/repl/). Les projets créés avec *create-react-app* ou *vite* sont configurés pour se compiler automatiquement. Nous en apprendrons plus sur ce sujet dans la [partie 7](/en/part7) de ce cours. + +Il est également possible d'écrire React en "pur JavaScript" sans utiliser JSX. Bien que personne avec un esprit sain ne le ferait réellement. + +En pratique, JSX ressemble beaucoup au HTML, à la différence qu'avec JSX, vous pouvez facilement intégrer du contenu dynamique en écrivant du JavaScript approprié entre accolades. L'idée de JSX est assez similaire à de nombreux moteurs de templates, tels que Thymeleaf utilisé avec Java Spring, qui sont utilisés sur les serveurs. + +JSX est "[XML](https://developer.mozilla.org/en-US/docs/Web/XML/XML_introduction)-like", ce qui signifie que chaque balise doit être fermée. Par exemple, une nouvelle ligne est un élément vide, qui en HTML peut être écrit comme suit : + +```html +
    +``` + +mais lors de l'écriture de JSX, la balise doit être fermée : + +```html +
    +``` + +### Composants multiples + +Modifions le fichier App.jsx comme suit : + +```js +// highlight-start +const Hello = () => { + return ( +
    +

    Hello world

    +
    + ) +} +// highlight-end + +const App = () => { + return ( +
    +

    Greetings

    + // highlight-line +
    + ) +} +``` + +Nous avons défini un nouveau composant Hello et l'avons utilisé dans le composant App. Naturellement, un composant peut être utilisé plusieurs fois : + +```js +const App = () => { + return ( +
    +

    Greetings

    + + // highlight-start + + + // highlight-end +
    + ) +} +``` + +**NB**: L'exportation (export) à la fin est omise dans ces exemples, maintenant et à l'avenir. Elle est toujours nécessaire pour que le code fonctionne. + +Écrire des composants avec React est facile, et en combinant des composants, même une application plus complexe peut rester assez maintenable. En effet, une philosophie centrale de React est de composer des applications à partir de nombreux composants spécialisés réutilisables. + +Une autre forte convention est l'idée d'un composant racine appelé App en haut de l'arborescence de composants de l'application. Néanmoins, comme nous le verrons dans [partie 6](/en/part6), il y a des situations où le composant App n'est pas exactement la racine, mais il est enveloppé dans un composant utilitaire approprié. + +### props : transmission de données aux composants + +Il est possible de transmettre des données aux composants à l'aide de ce qu'on appelle [props](https://react.dev/learn/passing-props-to-a-component). + +Modifions le composant Hello comme suit + +```js +const Hello = (props) => { // highlight-line + return ( +
    +

    Hello {props.name}

    // highlight-line +
    + ) +} +``` + +Maintenant, la fonction définissant le composant a un paramètre props. En argument, le paramètre reçoit un objet, qui a des champs correspondant à toutes les "props" définis par l'utilisateur du composant. + +Les props sont définis comme suit : + +```js +const App = () => { + return ( +
    +

    Greetings

    + // highlight-line + // highlight-line +
    + ) +} +``` + +Il peut y avoir un nombre arbitraire de props et leurs valeurs peuvent être des chaînes "codées en dur" ou des résultats d'expressions JavaScript. Si la valeur de la prop est obtenue à l'aide de JavaScript, elle doit être entourée d'accolades. + +Modifions le code pour que le composant Hello utilise deux props : + +```js +const Hello = (props) => { + return ( +
    +

    + Hello {props.name}, you are {props.age} years old // highlight-line +

    +
    + ) +} + +const App = () => { + const name = 'Peter' // highlight-line + const age = 10 // highlight-line + + return ( +
    +

    Greetings

    + // highlight-line + // highlight-line +
    + ) +} +``` + +Les props envoyées par le composant App sont les valeurs des variables, le résultat de l'évaluation de l'expression sum et une chaîne régulière. + +Le composant Hello enregistre également la valeur de l'objet props dans la console. + +J'espère vraiment que votre console était ouverte. Si ce n'était pas le cas, souvenez-vous de ce que vous avez promis : + +> Je promets de garder la console ouverte en permanence pendant ce cours, et pour le reste de ma vie lorsque je fais du développement web. + +Le développement de logiciels est difficile. Cela devient encore plus difficile si l'on n'utilise pas tous les outils disponibles, tels que la console web et l'impression de débogage avec _console.log_. Les professionnels utilisent les deux tout le temps, et il n'y a aucune raison pour qu'un débutant n'adopte pas l'utilisation de ces merveilleuses méthodes d'aide qui faciliteront grandement la vie. + +### Message d'erreur possible + +Selon l'éditeur que vous utilisez, vous pouvez recevoir le message d'erreur suivant à ce stade : + +![Capture d'écran de l'erreur eslint](../../images/1/1-vite5.png) + +Il ne s'agit pas réellement d'une erreur, mais d'un avertissement généré par l'outil [ESLint](https://eslint.org/). Vous pouvez supprimer l'avertissement [react/prop-types](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/prop-types.md) en ajoutant à votre fichier eslint.config.js la ligne suivante : + +```js +export default [ + { ignores: ['dist'] }, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + settings: { react: { version: '18.3' } }, + plugins: { + react, + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...js.configs.recommended.rules, + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + ...reactHooks.configs.recommended.rules, + 'react/jsx-no-target-blank': 'off', + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + 'react/prop-types': 0, // highlight-line + }, + }, +] +``` + +Nous en apprendrons davantage sur ESLint en détail dans [la partie 3](/osa3/validointi_ja_es_lint#lint). + + +### Quelques notes + +React a été configuré pour générer des messages d'erreur assez clairs. Malgré cela, vous devriez, du moins au début, avancer par **de très petits pas** et vous assurer que chaque modification fonctionne comme prévu. + +**La console doit toujours être ouverte**. Si le navigateur signale des erreurs, il n'est pas recommandé de continuer à écrire du code en espérant des miracles. Vous devriez plutôt essayer de comprendre la cause de l'erreur et, par exemple, revenir à l'état précédent qui fonctionnait : + +![Capture d'écran de l'erreur de propriété non définie](../../images/1/1-vite6.png) + +Comme nous l'avons déjà mentionné, lors de la programmation avec React, il est possible et utile d'écrire des commandes console.log() (qui affichent des messages dans la console) dans votre code. + +De plus, gardez à l'esprit que **la première lettre des noms de composants React doit être en majuscule**. Si vous essayez de définir un composant comme suit : + +```js +const footer = () => { + return ( +
    + greeting app created by mluukkai +
    + ) +} +``` + +et l'utiliser comme ça + +```js +const App = () => { + return ( +
    +

    Greetings

    + +
    // highlight-line +
    + ) +} +``` + +la page n'affichera pas le contenu défini dans le composant Footer, à la place, React crée uniquement un élément [footer](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/footer) vide, c'est-à-dire l'élément HTML intégré au lieu de l'élément React personnalisé du même nom. Si vous remplacez la première lettre du nom du composant par une lettre majuscule, React crée un élément div défini dans le composant Footer, qui est rendu sur la page. + +Notez que le contenu d'un composant React doit (généralement) contenir **un élément racine**. Si nous essayons, par exemple, de définir le composant App sans l'élément div le plus externe : + +```js +const App = () => { + return ( +

    Greetings

    + +
    + ) +} +``` + +Le résultat est un message d'erreur. + +![Capture d'écran de l'erreur de plusieurs éléments racine](../../images/1/1-vite7.png) + +L'utilisation d'un élément racine n'est pas la seule option de travail. Un tableau de composants est également une solution valide : + +```js +const App = () => { + return [ +

    Greetings

    , + , +
    + ] +} +``` + +Cependant, lors de la définition du composant racine de l'application, ce n'est pas une chose particulièrement judicieuse à faire, et cela rend le code un peu moche. + +Parce que l'élément racine est stipulé, nous avons des éléments div "supplémentaires" dans l'arbre DOM. Cela peut être évité en utilisant des [fragments](https://react.dev/reference/react/Fragment), c'est-à-dire en enveloppant les éléments à renvoyer par le composant avec un élément vide : + +```js +const App = () => { + const name = 'Peter' + const age = 10 + + return ( + <> +

    Greetings

    + + +
    + + ) +} +``` + +Ca compile maintenant avec succès et le DOM généré par React ne contient plus l'élément div supplémentaire. + +### Ne pas rendre d'objets + +Considérez une application qui affiche les noms et les âges de nos amis à l'écran : + +```js +const App = () => { + const friends = [ + { name: 'Peter', age: 4 }, + { name: 'Maya', age: 10 }, + ] + + return ( +
    +

    {friends[0]}

    +

    {friends[1]}

    +
    + ) +} + +export default App +``` + +Cependant, rien n'apparaît à l'écran. J'ai essayé de trouver un problème dans le code pendant 15 minutes, mais je n'arrive pas à comprendre où pourrait se trouver le problème. + +Je me souviens enfin de la promesse que nous avons faite : + +> Je promets de laisser la console ouverte en permanence pendant ce cours, et pour le reste de ma vie lorsque je fais du développement web + +La console s'affiche en rouge : + +![Outils de développement affichant une erreur avec une mise en évidence autour de "Les objets ne sont pas valides en tant qu'enfant React"](../../images/1/34new.png) + +Le coeur du problème est que les objets ne sont pas valides en tant qu'enfant React, c'est-à-dire que l'application tente de rendre des objets et échoue. + +Le code tente de rendre les informations d'un ami comme suit + +```js +

    {friends[0]}

    +``` + +et cela pose problème car l'élément à rendre entre les accolades est un objet. + +```js +{ name: 'Peter', age: 4 } +``` + +En React, les éléments individuels rendus entre accolades doivent être des valeurs primitives, telles que des nombres ou des chaînes. + +La correction est la suivante + +```js +const App = () => { + const friends = [ + { name: 'Peter', age: 4 }, + { name: 'Maya', age: 10 }, + ] + + return ( +
    +

    {friends[0].name} {friends[0].age}

    +

    {friends[1].name} {friends[1].age}

    +
    + ) +} + +export default App +``` + +Maintenant, le nom de l'ami est rendu séparément entre les accolades + +```js +{friends[0].name} +``` + +et l'âge + +```js +{friends[0].age} +``` + +Après avoir corrigé l'erreur, vous devriez effacer les messages d'erreur de la console en appuyant sur 🚫, puis recharger le contenu de la page et vous assurer qu'aucun message d'erreur n'apparaît. + +Une petite note supplémentaire par rapport à la précédente. React permet également de rendre des tableaux si le tableau contient des valeurs éligibles pour le rendu (telles que des nombres ou des chaînes). Ainsi, le programme suivant fonctionnerait, bien que le résultat ne soit peut-être pas celui que nous souhaitons : + +```js +const App = () => { + const friends = [ 'Peter', 'Maya'] + + return ( +
    +

    {friends}

    +
    + ) +} +``` + +Dans cette partie, il n'est même pas utile d'essayer d'utiliser le rendu direct des tableaux, nous y reviendrons dans la prochaine partie. + +
    + +
    +

    Exercices 1.1.-1.2.

    + +Les exercices sont soumis via GitHub et en marquant les exercices terminés sur le [système de soumission](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +Vous pouvez soumettre tous les exercices de ce cours dans le même référentiel ou utiliser plusieurs référentiels. Si vous soumettez des exercices de différentes parties dans le même référentiel, veuillez utiliser un schéma de nommage raisonnable pour les répertoires. + +Une structure de fichiers très fonctionnelle pour le référentiel de soumission est la suivante : + +``` +part0 +part1 + courseinfo + unicafe + anecdotes +part2 + phonebook + countries +``` + +Voir cet [exemple de dépôt de soumission](https://github.com/fullstack-hy2020/example-submission-repository) ! + +Pour chaque partie du cours, il y a un répertoire, qui se ramifie ensuite en sous-répertoires contenant une série d'exercices, comme "unicafe" pour la partie 1. + +Pour chaque application web d'une série d'exercices, il est recommandé de soumettre tous les fichiers relatifs à cette application, à l'exception du répertoire node\_modules. + +Les exercices sont soumis **une partie à la fois**. Lorsque vous avez soumis les exercices d'une partie du cours, vous ne pouvez plus soumettre d'exercices non terminés pour la même partie. + +Notez que dans cette partie, il y a plus d'exercices que ceux trouvés ci-dessous. Ne soumettez pas votre travail tant que vous n'avez pas terminé tous les exercices que vous souhaitez soumettre pour la partie correspondante. + +

    1.1 : courseinfo, étape 1

    + +L'application sur laquelle nous allons commencer à travailler dans cet exercice sera développée plus en détail dans quelques-uns des exercices suivants. Dans cette série d'exercices et d'autres à venir dans ce cours, il suffit de soumettre uniquement l'état final de l'application. Si vous le souhaitez, vous pouvez également créer un commit pour chaque exercice de la série, mais cela est facultatif. + +Utilisez Vite pour initialiser une nouvelle application. Modifiez main.jsx pour qu'il corresponde à ce qui suit + +```js +import ReactDOM from 'react-dom/client' + +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +et App.jsx pour correspondre à l'élément suivant + +```js +const App = () => { + const course = 'Half Stack application development' + const part1 = 'Fundamentals of React' + const exercises1 = 10 + const part2 = 'Using props to pass data' + const exercises2 = 7 + const part3 = 'State of a component' + const exercises3 = 14 + + return ( +
    +

    {course}

    +

    + {part1} {exercises1} +

    +

    + {part2} {exercises2} +

    +

    + {part3} {exercises3} +

    +

    Number of exercises {exercises1 + exercises2 + exercises3}

    +
    + ) +} + +export default App +``` + +et supprimer les fichiers supplémentaires App.css et index.css, ainsi que le répertoire assets. + +Malheureusement, toute l'application se trouve dans le même composant. Refactorisez le code afin qu'il se compose de trois nouveaux composants : Header, Content et Total. Toutes les données résident toujours dans le composant App, qui transmet les données nécessaires à chaque composant à l'aide des props. Header se charge de restituer le nom du cours, Content restitue les parties et leur nombre d'exercices et Total restitue le nombre total d'exercices. + +Définissez les nouveaux composants dans le fichier App.jsx. + +Le corps du composant App sera approximativement comme suit : + +```js +const App = () => { + // const-definitions + + return ( +
    +
    + + +
    + ) +} +``` + +**ATTENTION** N'essayez pas de programmer tous les composants simultanément, car cela risque presque certainement de faire échouer l'ensemble de l'application. Procédez par petites étapes : commencez par créer par exemple le composant Header et ce n'est que lorsque vous êtes sûr qu'il fonctionne que vous pouvez passer au composant suivant. + +Une progression prudente et progressive peut sembler lente, mais c'est en réalité de loin la façon la plus rapide de progresser. Le célèbre développeur Robert "Uncle Bob" Martin a déclaré : + +> "The only way to go fast, is to go well" + +c'est-à-dire que selon Martin, une progression minutieuse par petites étapes est même la seule façon d'être rapide. + +

    1.2 : courseinfo, étape 2

    + +Refactorisez le composant Content afin qu'il n'affiche pas les noms des parties ou leur nombre d'exercices par lui-même. Au lieu de cela, il ne rend que trois composants Part dont chacun rend le nom et le nombre d'exercices d'une partie. + +```js +const Content = ... { + return ( +
    + + + +
    + ) +} +``` + +Notre application transmet des informations de manière assez primitive pour le moment, car elle est basée sur des variables individuelles. Cette situation va bientôt s'améliorer. + +
    \ No newline at end of file diff --git a/src/content/1/fr/part1b.md b/src/content/1/fr/part1b.md new file mode 100644 index 00000000000..7840ff60750 --- /dev/null +++ b/src/content/1/fr/part1b.md @@ -0,0 +1,529 @@ +--- +mainImage: ../../../images/part-1.svg +part: 1 +letter: b +lang: fr +--- + +
    + +Tout au long du cours, nous avons un objectif et un besoin d'apprendre une quantité suffisante de JavaScript en plus du développement Web. + +JavaScript a progressé rapidement au cours des dernières années et dans ce cours, nous utilisons les fonctionnalités des versions les plus récentes. Le nom officiel de la norme JavaScript est [ECMAScript](https://en.wikipedia.org/wiki/ECMAScript). À l'heure actuelle, la dernière version est celle publiée en juin 2024 sous le nom [ECMAScript®2024](https://www.ecma-international.org/ecma-262/), également connue sous le nom d'ES15. + +Les navigateurs ne prennent pas encore en charge toutes les nouvelles fonctionnalités de JavaScript. De ce fait, une grande partie du code exécuté dans les navigateurs a été transpilé d'une version plus récente de JavaScript vers une version plus ancienne et plus compatible. + +Aujourd'hui, la façon la plus populaire de transpiler est d'utiliser [Babel](https://babeljs.io/). La transpilation est automatiquement configurée dans les applications React créées avec create-react-app. Nous reviendrons plus en détail sur la configuration de la transpilation dans la [partie 7](/fr/partie7) de ce cours. + +[Node.js](https://nodejs.org/en/) est un environnement d'exécution JavaScript basé sur le moteur JavaScript [Chrome V8](https://developers.google.com/v8/) de Google et fonctionne pratiquement n'importe où - des serveurs aux téléphones mobiles. Entraînons-nous à écrire du JavaScript en utilisant Node. Il est prévu que la version de Node.js installée sur votre machine soit au moins la version 16.13.2. Les dernières versions de Node comprennent déjà les dernières versions de JavaScript, le code n'a donc pas besoin d'être transpilé. + + +Le code est écrit dans des fichiers se terminant par .js qui sont exécutés en émettant la commande node name\_of\_file.js + +Il est également possible d'écrire du code JavaScript dans la console Node.js, qui s'ouvre en tapant _node_ dans la ligne de commande, ainsi que dans la console de l'outil de développement du navigateur. [Les dernières révisions de Chrome gèrent assez bien les nouvelles fonctionnalités de JavaScript](https://compat-table.github.io/compat-table/es2016plus/) sans transpiler le code. Vous pouvez également utiliser un outil tel que [JS Bin](https://jsbin.com/?js,console). + +JavaScript rappelle en quelque sorte, à la fois par son nom et sa syntaxe, Java. Mais en ce qui concerne le mécanisme de base du langage, ils ne pourraient pas être plus différents. Venant d'un arrière-plan Java, le comportement de JavaScript peut sembler un peu étranger, surtout si l'on ne fait pas l'effort de rechercher ses fonctionnalités. + +Dans certains cercles, il a également été populaire d'essayer de "simuler" les fonctionnalités Java et les modèles de conception en JavaScript. Nous vous déconseillons de le faire car les langues et les écosystèmes respectifs sont finalement très différents. + +### Variables + +En JavaScript, il existe plusieurs façons de définir des variables : + +```js +const x = 1 +let y = 5 + +console.log(x, y) // 1 5 est affiché +y += 10 +console.log(x, y) // 1 15 est affiché +y = 'sometext' +console.log(x, y) // 1 sometext est affiché +x = 4 // provoque une erreur +``` + +[const](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const) ne définit pas réellement une variable mais une constante dont la valeur ne pourra plus être modifiée. D'autre part, [let](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let) définit une variable normale. + +Dans l'exemple ci-dessus, nous voyons également que le type des données affectées à la variable peut changer pendant l'exécution. Au début _y_ stocke un entier et à la fin une chaîne. + +Il est également possible de définir des variables en JavaScript à l'aide du mot clé [var](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/var). var a longtemps été le seul moyen de définir des variables. const et let n'ont été ajoutés que récemment dans la version ES6. Dans des situations spécifiques, var fonctionne différemment des définitions de variables dans la plupart des langages - voir [JavaScript Variables - Should You Use let, var or const? on Medium](https://medium.com/craft-academy/javascript-variables-should-you-use-let-var-or-const-394f7645c88f) ou [Keyword: var vs. let on JS Tips](https://www.jstips.co/en/javascript/keyword-var-vs-let/) pour plus d'informations. Pendant ce cours, l'utilisation de var est déconseillée et vous devriez vous en tenir à const et let ! +Vous pouvez en savoir plus sur ce sujet sur YouTube - par ex. [var, let et const - Fonctionnalités JavaScript ES6](https://youtu.be/sjyJBL5fkp8) + +### Tableaux + +Un [array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array) et quelques exemples d'utilisation : + +```js +const t = [1, -1, 3] + +t.push(5) + +console.log(t.length) // 4 est affiché +console.log(t[1]) // -1 est affiché + +t.forEach(value => { + console.log(value) // les chiffres 1, -1, 3, 5 sont affichés, chacun sur une ligne +}) +``` + +Il convient de noter dans cet exemple le fait que le contenu du tableau peut être modifié même s'il est défini en tant que _const_. Comme le tableau est un objet, la variable pointe toujours vers le même objet. Cependant, le contenu du tableau change à mesure que de nouveaux éléments y sont ajoutés. + +Une façon de parcourir les éléments du tableau consiste à utiliser _forEach_ comme indiqué dans l'exemple. _forEach_ reçoit une fonction définie en utilisant la syntaxe des flèches comme paramètre. + +```js +value => { + console.log(value) +} +``` + +forEach appelle la fonction pour chacun des éléments du tableau, en passant toujours l'élément individuel comme argument. La fonction en tant qu'argument de forEach peut également recevoir [d'autres arguments](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach). + +Dans l'exemple précédent, un nouvel élément a été ajouté au tableau à l'aide de la méthode [push](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push). Lors de l'utilisation de React, des techniques de programmation fonctionnelle sont souvent utilisées. L'une des caractéristiques du paradigme de la programmation fonctionnelle est l'utilisation de structures de données [immuables](https://en.wikipedia.org/wiki/Immutable_object). Dans le code React, il est préférable d'utiliser la méthode [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat), qui n'ajoute pas l'élément au tableau, mais crée un nouveau tableau dans lequel le contenu de l'ancien tableau et le nouvel élément sont tous deux inclus. + +```js +const t = [1, -1, 3] + +const t2 = t.concat(5) + +console.log(t) // [1, -1, 3] est affiché +console.log(t2) // [1, -1, 3, 5] est affiché +``` + +L'appel de méthode _t.concat(5)_ n'ajoute pas un nouvel élément à l'ancien tableau mais renvoie un nouveau tableau qui, en plus de contenir les éléments de l'ancien tableau, contient également le nouvel élément. + +Il existe de nombreuses méthodes utiles définies pour les tableaux. Examinons un court exemple d'utilisation de la méthode [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map). + +```js +const t = [1, 2, 3] + +const m1 = t.map(value => value * 2) +console.log(m1) // [2, 4, 6] est affiché +``` + +Sur la base de l'ancien tableau, map crée un nouveau tableau, pour lequel la fonction donnée en paramètre est utilisée pour créer les éléments. Pour cet exemple, la valeur d'origine est multipliée par deux. + +Map peut également transformer le tableau en quelque chose de complètement différent : + +```js +const m2 = t.map(value => '
  • ' + value + '
  • ') +console.log(m2) +// [ '
  • 1
  • ', '
  • 2
  • ', '
  • 3
  • ' ] est affiché +``` + +Ici, un tableau rempli de valeurs entières est transformé en un tableau contenant des chaînes HTML à l'aide de la méthode map. Dans la [partie 2](/fr/part2) de ce cours, nous verrons que map est utilisée assez fréquemment dans React. + +Les éléments individuels d'un tableau sont faciles à affecter à des variables à l'aide de l'[affectation par déstructuration](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment). + +```js +const t = [1, 2, 3, 4, 5] + +const [first, second, ...rest] = t + +console.log(first, second) // 1 2 est affiché +console.log(rest) // [3, 4, 5] est affiché +``` + +Grâce à l'affectation, les variables _first_ et _second_ recevront comme valeurs les deux premiers entiers du tableau. Les nombres entiers restants sont "regroupés" dans un tableau qui leur est propre, qui est ensuite affecté à la variable _rest_. + +### Objets + +Il existe différentes manières de définir des objets en JavaScript. Une méthode très courante consiste à utiliser [object literals](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Grammar_and_types#Object_literals), qui se produit en répertoriant ses propriétés entre accolades : + +```js +const object1 = { + name: 'Arto Hellas', + age: 35, + education: 'PhD', +} + +const object2 = { + name: 'Full Stack web application development', + level: 'intermediate studies', + size: 5, +} + +const object3 = { + name: { + first: 'Dan', + last: 'Abramov', + }, + grades: [2, 3, 5, 3], + department: 'Stanford University', +} +``` + +Les valeurs des propriétés peuvent être de n'importe quel type, comme des entiers, des chaînes, des tableaux, des objets... + +Les propriétés d'un objet sont référencées en utilisant la notation "point" ou en utilisant des crochets : + +```js +console.log(object1.name) // Arto Hellas est affiché +const fieldName = 'age' +console.log(object1[fieldName]) // 35 est affiché +``` + +Vous pouvez également ajouter des propriétés à un objet à la volée en utilisant la notation par points ou les crochets : + +```js +object1.address = 'Helsinki' +object1['secret number'] = 12341 +``` + +Le dernier des ajouts doit être fait en utilisant des crochets, car lors de l'utilisation de la notation par points, le numéro secret n'est pas un nom de propriété valide en raison du caractère espace. + +Naturellement, les objets en JavaScript peuvent également avoir des méthodes. Cependant, pendant ce cours, nous n'avons pas besoin de définir des objets avec des méthodes qui leur sont propres. C'est pourquoi ils ne sont abordés que brièvement pendant le cours. + +Les objets peuvent également être définis à l'aide de fonctions dites constructeurs, ce qui se traduit par un mécanisme rappelant de nombreux autres langages de programmation, par ex. Les classes de Java. Malgré cette similitude, JavaScript n'a pas de classes au même sens que les langages de programmation orientés objet. Il y a eu, cependant, un ajout de la syntaxe de classe à partir de la version ES6, qui dans certains cas aide à structurer les classes orientées objet. + +### Les fonctions + +Nous nous sommes déjà familiarisés avec la définition des fonctions fléchées. Le processus complet pour définir une fonction flechée est le suivant : + +```js +const sum = (p1, p2) => { + console.log(p1) + console.log(p2) + return p1 + p2 +} +``` + +et la fonction est appelée comme on peut s'y attendre : + +```js +const result = sum(1, 5) +console.log(result) +``` + +S'il n'y a qu'un seul paramètre, nous pouvons exclure les parenthèses de la définition : + +```js +const square = p => { + console.log(p) + return p * p +} +``` + +Si la fonction ne contient qu'une seule expression, les accolades ne sont pas nécessaires. Dans ce cas, la fonction ne renvoie que le résultat de sa seule expression. Maintenant, si nous supprimons l'impression de la console, nous pouvons encore raccourcir la définition de la fonction : + +```js +const square = p => p * p +``` + +Cette forme est particulièrement pratique lors de la manipulation de tableaux - par ex. lors de l'utilisation de la méthode map : + +```js +const t = [1, 2, 3] +const tSquared = t.map(p => p * p) +// tSquared est devenu [1, 4, 9] +``` + +La fonctionnalité de fonction fléchée a été ajoutée à JavaScript il y a seulement quelques années, avec la version [ES6](https://rse.github.io/es6-features/). Avant cela, la seule façon de définir des fonctions était d'utiliser le mot-clé _function_. + +Il existe deux façons de référencer la fonction ; on donne un nom dans une [déclaration de fonction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function). + +```js +function product(a, b) { + return a * b +} + +const result = product(2, 6) +// result est maintenant 12 +``` + +L'autre façon de définir la fonction consiste à utiliser une [expression de fonction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/function). Dans ce cas, il n'est pas nécessaire de donner un nom à la fonction et la définition peut résider dans le reste du code : + +```js +const average = function(a, b) { + return (a + b) / 2 +} + +const result = average(2, 5) +// result est maintenant 3.5 +``` + +Pendant ce cours, nous définirons toutes les fonctions en utilisant la syntaxe des flèches. + +
    + +
    +

    Exercices 1.3.-1.5.

    + +Nous continuons à créer l'application sur laquelle nous avons commencé à travailler dans les exercices précédents. Vous pouvez écrire le code dans le même projet, car nous ne sommes intéressés que par l'état final de l'application soumise. + +**Conseil de pro :** vous pouvez rencontrer des problèmes en ce qui concerne la structure des props que les composants reçoivent. Un bon moyen de rendre les choses plus claires est d'afficher les props sur la console, par ex. comme suit: + +```js +const Header = (props) => { + console.log(props) // highlight-line + return

    {props.course}

    +} +``` + +

    1.3 : courseinfo, étape 3

    + +Passons à l'utilisation d'objets dans notre application. Modifiez les définitions des variables du composant App comme suit et refactorisez également l'application pour qu'elle fonctionne toujours : + +```js +const App = () => { + const course = 'Half Stack application development' + const part1 = { + name: 'Fundamentals of React', + exercises: 10 + } + const part2 = { + name: 'Using props to pass data', + exercises: 7 + } + const part3 = { + name: 'State of a component', + exercises: 14 + } + + return ( +
    + ... +
    + ) +} +``` + +

    1.4 : courseinfo, étape 4

    + +Et puis placez les objets dans un tableau. Modifiez les définitions de variable de App sous la forme suivante et modifiez les autres parties de l'application en conséquence : + +```js +const App = () => { + const course = 'Half Stack application development' + const parts = [ + { + name: 'Fundamentals of React', + exercises: 10 + }, + { + name: 'Using props to pass data', + exercises: 7 + }, + { + name: 'State of a component', + exercises: 14 + } + ] + + return ( +
    + ... +
    + ) +} +``` + +**NB** à ce stade, vous pouvez supposer qu'il y a toujours trois éléments, il n'est donc pas nécessaire de parcourir les tableaux à l'aide de boucles. Nous reviendrons sur le sujet du rendu des composants basés sur des éléments dans des tableaux avec une exploration plus approfondie dans la [prochaine partie du cours](../part2). + +Cependant, ne transmettez pas différents objets en tant que props distincts du composant App aux composants Content et Total. Au lieu de cela, transmettez-les directement sous forme de tableau : + +```js +const App = () => { + // const definitions + + return ( +
    +
    + + +
    + ) +} +``` + +

    1.5 : courseinfo, étape 5

    + +Poussons les changements un peu plus loin. Modifiez le cours et ses parties en un seul objet JavaScript. Réparez tout ce qui casse. + +```js +const App = () => { + const course = { + name: 'Half Stack application development', + parts: [ + { + name: 'Fundamentals of React', + exercises: 10 + }, + { + name: 'Using props to pass data', + exercises: 7 + }, + { + name: 'State of a component', + exercises: 14 + } + ] + } + + return ( +
    + ... +
    + ) +} +``` + +
    + +
    + +### Objets, Méthodes et "this" + +Étant donné que pendant ce cours, nous utilisons une version de React contenant des React Hooks, nous n'avons pas besoin de définir des objets avec des méthodes. **Le contenu de ce chapitre n'est pas pertinent pour le cours** mais est certainement bon à savoir à bien des égards. En particulier, lors de l'utilisation d'anciennes versions de React, il faut comprendre les sujets de ce chapitre. + +Les fonctions fléchées et les fonctions définies à l'aide du mot-clé _function_ varient considérablement en ce qui concerne leur comportement par rapport au mot-clé [this](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this), qui fait référence à l'objet lui-même. + +On peut assigner des méthodes à un objet en définissant des propriétés qui sont des fonctions : + +```js +const arto = { + name: 'Arto Hellas', + age: 35, + education: 'PhD', + // highlight-start + greet: function() { + console.log('hello, my name is ' + this.name) + }, + // highlight-end +} + +arto.greet() // "hello, my name is Arto Hellas" est affiché +``` + +Les méthodes peuvent être affectées aux objets même après la création de l'objet : + +```js +const arto = { + name: 'Arto Hellas', + age: 35, + education: 'PhD', + greet: function() { + console.log('hello, my name is ' + this.name) + }, +} + +// highlight-start +arto.growOlder = function() { + this.age += 1 +} +// highlight-end + +console.log(arto.age) // 35 est affiché +arto.growOlder() +console.log(arto.age) // 36 est affiché +``` + +Modifions légèrement l'objet : + +```js +const arto = { + name: 'Arto Hellas', + age: 35, + education: 'PhD', + greet: function() { + console.log('hello, my name is ' + this.name) + }, + // highlight-start + doAddition: function(a, b) { + console.log(a + b) + }, + // highlight-end +} + +arto.doAddition(1, 4) // 5 est affiché + +const referenceToAddition = arto.doAddition +referenceToAddition(10, 15) // 25 est affiché +``` + +Maintenant, l'objet a la méthode _doAddition_ qui calcule la somme des nombres qui lui sont donnés en tant que paramètres. La méthode est appelée de manière habituelle, en utilisant l'objet arto.doAddition(1, 4) ou en stockant une référence de méthode dans une variable et en appelant la méthode via la variable : referenceToAddition(10, 15). + +Si nous essayons de faire la même chose avec la méthode _greet_, nous rencontrons un problème : + +```js +arto.greet() // "hello, my name is Arto Hellas" est affiché + +const referenceToGreet = arto.greet +referenceToGreet() // affiche "hello, my name is undefined" +``` + +Lors de l'appel de la méthode via une référence, la méthode perd la connaissance de ce qu'était le _this_ d'origine. Contrairement à d'autres langages, en JavaScript, la valeur de [this](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this) est définie en fonction de comment la méthode s'appelle. Lors de l'appel de la méthode via une référence, la valeur de _this_ devient le soi-disant [objet global](https://developer.mozilla.org/en-US/docs/Glossary/Global_object) et le résultat final n'est souvent pas ce que le développeur de logiciels avait initialement prévu. + +Perdre la trace de _this_ lors de l'écriture de code JavaScript soulève quelques problèmes potentiels. Des situations surviennent souvent où React ou Node (ou plus précisément le moteur JavaScript du navigateur Web) doit appeler une méthode dans un objet que le développeur a défini. Cependant, dans ce cours, nous évitons ces problèmes en utilisant le "this-less" JavaScript. + +Une situation conduisant à la "disparition" de _this_ survient lorsque nous définissons un délai d'attente pour appeler la fonction _greet_ sur l'objet _arto_, en utilisant le [setTimeout](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout). + +```js +const arto = { + name: 'Arto Hellas', + greet: function() { + console.log('hello, my name is ' + this.name) + }, +} + +setTimeout(arto.greet, 1000) // highlight-line +``` + +Comme mentionné, la valeur de _this_ en JavaScript est définie en fonction de la façon dont la méthode est appelée. Lorsque setTimeout appelle la méthode, c'est le moteur JavaScript qui appelle réellement la méthode et, à ce stade, _this_ fait référence à l'objet global. + +Il existe plusieurs mécanismes par lesquels le _this_ original peut être préservé. L'une d'entre elles utilise une méthode appelée [bind](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind) : + +```js +setTimeout(arto.greet.bind(arto), 1000) +``` + +L'appel de arto.greet.bind(arto) crée une nouvelle fonction où _this_ pointe vers Arto, indépendamment de l'endroit et de la manière dont la méthode est appelée. + +En utilisant les [fonctions fléchées](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions), il est possible de résoudre certains des problèmes liés à _this_. Ils ne doivent cependant pas être utilisés comme méthodes pour les objets car alors _this_ ne fonctionne pas du tout. Nous reviendrons plus tard sur le comportement de _this_ par rapport aux fonctions fléchées. + +Si vous souhaitez mieux comprendre comment _this_ fonctionne en JavaScript, Internet regorge de documents sur le sujet, par ex. la série de screencasts [Understand JavaScript's this Keyword in Depth](https://egghead.io/courses/understand-javascript-s-this-keyword-in-depth) par [egghead.io](https://egghead.io) est fortement recommandé ! + +### Les classes + +Comme mentionné précédemment, il n'y a pas de mécanisme de classe en JavaScript comme ceux des langages de programmation orientés objet. Il existe cependant des fonctionnalités permettant de "simuler" des [classes](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes) orientées objet. + +Jetons un coup d'oeil à la syntaxe de classe qui a été introduite dans JavaScript avec ES6, qui simplifie considérablement la définition des classes (ou des choses semblables à des classes) en JavaScript. + +Dans l'exemple suivant, nous définissons une "classe" appelée Person et deux objets Person : + +```js +class Person { + constructor(name, age) { + this.name = name + this.age = age + } + greet() { + console.log('hello, my name is ' + this.name) + } +} + +const adam = new Person('Adam Ondra', 35) +adam.greet() + +const janja = new Person('Janja Garnbret', 22) +janja.greet() +``` + +En ce qui concerne la syntaxe, les classes et les objets créés à partir de celles-ci rappellent beaucoup les classes et objets Java. Leur comportement est également assez similaire aux objets Java. Au coeur, ce sont toujours des objets basés sur [l'héritage prototypal](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Inheritance) de JavaScript . Le type des deux objets est en fait _Object_, puisque JavaScript ne définit essentiellement que les types [Boolean, Null, Undefined, Number, String, Symbol, BigInt et Object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures). + +L'introduction de la syntaxe de classe était un ajout controversé. Découvrez [Not Awesome: ES6 Classes](https://github.com/petsel/not-awesome-es6-classes) ou [Is "Class" In ES6 The New "Bad" Part ? sur Medium](https://medium.com/@rajaraodv/is-class-in-es6-the-new-bad-part-6c4e6fe1ee65) pour plus de détails. + +La syntaxe de la classe ES6 est beaucoup utilisée dans "l'ancien" React et aussi dans Node.js, donc sa compréhension est bénéfique même dans ce cours. Cependant, comme nous utilisons la nouvelle fonctionnalité [Hooks](https://reactjs.org/docs/hooks-intro.html) de React tout au long de ce cours, nous n'avons aucune utilisation concrète de la syntaxe de classe de JavaScript. + +### Matériaux JavaScript + +Il existe à la fois de bons et de mauvais guides pour JavaScript sur Internet. La plupart des liens de cette page relatifs aux fonctionnalités JavaScript font référence au [Guide JavaScript de Mozilla](https://developer.mozilla.org/en-US/docs/Web/JavaScript). + +Il est fortement recommandé de lire immédiatement [A re-introduction to JavaScript (JS tutorial)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/A_re-introduction_to_JavaScript) sur le site Web de Mozilla. + +Si vous souhaitez connaître JavaScript en profondeur, il existe une excellente série de livres gratuits sur Internet appelée [You-Dont-Know-JS](https://github.com/getify/You-Dont-Know-JS). + +Une autre excellente ressource pour apprendre JavaScript est [javascript.info](https://javascript.info). + +[egghead.io](https://egghead.io) propose de nombreux screencasts de qualité sur JavaScript, React et d'autres sujets intéressants. Malheureusement, une partie du matériel est derrière un paywall. + +
    diff --git a/src/content/1/fr/part1c.md b/src/content/1/fr/part1c.md new file mode 100644 index 00000000000..c4cd29e7c02 --- /dev/null +++ b/src/content/1/fr/part1c.md @@ -0,0 +1,723 @@ +--- +mainImage: ../../../images/part-1.svg +part: 1 +letter: c +lang: fr +--- + +
    + +Reprenons avec React. + +On commence avec un nouvel exemple : + +```js +const Hello = (props) => { + return ( +
    +

    + Hello {props.name}, you are {props.age} years old +

    +
    + ) +} + +const App = () => { + const name = 'Peter' + const age = 10 + + return ( +
    +

    Greetings

    + + +
    + ) +} +``` + +### Fonctions d'assistance aux composants + +Développons notre composant Hello afin qu'il devine l'année de naissance de la personne accueillie : + +```js +const Hello = (props) => { + // highlight-start + const bornYear = () => { + const yearNow = new Date().getFullYear() + return yearNow - props.age + } + // highlight-end + + return ( +
    +

    + Hello {props.name}, you are {props.age} years old +

    +

    So you were probably born in {bornYear()}

    // highlight-line +
    + ) +} +``` + +La logique pour deviner l'année de naissance est séparée en une fonction qui est appelée lorsque le composant est rendu. + +L'âge de la personne n'a pas besoin d'être passé en tant que paramètre de la fonction, car il peut accéder directement à toutes les props passés au composant. + +Si nous examinons attentivement notre code actuel, nous remarquerons que la fonction d'assistance (helper) est en fait définie à l'intérieur d'une autre fonction qui définit le comportement de notre composant. En programmation Java, définir une fonction à l'intérieur d'une autre est complexe et fastidieux, donc pas si courant. En JavaScript, cependant, définir des fonctions dans des fonctions est une technique couramment utilisée. + +### Déstructuration + +Avant d'aller plus loin, nous allons jeter un oeil à une petite fonctionnalité mais utile du langage JavaScript qui a été ajoutée dans la spécification ES6, qui nous permet de [déstructurer](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) des valeurs des objets et des tableaux lors de l'affectation. + +Dans notre code précédent, nous devions référencer les données transmises à notre composant en tant que _props.name_ et _props.age_. De ces deux expressions, nous avons dû répéter _props.age_ deux fois dans notre code. + +Puisque props est un objet + +```js +props = { + name: 'Arto Hellas', + age: 35, +} +``` + +nous pouvons rationaliser notre composant en affectant les valeurs des propriétés directement dans deux variables _name_ et _age_ que nous pouvons ensuite utiliser dans notre code : + +```js +const Hello = (props) => { + // highlight-start + const name = props.name + const age = props.age + // highlight-end + + const bornYear = () => new Date().getFullYear() - age + + return ( +
    +

    Hello {name}, you are {age} years old

    // highlight-line +

    So you were probably born in {bornYear()}

    +
    + ) +} +``` + +Notez que nous avons également utilisé la syntaxe plus compacte pour les fonctions fléchées lors de la définition de la fonction _bornYear_. Comme mentionné précédemment, si une fonction fléchée consiste en une seule expression, le corps de la fonction n'a pas besoin d'être écrit à l'intérieur d'accolades. Dans cette forme plus compacte, la fonction renvoie simplement le résultat de l'expression unique. + +Pour récapituler, les deux définitions de fonction présentées ci-dessous sont équivalentes : + +```js +const bornYear = () => new Date().getFullYear() - age + +const bornYear = () => { + return new Date().getFullYear() - age +} +``` + +La déstructuration rend l'assignation des variables encore plus facile, puisque nous pouvons l'utiliser pour extraire et regrouper les valeurs des propriétés d'un objet dans des variables distinctes : +```js +const Hello = (props) => { + // highlight-start + const { name, age } = props + // highlight-end + const bornYear = () => new Date().getFullYear() - age + + return ( +
    +

    Hello {name}, you are {age} years old

    +

    So you were probably born in {bornYear()}

    +
    + ) +} +``` + + +Si l'objet que nous destructurons a les valeurs +```js +props = { + name: 'Arto Hellas', + age: 35, +} +``` + +l'expression const { name, age } = props attribue les valeurs 'Arto Hellas' à _name_ et 35 à _age_. + +Nous pouvons aller plus loin dans la déstructuration : +```js +const Hello = ({ name, age }) => { // highlight-line + const bornYear = () => new Date().getFullYear() - age + + return ( +
    +

    + Hello {name}, you are {age} years old +

    +

    So you were probably born in {bornYear()}

    +
    + ) +} +``` + +Les props passées au composant sont maintenant directement déstructurées dans les variables _name_ et _age_. + +Cela signifie qu'au lieu d'affecter l'intégralité de l'objet props dans une variable appelée props, puis d'affecter ses propriétés dans les variables _name_ et _age_ + +```js +const Hello = (props) => { + const { name, age } = props + ... +} +``` + +nous attribuons les valeurs des propriétés directement aux variables en déstructurant l'objet props qui est passé à la fonction du composant en tant que paramètre : + +```js +const Hello = ({ name, age }) => {...} +``` + +### Re-rendu de la page + +Jusqu'à présent, toutes nos applications ont été telles que leur apparence reste la même après le rendu initial. Et si on voulait créer un compteur dont la valeur augmente en fonction du temps ou au clic d'un bouton ? + +Commençons par ce qui suit. Le fichier App.jsx devient : + +```js +const App = (props) => { + const {counter} = props + return ( +
    {counter}
    + ) +} + +export default App +``` + +Et le fichier main.jsx devient : + +```js +import ReactDOM from 'react-dom/client' + +import App from './App' + +let counter = 1 + +ReactDOM.createRoot(document.getElementById('root')).render( + +) +``` + +Le composant App reçoit la valeur du compteur via la propriété _counter_. Ce composant restitue la valeur à l'écran. Que se passe-t-il lorsque la valeur de _counter_ change ? Même si nous devions ajouter ce qui suit + +```js +counter += 1 +``` + +le composant ne sera pas rendu à nouveau. Nous pouvons obtenir un nouveau rendu en appelant la méthode _render_ une deuxième fois, par ex. de la manière suivante : + +```js +let counter = 1 + +const refresh = () => { + ReactDOM.createRoot(document.getElementById('root')).render( + + ) +} + +refresh() +counter += 1 +refresh() +counter += 1 +refresh() +``` + +La commande de re-rendu a été intégrée à la fonction _refresh_ pour réduire la quantité de code copié-collé. + +Maintenant, le composant rend trois fois, d'abord avec la valeur 1, puis 2 et enfin 3. Cependant, les valeurs 1 et 2 sont affichées à l'écran pendant une durée si courte qu'elles ne peuvent pas être remarqué. + +Nous pouvons implémenter des fonctionnalités légèrement plus intéressantes en recréant et en incrémentant le compteur toutes les secondes en utilisant [setInterval](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setInterval) : + +```js +setInterval(() => { + refresh() + counter += 1 +}, 1000) +``` + +Faire des appels répétés de la méthode _render_ n'est pas la méthode recommandée pour rafraichir nos composants. Nous présenterons plus tard une meilleure façon d'obtenir cet effet. + +### Composant stateful + +Jusqu'à présent, tous nos composants étaient simples dans le sens où ils ne contenaient aucun état susceptible de changer au cours du cycle de vie du composant. + +Ensuite, ajoutons un état au composant App de notre application à l'aide du [state hook](https://react.dev/learn/state-a-components-memory) de React. + +Nous allons modifier l'application comme suit. main.jsx revient à + +```js +import ReactDOM from 'react-dom/client' + +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +et App.jsx change comme suit : + +```js +import { useState } from 'react' // highlight-line + +const App = () => { + const [ counter, setCounter ] = useState(0) // highlight-line + +// highlight-start + setTimeout( + () => setCounter(counter + 1), + 1000 + ) + // highlight-end + + return ( +
    {counter}
    + ) +} + +export default App +``` + + +Dans la première ligne, le fichier importe la fonction _useState_ : + +```js +import { useState } from 'react' +``` + +Le corps de la fonction qui définit le composant commence par l'appel de la fonction : + +```js +const [ counter, setCounter ] = useState(0) +``` + +L'appel de fonction ajoute state au composant et le rend initialisé avec la valeur zéro. La fonction renvoie un tableau qui contient deux éléments. Nous affectons les éléments aux variables _counter_ et _setCounter_ en utilisant la syntaxe d'affectation déstructurante présentée précédemment. + +La variable _counter_ reçoit la valeur initiale de state qui est zéro. La variable _setCounter_ est affectée à une fonction qui servira à modifier l'état. + +L'application appelle la fonction [setTimeout](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout) et lui transmet deux paramètres : une fonction pour incrémenter l'état du compteur et un délai d'expiration d'une seconde: + +```js +setTimeout( + () => setCounter(counter + 1), + 1000 +) +``` + +La fonction passée en premier paramètre à la fonction _setTimeout_ est invoquée une seconde après l'appel de la fonction _setTimeout_ + +```js +() => setCounter(counter + 1) +``` + +Lorsque la fonction de modification d'état _setCounter_ est appelée, React rend à nouveau le composant, ce qui signifie que le corps de la fonction du composant est réexécuté : + +```js +() => { + const [ counter, setCounter ] = useState(0) + + setTimeout( + () => setCounter(counter + 1), + 1000 + ) + + return ( +
    {counter}
    + ) +} +``` + +La deuxième fois que la fonction du composant est exécutée, elle appelle la fonction _useState_ et renvoie la nouvelle valeur de l'état : 1. L'exécution à nouveau du corps de la fonction effectue également un nouvel appel de fonction à _setTimeout_, qui exécute le délai d'attente d'une seconde et incrémente à nouveau l'état _counter_ . Étant donné que la valeur de la variable _counter_ est 1, l'incrémentation de la valeur de 1 revient essentiellement à une expression définissant la valeur de _counter_ sur 2. + +```js +() => setCounter(2) +``` +Pendant ce temps, l'ancienne valeur de _counter_ - "1" - est rendue à l'écran. + +Chaque fois que le _setCounter_ modifie l'état, il provoque le rendu du composant. La valeur de l'état sera incrémentée à nouveau après une seconde, et cela continuera à se répéter tant que l'application sera en cours d'exécution. + +Si le composant ne s'affiche pas lorsque vous pensez qu'il le devrait, ou s'il s'affiche au "mauvais moment", vous pouvez déboguer l'application en enregistrant les valeurs des variables du composant dans la console. Si nous faisons les ajouts suivants à notre code : + +```js +const App = () => { + const [ counter, setCounter ] = useState(0) + + setTimeout( + () => setCounter(counter + 1), + 1000 + ) + + console.log('rendering...', counter) // highlight-line + + return ( +
    {counter}
    + ) +} +``` + +Il est facile de suivre les appels effectués à la fonction de rendu du composant App : + +![Capture d'écran de la fonction de rendu avec les outils de développement.x](../../images/1/4e.png) + +Votre console de navigateur était-elle ouverte ? Si ce n'était pas le cas, promettez que ce sera la dernière fois qu'on vous le rappellera. + + +### Gestion des événements + +Nous avons déjà mentionné les gestionnaires d'événements qui sont enregistrés pour être appelés lorsque des événements spécifiques se produisent plusieurs fois dans la [partie 0](/fr/part0). Par exemple. l'interaction d'un utilisateur avec les différents éléments d'une page Web peut provoquer le déclenchement d'un ensemble de différents types d'événements. + +Modifions l'application pour que l'augmentation du compteur se produise lorsqu'un utilisateur clique sur un bouton, ce qui est implémenté avec l'élément [bouton](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button). + +Les éléments bouton prennent en charge les [mouse events](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent), dont [click](https://developer.mozilla.org/en-US/docs/Web/Events/click), l'événement le plus courant. L'événement clic sur un bouton peut également être déclenché avec le clavier ou un écran tactile malgré le nom mouse event. + +Dans React, [l'application d'un event handler](https://react.dev/learn/responding-to-events) à l'événement click se passe comme ceci : + +```js +const App = () => { + const [ counter, setCounter ] = useState(0) + + // highlight-start + const handleClick = () => { + console.log('clicked') + } + // highlight-end + + return ( +
    +
    {counter}
    + // highlight-start + + // highlight-end +
    + ) +} +``` + +Nous définissons la valeur de l'attribut onClick du bouton comme une référence à la fonction _handleClick_ définie dans le code. + +Désormais, chaque clic sur le bouton plus entraîne l'appel de la fonction _handleClick_, ce qui signifie que chaque événement de clic enregistrera un message cliqué dans la console du navigateur. + +La fonction de gestionnaire d'événements peut également être définie directement dans l'affectation de valeur de l'attribut onClick : + +```js +const App = () => { + const [ counter, setCounter ] = useState(0) + + return ( +
    +
    {counter}
    + +
    + ) +} +``` + +En modifiant le gestionnaire d'événements sous la forme suivante +```js + +``` + +nous obtenons le comportement souhaité, ce qui signifie que la valeur de _counter_ est augmentée de un et que le composant est re-rendu. + +Ajoutons également un bouton pour réinitialiser le compteur : + +```js +const App = () => { + const [ counter, setCounter ] = useState(0) + + return ( +
    +
    {counter}
    + + // highlight-start + + // highlight-end +
    + ) +} +``` + +Notre application est fin prête ! + + +### Le gestionnaire d'événements est une fonction + +Nous définissons les gestionnaires d'événements pour nos boutons où nous déclarons leurs attributs onClick : + +```js + +``` + +Et si nous essayions de définir les gestionnaires d'événements sous une forme plus simple ? + +```js + +``` + +Cela casserait complètement notre application : + +![](../../images/1/5c.png) + +Que se passe-t-il? Un gestionnaire d'événements est censé être soit une fonction soit une référence de fonction, et lorsque nous écrivons : + +```js + +``` + +Maintenant, l'attribut du bouton qui définit ce qui se passe lorsque le bouton est cliqué - onClick - a la valeur _() => setCounter(counter + 1)_. +La fonction setCounter est appelée uniquement lorsqu'un utilisateur clique sur le bouton. + +Habituellement, définir des gestionnaires d'événements dans des modèles JSX n'est pas une bonne idée. +Ici, ça va, car nos gestionnaires d'événements sont assez simples. + +Séparons quand même les gestionnaires d'événements en fonctions distinctes : + +```js +const App = () => { + const [ counter, setCounter ] = useState(0) + +// highlight-start + const increaseByOne = () => setCounter(counter + 1) + + const setToZero = () => setCounter(0) + // highlight-end + + return ( +
    +
    {counter}
    + + +
    + ) +} +``` + +Ici, les gestionnaires d'événements ont été définis correctement. La valeur de l'attribut onClick est une variable contenant une référence à une fonction : + +```js + +``` + +### Transmission de l'état aux composants enfants + +Il est recommandé d'écrire des composants React petits et réutilisables dans l'application et même dans les projets. Refactorisons notre application afin qu'elle soit composée de trois composants plus petits, un composant pour afficher le compteur et deux composants pour les boutons. + +Commençons par implémenter un composant Display chargé d'afficher la valeur du compteur. + +Une bonne pratique dans React consiste à [remonter l'état](https://react.dev/learn/sharing-state-between-components) dans la hiérarchie des composants. La documentation dit: + +> Souvent, plusieurs composants doivent refléter les mêmes données changeantes. Nous vous recommandons de remonter l'état partagé jusqu'à leur ancêtre commun le plus proche. + +Plaçons donc l'état de l'application dans le composant App et transmettons-le au composant Display via props : + +```js +const Display = (props) => { + return ( +
    {props.counter}
    + ) +} +``` + +L'utilisation du composant est simple, car nous n'avons qu'à lui transmettre l'état du _counter_ : + +```js +const App = () => { + const [ counter, setCounter ] = useState(0) + + const increaseByOne = () => setCounter(counter + 1) + const setToZero = () => setCounter(0) + + return ( +
    + // highlight-line + + +
    + ) +} +``` + +Tout fonctionne encore. Lorsque les boutons sont cliqués et que l'App est re-rendue, tous ses enfants, y compris le composant Display sont également re-rendus. + +Ensuite, créons un composant Button pour les boutons de notre application. Nous devons passer le gestionnaire d'événements ainsi que le titre du bouton via les props du composant : + +```js +const Button = (props) => { + return ( + + ) +} +``` + +Notre composant App ressemble maintenant à ceci : + +```js +const App = () => { + const [ counter, setCounter ] = useState(0) + + const increaseByOne = () => setCounter(counter + 1) + //highlight-start + const decreaseByOne = () => setCounter(counter - 1) + //highlight-end + const setToZero = () => setCounter(0) + + return ( +
    + + // highlight-start +
    + ) +} +``` + +Étant donné que nous avons maintenant un composant Button facilement réutilisable, nous avons également implémenté une nouvelle fonctionnalité dans notre application en ajoutant un bouton qui peut être utilisé pour décrémenter le compteur. + +Le gestionnaire d'événements est passé au composant Button via la prop _onClick_. Le nom de la prop en lui-même n'est pas très significatif, mais notre choix de nom n'était pas complètement aléatoire. + +Le [tutoriel](https://react.dev/learn/tutorial-tic-tac-toe) officiel de React suggère : "En React, il est conventionnel d'utiliser des noms de type onSomething pour les props qui représentent des événements et handleSomething pour les définitions de fonctions qui gèrent ces événements." + +### Les changements d'état entraînent un nouveau rendu + +Revenons une fois de plus sur les grands principes de fonctionnement d'une application. + +Lorsque l'application démarre, le code dans _App_ est exécuté. Ce code utilise un hook [useState](https://react.dev/reference/react/useState) pour créer l'état de l'application, en définissant une valeur initiale de la variable _counter_. +Ce composant contient le composant _Display_ - qui affiche la valeur du compteur, 0 - et trois composants _Button_. Les boutons ont tous des gestionnaires d'événements, qui sont utilisés pour changer l'état du compteur. + +Lorsque l'un des boutons est cliqué, le gestionnaire d'événements est exécuté. Le gestionnaire d'événements modifie l'état du composant _App_ avec la fonction _setCounter_. + +**L'appel d'une fonction qui modifie l'état provoque le rendu du composant.** + +Ainsi, si un utilisateur clique sur le bouton plus, le gestionnaire d'événements du bouton change la valeur de _counter_ à 1, et le composant _App_ est restitué. +Cela entraîne également le rendu de ses sous-composants _Display_ et _Button_. +_Display_ reçoit la nouvelle valeur du compteur, 1, comme prop. Les composants _Button_ reçoivent des gestionnaires d'événements qui peuvent être utilisés pour modifier l'état du compteur. + +### Refactoring des composants + +Le composant affichant la valeur du compteur est le suivant : + +```js +const Display = (props) => { + return ( +
    {props.counter}
    + ) +} +``` + +Le composant utilise uniquement le champ _counter_ de ses props. +Cela signifie que nous pouvons simplifier le composant en utilisant [la destructuration](/fr/part1/etat_des_composants_gestionnaires_devenements#destructuration), comme ceci : + +```js +const Display = ({ counter }) => { + return ( +
    {counter}
    + ) +} +``` + +La fonction qui définit le composant ne contient que l'instruction de retour, nous pouvons donc définir la fonction en utilisant la forme plus compacte des fonctions fléchées : + +```js +const Display = ({ counter }) =>
    {counter}
    +``` + +Nous pouvons également simplifier le composant Button. + +```js +const Button = (props) => { + return ( + + ) +} +``` + +Nous pouvons utiliser la destructuration pour obtenir uniquement les champs requis des props et utiliser la forme plus compacte des fonctions fléchées : + +**NB** : Lors de la création de vos propres composants, vous pouvez nommer les props des gestionnaires d'événements comme bon vous semble, pour cela, vous pouvez vous référer à la documentation de React sur [Naming event handler props](https://react.dev/learn/responding-to-events#naming-event-handler-props). Cela se présente comme suit : + +> Par convention, nommez les props des gestionnaires d'événements en commençant par `on` suivi d'une majuscule. +Par exemple, la prop `onClick` du composant Button aurait pu s'appeler `onSmash` : + +```js +const Button = ({ onClick, text }) => ( + +) +``` + +ou encore comme suit : + +```js +const Button = ({ onSmash, text }) => ( + +) +``` + +Nous pouvons simplifier une fois de plus le composant Button en déclarant l'instruction de retour en une seule ligne : + +```js +const Button = ({ onSmash, text }) => +``` + +**NB** : Cependant, veillez à ne pas trop simplifier vos composants, car cela rend l'ajout de complexité plus fastidieux par la suite. + +
    diff --git a/src/content/1/fr/part1d.md b/src/content/1/fr/part1d.md new file mode 100644 index 00000000000..bd6d5b87e0c --- /dev/null +++ b/src/content/1/fr/part1d.md @@ -0,0 +1,1240 @@ +--- +mainImage: ../../../images/part-1.svg +part: 1 +letter: d +lang: fr +--- + +
    + +### État complexe + +Dans notre exemple précédent, l'état de l'application était simple car il était composé d'un seul entier. Et si notre application nécessite un état plus complexe ? + +Dans la plupart des cas, le moyen le plus simple et plus adéquat d'y parvenir est d'utiliser la fonction _useState_ plusieurs fois pour créer des "morceaux" d'état séparés. + +Dans le code suivant, nous créons deux éléments d'état nommés _left_ et _right_ qui obtiennent tous deux la valeur initiale de 0 : + +```js +const App = () => { + const [left, setLeft] = useState(0) + const [right, setRight] = useState(0) + + return ( +
    + {left} + + + {right} +
    + ) +} +``` + +Le composant a accès aux fonctions _setLeft_ et _setRight_ qu'il peut utiliser pour mettre à jour les deux états. + +L'état du composant ou une partie de son état peut être de n'importe quel type. Nous pourrions implémenter la même fonctionnalité en enregistrant le nombre de clics des boutons gauche et droit dans un seul objet : + +```js +{ + left: 0, + right: 0 +} +``` + +Dans ce cas, l'application ressemblerait à ceci : + +```js +const App = () => { + const [clicks, setClicks] = useState({ + left: 0, right: 0 + }) + + const handleLeftClick = () => { + const newClicks = { + left: clicks.left + 1, + right: clicks.right + } + setClicks(newClicks) + } + + const handleRightClick = () => { + const newClicks = { + left: clicks.left, + right: clicks.right + 1 + } + setClicks(newClicks) + } + + return ( +
    + {clicks.left} + + + {clicks.right} +
    + ) +} +``` + +Désormais, le composant n'a qu'un seul état et les gestionnaires d'événements doivent s'occuper de modifier l'état de l'ensemble de l'application. + +Le gestionnaire d'événements semble un peu brouillon. Lorsque le bouton gauche est cliqué, la fonction suivante est appelée : + +```js +const handleLeftClick = () => { + const newClicks = { + left: clicks.left + 1, + right: clicks.right + } + setClicks(newClicks) +} +``` + +L'objet suivant est défini comme nouvel état de l'application : + +```js +{ + left: clicks.left + 1, + right: clicks.right +} +``` + +La nouvelle valeur de la propriété left est maintenant la même que la valeur de left + 1 de l'état précédent, et la valeur de la propriété right est la même que la valeur de la propriété right de l'état précédent. + +Nous pouvons définir le nouvel état de l'objet un peu plus précisément en utilisant la syntaxe de propagation de l'objet [object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax), +syntaxe qui a été ajoutée à la spécification du langage à l'été 2018 : + +```js +const handleLeftClick = () => { + const newClicks = { + ...clicks, + left: clicks.left + 1 + } + setClicks(newClicks) +} + +const handleRightClick = () => { + const newClicks = { + ...clicks, + right: clicks.right + 1 + } + setClicks(newClicks) +} +``` + +La syntaxe peut sembler un peu étrange au premier abord. En pratique, { ...clicks } crée un nouvel objet qui a des copies de toutes les propriétés de l'objet _clicks_. Lorsque nous spécifions une propriété particulière - par ex. right in { ...clicks, right: 1 }, la valeur de la propriété _right_ dans le nouvel objet sera 1. + +Dans l'exemple ci-dessus, ceci : + +```js +{ ...clicks, right: clicks.right + 1 } +``` + +crée une copie de l'objet _clicks_ où la valeur de la propriété _right_ est augmentée de un. + +L'affectation de l'objet à une variable dans les gestionnaires d'événements n'est pas nécessaire et nous pouvons simplifier les fonctions sous la forme suivante : + +```js +const handleLeftClick = () => + setClicks({ ...clicks, left: clicks.left + 1 }) + +const handleRightClick = () => + setClicks({ ...clicks, right: clicks.right + 1 }) +``` + +Certains lecteurs pourraient se demander pourquoi nous n'avons pas simplement mis à jour l'état directement, comme ceci : + +```js +const handleLeftClick = () => { + clicks.left++ + setClicks(clicks) +} +``` + +L'application semble fonctionner. Cependant, il est interdit dans React de muter directement l'état, car [cela peut entraîner des effets secondaires inattendus](https://stackoverflow.com/a/40309023). Le changement d'état doit toujours être effectué en définissant l'état sur un nouvel objet. Si les propriétés de l'objet d'état précédent ne sont pas modifiées, elles doivent simplement être copiées, ce qui se fait en copiant ces propriétés dans un nouvel objet et en le définissant comme nouvel état. + +Stocker tout l'état dans un seul objet d'état est un mauvais choix pour cette application particulière ; il n'y a aucun avantage apparent et l'application qui en résulte est beaucoup plus complexe. Dans ce cas, stocker les compteurs de clics dans des éléments d'état séparés est un choix bien plus approprié. + +Il existe des situations où il peut être avantageux de stocker une partie de l'état de l'application dans une structure de données plus complexe. [La documentation officielle de React](https://react.dev/learn/choosing-the-state-structure) contient des conseils utiles sur le sujet. + +### Gestion des tableaux + +Ajoutons un élément d'état à notre application contenant un tableau _allClicks_ qui se souvient de chaque clic qui s'est produit dans l'application. + +```js +const App = () => { + const [left, setLeft] = useState(0) + const [right, setRight] = useState(0) + const [allClicks, setAll] = useState([]) // highlight-line + +// highlight-start + const handleLeftClick = () => { + setAll(allClicks.concat('L')) + setLeft(left + 1) + } +// highlight-end + +// highlight-start + const handleRightClick = () => { + setAll(allClicks.concat('R')) + setRight(right + 1) + } +// highlight-end + + return ( +
    + {left} + + + {right} +

    {allClicks.join(' ')}

    // highlight-line +
    + ) +} +``` + +Chaque clic est stocké dans un élément d'état séparé appelé _allClicks_ qui est initialisé sous la forme d'un tableau vide : + +```js +const [allClicks, setAll] = useState([]) +``` + +Lorsque le bouton gauche est cliqué, nous ajoutons la lettre L au tableau _allClicks_ : + +```js +const handleLeftClick = () => { + setAll(allClicks.concat('L')) + setLeft(left + 1) +} +``` + +L'élément d'état stocké dans _allClicks_ est désormais défini comme un tableau contenant tous les éléments du tableau d'état précédent plus la lettre L. L'ajout du nouvel élément au tableau est accompli avec la méthode [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat), qui ne mute pas le tableau existant mais renvoie plutôt une nouvelle copie du tableau avec l'élément ajouté. + +Comme mentionné précédemment, il est également possible en JavaScript d'ajouter des éléments à un tableau avec la méthode [push](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push) . Si nous ajoutons l'élément en le poussant vers le tableau _allClicks_ puis en mettant à jour l'état, l'application semblerait toujours fonctionner : + +```js +const handleLeftClick = () => { + allClicks.push('L') + setAll(allClicks) + setLeft(left + 1) +} +``` + +Cependant, __ ne faites pas cela. Comme mentionné précédemment, l'état des composants React comme _allClicks_ ne doit pas être muté directement. Même si l'état de mutation semble fonctionner dans certains cas, cela peut entraîner des problèmes très difficiles à déboguer. + +Regardons de plus près comment le clic +est rendu sur la page : + +```js +const App = () => { + // ... + + return ( +
    + {left} + + + {right} +

    {allClicks.join(' ')}

    // highlight-line +
    + ) +} +``` + +Nous appelons la méthode [join](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/join) sur le tableau _allClicks_ qui joint tous les éléments en une seule chaîne, séparés par la chaîne passée en paramètre de la fonction, qui dans notre cas est un espace vide. + +### Rendu conditionnel + +Modifions notre application pour que le rendu de l'historique des clics soit géré par un nouveau composant History : + +```js +// highlight-start +const History = (props) => { + if (props.allClicks.length === 0) { + return ( +
    + the app is used by pressing the buttons +
    + ) + } + + return ( +
    + button press history: {props.allClicks.join(' ')} +
    + ) +} +// highlight-end + +const App = () => { + // ... + + return ( +
    + {left} + + + {right} + // highlight-line +
    + ) +} +``` + +Maintenant, le comportement du composant dépend du fait que des boutons aient été cliqués ou non. Si ce n'est pas le cas, ce qui signifie que le tableau allClicks est vide, le composant restitue un élément div avec quelques instructions à la place : + +```js +
    the app is used by pressing the buttons
    +``` + +Et dans tous les autres cas, le composant restitue l'historique des clics : + +```js +
    + button press history: {props.allClicks.join(' ')} +
    +``` + +Le composant History rend des éléments React complètement différents en fonction de l'état de l'application. C'est ce qu'on appelle le rendu conditionnel. + +React propose également de nombreuses autres façons de faire [le rendu conditionnel](https://react.dev/learn/conditional-rendering). Nous y reviendrons plus en détail dans la [partie 2](/fr/part2). + +Apportons une dernière modification à notre application en la refactorisant pour utiliser le composant _Button_ que nous avons défini précédemment : + +```js +const History = (props) => { + if (props.allClicks.length === 0) { + return ( +
    + the app is used by pressing the buttons +
    + ) + } + + return ( +
    + button press history: {props.allClicks.join(' ')} +
    + ) +} + +// highlight-start +const Button = ({ handleClick, text }) => ( + +) +// highlight-end + +const App = () => { + const [left, setLeft] = useState(0) + const [right, setRight] = useState(0) + const [allClicks, setAll] = useState([]) + + const handleLeftClick = () => { + setAll(allClicks.concat('L')) + setLeft(left + 1) + } + + const handleRightClick = () => { + setAll(allClicks.concat('R')) + setRight(right + 1) + } + + return ( +
    + {left} + // highlight-start +
    + ) +} +``` + +### Ancienne version de React + +Dans ce cours, nous utilisons le [state hook](https://react.dev/learn/state-a-components-memory) pour ajouter un état à nos composants React, qui fait partie des nouvelles versions de React et est disponible à partir de la version [ 16.8.0](https://www.npmjs.com/package/react/v/16.8.0) et versions ultérieures. Avant l'ajout des hooks, il n'y avait aucun moyen d'ajouter un état aux composants fonctionnels. Les composants qui nécessitaient un état devaient être définis en tant que composants [classes](https://react.dev/reference/react/Component), à l'aide de la syntaxe de classe JavaScript. + +Dans ce cours, nous avons pris la décision radicale d'utiliser exclusivement les hooks dès le premier jour, pour nous assurer que nous apprenons le style actuel et futur de React. Même si les composants fonctionnels sont l'avenir de React, il est toujours important d'apprendre la syntaxe de la classe, car il existe des milliards de lignes de code React que vous pourriez finir par maintenir un jour. Il en va de même pour la documentation et les exemples de React que vous pouvez trouver sur Internet. + +Nous en apprendrons plus sur les composants classes de React plus tard dans le cours. + +### Débogage des applications React + +Une grande partie du temps d'un développeur typique est consacrée au débogage et à la lecture du code existant. De temps en temps, nous écrivons une ligne ou deux de nouveau code, mais une grande partie de notre temps est consacrée à essayer de comprendre pourquoi quelque chose est cassé ou comment quelque chose fonctionne. Les bonnes pratiques et les outils de débogage sont extrêmement importants pour cette raison. + +Heureusement pour nous, React est une bibliothèque extrêmement conviviale pour les développeurs en matière de débogage. + +Avant de poursuivre, rappelons-nous l'une des règles les plus importantes du développement Web. + +

    La première règle du développement Web

    + +> **Gardez la console développeur du navigateur ouverte à tout moment.** +> +> L'onglet Console en particulier doit toujours être ouvert, sauf s'il existe une raison spécifique d'afficher un autre onglet. + +Gardez votre code et la page Web ouverts ensemble **en même temps, tout le temps**. + +Si et quand votre code ne compile pas et que votre navigateur s'allume comme un sapin de Noël : + +![](../../images/1/6x.png) + +n'écrivez pas plus de code mais plutôt trouvez et corrigez le problème **immédiatement**. Il n'y a pas encore eu de moment dans l'histoire du codage où le code qui ne compile pas commencerait miraculeusement à fonctionner après avoir écrit de grandes quantités de code supplémentaire. Je doute fortement qu'un tel événement se produise au cours de ce cours non plus. + +Le débogage à l'ancienne, basé sur l'impression, est toujours une bonne idée. Si le composant + +```js +const Button = ({ onClick, text }) => ( + +) +``` + +ne fonctionne pas comme prévu, il est utile de commencer à imprimer ses variables sur la console. Pour le faire efficacement, nous devons transformer notre fonction dans la forme la moins compacte et recevoir l'intégralité de l'objet props sans le déstructurer immédiatement : + +```js +const Button = (props) => { + console.log(props) // highlight-line + const { onClick, text } = props + return ( + + ) +} +``` + +Cela révélera immédiatement si, par exemple, l'un des attributs a été mal orthographié lors de l'utilisation du composant. + +**NB** Lorsque vous utilisez _console.log_ pour le débogage, ne combinez pas _objects_ à la manière de Java en utilisant l'opérateur plus. Au lieu d'écrire : + +```js +console.log('props value is ' + props) +``` + +Séparez les éléments que vous souhaitez consigner dans la console par une virgule : + +```js +console.log('props value is', props) +``` + +Si vous utilisez la manière Java de concaténer une chaîne avec un objet, vous vous retrouverez avec un message de journal plutôt peu informatif : + +```js +props value is [Object object] +``` + +Alors que les éléments séparés par une virgule seront tous disponibles dans la console du navigateur pour une inspection plus approfondie. + +Se connecter à la console n'est en aucun cas le seul moyen de déboguer nos applications. Vous pouvez suspendre l'exécution de votre code d'application dans le débogueur de la console développeur Chrome, en écrivant la commande [debugger](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/debugger) n'importe où dans votre code. + +L'exécution s'arrêtera une fois qu'elle arrivera à un point où la commande _debugger_ sera exécutée : + +![](../../images/1/7a.png) + +En allant dans l'onglet Console, il est facile d'inspecter l'état actuel des variables : + +![](../../images/1/8a.png) + +Une fois la cause du bogue découverte, vous pouvez supprimer la commande _debugger_ et actualiser la page. + +Le débogueur nous permet également d'exécuter notre code ligne par ligne avec les contrôles situés à droite de l'onglet Sources. + +Vous pouvez également accéder au débogueur sans la commande _debugger_ en ajoutant des points d'arrêt dans l'onglet Sources. L'inspection des valeurs des variables du composant peut être effectuée dans la section _Scope_ : + +![](../../images/1/9a.png) + +Il est fortement recommandé d'ajouter l'extension [React developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) à Chrome. Il ajoute un nouvel onglet _Components_ aux outils de développement. Le nouvel onglet des outils de développement peut être utilisé pour inspecter les différents éléments React de l'application, ainsi que leur état et leurs props: + +![](../../images/1/10ea.png) + + +L'état du composant _App_ est défini comme suit : + +```js +const [left, setLeft] = useState(0) +const [right, setRight] = useState(0) +const [allClicks, setAll] = useState([]) +``` + +Dev tools affichent l'état des hooks dans l'ordre de leur définition : + +![](../../images/1/11ea.png) + +Le premier State contient la valeur de l'état left, le suivant contient la valeur de l'état right et le dernier contient la valeur de l'état état de tous les clics. + +### Règles des Hooks + +Il y a quelques limitations et règles que nous devons suivre pour nous assurer que notre application utilise correctement les fonctions d'état basées sur les hooks. + +La fonction _useState_ (ainsi que la fonction _useEffect_ introduite plus tard dans le cours) ne doit pas être appelée depuis l'intérieur d'une boucle, d'une expression conditionnelle ou de tout endroit qui n'est pas une fonction définissant un composant. Cela doit être fait pour s'assurer que les hooks sont toujours appelés dans le même ordre, et si ce n'est pas le cas, l'application se comportera de manière erratique. + +Pour récapituler, les hooks ne peuvent être appelés que depuis l'intérieur d'un corps de fonction qui définit un composant React : + +```js +const App = () => { + // ceci est ok + const [age, setAge] = useState(0) + const [name, setName] = useState('Juha Tauriainen') + + if ( age > 10 ) { + // ceci ne marche pas! + const [foobar, setFoobar] = useState(null) + } + + for ( let i = 0; i < age; i++ ) { + // toujours pas ok ! + const [rightWay, setRightWay] = useState(false) + } + + const notGood = () => { + // et ceci est presqu'un péché ! + const [x, setX] = useState(-1000) + } + + return ( + //... + ) +} +``` + +### Gestion des événements revisitée + +La gestion des événements s'est avérée être un sujet difficile dans les versions précédentes de ce cours. + +C'est pourquoi nous reviendrons sur le sujet. + +Supposons que nous développions cette application simple avec le composant suivant App : + +```js +const App = () => { + const [value, setValue] = useState(10) + + return ( +
    + {value} + +
    + ) +} +``` + +Nous voulons que le clic sur le bouton réinitialise l'état stocké dans la variable _value_. + +Afin de faire réagir le bouton à un événement de clic, nous devons lui ajouter un event handler. + +Les gestionnaires d'événements doivent toujours être une fonction ou une référence à une fonction. Le bouton ne fonctionnera pas si le gestionnaire d'événements est défini sur une variable d'un autre type. + +Si nous devions définir notre event handler sous forme de chaîne : + +```js + +``` + +React nous en avertirait dans la console : + +```js +main.jsx:2178 Warning: Expected `onClick` listener to be a function, instead got a value of `string` type. + in button (at main.jsx:20) + in div (at main.jsx:18) + in App (at main.jsx:27) +``` + +La tentative suivante ne fonctionnerait pas non plus : + +```js + +``` + +Nous avons tenté de définir le gestionnaire d'événements sur _value + 1_ qui renvoie simplement le résultat de l'opération. React nous en avertira gentiment dans la console : + +```js +main.jsx:2178 Warning: Expected `onClick` listener to be a function, instead got a value of `number` type. +``` + +Cette tentative ne fonctionnerait pas non plus : + +```js + +``` + +Le gestionnaire d'événements n'est pas une fonction mais une affectation de variable, et React émettra à nouveau un avertissement sur la console. Cette tentative est également imparfaite dans le sens où nous ne devons jamais muter l'état directement dans React. + +Qu'en est-il des éléments suivants : + +```js + +``` + +Le message est affiché sur la console une fois lorsque le composant est rendu, mais rien ne se passe lorsque nous cliquons sur le bouton. Pourquoi cela ne fonctionne-t-il pas même lorsque notre gestionnaire d'événements contient une fonction _console.log_ ? + +Le problème ici est que notre gestionnaire d'événements est défini comme un appel de fonction, ce qui signifie que le gestionnaire d'événements se voit en fait attribuer la valeur renvoyée par la fonction, qui dans le cas de _console.log_ est undefined . + +L'appel de la fonction _console.log_ est exécuté lorsque le composant est rendu et pour cette raison, il est imprimé une fois sur la console. + +La tentative suivante est également erronée : + +```js + +``` + +Nous avons de nouveau essayé de définir un appel de fonction comme gestionnaire d'événements. Cela ne fonctionne pas. Cette tentative particulière provoque également un autre problème. Lorsque le composant est rendu, la fonction _setValue(0)_ est exécutée, ce qui entraîne à son tour le rendu du composant. Le re-rendu appelle à son tour _setValue(0)_, ce qui entraîne une récursivité infinie. + +L'exécution d'un appel de fonction particulier lorsque le bouton est cliqué peut être accompli comme ceci : + +```js + +``` + +Maintenant, le gestionnaire d'événements est une fonction définie avec la syntaxe de la fonction fléchée _() => console.log('clicked the button')_. Lorsque le composant est rendu, aucune fonction n'est appelée et seule la référence à la fonction fléchée est définie sur le gestionnaire d'événements. L'appel de la fonction n'a lieu qu'une fois le bouton cliqué. + +Nous pouvons implémenter la réinitialisation de l'état dans notre application avec cette même technique : + +```js + +``` + +Le gestionnaire d'événements est maintenant la fonction _() => setValue(0)_. + +Définir les gestionnaires d'événements directement dans l'attribut du bouton n'est pas nécessairement la meilleure idée possible. + +Vous verrez souvent des gestionnaires d'événements définis dans un endroit séparé. Dans la version suivante de notre application, nous définissons une fonction qui est ensuite affectée à la variable _handleClick_ dans le corps de la fonction composant : + +```js +const App = () => { + const [value, setValue] = useState(10) + + const handleClick = () => + console.log('clicked the button') + + return ( +
    + {value} + +
    + ) +} +``` + +La variable _handleClick_ est maintenant affectée à une référence à la fonction. La référence est transmise au bouton en tant qu'attribut onClick : + +```js + +``` + +Naturellement, notre fonction de gestion d'événements peut être composée de plusieurs commandes. Dans ces cas, nous utilisons la syntaxe des accolades plus longues pour les fonctions fléchées : + +```js +const App = () => { + const [value, setValue] = useState(10) + + // highlight-start + const handleClick = () => { + console.log('clicked the button') + setValue(0) + } + // highlight-end + + return ( +
    + {value} + +
    + ) +} +``` + +### Fonction qui renvoie une fonction + +Une autre façon de définir un gestionnaire d'événements consiste à utiliser fonction qui renvoie une fonction. + +Vous n'aurez probablement pas besoin d'utiliser des fonctions qui renvoient des fonctions dans aucun des exercices de ce cours. Si le sujet semble particulièrement déroutant, vous pouvez ignorer cette section pour le moment et y revenir plus tard. + +Apportons les modifications suivantes à notre code : + +```js +const App = () => { + const [value, setValue] = useState(10) + + // highlight-start + const hello = () => { + const handler = () => console.log('hello world') + + return handler + } + // highlight-end + + return ( +
    + {value} + +
    + ) +} +``` + +Le code fonctionne correctement même s'il semble compliqué. + +Le gestionnaire d'événements est maintenant défini sur un appel de fonction : + +```js + +``` + +Plus tôt, nous avons déclaré qu'un gestionnaire d'événements ne peut pas être un appel à une fonction, et qu'il doit être une fonction ou une référence à une fonction. Pourquoi alors un appel de fonction fonctionne-t-il dans ce cas ? + +Lorsque le composant est rendu, la fonction suivante est exécutée : + +```js +const hello = () => { + const handler = () => console.log('hello world') + + return handler +} +``` + +La valeur de retour de la fonction est une autre fonction affectée à la variable _handler_. + +Lorsque React affiche la ligne : + +```js + +``` + +Il attribue la valeur de retour de _hello()_ à l'attribut onClick. Essentiellement, la ligne se transforme en : + +```js + +``` + +Puisque la fonction _hello_ renvoie une fonction, le gestionnaire d'événements est maintenant une fonction. + +Quel est l'intérêt de ce concept ? + +Modifions un tout petit peu le code : + +```js +const App = () => { + const [value, setValue] = useState(10) + + // highlight-start + const hello = (who) => { + const handler = () => { + console.log('hello', who) + } + + return handler + } + // highlight-end + + return ( +
    + {value} + // highlight-start + + + + // highlight-end +
    + ) +} +``` + +Maintenant, l'application a trois boutons avec des gestionnaires d'événements définis par la fonction _hello_ qui accepte un paramètre. + +Le premier bouton est défini comme + +```js + +``` + +Le gestionnaire d'événements est créé en exécutant l'appel de fonction _hello('world')_. L'appel de fonction renvoie la fonction : + +```js +() => { + console.log('hello', 'world') +} +``` + +Le deuxième bouton est défini comme : + +```js + +``` + +L'appel de fonction _hello('react')_ qui crée le event handler renvoie : + +```js +() => { + console.log('hello', 'react') +} +``` + +Les deux boutons disposent de leurs propres gestionnaires d'événements individualisés. + +Les fonctions renvoyant des fonctions peuvent être utilisées pour définir des fonctionnalités génériques qui peuvent être personnalisées avec des paramètres. La fonction _hello_ qui crée les gestionnaires d'événements peut être considérée comme une usine qui produit des event handlers personnalisés destinés à accueillir les utilisateurs. + +Notre définition actuelle est légèrement verbeuse : + +```js +const hello = (who) => { + const handler = () => { + console.log('hello', who) + } + + return handler +} +``` + +Éliminons les variables d'assistance et renvoyons directement la fonction créée : + +```js +const hello = (who) => { + return () => { + console.log('hello', who) + } +} +``` + +Puisque notre fonction _hello_ est composée d'une seule commande de retour, nous pouvons omettre les accolades et utiliser la syntaxe plus compacte pour les fonctions fléchées : + +```js +const hello = (who) => + () => { + console.log('hello', who) + } +``` + +Enfin, écrivons toutes les flèches sur la même ligne : + +```js +const hello = (who) => () => { + console.log('hello', who) +} +``` + +Nous pouvons utiliser la même astuce pour définir des gestionnaires d'événements qui définissent l'état du composant à une valeur donnée. Apportons les modifications suivantes à notre code : + +```js +const App = () => { + const [value, setValue] = useState(10) + + // highlight-start + const setToValue = (newValue) => () => { + console.log('value now', newValue) // affiche la nouvelle valeur sur la console + setValue(newValue) + } + // highlight-end + + return ( +
    + {value} + // highlight-start + + + + // highlight-end +
    + ) +} +``` + +Lorsque le composant est rendu, le bouton thousand est créé : + +```js + +``` + +Le gestionnaire d'événements est défini sur la valeur de retour de _setToValue(1000)_ qui est la fonction suivante : + +```js +() => { + console.log('value now', 1000) + setValue(1000) +} +``` + +Le bouton d'incrémentation est déclaré comme suit : + +```js + +``` + +Le gestionnaire d'événements est créé par l'appel de fonction _setToValue(value + 1)_ qui reçoit en paramètre la valeur courante de la variable d'état _value_ augmentée de un. Si la valeur de _value_ était 10, alors le gestionnaire d'événements créé serait la fonction : + +```js +() => { + console.log('value now', 11) + setValue(11) +} +``` + +L'utilisation de fonctions qui renvoient des fonctions n'est pas nécessaire pour obtenir cette fonctionnalité. Renvoyons la fonction _setToValue_ qui est responsable de la mise à jour de l'état, dans une fonction normale : + +```js +const App = () => { + const [value, setValue] = useState(10) + + const setToValue = (newValue) => { + console.log('value now', newValue) + setValue(newValue) + } + + return ( +
    + {value} + + + +
    + ) +} +``` + +Nous pouvons maintenant définir le gestionnaire d'événements comme une fonction qui appelle la fonction _setToValue_ avec un paramètre approprié. Le gestionnaire d'événements pour réinitialiser l'état de l'application serait : + +```js + +``` + +Choisir entre les deux façons présentées pour définir vos gestionnaires d'événements est surtout une question de goût. + +### Passer vos events handlers aux composants enfants + +Extrayons le bouton dans son propre composant : + +```js +const Button = (props) => ( + +) +``` + +Le composant obtient la fonction de gestionnaire d'événements de la prop _handleClick_ et le texte du bouton de la prop _text_. + +L'utilisation du composant Button est simple, même si nous devons nous assurer que nous utilisons les noms d'attribut corrects lors de la transmission des props au composant. + +![](../../images/1/12e.png) + +### Ne pas définir de composants dans les composants + +Commençons à afficher la valeur de l'application dans son propre composant Display. + +Nous allons changer l'application en définissant un nouveau composant à l'intérieur du composant App. + +```js +// C'est le bon endroit pour définir un composant +const Button = (props) => ( + +) + +const App = () => { + const [value, setValue] = useState(10) + + const setToValue = newValue => { + console.log('value now', newValue) + setValue(newValue) + } + + // Ne pas définir de composants à l'intérieur d'un autre composant + const Display = props =>
    {props.value}
    // highlight-line + + return ( +
    + // highlight-line +
    + ) +} +``` + +L'application semble toujours fonctionner, mais **n'implémentez pas de composants comme celui-ci !** Ne définissez jamais de composants à l'intérieur d'autres composants. La méthode n'offre aucun avantage et entraîne de nombreux problèmes désagréables. Les plus gros problèmes sont dus au fait que React traite un composant défini à l'intérieur d'un autre composant comme un nouveau composant dans chaque rendu. Cela rend impossible pour React d'optimiser le composant. + +Déplaçons plutôt la fonction de composant Display à sa place correcte, qui est en dehors de la fonction de composant App : + +```js +const Display = props =>
    {props.value}
    + +const Button = (props) => ( + +) + +const App = () => { + const [value, setValue] = useState(10) + + const setToValue = newValue => { + console.log('value now', newValue) + setValue(newValue) + } + + return ( +
    + +
    + ) +} +``` + +### Lecture utile + +Internet regorge de contenu lié à React. Cependant, nous utilisons le nouveau style de React pour lequel une grande majorité du matériel trouvé en ligne est obsolète. + +Les liens suivants peuvent vous être utiles : + +- La [documentation officielle de React](https://react.dev/learn) vaut la peine d'être consultée à un moment donné, même si la plupart d'entre elles ne deviendront pertinentes que plus tard dans le cours. De plus, tout ce qui concerne les composants basés sur des classes ne nous concerne pas ; +- Certains cours sur [Egghead.io](https://egghead.io) comme [Start learning React](https://egghead.io/courses/start-learning-react) sont de haute qualité, et récemment mis à jour [ Le guide du débutant pour réagir](https://egghead.io/courses/the-beginner-s-guide-to-reactjs) est également relativement bon ; les deux cours introduisent des concepts qui seront également introduits plus tard dans ce cours. **NB** Le premier utilise des composants classes mais le second utilise les nouveaux composants fonctionnels. + +
    + +
    + +

    Exercices 1.6.-1.14.

    + +Soumettez vos solutions aux exercices en transmettant d'abord votre code à GitHub, puis en marquant les exercices terminés dans le [système de soumission](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +N'oubliez pas de soumettre **tous** les exercices d'une partie **en une seule soumission**. Une fois que vous avez soumis vos solutions pour une partie, **vous ne pouvez plus soumettre d'autres exercices pour cette partie**. + +Certains des exercices fonctionnent sur la même application. Dans ces cas, il suffit de soumettre uniquement la version finale de la demande. Si vous le souhaitez, vous pouvez effectuer un commit après chaque exercice terminé, mais ce n'est pas obligatoire. + +Dans certaines situations, vous devrez peut-être également exécuter la commande ci-dessous à partir de la racine du projet : + +```bash +rm -rf node_modules/ && npm i +``` + +

    1.6 : unicafé, étape1

    + +Comme la plupart des entreprises, [Unicafe](https://www.unicafe.fi/#/9/4) recueille les commentaires de ses clients. Votre tâche consiste à mettre en place une application Web pour recueillir les commentaires des clients. Il n'y a que trois options pour les commentaires : bon, neutre et mauvais. + +L'application doit afficher le nombre total de commentaires recueillis pour chaque catégorie. Votre application finale pourrait ressembler à ceci : + +![](../../images/1/13e.png) + +Notez que votre application ne doit fonctionner que pendant une seule session de navigateur. Une fois que vous avez actualisé la page, les commentaires recueillis sont autorisés à disparaître. + +Il est conseillé d'utiliser la même structure que celle utilisée dans le matériel et l'exercice précédent. Le fichier main.jsx est le suivant : + +```js +import ReactDOM from 'react-dom/client' + +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +Vous pouvez utiliser le code ci-dessous comme point de départ pour le fichier App.jsx : + +```js +import { useState } from 'react' + +const App = () => { + // enregistrer les clics de chaque bouton dans un état différent + const [good, setGood] = useState(0) + const [neutral, setNeutral] = useState(0) + const [bad, setBad] = useState(0) + + return ( +
    + code here +
    + ) +} + +export default App +``` + +

    1.7 : unicafé, étape2

    + +Développez votre application pour qu'elle affiche plus de statistiques sur les retours collectés : le nombre total de retours collectés, le score moyen (bon : 1, neutre : 0, mauvais : -1) et le pourcentage de retours positifs. + +![](../../images/1/14e.png) + +

    1.8 : unicafé, étape3

    + +Refactorisez votre application afin que l'affichage des statistiques soit extrait dans son propre composant Statistics. L'état de l'application doit rester dans le composant racine App. + +N'oubliez pas que les composants ne doivent pas être définis à l'intérieur d'autres composants : + +```js +// un endroit approprié pour définir un composant +const Statistics = (props) => { + // ... +} + +const App = () => { + const [good, setGood] = useState(0) + const [neutral, setNeutral] = useState(0) + const [bad, setBad] = useState(0) + + // ne pas définir un composant dans un autre composant + const Statistics = (props) => { + // ... + } + + return ( + // ... + ) +} +``` + +

    1.9 : unicafé, étape4

    + +Modifiez votre application pour n'afficher les statistiques qu'une fois les commentaires recueillis. + +![](../../images/1/15e.png) + +

    1.10 : unicafé, étape5

    + +Continuons à refactoriser l'application. Extrayez les deux composants suivants : + +- Bouton pour définir les boutons utilisés pour soumettre des commentaires +- StatisticLine pour afficher une seule statistique, par ex. la note moyenne. + +Pour être clair : le composant StatisticLine affiche toujours une seule statistique, ce qui signifie que l'application utilise plusieurs composants pour afficher toutes les statistiques : + +```js +const Statistics = (props) => { + /// ... + return( +
    + + + + // ... +
    + ) +} + +``` + +L'état de l'application doit toujours être conservé dans le composant racine App. + +

    1.11* : unicafé, étape6

    + +Affichez les statistiques dans un [tableau](https://developer.mozilla.org/en-US/docs/Learn/HTML/Tables/Basics) HTML, afin que votre application ressemble à peu près à ceci : + +![](../../images/1/16e.png) + +N'oubliez pas de garder votre console ouverte en tout temps. Si vous voyez cet avertissement dans votre console : + +![](../../images/1/17a.png) + +Effectuez ensuite les actions nécessaires pour faire disparaître l'avertissement. Essayez de coller le message d'erreur dans un moteur de recherche si vous êtes bloqué. + +Source typique d'une erreur `Unchecked runtime.lastError : Impossible d'établir la connexion. La fin de réception n'existe pas.` est l'extension Chrome. Essayez d'aller sur `chrome://extensions/` et essayez de les désactiver un par un et d'actualiser la page de l'application React ; l'erreur devrait éventuellement disparaître. + +**Assurez-vous qu'à partir de maintenant, vous ne voyez plus aucun avertissement dans votre console !** + +

    1.12* : anecdotes, étape1

    + +Le monde de l'ingénierie logicielle est rempli d'[anecdotes](http://www.comp.nus.edu.sg/~damithch/pages/SE-quotes.htm) qui distillent des vérités intemporelles de notre domaine en de courtes lignes. + +Développez l'application suivante en ajoutant un bouton sur lequel cliquer pour afficher une anecdote aléatoire du domaine du génie logiciel : + +```js +import { useState } from 'react' + +const App = () => { + const anecdotes = [ + 'If it hurts, do it more often.', + 'Adding manpower to a late software project makes it later!', + 'The first 90 percent of the code accounts for the first 10 percent of the development time...The remaining 10 percent of the code accounts for the other 90 percent of the development time.', + 'Any fool can write code that a computer can understand. Good programmers write code that humans can understand.', + 'Premature optimization is the root of all evil.', + 'Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it.', + 'Programming without an extremely heavy use of console.log is same as if a doctor would refuse to use x-rays or blood tests when diagnosing patients.' + ] + + const [selected, setSelected] = useState(0) + + return ( +
    + {anecdotes[selected]} +
    + ) +} + +export default App +``` + +Le contenu du fichier main.jsx est le même que dans les exercices précédents. + +Découvrez comment générer des nombres aléatoires en JavaScript, par exemple. via le moteur de recherche ou sur [Mozilla Developer Network](https://developer.mozilla.org). N'oubliez pas que vous pouvez tester la génération de nombres aléatoires, par ex. directement dans la console de votre navigateur. + +Votre application terminée pourrait ressembler à ceci : + +![](../../images/1/18a.png) + +

    1.13* : anecdotes, étape2

    + +Développez votre application afin de pouvoir voter pour l'anecdote affichée. + +![](../../images/1/19a.png) + +**NB** stocker les votes de chaque anecdote dans un tableau ou un objet dans l'état du composant. N'oubliez pas que la bonne façon de mettre à jour l'état stocké dans des structures de données complexes comme des objets et des tableaux est de faire une copie de l'état. + +Vous pouvez créer une copie d'un objet comme ceci : + +```js +const votes = { 0: 1, 1: 3, 2: 4, 3: 2 } + +const copy = { ...votes } +// incrémenter la valeur de la propriété 2 de un +copy[2] += 1 +``` + +OU une copie du tableau comme cela : + +```js +const votes = [1, 4, 6, 3] + +const copy = [...votes] +// incrémenter la valeur en position 2 de un +copy[2] += 1 +``` + +L'utilisation d'un tableau pourrait être le choix le plus simple dans ce cas. Une recherche sur Internet vous fournira de nombreux conseils sur la façon de [créer un tableau rempli de zéros d'une longueur souhaitée](https://stackoverflow.com/questions/20222501/how-to-create-a-zero-filled-javascript-tableau-de-longueur-arbitraire/22209781). + +

    1.14* : anecdotes, étape3

    + +Implémentez maintenant la version finale de l'application qui affiche l'anecdote avec le plus grand nombre de votes : + +![](../../images/1/20a.png) + +Si plusieurs anecdotes sont à égalité pour la première place, il suffit d'en montrer une seule. + +C'était le dernier exercice de cette partie du cours et il est temps de pusher votre code vers GitHub et de marquer tous vos exercices terminés dans le [système de soumission](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    diff --git a/src/content/1/ptbr/part1.md b/src/content/1/ptbr/part1.md new file mode 100644 index 00000000000..5bc9213a5ef --- /dev/null +++ b/src/content/1/ptbr/part1.md @@ -0,0 +1,14 @@ +--- +mainImage: ../../../images/part-1.svg +part: 1 +lang: ptbr +--- + +
    + +Nesta parte, nos familiarizaremos com a biblioteca React, que usaremos para escrever o código que roda no navegador. Também vamos dar uma olhada em algumas características de JavaScript que são importantes para entender React. + +Parte atualizada em 10 de janeiro de 2023 +- Sem maiores atualizações + +
    diff --git a/src/content/1/ptbr/part1a.md b/src/content/1/ptbr/part1a.md new file mode 100644 index 00000000000..2afdd4cbbbf --- /dev/null +++ b/src/content/1/ptbr/part1a.md @@ -0,0 +1,667 @@ +--- +mainImage: ../../../images/part-1.svg +part: 1 +letter: a +lang: ptbr +--- + +
    + +Agora, começaremos a nos familiarizar com provavelmente o tópico mais importante deste curso, a biblioteca [React](https://reactjs.org/). Vamos começar criando uma aplicação React simples, conhecendo os conceitos-chave de React. + +A maneira mais simples de começar é usando uma ferramenta chamada [create-react-app](https://github.com/facebook/create-react-app). É possível (mas não necessário) instalar o create-react-app em sua máquina se a ferramenta npm instalada junto com o Node estiver na versão 5.3, pelo menos. + +Durante o curo, você também pode utilizar a nova ferramenta frontend chamada [Vite](https://vitejs.dev/), se desejar. O create-react-app ainda é a ferramenta recomendada pelo time do React e é por isso que continua sendo a ferramenta padrão para configurar um projeto React neste curso. Leia [aqui](https://github.com/reactjs/reactjs.org/pull/5487#issuecomment-1409720741) como o time React enxerga o futuro das ferramentas React. + +Vamos criar uma aplicação chamada part1 e navegar até o seu diretório. + +```bash +npx create-react-app part1 +cd part1 +``` + +A aplicação é executada da seguinte forma: + +```bash +npm start +``` + +Por padrão, a aplicação é executada no localhost, porta 3000, no endereço . + +Seu navegador padrão deve ser automaticamente aberto. Abra **imediatamente** o console do navegador. Além disso, abra um editor de texto para que você possa ver o código e a página web ao mesmo tempo na tela: + +![código e navegador lado a lado](../../images/1/1e.png) + +O código da aplicação reside no diretório src. Vamos simplificar o código padrão para que o conteúdo do arquivo index.js fique assim: + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' + +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +E o arquivo App.js fique assim: + +```js +const App = () => ( +
    +

    Olá, mundo!

    +
    +) + +export default App +``` + +Os arquivos App.css, App.test.js, index.css, logo.svg, setupTests.js e reportWebVitals.js podem ser excluídos, pois não são necessários em nossa aplicação neste momento. + +### Componente + +O arquivo App.js agora define um componente [React](https://reactjs.org/docs/components-and-props.html) com o nome App. O comando na linha final do arquivo index.js + +```js +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +renderiza seu conteúdo dentro do elemento div, definido no arquivo public/index.html, com o valor de id 'root'. + +Por padrão, o arquivo public/index.html não contém nenhum marcador HTML que seja visível para nós no navegador: + +```html + + + + conteúdo não mostrado ... + + + +
    + + +``` + +Você pode até tentar adicionar algum HTML ao arquivo, no entanto, ao usar React, todo o conteúdo que precisa ser renderizado é geralmente definido como "componentes React". + +Vamos dar uma olhada mais de perto no código que define o componente: + +```js +const App = () => ( +
    +

    Olá, mundo!

    +
    +) +``` + +Como você provavelmente já adivinhou, o componente será renderizado como uma tag div, que envolve uma tag p contendo o texto "Olá, mundo!". + +Tecnicamente, o componente é definido como uma função JavaScript. O código a seguir também é uma função (que não recebe nenhum parâmetro): + +```js +() => ( +
    +

    Olá, mundo!

    +
    +) +``` + +A função é, então, atribuída a uma (variável) constante App: + +```js +const App = ... +``` + +Existem algumas maneiras de definir funções em JavaScript. Aqui usaremos as [funções de seta](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions) (arrow functions), que são descritas em uma versão mais recente de JavaScript conhecida como [ECMAScript 6](http://es6-features.org/#Constants), também chamada de ES6. + +Por conta da função consistir em apenas uma única expressão, usamos uma notação abreviada, que representa este trecho de código: + +```js +const App = () => { + return ( +
    +

    Olá, mundo!

    +
    + ) +} +``` + +Em outras palavras, a função retorna o valor da expressão. + +A função que define o componente pode conter qualquer tipo de código JavaScript. Modifique seu componente da seguinte maneira: + +```js +const App = () => { + console.log('Olá do componente!') + return ( +
    +

    Olá, mundo!

    +
    + ) +} + +export default App +``` + +E observe o que acontece no console do navegador: + +![console do navegador com uma seta mostrando o log com a mensagem "Hello from component"](../../images/1/30.png) + +A primeira regra do desenvolvimento web front-end: + +> Mantenha o console aberto o tempo todo. + +Vamos repetir juntos: Prometo manter o console aberto o tempo todo durante este curso e pelo resto da minha vida quando estiver desenvolvendo para a web. + +Também é possível renderizar conteúdo dinâmico dentro de um componente. + +Modifique o componente da seguinte maneira: + +```js +const App = () => { + const hoje = new Date() + const a = 10 + const b = 20 + console.log(hoje, a+b) + + return ( +
    +

    Olá, mundo! Hoje é {hoje.toString()}

    +

    + {a} mais {b} é {a + b} +

    +
    + ) +} +``` + +Qualquer código JavaScript dentro das chaves é avaliado e o resultado desta avaliação é incorporado no lugar definido no HTML produzido pelo componente. + +Note que você não deve remover a linha no final do componente: + +```js +export default App +``` + +A exportação não é mostrada na maioria dos exemplos do material do curso. Sem a exportação, o componente e a aplicação inteira desmoronam. + +Você se lembrou da sua promessa de deixar o console aberto? O que foi impresso? + +### JSX + +Parece que os componentes React estão retornando marcações HTML. No entanto, não é esse o caso. A maior parte da estrutura de componentes React é escrita usando [JSX](https://reactjs.org/docs/introducing-jsx.html) (JavaScript Syntax Extension [Extensão de Sintaxe para JavaScript]). Embora o JSX pareça com HTML, estamos lidando com uma maneira de escrever JavaScript. Por baixo dos panos, o JSX retornado por componentes React é compilado em JavaScript. + +Depois da compilação, nossa aplicação fica assim: + +```js +const App = () => { + const hoje = new Date() + const a = 10 + const b = 20 + return React.createElement( + 'div', + null, + React.createElement( + 'p', null, 'Olá, mundo! Hoje é ', hoje.toString() + ), + React.createElement( + 'p', null, a, ' mais ', b, ' é ', a + b + ) + ) +} +``` + +A compilação é gerenciada pelo [Babel](https://babeljs.io/repl/). Projetos criados com *create-react-app* são configurados para compilar automaticamente. Vamos aprender mais sobre esse tópico na [Parte 7](/ptbr/part7) deste curso. + +Também é possível escrever React como "JavaScript puro" sem usar JSX. Embora não seja recomendável. + +Na prática, o JSX é muito parecido com HTML com a diferença de que, com o JSX, é possível inserir facilmente conteúdo dinâmico escrevendo código JavaScript dentro de chaves. A ideia do JSX é bastante semelhante a muitas linguagens de modelos, como Thymeleaf usado junto ao Java Spring, que são usadas em servidores. + +JSX é "semelhante a [XML](https://developer.mozilla.org/en-US/docs/Web/XML/XML_introduction)" (Extensible Markup Language [Linguagem de Marcação Extensível]), o que significa que todas as tags precisam ser fechadas. Por exemplo, uma nova linha é um elemento vazio, que em HTML pode ser escrito da seguinte maneira: + +```html +
    +``` + +Mas ao escrever em JSX, a tag precisa ser fechada: + +```html +
    +``` + +### Múltiplos componentes + +Vamos modificar o arquivo App.js da seguinte forma (obs.: a exportação na parte inferior é omitida nestes exemplos, tanto agora quanto no futuro. Ela ainda é necessária para que o código funcione): + +```js +// highlight-start +const Hello = () => { + return ( +
    +

    Olá, mundo!

    +
    + ) +} +// highlight-end + +const App = () => { + return ( +
    +

    Olá a todos!

    + // highlight-line +
    + ) +} +``` + +Definimos um novo componente Hello e o usamos dentro do componente App. Naturalmente, um componente pode ser usado várias vezes: + +```js +const App = () => { + return ( +
    +

    Olá a todos!

    + + // highlight-start + + + // highlight-end +
    + ) +} +``` + +Escrever componentes em React é fácil, e utilizando combinação de componentes mesmo uma aplicação mais complexa pode ser mantida de forma organizada. De fato, uma das filosofias fundamentais do React é criar aplicações a partir de muitos componentes que são especializados e reutilizáveis. + +Outra forte convenção é a ideia de um componente root chamado App no topo da árvore de componentes da aplicação. No entanto, como aprenderemos na [Parte 6](/ptbr/part6), há situações em que o componente App não é exatamente a raiz (root), mas é envolto em um componente utilitário apropriado. + +### props: passando dados para componentes + +É possível passar dados para componentes usando as chamadas [props](https://reactjs.org/docs/components-and-props.html) (properties [propriedades]). + +Vamos modificar o componente Hello da seguinte maneira: + +```js +const Hello = (props) => { // highlight-line + return ( +
    +

    Olá {props.nome}

    // highlight-line +
    + ) +} +``` + +Agora, a função que define o componente tem um parâmetro "props". Como argumento, o parâmetro recebe um objeto, que possui campos correspondentes a todas as "props" que o usuário do componente define. + +As props são definidas da seguinte forma: + +```js +const App = () => { + return ( +
    +

    Olá a todos!

    + // highlight-line + // highlight-line +
    + ) +} +``` + +É possível haver um número arbitrário de props e seus valores podem ser strings "hard-coded" (dados ou estruturas em um código que não podem ser alterados sem modificar manualmente o programa) ou resultados de expressões JavaScript. Se o valor da prop é obtido usando JavaScript, ele deve ser envolvido em chaves. + +Vamos modificar o código para que o componente Hello use duas props: + +```js +const Hello = (props) => { + console.log(props) // highlight-line + return ( +
    +

    + Olá {props.nome}, você tem {props.idade} anos // highlight-line +

    +
    + ) +} + +const App = () => { + const nome = 'Peter' // highlight-line + const idade = 10 // highlight-line + + return ( +
    +

    Olá a todos!

    + // highlight-line + // highlight-line +
    + ) +} +``` + +As props enviadas pelo componente App são os valores das variáveis, isto é, o resultado da avaliação da expressão de soma e de uma string comum. + +O componente Hello também registra o valor do objeto props no console. + +Eu espero genuinamente que seu console esteja aberto. Se não estiver, lembre-se do que você prometeu: + +> Eu prometo manter o console aberto o tempo todo durante este curso e pelo resto da minha vida quando estiver desenvolvendo para a web. + +Desenvolvimento de software é difícil. Fica ainda mais difícil se não estiver usando todas as ferramentas possíveis, como o console web e a impressão de depuração com _console.log_. Profissionais usam ambos o tempo todo, e não há razão alguma para que um iniciante não adote o uso desses métodos maravilhosos que tornam a vida muito mais fácil. + +### Alguns lembretes + +O React foi configurado para gerar mensagens de erro bastante claras. Mesmo assim, você deve, pelo menos no começo, avançar com **passos bem curtos** e tendo certeza de que cada mudança funciona como desejado. + +**O console deve estar sempre aberto**. Se o navegador relatar erros, não é aconselhável continuar escrevendo mais código, esperando por milagres. Em vez disso, você deve tentar entender a causa do erro e, por exemplo, voltar ao estado anterior de funcionamento: + +![captura de tela de um erro prop indefinido](../../images/1/2a.png) + +Como já mencionamos, é possível e recompensador escrever comandos console.log() (que imprimem no console) ao programar em React. + +Além disso, tenha em mente que **os nomes de componentes React devem estar com a primeira letra em maiúsculo**. Se você tentar definir um componente da seguinte forma: + +```js +const footer = () => { + return ( +
    + Aplicação de Saudações criado por mluukkai +
    + ) +} +``` + +E usá-lo desta forma: + +```js +const App = () => { + return ( +
    +

    Olá a todos!

    + +
    // highlight-line +
    + ) +} +``` + +A página não vai exibir o conteúdo definido dentro do componente Footer e, em vez disso, React cria apenas um elemento [footer](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/footer) vazio, ou seja, o elemento HTML incorporado em vez do elemento React personalizado com o mesmo nome. Se você mudar a primeira letra do nome do componente para maiúsculo, o React cria um elemento div definido no componente Footer, que é renderizado na página. + +Note que o conteúdo de um componente React (normalmente) precisa conter **um elemento raiz (root)**. Se, por exemplo, tentarmos definir o componente App sem o elemento div externo: + +```js +const App = () => { + return ( +

    Olá a todos!

    + +
    + ) +} +``` + +o resultado é uma mensagem de erro. + +![captura de tela de múltiplos erros de elementos-raiz](../../images/1/3c.png) + +Usar um elemento raiz não é a única opção viável. Um array (vetor) de componentes também é uma solução válida: + +```js +const App = () => { + return [ +

    Olá a todos!

    , + , +
    + ] +} +``` + +Porém, definir o componente raiz da aplicação não é algo particularmente sábio a se fazer, e deixa o código com uma aparência um pouco feia. + +Por conta do elemento raiz ser compulsório, temos elementos div "extras" na árvore DOM. Isso pode ser evitado usando [fragmentos](https://reactjs.org/docs/fragments.html#short-syntax), ou seja, envolvendo os elementos a serem retornados pelo componente com um elemento vazio: + +```js +const App = () => { + const nome = 'Peter' + const idade = 10 + + return ( + <> +

    Olá a todos!

    + + +
    + + ) +} +``` + +Agora, a aplicação compila com sucesso, e a DOM gerada pelo React não contém mais o elemento "div" extra. + +### Não renderize objetos + +Considere uma aplicação que imprime os nomes e idades de nossos amigos na tela: + +```js +const App = () => { + const amigos = [ + { nome: 'Peter', idade: 4 }, + { nome: 'Maya', idade: 10 }, + ] + + return ( +
    +

    {amigos[0]}

    +

    {amigos[1]}

    +
    + ) +} + +export default App +``` + +No entanto, nada aparece na tela. Venho tentando encontrar o problema no código há 15 minutos, mas não consigo descobrir onde o problema poderia estar. + +Eu finalmente lembro da promessa que fizemos: + +> Eu prometo manter o console aberto o tempo todo durante este curso e e pelo resto da minha vida quando estiver desenvolvendo para a web. + +O console grita em vermelho: + +![](../../images/1/34new.png) + +O núcleo do problema é que: Objetos não são válidos como elementos-filho React, isto é, a aplicação tenta renderizar objetos e falha novamente. + +O código tenta renderizar as informações de um amigo da seguinte maneira: + +```js +

    {amigos[0]}

    +``` + +E isso causa um problema, porque o item a ser renderizado dentro das chaves é um objeto. + +```js +{ nome: 'Peter', idade: 4 } +``` + +Em React, elementos individuais renderizadas dentro das chaves devem ser valores primitivos, como números ou strings. + +A solução é a seguinte: + +```js +const App = () => { + const amigos = [ + { nome: 'Peter', idade: 4 }, + { nome: 'Maya', idade: 10 }, + ] + + return ( +
    +

    {amigos[0].nome} {amigos[0].idade}

    +

    {amigos[1].nome} {amigos[1].idade}

    +
    + ) +} + +export default App +``` + +O nome do amigo é renderizado separadamente dentro das chaves: + +```js +{amigos[0].nome} +``` + +Também a idade: + +```js +{amigos[0].idade} +``` + +Após corrigir o erro, limpe as mensagens de erro do console clicando em 🚫 e, em seguida, recarregue o conteúdo da página e garanta que não haja mensagens de erro exibidas. + +Uma adição ao lembrete anterior: React também permite que arrays sejam renderizados se conterem valores elegíveis para renderização (como números ou strings). Então, o seguinte programa funcionaria, embora o resultado não seja o que desejamos: + +```js +const App = () => { + const amigos = [ 'Peter', 'Maya'] + + return ( +
    +

    {amigos}

    +
    + ) +} +``` + +Nesta parte, nem vale a pena tentar usar a renderização direta de tabelas. Voltaremos a discutir isso na próxima parte. + +
    + +
    +

    Exercícios 1.1 a 1.2

    + +Os exercícios são enviados via GitHub, marcando os exercícios como concluídos na guia "my submissions" do [sistema de envio de exercícios](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +Os exercícios são enviados **uma parte de cada vez**. Quando você tiver enviado os exercícios para uma parte, não poderá mais enviar nenhum exercício não feito para essa parte. + +Note que nesta parte há [mais exercícios](/ptbr/part1/um_estado_mais_complexo_e_depuracao_de_aplicacoes_react#exercicios-1-6-a-1-14) além dos encontrados abaixo. Não envie seus exercícios até que você tenha concluído todos os exercícios desta parte. + +É possível colocar todos os exercícios em um mesmo repositório ou usar múltiplos repositórios diferentes. Se você enviar exercícios de diferentes partes para o mesmo repositório, dê nomes apropriados às suas pastas. + +Uma boa maneira de nomear as pastas no seu repositório de envio é a seguinte: + +```text +part0 +part1 + courseinfo + unicafe + anecdotes +part2 + phonebook + countries +``` + +Veja este [exemplo de repositório de submissão](https://github.com/fullstack-hy2020/example-submission-repository)! + +Para cada parte do curso, há um diretório, que se ramifica em diretórios contendo uma série de exercícios, como "unicafe" para a Parte 1. + +Para cada aplicação web em uma série de exercícios, é recomendado que você envie todos os arquivos relacionados a essa aplicação, exceto o diretório node_modules. + +**Obs.:** o conteúdo dos exercícios foram deixados no idioma original da tradução (inglês) por questões de conveniência, visto a revisão que os mantenedores do curso devem fazer no código enviado ao sistema de avaliação da Universidade de Helsinque. Desta forma, escreva suas aplicações utilizando os mesmos termos usados nas variáveis, componentes, etc que estão em inglês. + +

    1.1: course information — 1º passo

    + +A aplicação que começaremos a trabalhar neste exercício será desenvolvida em alguns dos exercícios seguintes. Neste e em outros conjuntos de exercícios futuros neste curso, é suficiente enviar apenas o estado final da aplicação. Se desejar, também pode criar um commit para cada exercício da série, mas é algo totalmente opcional. + +Use "create-react-app" para inicializar uma nova aplicação. Modifique o arquivo index.js para que fique desta forma: + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' + +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +E App.js para: + +```js +const App = () => { + const course = 'Desenvolvimento de aplicação Half Stack' + const part1 = 'Fundamentos da biblioteca React' + const exercises1 = 10 + const part2 = 'Usando props para passar dados' + const exercises2 = 7 + const part3 = 'Estado de um componente' + const exercises3 = 14 + + return ( +
    +

    {course}

    +

    + {part1} {exercises1} +

    +

    + {part2} {exercises2} +

    +

    + {part3} {exercises3} +

    +

    Number of exercises {exercises1 + exercises2 + exercises3}

    +
    + ) +} + +export default App +``` + +E remova arquivos extras (App.css, App.test.js, index.css, logo.svg, setupTests.js, reportWebVitals.js). + +Infelizmente, toda a aplicação está no mesmo componente. Refatore o código para que consista em três novos componentes: Header, Content e Total. Todos os dados ainda residem no componente App, que passa os dados necessários a cada componente usando props. Header cuida da renderização do nome do curso, Content renderiza as partes e o número de exercícios e Total renderiza o número total de exercícios. + +Defina os novos componentes no arquivo App.js. + +O corpo do componente App ficará desta forma: + +```js +const App = () => { + // definições "const" + + return ( +
    +
    + + +
    + ) +} +``` + +**ATENÇÃO I** Não tente programar todos os componentes simultaneamente, pois isso quase que certamente fará com que a aplicação desmorone. Avance em pequenos passos. Programe primeiro o componente Header, por exemplo, e somente quando estiver funcionando corretamente, prossiga para o próximo componente. + +O progresso cuidadoso e que avança em pequenos passos pode parecer lento, porém, na verdade, é de longe o caminho mais rápido para o progresso. O famoso desenvolvedor de software Robert "Uncle Bob" Martin afirmou: + +> "A única maneira de ir rápido é ir bem." + +Ou seja, de acordo com Martin, o progresso cuidadoso, passo a passo, é, ainda, a única maneira de ir rápido. + +**ATENÇÃO II** "create-react-app" faz automaticamente com que o projeto se torne um repositório git, a menos que a aplicação seja criada dentro de um repositório já existente. É muito provável que você **não queira** que o projeto se torne um repositório, então execute o comando _rm -rf .git_ na raiz do projeto. + +

    1.2: course information — 2º passo

    + +Refatore o componente Content de tal forma que ele não renderize os nomes das partes ou seus números de exercícios por si mesmo. Em vez disso, somente renderiza três componentes Part, cada um dos quais renderiza o nome e o número de exercícios de uma parte. + +```js +const Content = ... { + return ( +
    + + + +
    + ) +} +``` + +No momento, nossa aplicação passa informações de uma maneira bastante primitiva, já que está baseada em variáveis individuais. Vamos corrigir isso na [Parte 2](/ptbr/part2). + +
    diff --git a/src/content/1/ptbr/part1b.md b/src/content/1/ptbr/part1b.md new file mode 100644 index 00000000000..4409ab31f73 --- /dev/null +++ b/src/content/1/ptbr/part1b.md @@ -0,0 +1,540 @@ +--- +mainImage: ../../../images/part-1.svg +part: 1 +letter: b +lang: ptbr +--- + +
    + +Teremos, durante o curso, o objetivo e a necessidade de aprender certa quantidade de JavaScript, além de desenvolvimento web. + +JavaScript evoluiu rapidamente nos últimos anos e, neste curso, usamos as funcionalidades das versões mais recentes. O nome oficial do padrão de JavaScript é [ECMAScript](https://en.wikipedia.org/wiki/ECMAScript). Atualmente, a versão mais recente é a lançada em junho de 2024 com o nome de [ECMAScript®2024](https://www.ecma-international.org/ecma-262/), também conhecido como ES15. + +Os navegadores ainda não suportam todas as funcionalidades mais recentes de JavaScript e devido a esse fato, muito código executado em navegadores é transpilado de uma versão mais recente de JavaScript para uma versão mais antiga e compatível. + +Hoje em dia, a maneira mais popular de fazer a transpilação é usando o transcompilador [Babel](https://babeljs.io/). A transpilação é configurada automaticamente em aplicações React criadas com create-react-app. Vamos olhar mais de perto a configuração de transpilação na [Parte 7](/ptbr/part7) deste curso. + +[Node.js](https://nodejs.org/en/) é um ambiente de tempo de execução JavaScript baseado no motor JavaScript [Chrome V8](https://developers.google.com/v8/) da Google e funciona praticamente em qualquer lugar, desde servidores até telefones celulares. Vamos praticar a escrita de código JavaScript usando Node. As versões mais recentes do Node são compatíveis com as versões mais recentes de JavaScript, então o código não precisa ser transpilado. + +O código é escrito em arquivos com extensão .js que são executados ao emitir o comando node nome\_do\_arquivo.js + +Também é possível escrever código JavaScript na console do Node.js, que pode ser aberta digitando "node" na linha de comando, bem como na aba Console nas Ferramentas do Desenvolvedor do navegador. [As revisões mais recentes do Chrome lidam bem com as novas funcionalidades de JavaScript](https://compat-table.github.io/compat-table/es2016plus/) sem precisar transpilar o código. Alternativamente, você pode usar uma ferramenta como [JS Bin](https://jsbin.com/?js,console). + +JavaScript lembra mais ou menos o Java, tanto no nome quanto na sintaxe. Porém, quando se trata do mecanismo central da linguagem, eles não poderiam ser mais diferentes. Da perspectiva de alguém que vem de um background em Java, a forma como JavaScript se comporta pode parecer um pouco estranho, principalmente se não for feito algum esforço para entender suas características. + +Em determinados círculos, tem se popularizado tentar "simular" funcionalidades e padrões de _design_ de Java em JavaScript. Não recomendamos fazer isso, já que as linguagens e seus respectivos ecossistemas são, no final das contas, muito diferentes. + +### Variáveis + +Em JavaScript, existem algumas maneiras de definir variáveis: + +```js +const x = 1 +let y = 5 + +console.log(x, y) // 1 5 são impressos +y += 10 +console.log(x, y) // 1 15 são impressos +y = 'algum texto' +console.log(x, y) // 1 algum texto são impressos +x = 4 // causará um erro +``` + +[const](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const) não define uma variável, mas uma constante no qual o valor não pode mais ser alterado. Por outro lado, [let](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let) define uma variável padrão. + +No exemplo acima, também vemos que o tipo de dados da variável pode mudar durante a execução. No início, _y_ armazena um inteiro; no final, armazena uma string. + +Também é possível definir variáveis em JavaScript usando a palavra-chave [var](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/var), que foi, por muito tempo, a única maneira de definir variáveis. const e let foram adicionadas recentemente na versão ES6. Em situações específicas, var funciona de maneira diferente em comparação com as definições de variáveis na maioria das linguagens. Visite [JavaScript Variables - Should You Use let, var or const? on Medium](https://medium.com/craft-academy/javascript-variables-should-you-use-let-var-or-const-394f7645c88f) ou [Keyword: var vs. let on JS Tips](http://www.jstips.co/en/javascript/keyword-var-vs-let/) para mais informações. Durante este curso, o uso de var não é recomendado, devendo-se privilegiar const e let! +Você pode ver mais sobre este assunto no YouTube, por exemplo, [var, let and const - ES6 JavaScript Features](https://youtu.be/sjyJBL5fkp8) + +### Arrays + +Um [array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array) e alguns exemplos de seu uso: + +```js +const t = [1, -1, 3] + +t.push(5) + +console.log(t.length) // 4 é impresso +console.log(t[1]) // -1 é impresso + +t.forEach(value => { + console.log(value) // os números 1, -1, 3, 5 são impressos, cada um em sua própria linha +}) +``` + +O importante neste exemplo é o fato de que o conteúdo do array pode ser modificado mesmo que seja definido como uma _const_. Por conta do array ser um objeto, a variável sempre aponta para o mesmo objeto. No entanto, o conteúdo do array muda à medida que novos itens são adicionados a ele. + +Uma forma de iterar através dos itens do array é usando _forEach_, como visto no exemplo. _forEach_ recebe uma função definida usando a sintaxe de seta como parâmetro. + +```js +value => { + console.log(value) +} +``` + +forEach chama a função para cada um dos itens no array, sempre passando o item individual como argumento. A função como argumento de forEach também pode receber [outros argumentos](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach). + +No exemplo anterior, um novo item foi adicionado ao array usando o método [push](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push). Quando se usa React, técnicas de programação funcional são comumente usadas. Uma característica do paradigma de programação funcional é o uso de estruturas de dados [imutáveis](https://en.wikipedia.org/wiki/Immutable_object). No código React, é preferível usar o método [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat), que não adiciona o item ao array, mas cria um array novo no qual o conteúdo do antigo array e o novo item são ambos incluídos. + +```js +const t = [1, -1, 3] + +const t2 = t.concat(5) + +console.log(t) // [1, -1, 3] é impresso +console.log(t2) // [1, -1, 3, 5] é impresso +``` + +A chamada de método _t.concat(5)_ não adiciona um novo item ao array antigo, mas retorna um array novo que, além de conter os itens do array antigo, também contém o novo item. + +Há muitos métodos úteis definidos para arrays. Vamos dar uma olhada em um pequeno exemplo de uso do método [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map). + +```js +const t = [1, 2, 3] + +const m1 = t.map(valor => valor * 2) +console.log(m1) // [2, 4, 6] é impresso +``` + +Com base no array antigo, o map cria um array novo, para o qual a função dada como parâmetro é usada para criar os itens. No caso deste exemplo, o valor original é multiplicado por dois. + +O map também pode transformar o array em algo completamente diferente: + +```js +const m2 = t.map(valor => '
  • ' + valor + '
  • ') +console.log(m2) +// [ '
  • 1
  • ', '
  • 2
  • ', '
  • 3
  • ' ] é impresso +``` + +Aqui, um array preenchido com valores inteiros é transformado em um array contendo strings de HTML usando o método map. Na [parte 2](/ptbr/part2) deste curso, veremos que o map é usado com frequência em React. + +Itens individuais de um array são fáceis de atribuir a variáveis com a ajuda da [atribuição via desestruturação](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) (_destructuring assignment_). + +```js +const t = [1, 2, 3, 4, 5] + +const [primeiro, segundo, ...resto] = t + +console.log(primeiro, segundo) // 1 2 é impresso +console.log(resto) // [3, 4, 5] é impresso +``` + +Graças à atribuição, as variáveis _primeiro_ e _segundo_ receberão os dois primeiros inteiros do array como seus valores. Os inteiros restantes são "coletados" em um array próprio que é então atribuído à variável _resto_. + +### Objetos + +Existem algumas formas diferentes de se definir objetos em JavaScript. Um método muito comum é usar [objetos literais](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Grammar_and_types#Object_literals) (_object literals_), que ocorre listando suas propriedades dentro de chaves: + +```js +const objeto1 = { + nome: 'Arto Hellas', + idade: 35, + educacao: 'PhD', +} + +const objeto2 = { + nome: 'Desenvolvimento de aplicações web Full Stack', + nivel: 'Estudos intermediários', + tamanho: 5, +} + +const objeto3 = { + nome: { + primeiro: 'Dan', + ultimo: 'Abramov', + }, + notas: [2, 3, 5, 3], + departamento: 'Universidade Stanford', +} +``` + +Os valores das propriedades podem ser de qualquer tipo, como inteiros, strings, arrays, objetos... + +As propriedades de um objeto são referenciadas usando a notação de "ponto", ou usando colchetes: + +```js +console.log(objeto1.nome) // Arto Hellas é impresso +const nomePropriedade = 'idade' +console.log(objeto1[nomePropriedade]) // 35 é impresso +``` + +Você também pode adicionar propriedades a um objeto em tempo de execução usando a notação de ponto ou colchetes: + +```js +objeto1.endereco = 'Helsinque' // *endereço +objeto1['numero secreto'] = 12341 +``` + +A última adição representada acima tem que ser feita usando colchetes porque, quando se usa a notação de ponto, numero secreto não é um nome de propriedade válido devido ao caractere de espaço separando as duas palavras. + +Naturalmente, os objetos em JavaScript também podem ter métodos. No entanto, durante este curso, não precisaremos definir objetos com métodos próprios. É por isso que eles só são discutidos rapidamente durante o curso. + +Objetos também podem ser definidos usando funções construtoras, o que resulta em um mecanismo semelhante a muitas outras linguagens de programação, como Java. Apesar desta semelhança, o JavaScript não tem classes tal qual outras linguagens de programação orientadas a objetos. No entanto, a partir da versão ES6, foi adicionada a sintaxe para classes, o que em alguns casos ajuda a estruturar classes orientadas a objetos. + +### Funções + +Já nos familiarizamos com a definição de _arrow functions_ (funções de seta). O processo completo, sem atalhos, para definir uma _arrow function_ é o seguinte: + +```js +const soma = (p1, p2) => { + console.log(p1) + console.log(p2) + return p1 + p2 +} +``` + +E a função é chamada: + +```js +const resultado = soma(1, 5) +console.log(resultado) +``` + +Se houver apenas um parâmetro, podemos excluir os parênteses da definição: + +```js +const quadrado = p => { + console.log(p) + return p * p +} +``` + +Se a função contiver apenas uma expressão, então as chaves não são necessárias. Neste caso, a função retorna apenas o resultado de sua única expressão. Agora, se removermos a impressão do console, podemos encurtar ainda mais a definição da função: + +```js +const quadrado = p => p * p +``` + +Este formato é particularmente útil ao manipular arrays, como quando usamos o método "map": + +```js +const t = [1, 2, 3] +const tAoQuadrado = t.map(p => p * p) +// tAoQuadrado agora é [1, 4, 9] +``` + +A funcionalidade da _arrow function_ foi adicionada ao JavaScript há apenas alguns anos, com a versão [ES6](https://rse.github.io/es6-features/). Antes disso, a única maneira de definir funções era usando a palavra-chave _function_. + +Existem duas maneiras de se referenciar uma função; uma é atribuir um nome em uma [declaração de função](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function) (_function declaration_). + +```js +function produto(a, b) { + return a * b +} + +const resultado = produto(2, 6) +// resultado agora é 12 +``` + +Outra maneira de definir uma função é usando uma [expressão de função](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/function) (_function expression_). Neste caso, não é necessário atribuir um nome à função, e a definição pode residir dentro do restante do código: + +```js +const media = function(a, b) { // *média + return (a + b) / 2 +} + +const resultado = media(2, 5) +// resultado agora é 3.5 +``` + +Durante este curso, todas as funções serão definidas usando a sintaxe de seta. + +
    + +
    + +

    Exercícios 1.3 a 1.5

    + +Vamos continuar construindo a aplicação que começamos a trabalhar nas exercícios anteriores. Você pode escrever o código no mesmo projeto, pois estamos interessados apenas no estado final da aplicação. + +**DICA PRO:** você pode ter problemas com a estrutura das props que os componentes recebem. Uma boa maneira de tornar as coisas mais claras é imprimindo as props no console, como por exemplo: + +```js +const Header = (props) => { + console.log(props) // highlight-line + return

    {props.course}

    +} +``` + +Se e quando você encontrar a mensagem de erro: + +> Objetos não são válidos como elementos-filho React. + +... tenha em mente o explicado [aqui](/ptbr/part1/introducao_a_biblioteca_react#nao-renderize-objetos). + +**Obs.:** o conteúdo dos exercícios foram deixados no idioma original da tradução (inglês) por questões de conveniência, visto a revisão que os mantenedores do curso devem fazer no código enviado ao sistema de avaliação da Universidade de Helsinque. Desta forma, escreva suas aplicações utilizando os mesmos termos usados nas variáveis, componentes, etc que estão em inglês. + +

    1.3: course information — 3º passo

    + +Vamos avançar para o uso de objetos em nossa aplicação. Modifique as definições de variáveis do componente App da forma a seguir e também refatore a aplicação para que continue funcionando: + +```js +const App = () => { + const course = 'Desenvolvimento de aplicação Half Stack' + const part1 = { + name: 'Fundamentos da biblioteca React', + exercises: 10 + } + const part2 = { + name: 'Usando props para passar dados', + exercises: 7 + } + const part3 = { + name: 'Estado de um componente', + exercises: 14 + } + + return ( +
    + ... +
    + ) +} +``` + +

    1.4: course information — 4º passo

    + +Agora, coloque os objetos em um array. Modifique as definições de variáveis do componente App da seguinte maneira e modifique igualmente as outras partes da aplicação: + +```js +const App = () => { + const course = 'Desenvolvimento de aplicação Half Stack' + const parts = [ + { + name: 'Fundamentos da biblioteca React', + exercises: 10 + }, + { + name: 'Usando props para passar dados', + exercises: 7 + }, + { + name: 'Estado de um componente', + exercises: 14 + } + ] + + + return ( +
    + ... +
    + ) +} +``` + +**Obs.:** Neste ponto, presume-se que há sempre três itens, então não é necessário percorrer os arrays usando _loops_. Voltaremos ao tema de renderização de componentes com base em itens de arrays em uma abordagem minuciosa na [próxima parte do curso](../part2). + +De qualquer forma, não passe objetos diferentes como propriedades separadas do componente App para os componentes Content e Total. Em vez disso, passe-os diretamente como um array: + +```js +const App = () => { + // definições "const" + + return ( +
    +
    + + +
    + ) +} +``` + +

    1.5: course information — 5º passo

    + +Vamos dar um passo a frente com as mudanças. Transforme a constante "course" (curso) e suas "parts" (partes) em um único objeto JavaScript. Corrija tudo que venha a quebrar. + +```js +const App = () => { + const course = { + name: 'Desenvolvimento de aplicação Half Stack', + parts: [ + { + name: 'Fundamentos da biblioteca React', + exercises: 10 + }, + { + name: 'Usando props para passar dados', + exercises: 7 + }, + { + name: 'Estado de um componente', + exercises: 14 + } + ] + } + + return ( +
    + ... +
    + ) +} +``` + +
    + +
    + +### Métodos de objetos e "this" + +Como este curso usa uma versão de React que contém React Hooks, não é necessário definir objetos com métodos. **O conteúdo deste capítulo não é relevante para o curso**, mas certamente é bom conhecer. Em particular, ao usar versões antigas de React, é necessário compreender os tópicos deste capítulo. + +_Arrow functions_ e funções definidas usando a palavra-chave _function_ variam substancialmente em relação ao comportamento da palavra-chave [this](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this), que se refere ao próprio objeto. + +Podemos atribuir métodos a um objeto definindo propriedades que são funções: + +```js +const arto = { + nome: 'Arto Hellas', + idade: 35, + educacao: 'PhD', // *educação + // highlight-start + cumprimentar: function() { // *saudação + console.log('olá, meu nome é ' + this.nome) + }, + // highlight-end +} + +arto.cumprimentar() // "olá, meu nome é Arto Hellas" é impresso +``` + +Métodos podem ser atribuídos a objetos mesmo após a criação do objeto: + +```js +const arto = { + nome: 'Arto Hellas', + idade: 35, + educacao: 'PhD', + cumprimentar: function() { + console.log('olá, meu nome é ' + this.nome) + }, +} + +// highlight-start +arto.envelhecer = function() { + this.idade += 1 +} +// highlight-end + +console.log(arto.idade) // 35 é impresso +arto.envelhecer() +console.log(arto.idade) // 36 é impresso +``` + +Vamos modificar um pouco o objeto: + +```js +const arto = { + nome: 'Arto Hellas', + idade: 35, + educacao: 'PhD', + cumprimentar: function() { + console.log('olá, meu nome é ' + this.nome) + }, + // highlight-start + fazerAdicao: function(a, b) { // *fazerAdição + console.log(a + b) + }, + // highlight-end +} + +arto.fazerAdicao(1, 4) // 5 é impresso + +const referenciaParaAdicao = arto.fazerAdicao +referenciaParaAdicao(10, 15) // 25 é impresso +``` + +Agora, o objeto tem o método _fazerAdicao_, que calcula a soma dos números dados a ele como parâmetros. O método é chamado da maneira tradicional, usando o objeto arto.fazerAdicao(1, 4) ou armazenando uma referência ao método em uma variável e chamando o método através da variável: referenciaParaAdicao(10, 15). + +Se tentarmos fazer o mesmo com o método _cumprimentar_, deparamo-nos com um problema: + +```js +arto.cumprimentar() // "olá, meu nome é Arto Hellas" é impresso + +const referenciaParaCumprimentar = arto.cumprimentar +referenciaParaCumprimentar() // "olá, meu nome é undefined" é impresso +``` + +Ao chamar o método através de uma referência, o método perde o conhecimento do que era o _this_ original. Ao contrário de outras linguagens, em JavaScript, o valor de [this](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this) é definido com base em como o método é chamado. Ao chamar o método através de uma referência, o valor de _this_ se torna o chamado [objeto global](https://developer.mozilla.org/en-US/docs/Glossary/Global_object) (_global object_) e o resultado final sai geralmente diferente do que o desenvolvedor originalmente pretendeu. + +Perder o rastro do _this_ ao escrever código JavaScript traz alguns problemas eventuais. Algumas situações frequentemente surgem onde React ou o Node (ou mais especificamente o motor JavaScript do navegador) precisa chamar algum método em um objeto que o desenvolvedor tenha definido. No entanto, neste curso, evitamos esses problemas usando o JavaScript "sem this". + +Uma situação que leva ao "desaparecimento" do _this_ ocorre quando definimos um tempo limite para chamar a função _cumprimentar_ no objeto _arto_, usando a função [setTimeout](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout). + +```js +const arto = { + nome: 'Arto Hellas', + cumprimentar: function() { + console.log('olá, meu nome é ' + this.nome) + }, +} + +setTimeout(arto.cumprimentar, 1000) // highlight-line +``` + +Como mencionado, o valor de _this_ em JavaScript é definido com base na forma como o método é chamado. Quando o setTimeout está chamando o método, é o motor JavaScript que realmente chama o método e, nesse ponto, _this_ se refere ao objeto global. + +Existem vários mecanismos pelos quais o _this_ original pode ser preservado. Um desses é usando um método chamado [bind](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind) (significa amarrar ou atar): + +```js +setTimeout(arto.cumprimentar.bind(arto), 1000) +``` + +Chamar arto.cumprimentar.bind(arto) cria uma nova função onde _this_ é obrigado a apontar para Arto, independentemente de onde e como o método está sendo chamado. + +Usando [Arrow functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions) é possível resolver alguns dos problemas relacionados ao _this_. No entanto, eles não devem ser usados como métodos para objetos, pois o _this_ não funciona de forma alguma. Mais tarde, voltaremos a discutir o comportamento da palavra-chave _this_ em relação às _arrow functions_. + +Se deseja compreender de fato como _this_ funciona em JavaScript, a Internet está cheia de material sobre o assunto como, por exemplo, a série _screencast_ [Understand JavaScript's this Keyword in Depth](https://egghead.io/courses/understand-javascript-s-this-keyword-in-depth) por [egghead.io](https://egghead.io), que é extremamente recomendada! + +### Classes + +Como mencionado anteriormente, não há um "mecanismo" de classes em JavaScript como os de linguagens de programação orientadas a objetos. No entanto, há funcionalidades para tornar possível a "simulação" de [classes](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes) orientadas a objetos. + +Vamos dar uma olhada na sintaxe para classes que foi introduzida ao JavaScript com o ES6, o que simplifica substancialmente a definição de classes (ou estruturas semelhantes a classes) em JavaScript. + +No exemplo a seguir, definimos uma "classe" chamada Pessoa e dois objetos Pessoa: + +```js +class Pessoa { + constructor(nome, idade) { + this.nome = nome + this.idade = idade + } + cumprimentar() { + console.log('olá, meu nome é ' + this.nome) + } +} + +const adam = new Pessoa('Adam Ondra', 29) +adam.cumprimentar() + +const janja = new Pessoa('Janja Garnbret', 23) +janja.cumprimentar() +``` + +Quanto à sintaxe, as classes e os objetos criados a partir delas são muito semelhantes às classes e objetos Java. Seu comportamento também é bastante semelhante aos objetos Java. Mas em seu mecanismo interno, ainda são objetos baseados na [herança prototipal](https://developer.mozilla.org/en/docs/Learn/JavaScript/Objects/Inheritance) de JavaScript. O tipo de ambos os objetos é, na verdade, _Object_, uma vez que o JavaScript essencialmente define apenas os tipos [Boolean, Null, Undefined, Number, String, Symbol, BigInt e Object](https://developer.mozilla.org/en/docs/Web/JavaScript/Data_structures). + +A inserção da sintaxe para classes foi uma adição controversa. Confira [Not Awesome: ES6 Classes](https://github.com/petsel/not-awesome-es6-classes) ou [Is “Class” In ES6 The New “Bad” Part? on Medium](https://medium.com/@rajaraodv/is-class-in-es6-the-new-bad-part-6c4e6fe1ee65) para mais detalhes. + +A sintaxe para classe ES6 é muito utilizada no "antigo" React e também no Node.js, portanto, é benéfico ter compreensão dela mesmo neste curso. Entretanto, como estaremos usando a nova funcionalidade [Hooks](https://reactjs.org/docs/hooks-intro.html) do React ao longo deste curso, não teremos uso concreto da sintaxe para classes de JavaScript. + +### Materiais de JavaScript + +Existem guias bons e ruins para JavaScript na Internet. A maioria dos links nesta página relacionados às funcionalidades de JavaScript referem-se ao [Guia JavaScript da Mozilla](https://developer.mozilla.org/en/docs/Web/JavaScript). + +É recomendado ler imediatamente o artigo [A re-introduction to JavaScript (JS tutorial)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Language_Overview) no site da Mozilla. + +Se deseja conhecer profundamente JavaScript, há uma ótima série de livros gratuitos na Internet chamada [You-Dont-Know-JS](https://github.com/getify/You-Dont-Know-JS). + +Outra ótima fonte para aprender JavaScript é [javascript.info](https://javascript.info). + +O extremamente cativante e gratuito [Eloquent JavaScript](https://eloquentjavascript.net) te leva rapidamente dos conceitos básicos à construção de aplicações muito interessantes. É uma mistura de teoria, projetos e exercícios, e cobre tanto a teoria geral de programação quanto a linguagem JavaScript. + +[egghead.io](https://egghead.io) possui muitos _screencasts_ de qualidade sobre JavaScript, React e outros tópicos interessantes. Infelizmente, alguns dos materiais só são acessíveis na versão paga. + +
    diff --git a/src/content/1/ptbr/part1c.md b/src/content/1/ptbr/part1c.md new file mode 100644 index 00000000000..f30d4b93499 --- /dev/null +++ b/src/content/1/ptbr/part1c.md @@ -0,0 +1,742 @@ +--- +mainImage: ../../../images/part-1.svg +part: 1 +letter: c +lang: ptbr +--- + +
    + +Vamos voltar a trabalhar com React. + +Comecemos com um novo exemplo: + +```js +const Hello = (props) => { + return ( +
    +

    + Olá {props.nome}, você tem {props.idade} anos. +

    +
    + ) +} + +const App = () => { + const nome = 'Peter' + const idade = 10 + + return ( +
    +

    Olá a todos!

    + + +
    + ) +} +``` + +### Funções auxiliares de componentes + +Vamos expandir nosso componente Hello para que ele adivinhe o ano de nascimento da pessoa que está sendo saudada: + +```js +const Hello = (props) => { + // highlight-start + const anoDeNascimento = () => { + const anoDeHoje = new Date().getFullYear() + return anoDeHoje - props.idade + } + // highlight-end + + return ( +
    +

    + Olá {props.nome}, você tem {props.idade} anos. +

    +

    Então, você nasceu provavelmente em {anoDeNascimento()}.

    // highlight-line +
    + ) +} +``` + +A lógica para achar o ano de nascimento é separada em uma função que é chamada quando o componente é renderizado. + +A idade da pessoa não precisa ser passada como parâmetro para a função, já que ela pode acessar diretamente todas as propriedades que são passadas para o componente. + +Se examinarmos nosso código atual de perto, vamos perceber que a função "auxiliadora" é definida dentro de outra função que define o comportamento de nosso componente. Em Java, definir uma função dentro de outra é algo complexo e incômodo, portanto, não é algo muito comum. Em JavaScript, entretanto, definir funções dentro de funções é uma técnica amplamente usada. + +### Desestruturação (Destructuring) + +Antes de avançarmos, vamos dar uma olhada em uma pequena, porém útil, funcionalidade da linguagem JavaScript que foi adicionada na especificação ES6, que nos permite [desestruturar](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) (_destructuring assignment_ [atribuição via desestruturação]) valores de objetos e arrays por atribuição. + +Em nosso código anterior, tivemos que referenciar os dados passados para nosso componente como _props.nome_ e _props.idade_. Dessas duas expressões, tivemos que repetir _props.idade_ duas vezes em nosso código. + +Já que props é um objeto... + +```js +props = { + nome: 'Arto Hellas', + idade: 35, +} +``` + +... podemos simplificar nosso componente atribuindo os valores das propriedades diretamente em duas variáveis _nome_ e _idade_ que podemos utilizar em nosso código: + +```js +const Hello = (props) => { + // highlight-start + const nome = props.nome + const idade = props.idade + // highlight-end + + const anoDeNascimento = () => new Date().getFullYear() - idade + + return ( +
    +

    Olá {nome}, você tem {idade} anos

    // highlight-line +

    Então, você nasceu provavelmente em {anoDeNascimento()}.

    +
    + ) +} +``` + +Note que também utilizamos a sintaxe mais compacta para as _arrow functions_ ao definir a função _anoDeNascimento_. Como mencionado anteriormente, se uma _arrow function_ consiste em uma única expressão, então o corpo da função não precisa ser escrito dentro de chaves. Nesta forma mais compacta, a função simplesmente retorna o resultado da única expressão. + +Resumindo, as duas definições de função mostradas abaixo são equivalentes: + +```js +const anoDeNascimento = () => new Date().getFullYear() - idade + +const anoDeNascimento = () => { + return new Date().getFullYear() - idade +} +``` + +A desestruturação torna a atribuição de variáveis ainda mais fácil, já que podemos usá-la para extrair e reunir os valores das propriedades de um objeto em variáveis separadas: + +```js +const Hello = (props) => { + const { nome, idade } = props // highlight-line + const anoDeNascimento = () => new Date().getFullYear() - idade + + return ( +
    +

    Olá {nome}, você tem {idade} anos.

    +

    Então, você provavelmente nasceu em {anoDeNascimento()}.

    +
    + ) +} +``` + +Se o objeto que estamos desestruturando tem os valores... + +```js +props = { + nome: 'Arto Hellas', + idade: 35, +} +``` + +... a expressão const { nome, idade } = props atribui os valores 'Arto Hellas' para _nome_ e 35 para _idade_. + +Podemos levar a desestruturação um passo adiante: + +```js +const Hello = ({ nome, idade }) => { // highlight-line + const anoDeNascimento = () => new Date().getFullYear() - idade + + return ( +
    +

    + Olá {nome}, você tem {idade} anos. +

    +

    Então, você provavelmente nasceu em {anoDeNascimento()}.

    +
    + ) +} +``` + +As props que são passadas para o componente agora são diretamente desestruturadas nas variáveis _nome_ e _idade_. + +Isso significa que, em vez de atribuir o objeto props inteiro a uma variável chamada props e, em seguida, atribuir suas propriedades às variáveis _nome_ e _idade_... + +```js +const Hello = (props) => { + const { nome, idade } = props +``` + +... nós atribuímos os valores das propriedades diretamente para variáveis ​​por meio da desestruturação do objeto props que é passado como parâmetro à função do componente: + +```js +const Hello = ({ nome, idade }) => { +``` + +### Re-renderização da página + +Até agora, todas as nossas aplicações foram escritas de tal forma que sua aparência permanece a mesma após a renderização inicial. E se quiséssemos criar um contador onde o valor aumentasse em função do tempo ou com um clique em um botão? + +Vamos começar com o seguinte. O arquivo App.js fica assim: + +```js +const App = (props) => { + const {contador} = props + return ( +
    {contador}
    + ) +} + +export default App +``` + +E o arquivo index.js fica desta forma: + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' + +import App from './App' + +let contador = 1 + +ReactDOM.createRoot(document.getElementById('root')).render( + +) +``` + +É dado ao componente "App" o valor do contador via a prop _contador_. Este componente renderiza o valor na tela. O que acontece quando o valor de _contador_ muda? Mesmo se adicionarmos o seguinte... + +```js +contador += 1 +``` + +... o componente não será re-renderizado. Podemos fazer com que o componente seja re-renderizado chamando o método _render_ uma segunda vez, por exemplo, da seguinte maneira: + +```js +let contador = 1 + +const recarregar = () => { + ReactDOM.createRoot(document.getElementById('root')).render( + + ) +} + +recarregar() +contador += 1 +recarregar() +contador += 1 +recarregar() +``` + +O comando de re-renderização foi embalado dentro da função _recarregar_ para diminuir a quantidade de código copiado e colado. + +Agora, o componente renderiza três vezes: primeiro com o valor 1; depois 2 e finalmente 3. Porém, os valores 1 e 2 são exibidos na tela por um período tão curto de tempo que não podem nem ser notados. + +Podemos implementar uma funcionalidade um pouco mais interessante re-renderizando e incrementando o contador a cada segundo usando [setInterval](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setInterval) ("definir intervalo"): + +```js +setInterval(() => { + recarregar() + contador += 1 +}, 1000) +``` + +Fazer repetidas chamadas ao método _render_ não é a forma recomendada de re-renderização de componentes. A seguir, apresentaremos uma forma melhor para fazer essa re-renderização. + +### Componente "Stateful" (ou Componente com Estado) + +Todos os nossos componentes até agora foram simples no sentido de que não continham nenhum estado que pudesse mudar durante o ciclo de vida do componente. + +Então, vamos adicionar um "estado" ao componente App com a ajuda do [state hook](https://reactjs.org/docs/hooks-state.html) (Grosso modo, "gancho de estado", isto é, são funções que permitem a você “ligar-se” aos recursos de estado (state) e ciclo de vida do React a partir de componentes funcionais) do React. + +A aplicação será alterada da seguinte forma. O código de index.js retorna para: + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' + +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +E o código de App.js muda para o seguinte: + +```js +import { useState } from 'react' // highlight-line + +const App = () => { + const [ contador, setContador ] = useState(0) // highlight-line + +// highlight-start + setTimeout( + () => setContador(contador + 1), + 1000 + ) +// highlight-end + + return ( +
    {contador}
    + ) +} + +export default App +``` + +Na primeira linha, o arquivo importa a função _useState:_ + +```js +import { useState } from 'react' +``` + +O corpo da função que define o componente começa com a chamada da função: + +```js +const [ contador, setContador ] = useState(0) +``` + +A chamada da função adiciona estado (state) ao componente e o renderiza inicializado com o valor 0 (zero). A função retorna um array que contém dois itens. Atribuímos os itens às variáveis _contador_ e _setContador_ usando a sintaxe de atribuição via desestruturação mostrada anteriormente. + +A variável _contador_ é atribuída ao valor inicial de estado, que é zero. A variável _setContador_ é atribuída a uma função que será usada para modificar o estado. + +A aplicação chama a função [setTimeout](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout) ("definir intervalo") e passa dois parâmetros: uma função para incrementar o estado do contador e um tempo de espera de 1000 milissegundos, que é o mesmo que 1 segundo: + +```js +setTimeout( + () => setContador(contador + 1), + 1000 +) +``` + +A função passada como primeiro parâmetro na função _setTimeout_ é executada 1 segundo depois de ser executada a função _setContador_ + +```js +() => setContador(contador + 1) +``` + +Quando a função de modificação de estado _setContador_ é chamada, o React re-renderiza o componente, o que significa que o corpo da função do componente é reexecutado: + +```js +() => { + const [ contador, setContador ] = useState(0) + + setTimeout( + () => setContador(contador + 1), + 1000 + ) + + return ( +
    {contador}
    + ) +} +``` + +A segunda vez que a função do componente é executada, ela chama a função _useState_ e retorna o novo valor do estado: 1. Executar o corpo da função novamente também faz uma nova chamada de função para _setTimeout_, que executa o tempo de espera de um segundo e incrementa novamente o estado do _contador_. Por conta do valor da variável _contador_ ser 1, incrementar o valor em 1 é essencialmente a mesma coisa de uma expressão que define o valor de _contador_ para 2. + +```js +() => setContador(2) +``` + +Enquanto isso, o antigo valor de _contador_ — "1" — é renderizado na tela. + +Toda vez que o _setContador_ modifica o estado, ele faz com que o componente seja renderizado novamente. O valor do estado será incrementado novamente após um segundo, e esse evento continuará a se repetir enquanto a aplicação estiver em execução. + +Se o componente não for renderizado quando você acha que deveria, ou se for renderizado no "momento errado", é possível depurar a aplicação registrando os valores das variáveis do componente no console. Se fizermos as seguintes adições ao nosso código: + +```js +const App = () => { + const [ contador, setContador ] = useState(0) + + setTimeout( + () => setContador(contador + 1), + 1000 + ) + + console.log('renderizando...', contador) // highlight-line + + return ( +
    {contador}
    + ) +} +``` + +É fácil seguir e acompanhar as chamadas feitas à função de renderização do componente App: + +![captura de tela da função de renderização nas Ferramentas de Desenvolvimento](../../images/1/4e.png) + +O console do seu navegador estava aberto? Se não estava, prometa que essa foi a última vez que precisou ser lembrado disso. + +### Gerenciamento de eventos (Event handling) + +Na [Parte 0](/ptbr/part0), falamos rapidamente sobre os gerenciadores de eventos, que são registrados para serem chamados quando eventos específicos ocorrem várias vezes. A interação de um usuário com os diferentes elementos de uma página web pode causar uma coleção de vários tipos de eventos a serem acionados. + +Vamos mudar a aplicação para que o aumento do contador aconteça quando um usuário clicar em um botão, que é implementado com o elemento [button](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button) (botão). + +Os elementos de botão suportam os chamados [mouse events](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent) (Eventos de Mouse), dos quais [click](https://developer.mozilla.org/en-US/docs/Web/Events/click) é o evento mais comum. O evento click em um botão também pode ser acionado com o teclado ou com uma tela touch screen, apesar de serem "evento de mouse". + +Em React, registra-se uma [event handler function](https://reactjs.org/docs/handling-events.html) (função gerenciadora de eventos) para o evento click desta forma: + +```js +const App = () => { + const [ contador, setContador ] = useState(0) + + // highlight-start + const handleClique = () => { + console.log('clicado') + } + // highlight-end + + return ( +
    +
    {contador}
    + // highlight-start + + // highlight-end +
    + ) +} +``` + +Definimos o valor do atributo onClick do botão como uma referência à função _handleClique_ definida no código. + +A cada clique no botão mais+, a função _handleClique_ é chamada, o que significa que a cada evento de clique uma mensagem clicado será registrada no console do navegador. + +A função gerenciadora de eventos também pode ser definida diretamente na atribuição de valor do atributo "onClick": + +```js +const App = () => { + const [ contador, setContador ] = useState(0) + + return ( +
    +
    {contador}
    + +
    + ) +} +``` + +Ao mudar a função gerenciadora de eventos para a seguinte forma: +```js + +``` + +atingimos o comportamento desejado, ou seja, o valor de _contador_ é incrementado em 1 e o componente é re-renderizado. + +Vamos também adicionar um botão para redefinir o contador: + +```js +const App = () => { + const [ contador, setContador ] = useState(0) + + return ( +
    +
    {contador}
    + + // highlight-start + + // highlight-end +
    + ) +} +``` + +Nossa aplicação está pronta! + +### Um gerenciador de evento é uma função + +Definimos os gerenciadores de eventos para os nossos botões, onde declaramos seus atributos onClick: + +```js + +``` + +E se tentássemos definir os gerenciadores de eventos de uma forma mais simples? O que aconteceria? + +```js + +``` + +Quebraria completamente nossa aplicação: + +![captura de tela de erro de re-renderizadores](../../images/1/5c.png) + +O que está acontecendo? Um gerenciador de evento deve ser uma função ou uma referência de função. Quando escrevemos: + +```js + +``` + +Agora, o atributo do botão que define o que acontece quando o botão é clicado — onClick — tem o valor _() => setContador(contador + 1)_. +A função setContador é chamada somente quando um usuário clica no botão. + +Em geral, definir gerenciadores de eventos dentro de templates JSX não é uma boa ideia. +Aqui está ok, porque nossos gerenciadores de eventos são bem simples. + +De qualquer jeito, vamos colocar os gerenciadores de eventos em funções separadas: + +```js +const App = () => { + const [ contador, setContador ] = useState(0) + +// highlight-start + const aumentarEmUm = () => setContador(contador + 1) + + const zerarContador = () => setContador(0) +// highlight-end + + return ( +
    +
    {contador}
    + + +
    + ) +} +``` +Aqui, os gerenciadores de eventos foram definidos corretamente. O valor do atributo onClick é uma variável que contém referência a uma função: + +```js + +``` + +### Passagem de Estado para Componentes-filho + +Recomenda-se escrever componentes React pequenos e reutilizáveis ​​em toda a aplicação, e até mesmo em projetos. Vamos refatorar nossa aplicação para que seja composta por três componentes menores: um componente para exibir o contador e dois componentes para os botões. + +Vamos implementar primeiro um componente Exibir, que é responsável pela exibição do valor do contador. + +Uma boa prática em React é [elevar o estado](https://reactjs.org/docs/lifting-state-up.html) (_lift the state up_) na hierarquia de componentes. A documentação diz: + +> Com frequência, a modificação de um dado tem que ser refletida em vários componentes. Recomendamos elevar o estado compartilhado ao elemento pai comum mais próximo. + +Vamos colocar o estado da aplicação no componente App e passá-lo para o componente Exibir através de props: + +```js +const Exibir = (props) => { + return ( +
    {props.contador}
    + ) +} +``` + +O uso do componente é direto, objetivo, já que precisamos apenas passar o estado do _contador_ para ele: + +```js +const App = () => { + const [ contador, setContador ] = useState(0) + + const aumentarEmUm = () => setContador(contador + 1) + const zerarContador = () => setContador(0) + + return ( +
    + // highlight-line + + +
    + ) +} +``` + +Tudo ainda está funcionando. Quando os botões são clicados e o App é re-renderizado, todos os seus filhos, incluindo o componente Exibir, também são re-renderizados. + +Agora, vamos criar um componente Botao para os botões da nossa aplicação. Temos que passar o gerenciador de evento, bem como o título do botão, através das props do componente: + +```js +const Botao = (props) => { + return ( + + ) +} +``` + +O nosso componente App fica assim: + +```js +const App = () => { + const [ contador, setContador ] = useState(0) + + const aumentarEmUm = () => setContador(contador + 1) + //highlight-start + const diminuirEmUm = () => setContador(contador - 1) + //highlight-end + const zerarContador = () => setContador(0) + + return ( +
    + + // highlight-start + + + + // highlight-end +
    + ) +} +``` + +Por conta de agora termos disponível um componente Botao facilmente reutilizável, também implementamos uma nova funcionalidade em nossa aplicação, adicionando um botão que pode ser usado para decrementar o contador. + +O gerenciador de evento é passado para o componente Botao através da prop _onClick_. O nome da prop em si não é algo tão significativo, mas a escolha do nome que colocamos não foi de todo aleatória. O próprio [tutorial oficial do React](https://reactjs.org/tutorial/tutorial.html) sugere essa convenção. + +### Alterações no estado causam re-renderização + +Vamos revisar, mais uma vez, os princípios mais importantes de como uma aplicação funciona. + +Quando a aplicação inicia, o código em _App_ é executado. Este código usa um hook [useState](https://reactjs.org/docs/hooks-reference.html#usestate) para criar o estado da aplicação, definindo um valor inicial da variável _contador_. +Este componente contém o componente _Exibir_ — que exibe o valor do contador, 0 — e três componentes _Botao_. Os botões possuem gerenciadores de eventos, que são usados para mudar o estado do contador. + +Quando um dos botões é clicado, o gerenciador de evento é executado. O gerenciador de evento muda o estado do componente _App_ com a função _setContador_. +**Chamar uma função que muda o estado faz com que o componente seja re-renderizado.** + +Então, se um usuário clicar no botão mais+, o gerenciador de evento do botão muda o valor de _contador_ para 1, e o componente _App_ é re-renderizado. +Isso faz com que seus subcomponentes _Exibir_ e _Botao_ também sejam re-renderizados. +_Exibir_ recebe o novo valor do contador, 1, como props. Os componentes _Botao_ recebem gerenciadores de eventos que podem ser usados para mudar o estado do contador. + +Para ter certeza de que você entendeu como o programa funciona, vamos adicionar algumas declarações _console.log_ a ele: + +```js +const App = () => { + const [ contador, setContador ] = useState(0) + console.log('renderizando com o valor do contador em', contador) // highlight-line + + const aumentarEmUm = () => { + console.log('aumentando, valor anterior', contador) // highlight-line + setContador(contador + 1) + } + + const diminuirEmUm = () => { + console.log('diminuindo, valor anterior', contador) // highlight-line + setContador(contador - 1) + } + + const zerarContador = () => { + console.log('zerando, valor anterior', contador) // highlight-line + setContador(0) + } + + return ( +
    + + + + +
    + ) +} +``` + +Vejamos agora o que é renderizado no console quando os botões "mais+", "zerar" e "menos-" são clicados: + +![navegador mostrando o console com a renderização de valores em destaque](../../images/1/31.png) + +Nunca tente adivinhar o que o seu código faz. É melhor usar _console.log_ e ver com seus próprios olhos o que ele faz. + +### Refatorando os Componentes + +O componente que exibe o valor do contador é o seguinte: + +```js +const Exibir = (props) => { + return ( +
    {props.contador}
    + ) +} +``` + +O componente só usa o campo _contador_ de suas props. +Isso significa que podemos simplificar o componente usando [desestruturação](/ptbr/part1/estado_do_componente_gerenciadores_de_eventos#desestruturacao-destructuring), desta forma: + +```js +const Exibir = ({ contador }) => { + return ( +
    {contador}
    + ) +} +``` + +A função que define o componente contém apenas a instrução de retorno, então +podemos definir a função usando a forma mais compacta das _arrow functions_: + +```js +const Exibir = ({ contador }) =>
    {contador}
    +``` + +Também podemos simplificar o componente Botao: + +```js +const Botao = (props) => { + return ( + + ) +} +``` + +Podemos usar a desestruturação para obter apenas os campos necessários de props e usar a forma mais compacta de arrow functions: + +```js +const Botao = ({ onClick, texto }) => ( + +) +``` + +Podemos simplificar ainda mais o componente Botao, fazendo com que a declaração de retorno caiba em apenas uma linha: + +```js +const Botao = ({ onClick, texto }) => +``` + +Porém, tenha cuidado para não simplificar demais seus componentes, porque pode ficar mais difícil lidar com a complexidade do código à medida em que ele for crescendo em tamanho. + +
    diff --git a/src/content/1/ptbr/part1d.md b/src/content/1/ptbr/part1d.md new file mode 100644 index 00000000000..27f28599935 --- /dev/null +++ b/src/content/1/ptbr/part1d.md @@ -0,0 +1,1351 @@ +--- +mainImage: ../../../images/part-1.svg +part: 1 +letter: d +lang: ptbr +--- + +
    + +### Um estado complexo (complex state) + +Em nosso exemplo anterior, o estado da aplicação era simples, pois consistia em apenas um número inteiro. E se a nossa aplicação precisar de um estado mais complexo? + +Na maioria dos casos, a maneira mais fácil e melhor de fazer isso é usando a função _useState_ múltiplas vezes para criar "pedaços" separados de estado. + +No código a seguir, criamos dois pedaços de estado para a aplicação, chamados _esquerda_ e _direita_, ambos com o valor inicial 0: + +```js +const App = () => { + const [esquerda, setEsquerda] = useState(0) + const [direita, setDireita] = useState(0) + + return ( +
    + {esquerda} + + + {direita} +
    + ) +} +``` + +O componente têm acesso às funções _setEsquerda_ e _setDireita_, que podem ser usadas para atualizar os dois pedaços de estado. + +O estado ou um pedaço de estado do componente pode ser de qualquer tipo. Poderíamos implementar a mesma funcionalidade salvando a contagem de cliques tanto dos botões "esquerda" quanto "direita" em um único objeto: +```js +{ + esquerda: 0, + direita: 0 +} +``` + +Nesse caso, a aplicação ficaria assim: + +```js +const App = () => { + const [cliques, setCliques] = useState({ + esquerda: 0, direita: 0 + }) + + const handleCliqueEsquerda = () => { + const novosCliques = { + esquerda: cliques.esquerda + 1, + direita: cliques.direita + } + setCliques(novosCliques) + } + + const handleCliqueDireita = () => { + const novosCliques = { + esquerda: cliques.esquerda, + direita: cliques.direita + 1 + } + setCliques(novosCliques) + } + + return ( +
    + {cliques.esquerda} + + +
    + ) +} +``` + +Agora, o componente tem apenas um único pedaço de estado, e os gerenciadores de eventos precisam cuidar da mudança do estado inteiro da aplicação. + +O formato do gerenciador de evento parece confuso aqui. Quando o botão da esquerda é clicado, a seguinte função é chamada: +```js +const handleCliqueEsquerda = () => { + const novosCliques = { + esquerda: cliques.esquerda + 1, + direita: cliques.direita + } + setCliques(novosCliques) +} +``` + +O objeto a seguir é definido como o novo estado da aplicação: +```js +{ + esquerda: cliques.esquerda + 1, + direita: cliques.direita +} +``` + +O novo valor da propriedade esquerda agora é o mesmo que o valor de esquerda + 1 do estado anterior, e o valor da propriedade direita é o mesmo que o valor da propriedade direita do estado anterior. + +Podemos definir mais claramente o novo objeto de estado usando a ([sintaxe de espalhamento](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax)) (Spread syntax (...)) que foi adicionada à especificação da linguagem no verão de 2018: + +```js +const handleCliqueEsquerda = () => { + const novosCliques = { + ...cliques, + esquerda: cliques.esquerda + 1 + } + setCliques(novosCliques) +} + +const handleCliqueDireita = () => { + ...cliques, + direita: cliques.direita + 1 + } + setCliques(novosCliques) +} +``` + +A sintaxe pode parecer um tanto estranha no começo. Na prática, { ...cliques } cria um novo objeto que tem cópias de todas as propriedades do objeto _cliques_. Quando discriminamos uma propriedade específica — por exemplo, direita em { ...cliques, direita: 1 }, o valor da propriedade _direita_ no novo objeto será 1. + +No exemplo acima, este trecho: + +```js +{ ...cliques, direita: cliques.direita + 1 } +``` + +cria uma cópia do objeto _cliques_, onde o valor da propriedade _direita_ é aumentado em 1. + +Não é necessário atribuir o objeto a uma variável nos gerenciadores de eventos, e podemos simplificar as funções da seguinte maneira: + +```js +const handleCliqueEsquerda = () => + setCliques({ ...cliques, esquerda: cliques.esquerda + 1 }) + +const handleCliqueDireita = () => +``` +Alguns leitores podem estar se perguntando o motivo de não termos atualizado o estado diretamente, desta forma: + +```js +const handleCliqueEsquerda = () => { + cliques.esquerda++ + setCliques(cliques) +} +``` + +A aplicação parece funcionar. Entretanto, em React, é proibido mudar (mutate) diretamente o estado, já que [pode resultar em efeitos colaterais inesperados](https://stackoverflow.com/a/40309023). A mudança de estado sempre tem que ser feita pela definição/atribuição do estado a um novo objeto. Se as propriedades do objeto de estado anterior não forem alteradas, podem simplesmente ser copiadas, o que se faz copiando essas propriedades em um novo objeto e definindo-o como o novo estado. + +Armazenar todo o estado em um único objeto de estado é uma má escolha para esta aplicação, especificamente; não há qualquer benefício aparente, e a aplicação resultante fica muito mais complexa. Neste caso, armazenar os contadores de cliques em pedaços separados de estado é uma escolha muito mais adequada. + +Há situações em que pode ser benéfico armazenar um pedaço de estado da aplicação em uma estrutura de dados mais complexa. [A documentação oficial de React](https://reactjs.org/docs/hooks-faq.html#should-i-use-one-or-many-state-variables) contém algumas orientações úteis sobre o assunto. + +### Gerenciando Arrays + +Vamos adicionar um pedaço de estado à nossa aplicação contendo o array _todosOsCliques_, que lembra cada clique que ocorreu na aplicação. + +```js +const App = () => { + const [esquerda, setEsquerda] = useState(0) + const [direita, setDireita] = useState(0) + const [todosOsCliques, setTodos] = useState([]) // highlight-line + +// highlight-start + const handleCliqueEsquerda = () => { + setTodos(todosOsCliques.concat('E')) + setEsquerda(esquerda + 1) + } +// highlight-end + +// highlight-start + const handleCliqueDireita = () => { + setDireita(direita + 1) + } +// highlight-end + + return ( +
    + {esquerda} + + +

    {todosOsCliques.join(' ')}

    // highlight-line +
    + ) +} +``` + +Cada clique é armazenado em um pedaço separado de estado chamado _todosOsCliques_, que é inicializado como um array vazio: + +```js +const [todosOsCliques, setTodos] = useState([]) +``` + +Quando o botão Esquerda é clicado, adicionamos a letra E ao array _todosOsCliques_: + +```js +const handleCliqueEsquerda = () => { + setTodos(todosOsCliques.concat('E')) + setEsquerda(esquerda + 1) +} +``` + +O pedaço de estado armazenado em _todosOsCliques_ agora é definido para ser um array que contém todos os itens do array anterior mais a letra E. O método [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat) (concatenar) adiciona o novo item ao array, que não muda o array existente, mas sim retorna uma nova cópia do array com o item adicionado a ele. + +Como mencionado anteriormente, também é possível em JavaScript adicionar itens a um array com o método [push](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push) (Significa, literalmente, "empurrar", "apertar", "pressionar". Porém, nestes termos, o método push() ADICIONA um ou mais elementos ao final de um array e retorna o novo comprimento desse array). Se adicionarmos o item "empurrando-o" para o array _todosOsCliques_ e então atualizando o estado, a aplicação ainda aparentará funcionar: + +```js +const handleCliqueEsquerda = () => { + todosOsCliques.push('E') + setTodos(todosOsCliques) + setEsquerda(esquerda + 1) +} +``` + +No entanto, __não__ faça isso. Como mencionado anteriormente, o estado dos componentes em React, tal como _todosOsCliques_, não devem ser mudados diretamente. Mesmo se mudando o estado parecer funcionar em alguns casos, tal decisão pode levar a erros no código muito difíceis de depurar. + +Vamos olhar mais de perto em como o clique é renderizado na página: + +```js +const App = () => { + // ... + + return ( +
    + {esquerda} + + + {direita} +

    {todosOsCliques.join(' ')}

    // highlight-line +
    + ) +} +``` + +Chamamos o método [join](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/join) (juntar, conectar) no array _todosOsCliques_ que une todos os itens em uma única string, separados pela string passada como parâmetro da função, que no caso é um espaço vazio. + +### A atualização do estado é assíncrona + +Vamos expandir a aplicação para que ela mantenha o controle do número total de cliques nos botões no estado _total_, cujo valor é sempre atualizado quando os botões são pressionados: + +```js +const App = () => { + const [esquerda, setEsquerda] = useState(0) + const [direita, setDireita] = useState(0) + const [todosOsCliques, setTodos] = useState([]) + const [total, setTotal] = useState(0) // highlight-line + + const handleCliqueEsquerda = () => { + setTodos(todosOsCliques.concat('E')) + setEsquerda(esquerda + 1) + setTotal(esquerda + direita) // highlight-line + } + + const handleCliqueDireita = () => { + setDireita(direita + 1) + setTotal(esquerda + direita) // highlight-line + } + + return ( +
    + {esquerda} + + +

    {todosOsCliques.join(' ')}

    +

    Total {total}

    // highlight-line +
    + ) +} +``` + +A solução não funciona corretamente: + +![o navegador mostrando 2 left|right 1, RLL total 2](../../images/1/33.png) + +Por alguma razão, o total de cliques nos botões está sempre um clique atrás do valor real. + +Vamos adicionar alguns comandos ```console.log``` ao gerenciador de eventos: + +```js +const App = () => { + // ... + const handleCliqueEsquerda = () => { + setTodos(todosOsCliques.concat('E')) + console.log('clique esquerdo anterior', esquerda) // highlight-line + setEsquerda(esquerda + 1) + console.log('clique esquerdo posterior', esquerda) // highlight-line + setTotal(esquerda + direita) + } + + // ... +} +``` + +O console revela o problema: + +![o console das ferramentas do desenvolvedor exibe left before 4 and left after 4](../../images/1/32.png) + +Embora um novo valor tenha sido definido para _esquerda_ chamando _setEsquerda(esquerda + 1)_, o valor antigo ainda está lá, apesar da atualização! Por causa disso, a tentativa de contar o número de cliques nos botões produz um resultado menor do que o correto: + +```js +setTotal(esquerda + direita) +``` + +O motivo para isso é que uma atualização de estado no React acontece [assincronicamente](https://reactjs.org/docs/state-and-lifecycle.html#state-updates-may-be-asynchronous) (asynchronously), ou seja, não imediatamente, mas "em algum momento" antes que o componente seja renderizado novamente. + +Podemos consertar a aplicação da seguinte forma: + +```js +const App = () => { + // ... + const handleCliqueEsquerda = () => { + setTodos(todosOsCliques.concat('E')) + const atualizaEsquerda = esquerda + 1 + setEsquerda(atualizaEsquerda) + setTotal(atualizaEsquerda + direita) + } + + // ... +} +``` + +Assim, o número de cliques nos botões é agora, de forma definitiva, baseado no número correto de cliques no botão esquerdo. + +### Renderização Condicional + +Vamos modificar nossa aplicação para que a renderização do histórico de cliques seja gerenciada por um novo componente chamado Historico: + +```js +// highlight-start +const Historico = (props) => { + if (props.todosOsCliques.length === 0) { + return ( +
    + Clique em um dos botões para usar a aplicação! +
    + ) + } + + return ( +
    + Histórico de cliques nos botões: {props.todosOsCliques.join(' ')} +
    + ) +} +// highlight-end + +const App = () => { + // ... + + return ( +
    + {esquerda} + + + {direita} + // highlight-line +
    + ) +} +``` + +Agora, o comportamento do componente depende se algum dos botões foi clicado ou não. Se não, ou seja, o array todosOsCliques estando vazio, o componente renderiza um elemento "div" com algumas instruções: + +```js +
    Clique em um dos botões para usar a aplicação!
    +``` + +E em todos os outros casos, o componente renderiza o histórico de cliques: + +```js +
    + Histórico de cliques nos botões: {props.todosOsCliques.join(' ')} +
    +``` + +O componente Historico renderiza elementos React completamente diferentes dependendo do estado da aplicação. Isso é chamado de renderização condicional (conditional rendering). + +React também oferece muitas outras formas de fazer [renderização condicional](https://reactjs.org/docs/conditional-rendering.html). Veremos isso na prática na [Parte 2](/ptbr/part2). + +Vamos fazer mais uma modificação a nossa aplicação, refatorando-a para usar o componente _Botao_ que definimos anteriormente: + +```js +const Historico = (props) => { + if (props.todosOsCliques.length === 0) { + return ( +
    + Clique em um dos botões para usar a aplicação! +
    + ) + } + + return ( +
    + Histórico de cliques nos botões: {props.todosOsCliques.join(' ')} +
    + ) +} + +// highlight-start +const Botao = ({ handleClique, texto }) => ( + +) +// highlight-end + +const App = () => { + const [esquerda, setEsquerda] = useState(0) + const [direita, setDireita] = useState(0) + const [todosOsCliques, setTodos] = useState([]) + + const handleCliqueEsquerda = () => { + setTodos(todosOsCliques.concat('E')) + setEsquerda(esquerda + 1) + } + + const handleCliqueDireita = () => { + setDireita(direita + 1) + } + + return ( +
    + {esquerda} + // highlight-start + + + {direita} + +
    + ) +} +``` + +### React antigo + +Neste curso, usamos o [state hook](https://reactjs.org/docs/hooks-state.html) ("gancho de estado") para adicionar estado aos nossos componentes React, que faz parte das versões mais recentes da biblioteca e está disponível a partir da versão [16.8.0](https://www.npmjs.com/package/react/v/16.8.0) em diante. Antes da adição dos hooks, não havia maneira de adicionar estado a componentes funcionais. Componentes que precisavam de estado tinham que ser definidos como componentes de [classe](https://reactjs.org/docs/react-component.html), usando a sintaxe de classe JavaScript. + +Neste curso, fizemos a decisão um pouco radical de usar exclusivamente hooks desde o primeiro dia, para garantir que estamos aprendendo as variações atuais e futuras de React. Embora os componentes funcionais sejam o futuro da biblioteca, ainda é importante aprender a sintaxe de classe, já que existem bilhões de linhas de código React legado que você pode acabar fazendo manutenção algum dia. O mesmo se aplica à documentação e exemplos de React que você pode encontrar na internet. + +Vamos aprender mais sobre componentes de classe React mais tarde no curso. + +### Depuração de aplicações React + +Grande parte do tempo de um desenvolvedor é gasto na depuração e na leitura de códigos existentes. De vez em quando, conseguimos escrever uma ou duas linhas de código novo, mas grande parte do nosso tempo é gasto tentando descobrir por que algo está quebrado ou como algo funciona. Boas práticas e ferramentas de depuração são extremamente importantes por esta razão. + +Felizmente para nós, React é uma biblioteca extremamente amigável para com os desenvolvedores quando se trata de depuração. + +Antes de continuarmos, vamos nos lembrar de uma das regras mais importantes do desenvolvimento web. + +

    A primeira regra do desenvolvimento web

    + +> **Mantenha o Console do navegador aberto o tempo todo.** +> +> A guia Console em particular deve estar sempre aberta, a menos que haja uma razão específica para visualizar outra guia. + +Mantenha tanto o seu código quanto a página web abertos juntos **o tempo todo**. + +Se e quando seu código não compilar e seu navegador brilhar igual uma árvore de Natal: + +![captura de tela do código](../../images/1/6x.png) + +não escreva nenhuma linha de código a mais, mas encontre e corrija **imediatamente** o problema. Ainda não aconteceu na história da programação de o código que não estivesse compilando começasse a funcionar após a adição de mais linhas de código. Duvido que tal evento ocorra durante este curso também. + +A depuração (_debug_) "old-school", baseada na impressão no Console, é sempre uma das melhores opções. Se o componente + +```js +const Botao = ({ handleClique, texto }) => ( + +) +``` + +não estiver funcionando como desejado, é útil começar a imprimir suas variáveis ​​no console. Para que isso funcione, devemos transformar nossa função na forma menos compactada e receber todo o objeto "props" sem desestruturá-lo de forma imediata: + +```js +const Botao = (props) => { + console.log(props) // highlight-line + const { handleClique, texto } = props + return ( + + ) +} +``` + +Isso revelará imediatamente se, por exemplo, um dos atributos foi escrito incorretamente ao usar o componente. + +**Obs.:** Quando você usar _console.log_ para depuração, não combine _objetos (objects)_ do jeito Java de se fazer usando o operador de adição. Em vez de escrever + +```js +console.log('o valor de props é ' + props) +``` + +separe as coisas que você deseja registrar no console com uma vírgula: + +```js +console.log('o valor de props é', props) +``` + +Se você usar o jeito Java de concatenar uma string com um objeto, aparecerá uma mensagem de log muito pouco informativa: + +```js +o valor de props é [object Object] +``` + +Registrar a saída no console não é de maneira alguma a única forma de depurar nossas aplicações. Você pode pausar a execução do código da sua aplicação no _depurador (debugger)_ no Console do Desenvolvedor do Chrome, escrevendo o comando [debugger](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/debugger) em qualquer lugar do seu código. + +A execução será pausada assim que chegar a um ponto onde o comando _debugger_ for executado: + +![debugger pausado na Ferramenta do Desenvolvedor](../../images/1/7a.png) + +Ao ir para a guia Console, é fácil inspecionar o estado atual das variáveis: + +![captura de tela de inspeção de console](../../images/1/8a.png) + +Uma vez que a causa do erro é descoberta, é possível remover o comando _debugger_ e atualizar a página. + +O depurador também nos permite executar nosso código linha por linha com os controles encontrados na parte direita da guia Fontes (Sources). + +Você também pode acessar o depurador sem o comando _debugger_, adicionando pontos de interrupção na guia Fontes (Sources). Inspecionar os valores das variáveis do componente pode ser feito na seção _Escopo (Scope)_: + +![exemplo de ponto de interrupção nas ferramentas do desenvolvedor](../../images/1/9a.png) + +É extremamente recomendado adicionar a extensão [React developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) ao Chrome. Ele adiciona uma nova guia _Components_ às ferramentas de desenvolvedor. A nova guia de ferramentas de desenvolvedor pode ser usada para inspecionar os diferentes elementos React na aplicação, juntamente com seu estado e props: + +![captura de tela da extensão de ferramentas de desenvolvedor React](../../images/1/10ea.png) + +O estado do componente _App_ é definido assim: + +```js +const [esquerda, setEsquerda] = useState(0) +const [direita, setDireita] = useState(0) +const [todosOsCliques, setTodos] = useState([]) +``` + +As ferramentas do desenvolvedor mostram o estado dos hooks na ordem de sua definição: + +![estado dos hooks nas ferramentas do desenvolvedor React](../../images/1/11ea.png) + +O primeiro State (Estado) contém o valor do estado esquerda; o próximo contém o valor do estado direita e o último contém o valor do estado todosOsCliques. + +### Regras dos Hooks + +Há algumas limitações e regras que devemos seguir para garantir que a nossa aplicação use corretamente as funções de estado baseadas em hooks. + +A função _useState_ ("usarEstado", assim como a função _useEffect_, ou "usarEfeito", introduzida mais tarde neste curso) não deve ser chamada dentro de um loop, uma expressão condicional ou qualquer lugar que não seja uma função que define um componente. Assim deve ser para garantir que os hooks sejam sempre chamados na mesma ordem e, se isso não acontecer, a aplicação se apresentará erros. + +Resumindo, hooks só podem ser chamados de dentro do corpo de uma função que define um componente React: + +```js +const App = () => { + // Desta forma funciona! + const [idade, setIdade] = useState(0) + const [nome, setNome] = useState('Juha Tauriainen') + + if ( idade > 10 ) { + // Desta forma não funciona! + const [foobar, setFoobar] = useState(null) + } + + for ( let i = 0; i < idade; i++ ) { + // Não faça deste jeito também! + const [formaCorreta, setFormaCorreta] = useState(false) + } + + const bemRuim = () => { + // Isso também não é permitido! + const [x, setX] = useState(-1000) + } + + return ( + //... + ) +} +``` + +### Revisão sobre Gerenciamento de Eventos (_Event Handling_) + +O gerenciamento de eventos se mostrou um tópico difícil em iterações anteriores neste curso. + +Por essa razão, revisaremos o tópico. + +Vamos supor que estejamos desenvolvendo essa aplicação simples com o seguinte componente App: +```js +const App = () => { + const [valor, setValor] = useState(10) + + return ( +
    + {valor} + +
    + ) +} +``` + +Queremos que o clique do botão reinicialize o estado armazenado na variável _valor_. + +Para fazer com que o botão reaja a um evento de clique, precisamos adicionar um gerenciador de evento a ele. + +Os gerenciadores de eventos devem sempre ser uma função ou uma referência a uma função. O botão não funcionará se o gerenciador de evento for definido como uma variável de outro tipo. + +Se definíssemos o gerenciador de evento como uma string: + +```js + +``` + +o React nos avisaria sobre isso no console: + +```js +index.js:2178 Warning: Expected `onClick` listener to be a function, instead got a value of `string` type. + in button (at index.js:20) + in div (at index.js:18) + in App (at index.js:27) +``` +A mensagem de erro diz: index.js:2178 Aviso: Esperava-se que o ouvinte `onClick` fosse uma função, mas obteve-se um valor do tipo `string`. + +O seguinte também não funcionaria: + +```js + +``` + +Tentamos definir o gerenciador de evento como _valor + 1_, o que simplesmente retorna o resultado da operação. React nos avisará sobre isso no console: + +```js +index.js:2178 Warning: Expected `onClick` listener to be a function, instead got a value of `number` type. +``` +A mensagem de erro diz: index.js:2178 Aviso: Esperava-se que o ouvinte `onClick` fosse uma função, mas obteve-se um valor do tipo `number`. + +Este também não funcionaria: +```js + +``` + +O gerenciador de evento não é uma função, mas uma **atribuição de variável**, e React, mais uma vez, emitirá um aviso no console. Esta tentativa também é falha no sentido de que nunca devemos mudar diretamente o estado em React. + +Vejamos o próximo exemplo: + +```js + +``` + +A mensagem é impressa no console assim que o componente é renderizado, mas nada acontece quando clicamos no botão. Por que não funciona mesmo quando nosso gerenciador de evento contém a função _console.log_? + +O problema aqui é que nosso gerenciador de evento é definido como uma chamada de função, o que significa que o gerenciador de evento é atribuído ao valor retornado da função, que no caso de _console.log_ é undefined (indefinido). + +A função _console.log_ é chamada quando o componente é renderizado e, por esse motivo, é impresso uma vez no console. + +A tentativa a seguir também não funciona: + +```js + +``` + +Novamente, tentamos definir uma chamada de função como o gerenciador de evento. Isso não funciona. Essa tentativa específica também causa outro problema: quando o componente é renderizado, a função _setValue(0)_ é executada, o que por sua vez faz com que o componente seja renderizado novamente. A re-renderização, por conseguinte, chama _setValue(0)_ novamente, resultando em uma recursão infinita. + +A execução de uma chamada de função específica quando o botão é clicado pode ser realizada da seguinte maneira: + +```js + +``` + +Agora, o gerenciador de evento é uma função definida com a sintaxe de uma _arrow function_, isto é, ```() => console.log('clicou no botão')```. Quando o componente é renderizado, nenhuma função é chamada e apenas a referência à _arrow function_ é definida como o gerenciador de evento. A chamada da função ocorre apenas quando o botão é clicado. + +Podemos implementar a reinicialização do estado em nossa aplicação com essa mesma técnica: + +```js + +``` + +O gerenciador de evento agora é a função ```() => setValue(0)```. + +Definir gerenciadores de eventos diretamente no atributo do botão nem sempre é a melhor opção a se aplicar. + +Você verá frequentemente gerenciadores de eventos definidos em um lugar separado. Na versão seguinte de nossa aplicação, definimos uma função que então é atribuída à variável _handleClique_ no corpo da função do componente: + +```js +const App = () => { + const [valor, setValor] = useState(10) + + const handleClique = () => + console.log('clicou no botão') + + return ( +
    + {valor} + +
    + ) +} +``` + +Agora, a variável _handleClique_ está atribuída a uma referência à função. A referência é passada ao botão como o atributo onClick: + +```js + +``` + +Naturalmente, nossa função gerenciadora de eventos pode ser composta por múltiplos comandos. Nestes casos, usamos a sintaxe de chaves mais longa para _arrow functions_: + +```js +const App = () => { + const [valor, setValor] = useState(10) + +// highlight-start + const handleClique = () => { + console.log('clicou no botão') + setValor(0) + } +// highlight-end + + return ( +
    + {valor} + +
    + ) +} +``` + +### Uma função que retorna outra função + +Outra maneira de definir um gerenciador de evento é usar uma função que retorna outra função. + +Provavelmente, você não precisará usar funções que retornam funções em nenhum dos exercícios deste curso. Se o tópico parecer confuso demais, você pode pular esta seção por enquanto e retornar a ela mais tarde. + +Vamos fazer as seguintes alterações em nosso código: + +```js +const App = () => { + const [valor, setValor] = useState(10) + + // highlight-start + const ola = () => { + const gerenciador = () => console.log('Olá, mundo!') + + return gerenciador + } + // highlight-end + + return ( +
    + {valor} + +
    + ) +} +``` + +O código funciona corretamente, apesar de parecer complicado. + +O gerenciador de evento agora está definido como uma chamada de função: + +```js + +``` + +Anteriormente, afirmamos que um gerenciador de evento não pode ser uma chamada de função e que precisa ser ou uma função ou uma referência a uma função. Então, por que uma chamada de função funciona neste caso? + +Quando o componente é renderizado, a seguinte função é executada: + +```js +const ola = () => { + const gerenciador = () => console.log('Olá, mundo!') + + return gerenciador +} +``` + +O valor de retorno da função é outra função que é atribuída à variável _gerenciador_. + +Quando o React renderiza a linha: + +```js + +``` + +Ele atribui o valor de retorno de _ola()_ ao atributo onClick. Essencialmente, a linha se transforma em: + +```js + +``` + +Como a função _ola_ retorna uma função, o gerenciador de evento passa, agora, a ser uma função. + +Qual é o objetivo deste conceito? + +Vamos mudar um pouco o código: + +```js +const App = () => { + const [valor, setValor] = useState(10) + + // highlight-start + const ola = (quem) => { + const gerenciador = () => { + console.log('Olá', quem) + } + + return gerenciador + } + // highlight-end + + return ( +
    + {valor} + // highlight-start + + + + // highlight-end +
    + ) +} +``` + +Agora, a aplicação têm três botões com gerenciadores de eventos definidos pela função _ola_ que aceita um único parâmetro. + +O primeiro botão é definido como: + +```js + +``` + +O gerenciador de evento é criado executando a chamada da função _ola('mundo')_. A chamada da função retorna a função: + +```js +() => { + console.log('Olá', 'mundo') +} +``` + +O segundo botão é definido como: + +```js + +``` + +A chamada da função _ola('react')_ que cria o gerenciador de evento retorna: + +```js +() => { + console.log('Olá', 'react') +} +``` + +Ambos os botões obtêm seus gerenciadores de eventos individualizados. + +Funções que retornam funções podem ser utilizadas na definição de funcionalidades genéricas que podem ser personalizadas com parâmetros. A função _ola_ que cria os gerenciadores de eventos pode ser analisada como uma fábrica que produz gerenciadores de eventos personalizados destinados a saudar usuários. + +Nossa definição atual é um tanto verbosa: + +```js +const ola = (quem) => { + const gerenciador = () => { + console.log('Olá', quem) + } + + return gerenciador +} +``` + +Vamos excluir as variáveis de ajuda e retornar diretamente a função criada: + +```js +const ola = (quem) => { + return () => { + console.log('Olá', quem) + } +} +``` + +Por conta de nossa função _ola_ ser composta por um único comando de retorno, podemos omitir as chaves e usar a sintaxe mais compacta para funções de seta: + +```js +const ola = (quem) => + () => { + console.log('Olá', quem) + } +``` + +Por fim, vamos escrever todas as setas na mesma linha: + +```js +const ola = (quem) => () => { + console.log('Olá', quem) +} +``` + +Podemos usar o mesmo "macete" para definir gerenciadores de eventos que definem o estado do componente para um determinado valor. Vamos fazer as seguintes alterações em nosso código: + +```js +const App = () => { + const [valor, setValor] = useState(10) + + // highlight-start + const setNoValor = (novoValor) => () => { + console.log('setValor atual', novoValor) // Imprime o novo valor no console + setValor(novoValor) + } + // highlight-end + + return ( +
    + {valor} + // highlight-start + + + + // highlight-end +
    + ) +} +``` + +Quando o componente é renderizado, é criado o botão mil: + +```js + +``` + +O gerenciador de evento é definido como o valor retornado de _setNoValor(1000)_, que é a seguinte função: + +```js +() => { + console.log('setValor atual', 1000) + setValor(1000) +} +``` + +O botão de incremento é declarado da seguinte forma: + +```js + +``` + +O gerenciador de evento é criado pela chamada da função _setNoValor(valor + 1)_, que recebe como parâmetro o valor atual da variável de estado _valor_ incrementado em 1 (um). Se o conteúdo de _valor_ fosse 10, então o gerenciador de evento criado seria a seguinte função: + +```js +() => { + console.log('setValor atual', 11) + setValor(11) +} +``` + +Não é necessário usar funções que retornam funções para alcançar esta funcionalidade. Vamos retornar a função _setNoValor_, responsável por atualizar o estado, como uma função normal: + +```js +const App = () => { + const [valor, setValor] = useState(10) + + const setNoValor = (novoValor) => { + console.log('setValor atual', novoValor) + setValor(novoValor) + } + + return ( +
    + {valor} + + + +
    + ) +} +``` + +Agora, podemos definir o gerenciador de evento como uma função que chama a função _setNoValor_ com um parâmetro apropriado. O gerenciador de evento utilizado para redefinir o estado da aplicação seria: + +```js + +``` + +Escolher entre as duas formas apresentadas de definir seus gerenciadores de eventos é, em grande parte, uma questão de gosto. + +### Passando Gerenciadores de Evento para Componentes-filho + +Vamos extrair o botão para seu próprio componente: + +```js +const Botao = (props) => ( + +) +``` + +O componente obtém a função de gerência de evento da propriedade _handleClique_, e o texto do botão da propriedade _texto_. Vamos usar o novo componente: + +```js +const App = (props) => { + // ... + return ( +
    + {valor} + // highlight-line + // highlight-line + // highlight-line +
    + ) +} +``` + +Usar o componente Botao é simples, embora tenhamos que nos certificar de usar os nomes corretos de atributo ao passar props para o componente. + +![captura de tela do código de nomes de atributos corretos](../../images/1/12e.png) +Nota dos tradutores: ao longo do texto, apresentamos os códigos contendo termos traduzidos para o português, os quais não aparecem na imagem acima, pois esta traz o código escrito com os termos em inglês. + +### Não defina Componentes dentro de Componentes + +Vamos começar a exibir o valor da aplicação em seu componente Exibir. + +Vamos mudar a aplicação definindo um novo componente dentro do componente App. + +```js +// Este é o lugar correto para definir um componente +const Botao = (props) => ( + +) + +const App = () => { + const [valor, setValor] = useState(10) + + const setNoValor = novoValor => { + console.log('setValor atual', novoValor) + setValor(novoValor) + } + + // Não defina um componente dentro de outro componente + const Exibir = props =>
    {props.valor}
    // highlight-line + + return ( +
    + // highlight-line + setNoValor(1000)} texto="mil" /> + setNoValor(0)} texto="zerar" /> + setNoValor(valor + 1)} texto="incrementar" /> +
    + ) +} +``` + +A aplicação ainda parece funcionar, porém, **não implemente componentes desta forma!** +Nunca defina componentes dentro de outros componentes. O método não oferece nenhum benefício e leva a muitos problemas desagradáveis. Os maiores problemas acontecem devido ao React tratar um componente definido dentro de outro componente como um novo componente em cada renderização. Isso torna impossível para o React otimizar o componente. + +Em vez disso, vamos mover a função do componente Exibir para o seu lugar correto, que fica fora da função do componente App: + +```js +const Exibir = props =>
    {props.valor}
    + +const Botao = (props) => ( + +) + +const App = () => { + const [valor, setValor] = useState(10) + + const setNoValor = novoValor => { + console.log('setValor atual', novoValor) + setValor(novoValor) + } + + return ( +
    + + setNoValor(1000)} texto="mil" /> + setNoValor(0)} texto="zerar" /> + setNoValor(valor + 1)} texto="incrementar" /> +
    + ) +} +``` + +### Leitura Recomendada + +A internet está cheia de material relacionado à biblioteca React. No entanto, usamos o novo estilo de programação em React para o qual a grande maioria do material encontrado online está desatualizado. + +Estes links talvez possam lhe ser úteis: + +- Vale a pena dar uma olhada em algum momento na [documentação oficial React](https://reactjs.org/docs/hello-world.html), embora a maior parte dela só se torne relevante mais para frente no curso. Além disso, tudo relacionado a componentes baseados em classe é irrelevante para nós; +- Alguns cursos no [Egghead.io](https://egghead.io), como o [Start learning React](https://egghead.io/courses/start-learning-react), são de altíssima qualidade; e o recentemente atualizado [Beginner's Guide to React](https://egghead.io/courses/the-beginner-s-guide-to-reactjs) também é relativamente bom; ambos os cursos introduzem conceitos que também serão introduzido no decorrer deste curso. **Obs.: O primeiro curso usa componentes de classe, mas o segundo usa a nova abordagem baseada em funções.** + +### Juramento do Programador Web + +Programar é difícil, e é por isso que eu usarei todos os meios possíveis para ser mais fácil: + +- Eu manterei meu Console do navegador aberto o tempo todo; +- Eu vou progredir aos poucos, passo a passo; +- Eu escreverei muitas instruções _console.log_ para ter certeza de que estou entendendo como o código se comporta e para me ajudar a identificar os erros; +- Se meu código não funcionar, não escreverei mais nenhuma linha no código. Em vez disso, começarei a excluir o código até que funcione ou retornarei ao estado em que tudo ainda estava funcionando; e +- Quando eu pedir ajuda no canal do Discord do curso ou em outro lugar, formularei minhas perguntas de forma adequada. Veja [aqui](/ptbr/part0/informacoes_gerais#como-pedir-ajuda-no-discord) como pedir ajuda. + +
    + +
    + +

    Exercícios 1.6 a 1.14

    + +Envie suas soluções aos exercícios dando "push" para seu repositório no GitHub e, em seguida, marque os exercícios concluídos na guia "my submissions" no [sistema de envio de exercícios](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +Lembre-se: envie **todos** os exercícios de uma parte **de uma única vez**; isto é, envie todas as suas soluções de uma vez para seu repositório. Uma vez que você tenha enviado suas soluções para uma parte, **não é mais possível enviar mais exercícios para essa parte**. + + Alguns dos exercícios funcionam na mesma aplicação. Nestes casos, é suficiente enviar apenas a versão final da aplicação. Se desejar, você pode fazer um "commit" após cada exercício concluído, mas isso não é obrigatório. + +**AVISO**: "create-react-app" transformará automaticamente seu projeto em um repositório git, a menos que você crie sua aplicação dentro de um repositório git já existente. **Você muito provavelmente não quer que cada um de seus projetos seja um repositório separado**, então basta executar o comando _rm -rf .git_ na raiz de sua aplicação para aplicar as modificações. + +Algumas vezes você terá que executar na raiz do projeto o comando abaixo: + +```bash +rm -rf node_modules/ && npm i +``` + +Se e quando você encontrar uma mensagem de erro + +> Objects are not valid as a React child + +lembre-se do que foi explicado [aqui](/ptbr/part1/introducao_a_biblioteca_react#nao-renderize-objetos). + +**Obs.:** o conteúdo dos exercícios foram deixados no idioma original da tradução (inglês) por questões de conveniência, visto a revisão que os mantenedores do curso devem fazer no código enviado ao sistema de avaliação da Universidade de Helsinque. Desta forma, escreva suas aplicações utilizando os mesmos termos usados nas variáveis, componentes, etc que estão em inglês. + +

    1.6: unicafe — 1º passo

    + +Como a maioria das empresas, o restaurante universitário da Universidade de Helsinque, [Unicafe](https://www.unicafe.fi), coleta o feedback de seus clientes. Sua tarefa é implementar uma aplicação web que colete o feedback dos clientes. Existem apenas três opções para feedback: good (bom), neutral (neutro) e bad (ruim). + +A aplicação deve exibir o número total de feedbacks coletados para cada categoria. Sua aplicação final pode ficar assim: + +![captura de tela das opções de feedback](../../images/1/13e.png) + +Note que sua aplicação precisa funcionar apenas durante uma única sessão de navegação. É permitido que, assim que você atualizar a página, o feedback coletado desapareça. + +É aconselhável usar a mesma estrutura que é usada no material e no exercício anterior. O arquivo index.js fica assim: + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' + +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +Você pode usar o código abaixo como ponto de partida para o arquivo App.js: + +```js +import { useState } from 'react' + +const App = () => { + // salve os cliques de cada botão em seu próprio estado + const [good, setGood] = useState(0) + const [neutral, setNeutral] = useState(0) + const [bad, setBad] = useState(0) + + return ( +
    + Programe aqui! +
    + ) +} + +export default App +``` + +

    1.7: unicafe — 2º passo

    + +Expanda sua aplicação para que ela mostre mais estatísticas sobre o feedback coletado: o número total de feedback coletados, a pontuação média (good: 1, neutral: 0, bad: -1) e a porcentagem de feedback positivo. + +![captura de tela do feedback positivo, médio e percentual](../../images/1/14e.png) + +

    1.8: unicafe — 3º passo

    + +Refatore sua aplicação de maneira que a exibição de estatísticas seja extraída para seu próprio componente Statistics. O estado da aplicação deve permanecer no componente raiz App. + +Lembre-se de que componentes não devem ser definidos dentro de outros componentes: + +```js +// lugar adequado para definir um componente +const Statistics = (props) => { + // ... +} + +const App = () => { + const [good, setGood] = useState(0) + const [neutral, setNeutral] = useState(0) + const [bad, setBad] = useState(0) + + // não defina um componente dentro de outro componente + const Statistics = (props) => { + // ... + } + + return ( + // ... + ) +} +``` + +

    1.9: unicafe — 4º passo

    + +Modifique sua aplicação para exibir as estatísticas somente após o feedback ter sido coletado. + +![nenhum feedback dado texto screenshot](../../images/1/15e.png) + +

    1.10: unicafe — 5º etapa

    + +Continuemos refatorando a aplicação. Extraia esses dois componentes: + +- Button para definir os botões usados para enviar feedback; e +- StatisticLine para exibir uma única estatística, por exemplo, a pontuação média. + +Deixando claro: o componente StatisticLine sempre exibe uma única estatística, o que significa que a aplicação usa vários componentes para renderizar todas as estatísticas: + +```js +const Statistics = (props) => { + /// ... + return( +
    + + + + // ... +
    + ) +} + +``` + +O estado da aplicação deve ser mantido no componente raiz App. + +

    1.11*: unicafe — 6º passo

    + +Exiba as estatísticas em uma [tabela HTML](https://developer.mozilla.org/en-US/docs/Learn/HTML/Tables/Basics), para que sua aplicação pareça mais ou menos assim: + +![captura de tela da tabela de estatísticas](../../images/1/16e.png) + +Lembre-se de manter seu console aberto o tempo todo. Se você ver este aviso no seu console + +![aviso do console](../../images/1/17a.png) + +faça o necessário para fazer o aviso desaparecer. Tente colar a mensagem de erro em um buscador (Google, Bing, etc) se ficar preso. + +A origem típica de um erro `Unchecked runtime.lastError: Could not establish connection. Receiving end does not exist.` vem de alguma extensão do Chrome. Vá até `chrome://extensions/` e desative uma por uma e atualize a página da aplicação React; o erro deve por fim desaparecer. + +**Certifique-se de que, a partir de agora, você não verá mais avisos no seu console!** + +

    1.12*: anecdotes — 1º passo

    + +O mundo da engenharia de software é cheio de [anedotas](http://www.comp.nus.edu.sg/~damithch/pages/SE-quotes.htm) que distilam verdades atemporais de nosso campo em frases curtas. + +Expanda a aplicação a seguir, adicionando um botão que, ao ser clicado, exiba uma anedota aleatória da área da engenharia de software: + +```js +import { useState } from 'react' + +const App = () => { + const anecdotes = [ + 'Se fazer algo dói, faça isso com mais frequência.', + 'Contratar mão de obra para um projeto de software que já está atrasado, faz com que se atrase mais ainda!', + 'Os primeiros 90% do código correspondem aos primeiros 10% do tempo de desenvolvimento... Os outros 10% do código correspondem aos outros 90% do tempo de desenvolvimento.', + 'Qualquer tolo escreve código que um computador consegue entender. Bons programadores escrevem código que humanos conseguem entender.', + 'Otimização prematura é a raiz de todo o mal.', + 'Antes de mais nada, depurar é duas vezes mais difícil do que escrever o código. Portanto, se você escrever o código da forma mais inteligente possível, você, por definição, não é inteligente o suficiente para depurá-lo.', + 'Programar sem o uso extremamente intenso do console.log é o mesmo que um médico se recusar a usar raio-x ou testes sanguíneos ao diagnosticar pacientes.', + 'A única maneira de ir rápido é ir bem.' + ] + + const [selected, setSelected] = useState(0) + + return ( +
    + {anecdotes[selected]} +
    + ) +} + +export default App +``` + +O conteúdo do arquivo index.js é o mesmo dos exercícios anteriores. + +Descubra como gerar números aleatórios (_random numbers_) em JavaScript, por exemplo, pesquisando na internet ou lendo o [Mozilla Developer Network](https://developer.mozilla.org). Lembre-se de que você pode testar a criação de números aleatórios diretamente no console do seu navegador, por exemplo. + +Sua aplicação no estado final pode ficar mais ou menos assim: + +![anedota aleatória com botão "próximo"](../../images/1/18a.png) + +**AVISO**: "create-react-app" transformará automaticamente seu projeto em um repositório git, a menos que você crie sua aplicação dentro de um repositório git já existente. **Você muito provavelmente não quer que cada um de seus projetos seja um repositório separado**, então basta executar o comando _rm -rf .git_ na raiz de sua aplicação para aplicar as modificações. + +

    1.13*: anecdotes — 2º passo

    + +Amplie sua aplicação para que você possa votar na anedota exibida. + +![aplicação de anedotas com botão de votos adicionado](../../images/1/19a.png) + +**Obs.:** armazene os votos de cada anedota em um array ou objeto no estado do componente. Lembre-se de que a forma correta de atualizar o estado armazenado em estruturas de dados complexas, como objetos e arrays, é fazer uma cópia do estado. + +Você pode criar uma cópia de um objeto assim: + +```js +const pontos = { 0: 1, 1: 3, 2: 4, 3: 2 } + +const copia = { ...pontos } +// incrementa o valor da propriedade 2 (dois) por 1 (um) +copia[2] += 1 +``` + +Ou uma cópia de um array assim: + +```js +const pontos = [1, 4, 6, 3] + +const copia = [...pontos] +// incrementa o valor na posição 2 (dois) por 1 (um) +copia[2] += 1 +``` + +Utilizar um array pode ser a escolha mais simples neste caso. Uma pesquisa na Internet vai te mostrar muitas formas de como [criar um array preenchido com zeros com um comprimento arbitrário](https://stackoverflow.com/questions/20222501/how-to-create-a-zero-filled-javascript-array-of-arbitrary-length/22209781). + +

    1.14*: anecdotes — 3º passo

    + +Agora, implemente a versão final da aplicação que exibe a anedota com o maior número de votos: + +![anedota com o maior número de votos](../../images/1/20a.png) + +Se múltiplas anedotas estiverem empatadas no primeiro lugar, exiba apenas uma delas. + +Este foi o último exercício para esta parte do curso, e é hora de enviar seu código para o GitHub e marcar todos os seus exercícios concluídos na guia "my submissions" do [sistema de envio de exercícios](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    diff --git a/src/content/1/zh/part1.md b/src/content/1/zh/part1.md index 86d120637fd..ffaf459894d 100644 --- a/src/content/1/zh/part1.md +++ b/src/content/1/zh/part1.md @@ -6,10 +6,16 @@ lang: zh
    + +在这一章节,我们将熟悉一下React库,并用它来编写在浏览器中运行的代码。我们还将学习一下JavaScript的一些特性,这些特性对理解React很重要。 - + +该部分原文更新于2025年1月17日 -在这一章中,我们将先简单了解一下 React ,然后我们将使用它来编写浏览器端的代码。 我们还将研究一些对于理解 React 非常重要的 JavaScript 特性。 + +- Node更新至版本v22.3.0 -
    + +- Eslint设置移至eslint.config.js文件 + diff --git a/src/content/1/zh/part1a.md b/src/content/1/zh/part1a.md index a3fadaa6741..bc36adfb509 100644 --- a/src/content/1/zh/part1a.md +++ b/src/content/1/zh/part1a.md @@ -6,78 +6,123 @@ lang: zh ---
    - + -我们即将开始学习大概是本门课程中最重要的议题—— [React](https://reactjs.org/)。 让我们从制作一个简单的 React 应用开始,同时了解一下 React 的核心概念。 +我们现在将开始入门的可能是本课程最重要的主题,即[React](https://react.dev/)库。让我们从制作一个简单的React应用开始,同时了解React的核心概念。 - + +到目前为止,最简单的方法是使用一个叫做[Vite](https://vitejs.dev/)的工具来开始。 + + +让我们用create-vite创建一个新的应用: -目前来说,创建一个React应用最简单的方式是使用一个叫做[create-react-app](https://github.com/facebook/create-react-app) 的工具。 如果你随着node安装的npm工具版本号不小于5.3,你就可以(也不是必须的)在机器上安装 create-react-app 了。 +```bash +npm create vite@latest +``` + + +让我们按照下列方式回答create-vite提出的问题: + +![](../../images/1/1-create-vite.png) + + +我们现在创建了一个名为part1的应用。如果我们在回答问题“Install with npm and start now?”时选“Yes”的话,create-vite还会自动安装需要的依赖并启动应用。但是我们打算手动执行这些步骤,这样我们可以看看他们是怎么做的。 - -让我们创建一个名为 part1 的应用,并进入到它的目录。 + +接下来,让我们进入应用的目录并安装需要的库: ```bash -$ npx create-react-app part1 -$ cd part1 +cd part1 +npm install ``` - -从现在开始,所有以$ 开头的命令都表示是输入到终端的,也就是命令行。 但不要把 $ 本身敲到终端,它只是一个输入终端的提示符。 - - -用如下命令就可以让应用运行起来了 + +该应用的打开方式如下 ```bash -$ npm start +npm run dev ``` - -默认情况下,应用在本地localhost,3000端口运行,地址为 http://localhost:3000 + +控制台显示该应用已在本地主机的5173端口运行,地址为: - +![](../../images/1/1-vite1.png) -Chrome这时应该会自动启动。 别忘了,**立即**打开浏览器控制台。 还可以打开一个文本编辑器,这样你就可以同时在屏幕上查看代码和网页了: + +Vite[默认](https://vitejs.dev/config/server-options.html#server-port)在端口5173启动应用。如果这个端口被占用,Vite使用下一个空闲端口号。 -![](../../images/1/1e.png) + +打开浏览器和文本编辑器,这样你就能在屏幕上同时看到代码和网页。 - +![](../../images/1/1-vite4.png) -应用的代码位于src 文件夹中。 让我们简化一下默认代码,将文件index.js 的内容改成: + +应用的代码位于src文件夹中。让我们简化默认代码,使文件main.jsx的内容如下所示: ```js -import React from 'react' -import ReactDOM from 'react-dom' +import ReactDOM from 'react-dom/client' -const App = () => ( -
    -

    Hello world

    -
    -) +import App from './App' -ReactDOM.render(, document.getElementById('root')) +ReactDOM.createRoot(document.getElementById('root')).render() ``` - -文件App.jsApp.cssApp.test.jslogo.svg、, setupTests.jsserviceWorker.js 可以删除,因为它们目前在我们的应用中不并需要。 + +而文件App.jsx看起来是这样的: -### Component -【组件】 - -文件index.js 定义了一个 React-[组件component](https://reactjs.org/docs/components-and-props.html) ,命名为App,最后一行代码为: +```js +const App = () => { + return ( +
    +

    Hello world

    +
    + ) +} + +export default App +``` + + +文件App.cssindex.css和目录assets可以删除,因为我们现在的应用并不需要它们。 + + +### 组件 + + +文件App.jsx现在定义了一个名为App的[React组件](https://react.dev/learn/your-first-component)。文件main.jsx的最后一行命令 ```js -ReactDOM.render(, document.getElementById('root')) +ReactDOM.createRoot(document.getElementById('root')).render() ``` - -这是将其内容渲染到div 元素中,其 id 值为 'root',该元素在文件public/index.html中定义。 + +将其内容渲染到div-元素中,该元素在文件public/index.html中定义,其id值为'root'。 + + +默认情况下,文件index.html不包含任何我们在浏览器中可见的HTML标记: - -默认情况下,文件 public/index.html 为空。 您可以尝试在文件中添加一些 HTML。 但是,在用 React 开发时,需要渲染的内容通常需要定义为 React 组件。 +```html + + + + + + + part1 + + +
    + + + +``` - -让我们仔细看看定义组件的代码: + +你可以试着在该文件中添加一些HTML。但当使用React时,所有需要渲染的内容通常被定义为React组件。 + + +让我们仔细看一下定义组件的代码: ```js const App = () => ( @@ -87,11 +132,11 @@ const App = () => ( ) ``` - -您可能已经猜到,该组件将被渲染为div-标签,div中又包含一个p-标签,p标签包含的文本为Hello world 。 + +正如你可能猜到的,这个组件将被渲染成一个包裹着p-标签的div-标签,而p-标签包含文本Hello world。 - -严格来说,这个组件被定义成了一个 JavaScript 函数。如下所示,这是一个不接收任何参数的函数 : + +从技术角度来说,该组件被定义为一个JavaScript函数。下面是一个函数(它不接收任何参数): ```js () => ( @@ -101,19 +146,18 @@ const App = () => ( ) ``` - -然后该函数被赋给一个 const 修饰的变量 App: + +然后这个函数被赋值给一个常量App。 ```js const App = ... ``` - - -在 JavaScript 中定义函数有几种方法。 在这里,我们会一直使用[箭头函数](https://developer.mozilla.org/en-us/docs/web/JavaScript/reference/functions/arrow_functions) ,箭头函数定义在新版本的 JavaScript 标准中,即[ECMAScript 6](http://ES6-features.org/#constants) ,也叫做 ES6。 + +在JavaScript中定义函数有多种方法。这里我们将使用[箭头函数](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions),它是在较新的JavaScript版本[ECMAScript 6](http://es6-features.org/#Constants),又称ES6中引入的。 - -由于这个函数只包含一个表达式,所以我们使用了简写,不简写的话是如下这段代码: + +因为函数只由一个表达式组成,所以我们使用了简写来表示这一段代码: ```js const App = () => { @@ -125,11 +169,11 @@ const App = () => { } ``` - -换句话说,这个函数返回了表达式的值。 + +换句话说,该函数返回表达式的值。 - -定义组件的函数中可以包含任何类型的 JavaScript 代码。按如下修改你的组件,观察控制台中的内容: + +定义该组件的函数可以包含任何种类的JavaScript代码。把你的组件修改成: ```js const App = () => { @@ -140,14 +184,29 @@ const App = () => {
    ) } + +export default App ``` - + +然后观察控制台中发生了什么 + +![](../../images/1/30.png) -你还可以在组件内部渲染动态内容。 + +前端开发的第一规矩: - -对组件修改如下: + +> 始终打开控制台 + + +让我们一起重复一遍:我保证在课程中,以及在我接下来的人生中,在开发网页时始终打开控制台。 + + +也可以在一个组件内渲染动态内容。 + + +将组件修改成: ```js const App = () => { @@ -166,22 +225,31 @@ const App = () => { } ``` - + +大括号内的任何JavaScript代码都会被计算,计算的结果会被嵌入到组件产生的HTML中的定义位置。 -大括号中的任何代码都会被计算,并且计算的结果将嵌入到HTML中,嵌入的位置就是 HTML 中定义的位置。 + +注意不要丢掉组件底下的这行代码 + +```js +export default App +``` + + +export语句在教材的大多数示例中都被省略了。但如果没有export语句,组件和整个应用都会无法运行。 + + +还记得之前保证过始终打开控制台吗?去掉这一行后,控制台打印出了什么? ### JSX - -看起来 React 组件返回的是 HTML 标签,但实际并不是这样。 React 组件的布局大部分是使用[JSX](https://reactjs.org/docs/introducing-JSX.html)编写的。 尽管 JSX 看起来像 HTML,但我们其实是在用一种特殊的方法写 JavaScript 。 在底层,React 组件实际上返回的 JSX 会被编译成 JavaScript。 + +看起来React组件返回的是HTML标记。然而,事实并非如此。React组件的布局大多是用[JSX](https://react.dev/learn/writing-markup-with-jsx)编写的。虽然JSX看起来像HTML,但我们实际上是在写JavaScript。在底层,由React组件返回的JSX会被编译成JavaScript。 - -编译后,我们的应用如下所示: + +编译后,我们的应用如下所示: ```js -import React from 'react' -import ReactDOM from 'react-dom' - const App = () => { const now = new Date() const a = 10 @@ -197,42 +265,36 @@ const App = () => { ) ) } - -ReactDOM.render( - React.createElement(App, null), - document.getElementById('root') -) ``` - -编译是由[Babel](https://babeljs.io/repl/)处理的。 使用 *create-react-app* 创建的项目会配置为自动编译。 我们将在本课程的[第7章节](/zh/part7)中学习更多关于这个议题的知识。 + +编译是由[Babel](https://babeljs.io/repl/)处理的。用*Vite*创建的项目会自动编译。我们将在本课程的[第7章节](/zh/part7)中学习更多关于这个主题的内容。 - -也可以将 React 写成“纯 JavaScript”,而不用 JSX。 但没有一个精神正常的人会这样做的。 + +也可以把React写成“纯JavaScript”而不使用JSX。虽然没有正常人会这样做的。 - + +实际上,JSX很像HTML,区别在于通过JSX,你可以在大括号内编写适当的JavaScript来轻松嵌入动态内容。JSX的理念与许多模板语言非常相似,例如和Java Spring一起在服务端使用的Thymeleaf。 -实际上,JSX 与 HTML 非常相似,其区别在于,通过在大括号中编写一些 JavaScript,可以轻松地嵌入一些动态内容。 JSX 的思想与许多模板语言非常相似,就如在 Java Spring 中使用的 Thymeleaf(是一种服务器模板语言)。 - - -JSX 是一种“类[XML](https://developer.mozilla.org/en-us/docs/web/XML/xml_introduction)”语言,这意味着每个标签都需要关闭。 例如,换行符是一个空元素,在 HTML 中可以这样写: + +JSX是“类[XML](https://developer.mozilla.org/en-US/docs/Web/XML/XML_introduction)”语言,这意味着每个标签都需要关闭。例如,换行是一个空元素,在HTML中可以这样写: ```html
    ``` - - -但是在写 JSX 时,标签需要如下关闭: + +但在编写JSX时,标签需要关闭: ```html
    ``` -### Multiple components -【多组件】 - -让我们按照如下方式修改应用(注意: 文件顶部的imports在这些示例中被省略了,以后也会这么处理。 但它们是代码正常运行必需的) : + +### 多个组件 + + +让我们修改App.js文件如下: ```js // highlight-start @@ -253,13 +315,10 @@ const App = () => { ) } - -ReactDOM.render(, document.getElementById('root')) ``` - - -这里我们定义了一个新的组件Hello,并在组件App 中引用了它。 当然,一个组件可以重用: + +我们定义了一个新的组件Hello,并把它用在了App组件里。当然,一个组件可以多次使用: ```js const App = () => { @@ -276,24 +335,23 @@ const App = () => { } ``` - - -使用 React 编写组件很容易,通过组合组件,甚至可以使相当复杂的应用保持很好的可维护性。 实际上,React 的核心理念,就是将许多定制化的、可重用的组件组合成应用。 + +**注意**:在这些示例以及将来的示例中,底部的export部分被省略。但它仍然是代码正常运行所必须的 - + +用React编写组件是很容易的,通过组合组件,即使是比较复杂的应用也可以保持相当的可维护性。事实上,React的一大核心理念就是将应用由许多专门的可重复使用的组件组成。 -还有一个约定,就是应用的组件树顶部都要有一个root 组件 叫做App。 然而,正如我们将在[第6章](/zh/ part6)将要讲到的,在某些情况下,组件的根并不一定是App ,而是包装在了一些工具组件中。 + +另一个强制的惯例是在应用的组件树的顶端有一个叫做App根组件。然而,我们将在[第6章节](/zh/part6)中讲到,有些情况下,App实际上并不是根组件,而是被包裹在一个适当的实用组件中。 -### props: passing data to components -【props:向组件传递数据】 + +### props:向组件传递数据 + +可以使用所谓的[props](https://react.dev/learn/passing-props-to-a-component)向组件传递数据。 - - -使用所谓的[props](https://reactjs.org/docs/components-and-props.html),可以将数据传递给组件。 - - -让我们按照如下方式修改组件Hello + +让我们将Hello组件修改如下: ```js const Hello = (props) => { // highlight-line @@ -305,12 +363,11 @@ const Hello = (props) => { // highlight-line } ``` - + +现在定义组件的函数有一个参数props。作为一个参数,该参数接收一个对象,其字段对应于组件使用者定义的所有“props”。 -现在定义组件的函数有一个参数props。 作为参数,它接收了一个对象,该对象具有组件中所定义的、用于定义user的所有“属性”所对应的字段。 - - -props 按如下定义: + +props的定义如下: ```js const App = () => { @@ -324,15 +381,15 @@ const App = () => { } ``` - - -可以有任意数量的props ,它们的值可以是“硬编码的”字符串,也可以是 JavaScript 表达式的结果。 如果props的值是通过 JavaScript 表达式实现的,那么它必须用花括号括起来。 + +props的数量可以是任意的,它们的值可以是“硬编码”的字符串或JavaScript表达式的结果。如果props的值是通过JavaScript得到的,props必须包裹在大括号中。 - -让我们修改一下代码,使组件Hello 使用两个props: + +让我们修改代码,让Hello组件使用两个props: ```js const Hello = (props) => { + console.log(props) // highlight-line return (

    @@ -356,40 +413,100 @@ const App = () => { } ``` - -上面App 组件传递的props有变量的值、求和表达式的计算结果和一个常规字符串。 + +App组件发送的props有变量的值、表达式的计算结果和普通的字符串。 -### Some note -【一些注意事项】 - + +Hello组件还会将对象props的值打印到控制台上。 - 尽管React 可以生成非常清晰的错误消息,你也应该,至少在一开始的时候,每次前进一小步,并确保每一个修改都能按照预期的方式工作。 + +我真心希望你的控制台是开着的。否则,记住之前保证过的: - + +> 我保证在课程中,以及在我接下来的人生中,在开发网页时始终打开控制台 -控制台应该始终开着 。 如果浏览器报错,那么本着大力出奇迹,继续往下编写代码就很不明智了。 相反,你应该试着理解错误的原因,例如,回退到之前的工作状态: + +软件开发并非易事。如果不利用所有可用的工具,比如网页控制台和_console.log_打印的调试信息,那就更难了。专业人士始终都用这两样工具。对初学者来说,没有任何理由不用这些美妙的工具来让生活更轻松。 -![](../../images/1/2a.png) + +### 可能遇见的错误信息 - -最好记住,在 React 的代码中编写 console.log() 命令(打印到控制台)是可行的,而且是提倡的。 + +如果你的项目装的React版本是18或更早的,你可能会在此时遇到这样的报错信息: - -还要记住 **React 组件名称必须大写**。 如果你像如下这么定义: +![](../../images/1/1-vite5.png) + + +这实际上并不是个错误,只是工具[ESLint](https://eslint.org/)产生的警告。你可以在文件eslint.config.js中添加下面这一行来关闭[react/prop-types](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/prop-types.md)警告 + +```js +export default [ + { ignores: ['dist'] }, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + settings: { react: { version: '18.3' } }, + plugins: { + react, + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...js.configs.recommended.rules, + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + ...reactHooks.configs.recommended.rules, + 'react/jsx-no-target-blank': 'off', + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + 'react/prop-types': 0, // highlight-line + }, + }, +] +``` + + +我们将在[第3章节](/zh/part3/es_lint与代码检查#lint)详细介绍ESLint。 + + +### 一些注意事项 + + +React已经能生成相当清晰的错误信息。尽管如此,你仍应该以**非常小的步骤**前进,来确保每一个改变都能如愿以偿,至少在初学的时候如此。 + + +**控制台应始终打开**。如果浏览器报告错误,不建议继续写更多的代码,寄希望有奇迹出现。相反,你应该试着理解错误的原因,然后比如说回到之前的工作状态。 + +![](../../images/1/1-vite6.png) + + +我们之前提到过,在编写React时,在代码中写console.log()命令(打印到控制台)是可行的,也是值得的。 + + +还要记住,**React组件名称的首字母必须大写**。如果你尝试这样定义一个组件: ```js const footer = () => { return (

    - mluukkai greeting app created by mluukkai
    ) } ``` - -然后像如下这样使用它 + +然后这样使用它 ```js const App = () => { @@ -403,12 +520,11 @@ const App = () => { } ``` - + +页面不会显示在Footer组件中定义的内容,相反,React只会创建一个空的[footer](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/footer)元素,即HTML内置的元素,而不是同名的自定义React元素。如果你把组件名称的第一个字母改为大写字母,那么React就会创建一个定义在Footer组件中的div-元素,并在页面上渲染。 -页面是不会显示 Footer 组件中定义的内容,React 只会创建一个空的Footer 元素。 只有您将组件名称的第一个字母更改为大写字母, React 才会创建在 Footer 组件中定义的div-元素,并将该元素渲染在页面上。 - - -注意 React 组件的内容(通常)需要包含 **一个根元素** 。 例如,如果我们尝试定义App组件而不使用最外面的div-元素: + +注意,React组件的内容(通常)需要包含**一个根元素**。比如如果我们试图定义没有最外层的div元素的App组件: ```js const App = () => { @@ -420,13 +536,13 @@ const App = () => { } ``` - -结果会得到一个错误信息。 + +结果一条错误信息。 -![](../../images/1/3e.png) +![](../../images/1/1-vite7.png) - -但使用根元素并也不是唯一可行的选择,通过创建组件数组 也是一个有效的解决方案: + +使用根元素并不是唯一可行的选择。一个组件的数组也是一个有效的解决方案: ```js const App = () => { @@ -438,11 +554,11 @@ const App = () => { } ``` - -但是,在定义应用的根组件时,数组这种方案并不明智,而且会使代码看起来有点难看。 + +然而,在定义应用的根元素时,这种做法并不特别明智,并且这样做使代码看起来有点难看。 - -由于根元素是必须的,所以在 Dom 树中会有“额外的” div 元素。 这可以通过使用[fragments](https://reactjs.org/docs/fragments.html#short-syntax)来避免,即用一个空元素来包装组件的返回内容: + +由于根元素是强制规定的,我们在DOM树中有“额外的”div元素。我们可以通过使用[Fragment](https://react.dev/reference/react/Fragment)来避免“额外的”div元素,即用一个空元素来包装组件要返回的元素: ```js const App = () => { @@ -460,24 +576,145 @@ const App = () => { } ``` - -现在它已经成功地编译了,React 生成的 DOM 不再包含额外的 div-元素了。 + +现在编译成功了,由React生成的DOM也不再包含额外的div元素。 + + +### 不要渲染对象 + + +考虑一个将我们朋友的姓名和年龄打印到屏幕上的应用: + +```js +const App = () => { + const friends = [ + { name: 'Peter', age: 4 }, + { name: 'Maya', age: 10 }, + ] + + return ( +
    +

    {friends[0]}

    +

    {friends[1]}

    +
    + ) +} + +export default App +``` + + +可是,屏幕上什么都没有。我在代码里找问题找了15分钟,但是我还是不知道哪里出了问题。 + + +我终于想起来我们之前保证过的 + + +> 我保证在课程中,以及在我接下来的人生中,在开发网页时始终打开控制台 + + +控制台飘着红色的文字: + +![](../../images/1/34new.png) + + +问题的核心在于对象不是有效的React子组件,也就是说应用尝试渲染对象但失败了。 + + +代码尝试这样渲染一个朋友的信息 + +```js +

    {friends[0]}

    +``` + + +但出了问题,因为大括号中要渲染的东西是一个对象。 + +```js +{ name: 'Peter', age: 4 } +``` + + +在React里,在大括号中渲染的每个东西都必须是原始值,比如数字或字符串。 + + +修好的代码如下: + +```js +const App = () => { + const friends = [ + { name: 'Peter', age: 4 }, + { name: 'Maya', age: 10 }, + ] + + return ( +
    +

    {friends[0].name} {friends[0].age}

    +

    {friends[1].name} {friends[1].age}

    +
    + ) +} + +export default App +``` + + +现在朋友的名字分别在大括号中渲染 + +```js +{friends[0].name} +``` + + +年龄亦然 + +```js +{friends[0].age} +``` + + +在改正错误后,你应该按🚫来清除控制台的错误,然后刷新页面并确保没有显示新的错误信息。 + + +还有一个小小的注意事项。React是支持渲染数组的,只要数组中的每个元素都能渲染(比如数字或字符串)。所以下面这个程序是能运行的,虽然结果可能不是我们想要的: + +```js +const App = () => { + const friends = [ 'Peter', 'Maya'] + + return ( +
    +

    {friends}

    +
    + ) +} +``` + + +在这一章节中,还不需要用到表格的直接渲染,我们会在下一章节中提起。
    -

    Exercises 1.1.-1.2.

    + +

    练习1.1.~1.2.

    - -练习通过 GitHub 提交,并在[提交应用](https://studies.cs.helsinki.fi/stats/courses/fullstackopen) 中标记练习为已完成。 + +练习通过GitHub上交,并在[上交应用](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)的“my submissions”标签页中标记所有已完成的练习。 - -您可以将本课程的所有练习提交到同一个仓库,或者使用多个不同的仓库。 如果您将来自不同章节的练习提交到同一个仓库中,请使用一个合理的目录命名方案。 + +练习是**一次上交一个章节**的。当你上交了课程中某一章节的练习,你就不能再上交同一章节的未完成的练习。 - -用于提交仓库的一个非常实用的文件结构如下: + +请注意,在这一章节,除了下面的练习,还有[更多的练习](/zh/part1/复杂状态,调试_react应用#练习-1-6-1-14)。在你完成该章节的所有练习之前,请不要上交你的作品。 -``` + +你可以将本课程的所有练习上交到同一个仓库,也可以使用多个仓库。如果你将不同章节的练习上交到同一个仓库,请使用合理的目录命名方案。 + + +下面是一个非常实用的上交仓库的文件结构: + +```text part0 part1 courseinfo @@ -488,35 +725,39 @@ part2 countries ``` - -参考 [这里](https://github.com/fullstack-hy2020/example-submission-repository)! + +请看这个[示例上交库](https://github.com/fullstack-hy2020/example-submission-repository)! - -为课程的每一章节都创建一个目录,它进一步分支成一系列练习的目录,如第1章节的“ unicafe”。 + +课程的每一章节都有一个目录,每个目录下面还有一系列练习的目录,如第1章节的“unicafe”。 - -针对一个 web 应用的一系列练习,建议提交与该应用相关的所有文件,但不要提交node\_modules 目录。 + +课程中的大多数联系都会一点一点地构成更大的应用,比如这一章节的courseinfo、unicafe和anecdotes。只要上交最终完成的应用就可以了。你可以每完成一道练习就在git中做一次提交,但这不是必须的。比如练习1.1.~1.5会构建一个课程信息的应用,只需要上交完成1.5后的成果就可以了! - -提交一章节练习只有一次机会。 当你已经提交了某个章节课程的练习,你就不能再提交该章节的其他未完成的练习了。 + +对于每道Web应用的系列练习,建议上交所有与该应用有关的文件,除了目录node\_modules。 - + +

    1.1:课程信息,第1步

    -请注意,在这一章节,除了下面的练习,还有更多的练习。 直到完成了这章的所有练习,再提交你的 工作。 + +我们在这道练习中将要开始处理的应用程序将在以下几道练习中得到进一步开发。在本课程的这个和接下来的练习集中,只要上交应用程序的最终状态就足够了。如果你想,你也可以为系列的每道练习创建一个git提交,但这不是必要的。 -

    1.1: 课程信息 步骤1

    + +使用Vite来初始化一个新的应用。修改main.jsx为 - +```js +import ReactDOM from 'react-dom/client' + +import App from './App' -我们将在接下来的几个练习中,进一步开发基于这次练习的应用。 在本课程的这个练习和其他即将进行的练习中,只需提交应用的最终状态即可。 如果需要,还可以为该系列的每个练习创建一个commit,但这是完全是可选项。 +ReactDOM.createRoot(document.getElementById('root')).render() +``` - -使用 create-react-app 来初始化一个新的应用,将index.js 的内容修改如下: + +修改App.jsx为 ```js -import React from 'react' -import ReactDOM from 'react-dom' - const App = () => { const course = 'Half Stack application development' const part1 = 'Fundamentals of React' @@ -543,18 +784,20 @@ const App = () => { ) } -ReactDOM.render(, document.getElementById('root')) +export default App ``` - -并删除额外的文件(App.js、 App.css、 App.test.js、logo.svg、setupTests.js、serviceWorker.js)。 + +然后删除多余的文件App.css和index.css,以及目录assets。 - + +整个应用都在同一个组件中。重构代码,使其由三个新组件组成:HeaderContentTotal。所有数据仍驻留在App组件中,使用props将必要的数据传递给每个组件。Header负责显示课程的名称,Content显示各部分及其练习的数量,Total显示练习的总数量。 -不幸的是,目前整个应用都在同一个组件中。 重构代码,使其由三个新组件组成:HeaderContentTotal。 所有数据仍然耦合在App 组件中,让该组件使用props 将必要的数据传递给每个组件。Header 负责显示课程的名称,Content显示课程的章节及其练习的数量, Total 显示练习的总数。 + +在文件App.jsx中定义新组件。 - -App 组件的body大致如下: + +App组件的主体将大致如下: ```js const App = () => { @@ -570,14 +813,24 @@ const App = () => { } ``` - + +**警告** 不要想着同时做好所有组件,这基本上肯定会让整个应用无法运行。一小步一小步地前进,比如首先做Header组件,等到确定Header能运行了,再做下一个组件。 -**警告**: create-react-app 会自动使项目成为一个 git 仓库,除非应用是在已有仓库中创建的。 而您很可能不希望项目成为一个存储库,因此可以在项目的根目录中运行命令 _rm -rf .git_ 。 + +当心,小步前进也许看起来很慢,但这实际上是至今为止最快的前进方法。知名软件开发者Robert "Uncle Bob" Martin曾说过 -

    1.2: 课程信息 步骤2

    +> "The only way to go fast, is to go well" - -重构Content 组件,使它本身不渲染任何章节的名称或练习的数量。 而是让它只渲染三个Part 组件,每个组件渲染一个章节的名称和练习次数。 +> “快速前进的唯一办法,是稳步前进” + + +也就是说,按Martin所说的,小心谨慎地小步前进甚至是快速前进的唯一的方法。 + + +

    1.2:课程信息,第2步

    + + +重构Content组件,使其本身不渲染任何部件的名称或其练习次数。相反,它只渲染三个Part组件,每个组件渲染一个部分的名称和练习的次数。 ```js const Content = ... { @@ -591,8 +844,7 @@ const Content = ... { } ``` - -我们的应用目前是在以相当原始的方式传递信息,因为它是基于单个变量的。但这种情况很快就会好转。 + +我们的应用目前以相当原始的方式传递信息,因为它是基于独立变量的。我们将在[第2章节](/zh/part2)改善,但在此之前,让我们先去第1b章学习一下JavaScript。
    - diff --git a/src/content/1/zh/part1b.md b/src/content/1/zh/part1b.md index 5e367bb0992..c0423671e00 100644 --- a/src/content/1/zh/part1b.md +++ b/src/content/1/zh/part1b.md @@ -6,76 +6,68 @@ lang: zh ---
    - -在本课程中,除了网页开发,我们还有一个目标和需求,就是学习足量的 JavaScript 知识。 - -Javascript 在过去的几年里发展非常迅速,在本课程中,我们将使用新版本的特性。 JavaScript 标准的正式名称是[ECMAScript](https://en.wikipedia.org/wiki/ECMAScript)。 目前(2020年3月,译者注),最新的版本是2019年6月发布的,名为[ECMAScript 2019](http://www.ecma-international.org/ecma-262/10.0/index.html) ,即ES10。 + +在课程中,除了网络开发,我们还有一个目标和需求,就是学习足够多的JavaScript。 - + +JavaScript在过去的几年里进步很快,在本课程中,我们使用了较新版本的功能。JavaScript标准的官方名称是[ECMAScript](https://en.wikipedia.org/wiki/ECMAScript)。目前,最新的版本是2024年6月发布的版本,叫[ECMAScript®2024](https://www.ecma-international.org/ecma-262/),又叫ES15。 -浏览器还不能支持所有 JavaScript 的最新特性。 基于这个事实,许多在浏览器中运行的代码需要从一个新版本的 JavaScript 转译到了一个更旧、更兼容的版本。 + +浏览器还不支持JavaScript的所有最新功能。由于这个事实,很多在浏览器中运行的代码都是从较新版本的JavaScript转译成较旧的、更兼容的版本。 - + +如今,最流行的转译方式是通过[Babel](https://babeljs.io/)。在用Vite创建的React应用中,转译是自动配置的。我们将在本课程的[第7章节](/zh/part7)中仔细研究转译的配置问题。 -如今,最流行的转译方法是使用 [Babel](https://babeljs.io/)。 在使用 create-React-app 创建的 React 应用中转译是自动配置好的。 我们将在本课程的[第7章节](/zh/part7)中仔细研究转译的配置。 + +[Node.js](https://nodejs.org/en/)是一个基于Google的[Chrome V8](https://developers.google.com/v8/) JavaScript引擎的JavaScript运行环境,几乎可以在任何地方运行——从服务器到手机应用。让我们练习一下使用Node编写一些JavaScript。最新版本的Node已经能够理解最新版本的JavaScript,所以代码不需要转译。 - -[Node.js](https://nodejs.org/en/)是一个基于谷歌的 [chrome V8](https://developers.google.com/v8/) 引擎的 JavaScript 运行时环境,可以在任何地方工作,从服务端到移动端。 让我们练习使用 Node 编写一些 JavaScript 您机器上安装的 Node.js 版本至少是 v10.18.0 。 最新版本的 Node 能够理解 JavaScript 最新版本的特性,因此代码不需要被转译。 + +代码被写入以.js结尾的文件中,通过键入node name\_of\_file.js命令来运行。 - + +也可以将JavaScript代码写入Node.js控制台,该控制台可以通过在命令行中输入_node_打开,也可以写入浏览器的开发者工具控制台。[Chrome浏览器的最新版本可以很好地处理JavaScript的新功能](https://compat-table.github.io/compat-table/es2016plus/),无需转译。另外,你可以使用[JS Bin](https://jsbin.com/?js,console)这样的工具。 -代码文件以 .js结尾,通过 node 文件名.js 命令以运行文件。 + +JavaScript在名字和语法上都有点让人想到Java。但是在语言的核心机制上,它们是非常不同的。对于学习过Java的人,尤其是那些没有花时间去研究的JavaScript特性的人,可能会对JavaScript的行为感到有点陌生。 - + +在某些圈子里,还流行尝试用JavaScript“模拟”Java的特性和设计模式。我们不建议这样做,因为这两种语言和各自的生态系统最终都是非常不同的。 -还可以将 JavaScript 代码编写到 Node.js 控制台(通过在命令行中键入 _node_ 打开),或者浏览器的开发工具控制台中。 最新版本的 Chrome 能 [很好地](http://kangax.github.io/compat-table/es2016plus/) 处理 JavaScript 的新特性,而且不需要转译代码。 + +### 变量 - - -JavaScript 都有点像 Java。 但是当涉及到语言的核心机制时,它们就大不相同了。 如果你是 Java 背景,Javascript 的写法可能看起来有点古怪,尤其是如果你不花精力去查阅它的特性的话。 - - - -在某些圈儿里,也很流行在 Javascript 中“模拟” Java 的特性和设计模式。但我们不建议这样做。 - -### Variables -【变量】 - - -在 JavaScript 中有以下几种定义变量的方法: + +在JavaScript中,有几种方法来定义变量: ```js const x = 1 let y = 5 -console.log(x, y) // 1, 5 are printed +console.log(x, y) // 1 5 are printed y += 10 -console.log(x, y) // 1, 15 are printed +console.log(x, y) // 1 15 are printed y = 'sometext' -console.log(x, y) // 1, sometext are printed +console.log(x, y) // 1 sometext are printed x = 4 // causes an error ``` - - -[const](https://developer.mozilla.org/en-us/docs/web/javascript/reference/statements/const)实际上并没有定义一个变量,而是定义了一个常量,也就是其值不能再更改了。 相对应的,[let](https://developer.mozilla.org/en-us/docs/web/javascript/reference/statements/let)定义了一个普通变量。 - - + +[const](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const)实际上并没有定义一个变量,而是一个常量,其值不能再被改变。相对应的,[let](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let)定义了一个普通变量。 -在示例中,我们还可以看到,分配给变量的数据类型,在执行过程中可以发生更改。 例如开头的 y 存储了一个整数,但最后存储一个字符串。 + +在上面的例子中,我们还看到分配给变量的数据类型在执行过程中可以改变。在开始时_y_存储的是一个整数,在结束时是一个字符串。 - + +在JavaScript中也可以使用关键字[var](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/var)来定义变量。在很长一段时间内,var是定义变量的唯一方法。 const和let是在2015年的ES6版本中引入的。在特定情况下,与大多数语言中的变量定义相比,var的运行方式有所不同——更多信息请参见[Medium上的JavaScript Variables - Should You Use let, var or const?](https://medium.com/craft-academy/javascript-variables-should-you-use-let-var-or-const-394f7645c88f)或[JS Tips上的Keyword: var vs. let](http://www.jstips.co/en/javascript/keyword-var-vs-let/) 。在本课程中,使用var是不明智的,你应该坚持使用const和let! + +你可以在YouTube上找到更多关于这个主题的信息——例如 [var, let and const - ES6 JavaScript Features](https://youtu.be/sjyJBL5fkp8) -也可以使用关键字[var](https://developer.mozilla.org/en-us/docs/web/Javascript/reference/statements/var)在 JavaScript 中定义变量。 在很长一段时间里,var 是定义变量的唯一方法。 const 和 let 是最近才在 ES6版本中添加的。 在一些特定情况,var 的工作方式与大多数语言中的变量定义相比是[十分不同的](https://medium.com/craft-academy/javascript-variables-should-you-use-let-var-or-const-394f7645c88f)。 在本课程中明确不建议使用var,你应该坚持使用 const 和 let! + +### 数组 - -你可以在 YouTube中找到更多关于这个 [var, let and const - ES6 JavaScript Features](https://youtu.be/sjyJBL5fkp8)议题的讨论 - -### Arrays -【数组】 - -以下是[数组](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/array)和它的几个使用示例: + +一个[数组](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array)和几个使用它的例子: ```js const t = [1, -1, 3] @@ -87,16 +79,14 @@ console.log(t[1]) // -1 is printed t.forEach(value => { console.log(value) // numbers 1, -1, 3, 5 are printed, each to own line -}) +}) ``` - - -在这个示例中值得注意的是,即使将数组用 const 定义,也可以修改该数组中的内容。 因为数组是一个对象,而数组变量总是指向这同一个对象。 当添加新的元素时,数组的内容也将发生变化。 + +需要注意的是在这个例子中,虽然用const声明的变量不可以再被重新赋值,但是对象引用的内容依然可以更改。这是因为const声明保证的是引用地址的不变性,而非引用数据的不变性。这就好比改变房子里的家具的时候,房子的地址还是一样的。 - - -遍历元素的一种方法是使用 _forEach_ ,如示例中所示, _forEach_ 接收一个函数作为入参,这个函数用到了箭头语法。 + +遍历数组项目的一种方法是使用_forEach_,如示例中所示。_forEach_接收一个用箭头语法定义的函数作为参数。 ```js value => { @@ -104,13 +94,11 @@ value => { } ``` - - -forEach 为数组中的每个元素调用了这个函数,并总是将这单个项作为参数传递。 作为 forEach 的入参函数,也可以接收[一些其他参数](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/array/forEach)。 + +forEach为数组中的每一项调用函数,总是传递单个项作为参数。作为forEach参数的函数也可以接收[其他参数](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach)。 - - -在前面的示例中,使用了[push](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/array/push)方法将一个新元素添加到数组中。 在使用 React 时,经常使用函数式编程的技巧。 函数编程范型的一个特点,就是使用[不可变的](https://en.wikipedia.org/wiki/immutable_object)数据结构。 在React代码中,最好使用[concat](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/array/concat)方法 ,因为它不向数组中添加元素,而是创建一个新数组,新数组中包含了旧数组和新的元素。 + +在前面的例子中,使用方法[push](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push)将一个新项添加到数组中。在使用React时,经常使用函数式编程的方法。函数式编程范式的一个特点是使用[不可变的](https://en.wikipedia.org/wiki/Immutable_object)数据结构。在React代码中,最好使用[concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat)方法,该方法会创建一个包含新项的新数组。这样可以保证原始数组保持不变。 ```js const t = [1, -1, 3] @@ -121,14 +109,11 @@ console.log(t) // [1, -1, 3] is printed console.log(t2) // [1, -1, 3, 5] is printed ``` + +方法调用_t.concat(5)_并没有向旧数组添加一个新的项,而是返回一个新的数组,这个数组除了包含旧数组的项之外,还包含新的项。 - - - - _t.concat(5)_ 这种方法调用不会向旧数组添加新的元素,而是直接返回一个新数组,该数组除了包含旧数组的元素外,还包含新的元素。 - - -数组中定义了许多有用的方法,让我们来看一个使用[map](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/array/map)方法的简短示例。 + +数组定义了很多有用的方法。让我们看看一个使用[map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map)方法的简短例子。 ```js const t = [1, 2, 3] @@ -137,47 +122,41 @@ const m1 = t.map(value => value * 2) console.log(m1) // [2, 4, 6] is printed ``` + +map基于旧数组创建了一个新数组,这个数组使用作为参数的函数来创建每一项。在这个例子中,是将原始值乘以2。 - - - -基于旧的数组,map 创建一个 新的数组,旧数组的每一项作为函数的入参来创建新的元素。 在这个例子中,就是旧数组的元素乘以2。 - - -Map 还可以将数组转换为完全不同的内容: + +map也可以将数组转化为完全不同的东西: ```js const m2 = t.map(value => '
  • ' + value + '
  • ') -console.log(m2) +console.log(m2) // [ '
  • 1
  • ', '
  • 2
  • ', '
  • 3
  • ' ] is printed ``` - + +这里通过map方法将一个充满整数值的数组转化为一个包含HTML字符串的数组。在本课程的[第2章节](/zh/part2)中,我们看到map在React中的使用得相当频繁。 -这个例子使用 map 方法将整数值的数组转换为了包含 HTML 字符串的数组。 在本课程的[第2章](/zh/part2)中,我们将看到 map 在 React 中使用得相当频繁。 - - - -数组中的单个元素可以很容易地通过[解构赋值](destructuring assignment)赋给变量。 + +在[解构赋值](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment)的帮助下,很容易将数组中的每个项目赋值给变量。 ```js const t = [1, 2, 3, 4, 5] const [first, second, ...rest] = t -console.log(first, second) // 1, 2 is printed -console.log(rest) // [3, 4 ,5] is printed +console.log(first, second) // 1 2 is printed +console.log(rest) // [3, 4, 5] is printed ``` - - -由于这种解构赋值方式,变量 _first_ 和 _second_ 将接收数组的前两个整数作为它们的值。 剩余的整数被“收集”到另一个数组中,然后分配给变量 rest。 + +上面,数组的第一个整数赋值给了变量_first_,数组的第二个整数赋值给了变量_second_。变量_rest_“收集”其余的整数到自己的数组中。 -### Objects -【对象】 - + +### 对象 -在 JavaScript 中,定义对象有几种不同的方式。 一个非常常见的方法是使用[对象字面量](https://developer.mozilla.org/en-us/docs/web/javascript/guide/grammar_and_types#object_literals) ,就是通过在大括号中列出它的属性来实现的: + +在JavaScript中,有多种不同的方法来定义对象。一种非常常见的方法是使用[对象字面量](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Grammar_and_types#Object_literals),也就是在大括号内列出其属性: ```js const object1 = { @@ -202,43 +181,40 @@ const object3 = { } ``` - + +属性的值可以是任何类型的,比如整数、字符串、数组、对象…… -属性的值可以是任何类型的,比如整数、字符串、数组、对象...。 - - -对象的属性可以使用“句点”号或括号进行引用: + +一个对象的属性是通过“点”号或中括号来引用的: ```js console.log(object1.name) // Arto Hellas is printed -const fieldName = 'age' +const fieldName = 'age' console.log(object1[fieldName]) // 35 is printed ``` - -你也可以使用句点符号或括号来动态地往对象中添加属性: + +你也可以通过使用点号或中括号来为一个对象即时添加属性: ```js object1.address = 'Helsinki' object1['secret number'] = 12341 ``` - -后面的那个属性的添加必须通过使用中括号来完成,因为在使用点符号的话,带空格的secret number并不是一个合法的属性名。 - - + +后一种添加必须使用中括号,因为当使用点号时,由于有空格字符,secret number不是一个有效的属性名称。 -当然,JavaScript 中的对象也可以包含方法。 但是,在这个课程中,我们并不需要定义带方法的对象,因此这里只是简单地提及它。 + +自然地,JavaScript中的对象也可以有方法。然而在本课程中,我们不需要定义任何有自己方法的对象。这就是为什么在本课程中只简单地讨论它们。 - + +对象也可以用所谓的构造函数来定义,这一机制让人想起许多其他编程语言,例如Java的类。尽管有这种相似性,JavaScript并没有与面向对象的编程语言一样的类。然而,从ES6版本开始,增加了class语法,这在某些情况下有助于构造面向对象的类。 -对象也可以使用所谓的构造函数来定义,这产生了一种类似其他编程语言的机制,例如 Java 中的类。 尽管有相似之处,JavaScript 并没有对标面向对象程序设计语言中类的概念。 但是,从 ES6版本开始,增加了类语法,这在某些情况下有助于构造面向对象的类。 + +### 函数 -### Functions -【函数】 - - -我们已经了解了箭头函数的定义。 定义箭头函数的完整过程如下: + +我们已经熟悉了定义箭头函数的方法。在不走弯路的情况下,定义一个箭头函数的完整过程如下: ```js const sum = (p1, p2) => { @@ -248,17 +224,16 @@ const sum = (p1, p2) => { } ``` - - -这个函数可以被如下方式调用: + +函数的调用和预期的一样: ```js const result = sum(1, 5) console.log(result) ``` - -如果只有一个参数,我们可以在定义中去掉括号: + +如果只有一个参数,我们可以在定义中排除括号。 ```js const square = p => { @@ -267,18 +242,15 @@ const square = p => { } ``` - - -如果函数只包含一个表达式,则不需要写大括号。 在这种情况下,函数只返回这个唯一表达式的结果。 现在,如果我们去掉控制台打印,可以进一步缩短函数定义如下: + +如果函数只包含一个表达式,那么大括号就不需要了。在这种情况下,函数只返回其唯一表达式的结果。现在,如果我们去掉控制台打印,我们可以进一步简化函数定义: ```js const square = p => p * p ``` - - - -这种方式在操作数组时特别方便,例如,使用 map 方法: + +这种形式在操作数组时特别方便——例如使用map方法时: ```js const t = [1, 2, 3] @@ -286,14 +258,11 @@ const tSquared = t.map(p => p * p) // tSquared is now [1, 4, 9] ``` + +箭头函数的功能是在2015年的[ES6](https://rse.github.io/es6-features/)版本才加入到JavaScript中的。在这之前,定义函数的唯一方法是使用关键字_function_。 - - - -这个箭头函数是几年前随 [ES6](http://es6-features.org/) 一起添加到 Javascript 中。 在此之前,定义函数的唯一方法是使用关键字 _function_。 - - -有两种方法可定义函数function; 一种是在[函数声明](https://developer.mozilla.org/en-us/docs/web/javascript/reference/statements/function)中给一个名字。 + +有两种方式来引用函数;一种是在[函数声明](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function)中给出一个名称。 ```js function product(a, b) { @@ -304,9 +273,8 @@ const result = product(2, 6) // result is now 12 ``` - - -另一种定义函数的方法是使用[函数表达式](https://developer.mozilla.org/en-us/docs/web/javascript/reference/operators/function)。 在这种情况下,没有必要为函数命名,定义可以放在代码的其它位置: + +另一种定义函数的方式是使用[函数表达式](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/function)。在这种情况下,不需要给函数一个名字,定义可以存在于代码的其他部分: ```js const average = function(a, b) { @@ -317,21 +285,20 @@ const result = average(2, 5) // result is now 3.5 ``` - - - -在本课程中,我们将使用箭头语法定义所有函数。 + +在本课程中,所有函数都使用箭头语法定义。
    -

    Exercises1.3-1.5

    + +

    练习1.3.~1.5.

    -We continue building the application that we started working on in the previous exercises. You can write the code into the same project, since we are only interested in the final state of the submitted application. -我们继续构建我们在前面的练习中开始的应用。 您可以将代码编写到同一个项目中,因为我们只关心提交的应用的最终状态。 + +我们将继续构建我们在之前练习中开始编写的应用程序。你可以将代码编写到同一个项目中,因为我们只关心上交的应用程序的最终状态。 - -专业提示: 当涉及到组件接收的props 的结构时,您可能会遇到问题。 一个很好的方法就是把props打印到控制台上,例如: + +**建议:**当涉及到组件接收的props的结构时,你可能会遇到问题。一个让事情更明确的好方法是把props打印到控制台,例如: ```js const Header = (props) => { @@ -340,11 +307,19 @@ const Header = (props) => { } ``` -

    1.3: 步骤3,课程信息

    + +每当你遇到报错信息 - +> Objects are not valid as a React child -让我们继续在应用中使用对象。 按如下方式修改App 组件的变量定义来重构应用,使其仍然可以正常工作: + +记住[这里](/zh/part1/react简介#不要渲染对象)提到的内容。 + + +

    1.3:课程信息 第3步

    + + +让我们开始在我们的应用中使用对象。修改App组件的变量定义如下,同时重构应用,使其仍能运行: ```js const App = () => { @@ -370,10 +345,11 @@ const App = () => { } ``` -

    1.4: 课程信息 步骤4

    + +

    1.4:课程信息 第4步

    - -然后将对象放到一个数组中。按如下方式修改App 变量的定义,并相应地修改应用的其他部分: + +然后将对象放入一个数组。将App的变量定义修改为以下形式,并相应地修改应用的其他部分: ```js const App = () => { @@ -401,12 +377,11 @@ const App = () => { } ``` - -**注意** 在这里,我假定它总是有三个元素,所以没有必要使用循环遍历数组。 我们将在[课程的下一章节](/zh/part2),即“基于数组中的元素渲染组件”这一议题中进行更深入的讨论。 - - + +**注意**当前你可以假设总是有三个项目,所以没有必要用循环来遍历数组。我们将在[课程的下一章节](../part2)中以更深入的探索来回到基于数组中的项目来渲染组件的话题。 -但也不要把这些对象作为单独的 props 从App 组件传递给ContentTotal 两个组件。 而应将它们直接作为数组传递: + +然而,不要把不同的对象作为多个props从App组件传递给ContentTotal组件,而是直接将它们作为一个数组传递: ```js const App = () => { @@ -422,10 +397,10 @@ const App = () => { } ``` -

    1.5: 课程信息 步骤5

    - - -让我们进一步做一些改变。 将课程及其章节合成为一个 JavaScript 对象。 修复好之前所有的缺陷。 + +

    1.5: 课程信息 第5步

    + +让我们再进一步改变。把课程和它的部分改成单个JavaScript对象。修复所有无法运行的地方。 ```js const App = () => { @@ -457,22 +432,19 @@ const App = () => {
    -
    + +### 对象方法和“this” -### Object methods and "this" -【对象方法以及“ this”关键字】 - - -由于在本课程中我们使用的React版本里包含 React hook ,所以我们不需要定义带有函数的对象。 因此**本章的内容与本课程无关** ,但在许多方面确实值得了解。 特别是在使用旧版本的 React 时,必须理解本章的议题。 + +由于本课程使用的是包含React Hooks的React版本,我们无需定义带方法的对象。**这一章的内容与本课程无关**,但在很多方面肯定是值得了解的。特别是在使用旧版本的React时,必须了解本章的主题。 - + +箭头函数和使用_function_关键字定义的函数,在对关键字[this](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this),即对象本身的行为方式上有很大不同。 -箭头函数和使用function关键字的函数,在涉及到 [this](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this) 关键字(指向对象本身)的行为上,有很大的不同。 - - -我们可以通过给一个对象定义函数属性,来给对象分配方法: + +我们可以通过定义函数类型的属性来将方法赋值给对象: ```js const arto = { @@ -481,16 +453,16 @@ const arto = { education: 'PhD', // highlight-start greet: function() { - console.log('hello, my name is', this.name) + console.log('hello, my name is ' + this.name) }, // highlight-end } -arto.greet() // hello, my name is Arto Hellas gets printed +arto.greet() // "hello, my name is Arto Hellas" gets printed ``` - -方法甚至可以在对象创建之后再赋值给对象: + +即使在对象创建之后,也可以将方法赋值给对象: ```js const arto = { @@ -498,7 +470,7 @@ const arto = { age: 35, education: 'PhD', greet: function() { - console.log('hello, my name is', this.name) + console.log('hello, my name is ' + this.name) }, } @@ -513,8 +485,8 @@ arto.growOlder() console.log(arto.age) // 36 is printed ``` - -让我们稍微修改一下对象 + +让我们稍微修改一下对象: ```js const arto = { @@ -522,7 +494,7 @@ const arto = { age: 35, education: 'PhD', greet: function() { - console.log('hello, my name is', this.name) + console.log('hello, my name is ' + this.name) }, // highlight-start doAddition: function(a, b) { @@ -537,76 +509,69 @@ const referenceToAddition = arto.doAddition referenceToAddition(10, 15) // 25 is printed ``` - - -现在对象有了 doAddition 方法,该方法将传递给他的参数进行求和。 该方法通常使用对象的 arto.doAddition(1, 4) 来调用,或者通过赋值给变量的 方法引用 referenceToAddition(10, 15)来调用该方法 - - + +现在这个对象有一个方法_doAddition_计算给它的参数的数字之和。该方法的调用方式和平常一样,使用对象arto.doAddition(1, 4),或者将方法引用存储到变量中,然后通过该变量调用该方法:referenceToAddition(10, 15)。 - -如果我们用同样的方式调用_greet_函数,我们就会遇到一个问题: + +如果我们试图对方法_greet_做同样的事情,我们会遇到一个问题。 ```js -arto.greet() // hello, my name is Arto Hellas gets printed +arto.greet() // "hello, my name is Arto Hellas" gets printed const referenceToGreet = arto.greet -referenceToGreet() // prints only hello, my name is +referenceToGreet() // prints "hello, my name is undefined" ``` + +当通过引用调用方法时,该方法失去了对原始_this_的引用。与其他语言相反,在JavaScript中,[this](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this)的值是根据方法的调用方式定义的。当通过引用调用方法时,_this_的值就变成了所谓的[全局对象](https://developer.mozilla.org/en-US/docs/Glossary/Global_object),最终的结果往往不是开发者最初的意图。 + +编写JavaScript代码时丢掉对_this_的跟踪带来了一些潜在的问题。当React或Node(或者更确切地说,网络浏览器的JavaScript引擎)需要调用开发者定义的对象中的某些方法时经常会出现这样的情况。然而,在本课程中,我们通过使用“无this”的JavaScript来避免这些问题。 - - -当通过引用调用referenceToGreet() 方法时,该方法已经不认识原始的this是什么了。 与其他语言相反,在 JavaScript 中,[this](https://developer.mozilla.org/en-us/docs/web/Javascript/reference/operators/this)的值是根据 方法如何调用 来定义的。 当通过引用调用该方法时, _this_ 的值就变成了所谓的[全局对象](https://developer.mozilla.org/en-us/docs/glossary/global_object) ,而最终结果往往不是软件开发人员设想的那样。 - - - -失去对this 关键字的追踪,在编写 Javascript 代码时会带来一些潜在的问题。 通常情况下,React 或 Node (或者更确切地说是 web 浏览器的 Javascript 引擎) 需要调用开发人员定义的对象中的某个方法。 然而,在本课程中,我们会使用“ 去this” (避免使用this关键字)的JavaScript 来避免这些问题。 - - - -一种消除“this”所引起的问题的一种方法就是,利用[setTimeout](https://developer.mozilla.org/en-us/docs/web/api/windoworworkerglobalscope/setTimeout)方法,让arto对象1秒钟后调用greet。 + +当我们使用[setTimeout](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout)函数设置超时来调用_arto_对象上的_greet_函数时,就会出现_this_“消失”的情况。 ```js const arto = { name: 'Arto Hellas', greet: function() { - console.log('hello, my name is', this.name) + console.log('hello, my name is ' + this.name) }, } setTimeout(arto.greet, 1000) // highlight-line ``` - -在 JavaScript 中,this 的值是根据方法的调用方式来定义的。 当 setTimeout 使用该方法时,是JavaScript引擎在调用该方法,此时的this是指向的Timeout 对象。 + +如前所述,JavaScript中_this_的值是根据方法被调用的方式来定义的。当setTimeout在调用方法时,是JavaScript引擎在实际调用方法,此时,_this_是指全局对象。 - -有几种机制可以保留这种原始的 this 。 其中一个是使用[bind](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/function/bind)方法: + +有几种机制可以保留原来的_this_。其中之一是使用方法[bind](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind)。 ```js setTimeout(arto.greet.bind(arto), 1000) ``` - -命令 arto.greet.bind(arto) 创建了一个新函数,它将 this 绑定指向到了 Arto,这与方法的调用位置和方式无关。 + +调用arto.greet.bind(arto)创建一个新的函数,其中_this_被绑定为指向Arto,与调用该方法的地点和方式无关。 - -使用[箭头函数](https://developer.mozilla.org/en-us/docs/web/javascript/reference/functions/arrow_functions)可以解决与 _this_相关的一系列问题。 但是,它不能当做对象的方法来使用,因为那样的话this就不起作用了。 稍后我们将回到_this_与箭头函数的关系。 + +使用[箭头函数](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions)可以解决一些与_this_有关的问题。然而,它们不应该被用作对象的方法,因为那样的话_this_就完全不起作用了。我们稍后会回到_this_与箭头函数相关的行为上。 - -如果你想更好地理解 JavaScript 的工作原理,互联网上充满了关于这个议题的材料,例如 [egghead.io](https://egghead.io)的一系列[Understand JavaScript's this Keyword in Depth](https://egghead.io/courses/understand-javascript-s-this-keyword-in-depth)短视频,强烈推荐! + +如果你想更好地了解_this_在JavaScript中是如何运行的,互联网上有很多关于这个主题的资料,例如,强烈推荐[egghead.io](https://egghead.io)的截屏系列[Understand JavaScript's this Keyword in Depth](https://egghead.io/courses/understand-javascript-s-this-keyword-in-depth)! -### Classes -【类】 - -正如前面提到的,JavaScript 中并没有像面向对象程序语言中的类机制。 然而,JavaScript 中的一些新特性使得它能够“模拟”面向对象中的[类](https://developer.mozilla.org/en-us/docs/web/Javascript/reference/classes)。 + +### 类 - -让我们快速看一下与 ES6一起引入到 JavaScript 中的类语法,它在很大程度上简化了 Javascript 中的类(或者说像是类)的定义。 + +如前所述,在JavaScript中没有像面向对象编程语言中的类机制。然而,有一些功能可以“模拟”面向对象的[类](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes)。 - -在下面的代码中,我们定义了一个名为 Person 的“类”和两个 Person 对象。 + +让我们快速浏览一下ES6引入JavaScript的类语法,它大大简化了JavaScript中类(或类似类的东西)的定义。 + + +在下面的例子中,我们定义了一个名为Person的 “类”和两个Person对象。 ```js class Person { @@ -615,7 +580,7 @@ class Person { this.age = age } greet() { - console.log('hello, my name is', this.name) + console.log('hello, my name is ' + this.name) } } @@ -626,29 +591,37 @@ const janja = new Person('Janja Garnbret', 22) janja.greet() ``` - + +在语法上,JavaScrip的类及其创建的对象很容易让人联想到Java的类和对象。JavaScript的类及其创建的对象的行为也与Java的类和对象对象相当相似。但JavaScript类创建的对象的内核仍然是基于[原型继承](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Inheritance)的普通的JavaScript对象。任何类的对象实际上都是_Object_,因为JavaScript定义的类型只有[Boolean、Null、Undefined、Number、String、Symbol、BigInt和Object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures)。 -在语法方面,类以及由它们创建的对象非常类似于 Java 的类和对象。 它们的行为也非常类似于 Java 对象。 但在本质上,它们仍然是基于 JavaScript 的[原型继承](https://developer.mozilla.org/en-us/docs/learn/Javascript/objects/inheritance)的对象。 这两个对象的类型实际上都是 Object,因为 JavaScript 实质上只定义了[Boolean,Null,Undefined,Number,String,Symbol,以及 Object](https://developer.mozilla.org/en-us/docs/web/Javascript/data_structures)几种类型。 + +类语法的引入是有争议的。请查看[Not Awesome: ES6 Classes](https://github.com/petsel/not-awesome-es6-classes)或[Medium上的Is "Class" In ES6 The New "Bad" Part?](https://medium.com/@rajaraodv/is-class-in-es6-the-new-bad-part-6c4e6fe1ee65)了解更多细节。 - -类语法的引入是一个有争议的新特性,例如[Not Awesome: ES6 Classes](https://github.com/petsel/not-awesome-es6-classes) 或者[Is “Class” In ES6 The New “Bad” Part?](https://medium.com/@rajaraodv/is-class-in-es6-the-new-bad-part-6c4e6fe1ee65)这两篇文章所讨论的。 + +ES6类的语法在“老”React和Node.js中用得很多,因此即使在这个课程中,对它的理解也是有益的。然而,由于我们在整个课程中使用React的新[Hook](https://react.dev/reference/react/hooks)功能,我们对JavaScript的类语法没有具体的使用。 - -ES6的类语法在“老的” React 和 Node.js 中被广泛使用,因此即使在本课程中,对它有所了解也是有益的。 但是因为我们在整个课程中都使用了 React 的新的[hook](https://reactjs.org/docs/hooks-intro.html)特性,所以我们没有具体使用 JavaScript 的类语法。 + +### JavaScript资料 -### Javascript materials -【Javascript 教材】 - -互联网上的 JavaScript 指南既有好的,也有不好的。 这个页面上大多数与 JavaScript 特性相关的链接都参考了 Mozilla 的 JavaScript 指南[Mozilla's Javascript Guide](https://developer.mozilla.org/en-US/docs/Web/JavaScript)。 + +互联网上的JavaScript指南良莠不齐。本页面中大多数与JavaScript特性有关的链接都参考了[Mozilla的JavaScript指南](https://developer.mozilla.org/en-US/docs/Web/JavaScript)。 - -强烈建议你立即在 Mozillas 网站上阅读[重新认识JavaScript(JS 教程)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/A_re-introduction_to_JavaScript)。 + +强烈建议立即阅读Mozilla网站上的[JavaScript语言概览](https://developer.mozilla.org/en-US/docs/Web/JavaScript/A_re-introduction_to_JavaScript)。 - -如果你想深入了解 JavaScript,互联网上有一个很棒的免费书系列叫做[You-Dont-Know-JS](https://github.com/getify/You-Dont-Know-JS)。 + +如果你想深入了解JavaScript,网上有个很棒的免费丛书,叫做[You-Dont-Know-JS](https://github.com/getify/You-Dont-Know-JS)。 - -[egghead.io](https://egghead.io) 上有大量关于 JavaScript、 React 及其他有趣议题的高质量短视频。不幸的是,有些材料是付费后才能看的。 + +另一个学习JavaScript的好资源是[javascript.info](https://javascript.info)。 -
    + +[Eloquent JavaScript](https://eloquentjavascript.net)既免费又引人入胜,它能够帮助你快速从基础入门到深入学习。这本书结合了理论、项目和练习,既涵盖了通用编程理论,也介绍了JavaScript语言本身。 + +[Namaste 🙏 JavaScript](https://www.youtube.com/playlist?list=PLlasXeu85E9cQ32gLCvAvr9vNaUccPVNP)是另一个非常棒且强烈推荐的免费JavaScript教程,可以帮助你理解JS的底层原理。Namaste JavaScript是一个纯深入的JavaScript课程,在YouTube上可以免费观看。它详细讲解了JavaScript的核心概念以及JS在JavaScript引擎内部的运行机制。 + + +[egghead.io](https://egghead.io)有大量关于JavaScript、React和其他有趣话题的高质量截屏。不幸的是,有些资料需要付费。 + + diff --git a/src/content/1/zh/part1c.md b/src/content/1/zh/part1c.md index df07afffeb8..5fe41f02160 100644 --- a/src/content/1/zh/part1c.md +++ b/src/content/1/zh/part1c.md @@ -6,11 +6,12 @@ lang: zh ---
    - -让我们回到 React。 - -我们从一个新的例子开始: + +让我们回到使用React的工作上来。 + + +我们从一个新的例子开始: ```js const Hello = (props) => { @@ -37,10 +38,11 @@ const App = () => { } ``` -### Component helper functions -【组件辅助函数】 - -让我们扩展一下Hello 组件,让它能猜到被问候(greeted)者的出生年份: + +### 组件的辅助函数 + + +让我们扩展我们的Hello组件,让它猜测被问候者的出生年份: ```js const Hello = (props) => { @@ -62,29 +64,26 @@ const Hello = (props) => { } ``` + +猜测出生年份的逻辑被包裹在自己的函数中,并在组件渲染时被调用。 + +人的年龄无需显式作为参数传给函数,因为函数可以直接访问传给组件的所有props。 - -猜测出生年份的逻辑被放到了它自己的函数中,这个函数会在渲染组件时被调用。 - - -用户的年龄不必单独作为参数传递给函数,因为它可以直接访问传递给组件的所有props。 - - + +如果我们仔细检查我们当前的代码,我们会注意到这个辅助函数实际上是定义在另一个定义我们组件行为的函数里面。在Java编程中,在一个函数中定义另一个函数比较复杂,所以不是那么常见。然而,在JavaScript中,在函数中定义函数是一种常规操作。 -如果仔细观察当前代码,我们会注意到这种辅助函数实际上是在另一个函数中定义的,而这个函数是我们用来定义组件行为的。 在 java 中,在一个方法中定义另一个方法是不可能的,但在 JavaScript 中,在函数中定义函数是一种常规操作。 + +### 解构 -### Destructuring -【解构】 - + +在我们继续前进之前,我们将看一下JavaScript语言在ES6规范中添加的一个微小但有用的功能,它允许我们在赋值时从对象和数组中[解构](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment)取值。 -在我们继续之前,我们将看一看 JavaScript 在 ES6规范中添加的的一个很小、但是有用的特性,它允许我们在赋值时从对象和数组中[解构](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment)出值。 + +在我们之前的代码中,我们必须将传递给我们组件的数据引用为_props.name_和_props.age_。在这两个表达式中,我们不得不在代码中重复_props.age_两次。 - -在前面的代码中,我们必须将 props.name 和 props.age 传递给组件让组件来引用。 在这两个表达式中,我们必须在代码中重复 props.age 两次。 - - -因为props 是一个对象 + +由于props是一个对象 ```js props = { @@ -93,9 +92,8 @@ props = { } ``` - - -我们可以通过将属性值直接赋值为两个变量, name 和 age 来简化我们的组件,然后我们可以在代码中使用这两个变量: + +我们可以通过将属性值直接赋值给两个变量_name_和_age_来简化我们的组件,从而我们可以在代码中使用这两个变量: ```js const Hello = (props) => { @@ -108,18 +106,18 @@ const Hello = (props) => { return (
    -

    Hello {name}, you are {age} years old

    +

    Hello {name}, you are {age} years old

    // highlight-line

    So you were probably born in {bornYear()}

    ) } ``` - -注意,在定义 bornYear 函数时,我们为箭头函数使用了更紧凑的语法。 如前所述,如果一个箭头函数由单个命令组成,那么函数体就不需要用花括号括起来。 在这种更紧凑的形式中,函数只返回单个命令的结果。 + +注意,在定义_bornYear_函数时,我们也利用了箭头函数的更紧凑的语法。如前所述,如果一个箭头函数由单个表达式组成,那么函数体就不需要写在大括号里。在这种更紧凑的形式下,函数只是返回单个表达式的结果。 - -也就是说,下面的两个函数定义是等价的: + +简而言之,下面显示的两个函数定义是等价的。 ```js const bornYear = () => new Date().getFullYear() - age @@ -128,8 +126,8 @@ const bornYear = () => { } ``` - -解构使变量的赋值变得更加容易,因为我们可以使用它来提取和收集对象属性的值,将其提取到单独的变量中: + +解构使得变量的赋值更加容易,因为我们可以用它来提取和收集一个对象的属性值到专门的变量中。 ```js const Hello = (props) => { @@ -147,11 +145,8 @@ const Hello = (props) => { } ``` - - - -如果我们要解构的对象具有值 - + +当我们要解构的对象有以下值时 ```js props = { name: 'Arto Hellas', @@ -159,11 +154,11 @@ props = { } ``` - -表达式 const { name, age } = props 会将值 'Arto Hellas' 赋值给 name,35赋值给 age。 + +表达式const { name, age } = props将值'Arto Hellas'赋给_name_,将35赋给_age_。 - -我们可以进一步解构: + +我们可以进一步解构: ```js const Hello = ({ name, age }) => { // highlight-line @@ -180,31 +175,32 @@ const Hello = ({ name, age }) => { // highlight-line } ``` - -传递给组件的props现在直接解构为变量 name 和 age。 + +传递给组件的props现在被直接解构为变量_name_和_age_。 - -这意味着不需要将整个 props 对象赋值给一个名为props 的变量中,然后再将其属性分配到变量 name 和 age 中: + +这意味着我们不是把整个props对象赋值给一个叫做props的变量中,然后再把它的属性赋值给变量_name_和_age_ ```js const Hello = (props) => { const { name, age } = props ``` - -我们只需将 props 对象作为参数传递给组件函数,通过对 props 对象的解构,能够直接将属性值赋给变量: + +而是通过对作为参数传递给组件函数的props对象进行解构,直接将属性值赋值给变量: ```js const Hello = ({ name, age }) => { ``` -### Page re-rendering -【页面重渲染】 - -到目前为止,我们的所有应用都是这样的,即在最初的渲染之后,它们的外观一直是相同的。 如果我们想要创建一个计数器,在这个计数器中的值随着时间的变化而增加,或者点通过击一个按钮而增加,会是什么样呢? + +### 页面的重新渲染 - -让我们从下面的主体开始: + +到目前为止,我们所有的应用都是静态的——在最初的渲染之后,其外观保持不变。如果我们想创建一个计数器,其值随着时间的推移或点击按钮而增加呢? + + +让我们从将文件App.js变成下面的样子开始: ```js const App = (props) => { @@ -214,37 +210,43 @@ const App = (props) => { ) } +export default App +``` + + +将文件index.js改成: + +```js +import ReactDOM from 'react-dom/client' + +import App from './App' + let counter = 1 -ReactDOM.render( - , - document.getElementById('root') +ReactDOM.createRoot(document.getElementById('root')).render( + ) ``` - -根组件通过counter属性,接收到counter的值。 根组件随即将值渲染到屏幕上。 但是当计数器的值发生变化时会发生什么呢? 即,如果我们要添加命令 + +App组件通过_counter_ props得到了计数器的值。这个组件将该值渲染到屏幕上。当_counter_的值改变时,会发生什么?即使我们加入以下内容 ```js counter += 1 ``` - -部件并不会重新渲染。 我们可以通过再次调用 ReactDOM.render 方法让组件重新渲染,例如: + +组件也不会重新渲染。我们可以通过再次调用_render_方法来重新渲染组件,例如: ```js -const App = (props) => { - const { counter } = props - return ( -
    {counter}
    - ) -} - let counter = 1 +const root = ReactDOM.createRoot(document.getElementById('root')) + const refresh = () => { - ReactDOM.render(, - document.getElementById('root')) + root.render( + + ) } refresh() @@ -254,14 +256,14 @@ counter += 1 refresh() ``` - -重新渲染命令被包装在了 _refresh_ 函数中,以减少复制粘贴代码的数量。 + +重新渲染的命令已经被包裹在_refresh_函数中,以减少复制粘贴的代码量。 - -现在,组件渲染了三次,值由1、2最终变成了3。 但是,值1和2在屏幕上显示的时间非常短,因此无法看到它们。 + +现在这个组件渲染了三次,首先是数值1,然后是2,最后是3。 然而,数值1和2在屏幕上显示的时间非常短,以至于它们无法被注意到。 - -我们可以通过使用 [setInterval](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setInterval),通过每隔一秒来重渲染一次并让计数器+1,来实现这个有趣的功能 : + +我们可以通过使用[setInterval](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setInterval)来实现更有趣的功能,每秒钟重新渲染并增加计数器。 ```js setInterval(() => { @@ -270,25 +272,36 @@ setInterval(() => { }, 1000) ``` - -重复调用 _ReactDOM.render_-方法并不是重新渲染组件的推荐方法。 接下来,我们将介绍一种更好的,实现相同效果的方法。 + +重复调用_render_方法并不是重新渲染组件的推荐方式。接下来,我们将介绍实现这一效果的更好的方法。 -### Stateful component -【有状态组件】 - -到目前为止,我们的所有组件都很简单,因为它们没有包含任何组件(生命周期中可能变化)的状态。 + +### 带状态的组件 - -接下来,让我们通过 React 的 [state hook](https://reactjs.org/docs/hooks-state.html) 向应用的App 组件中添加状态。 + +到目前为止,我们所有的组件都是简单的,它们所有的状态在组件生命周期中都不会发生变化。 - -我们会把应用做如下修改: + +接下来,让我们借助React的[状态hook](https://react.dev/learn/state-a-components-memory)来给我们的应用的App组件添加状态。 + + +我们将改变应用的内容如下。main.js回到: ```js -import React, { useState } from 'react' // highlight-line -import ReactDOM from 'react-dom' +import ReactDOM from 'react-dom/client' -const App = (props) => { +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() +``` + + +而App.js则改为: + +```js +import { useState } from 'react' // highlight-line + +const App = () => { const [ counter, setCounter ] = useState(0) // highlight-line // highlight-start @@ -303,34 +316,32 @@ const App = (props) => { ) } -ReactDOM.render( - , - document.getElementById('root') -) +export default App ``` - -在第一行中,应用导入了 useState-函数: + + +在第一行,文件导入了_useState_函数。 ```js -import React, { useState } from 'react' +import { useState } from 'react' ``` - -定义组件的函数体以如下函数调用开始: + +该组件的函数体以函数调用开始: ```js const [ counter, setCounter ] = useState(0) ``` - -函数调用将state 添加到组件,并将其值用0进行初始化。 该函数返回一个包含两个元素的数组。 我们使用前面所讲的解构赋值语法将元素分配给变量 _counter_ 和 _setCounter_ 。 + +该函数调用将状态添加到组件中,并将其初始化为0。该函数返回一个包含两个项目的数组。我们通过使用之前提到过的解构赋值语法将这些项赋值给变量_counter_和_setCounter_。 - - _counter_ 变量被赋予的初始值state 为零。 变量 setCounter 被分配给一个函数,该函数将用于修改 state。 + +_counter_变量被赋了状态的初始值,即0。变量_setCounter_被赋了一个用来修改状态的函数。 - -这个应用调用[setTimeout](https://developer.mozilla.org/en-us/docs/web/api/windoworworkerglobalscope/setTimeout)函数,并传递给它两个参数: 第一个是增加计数器状态的函数,第二个是1秒钟的超时设置: + +应用调用[setTimeout](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout)函数并传递给它两个参数:一个用于增加计数器的状态的函数,和一个一秒钟的定时。 ```js setTimeout( @@ -339,18 +350,18 @@ setTimeout( ) ``` - -函数作为第一个参数传递给 setTimeout ,并会在调用 setTimeout 函数一秒钟后被调用 + +作为第一个参数传递给_setTimeout_函数的函数在调用_setTimeout_函数一秒钟后被调用 ```js () => setCounter(counter + 1) ``` - -当状态修改函数—— setCounter 被调用时, React 重新渲染了这个组件 ,这意味着组件函数的函数体被重新执行: + +当修改状态的函数_setCounter_被调用时,React重新渲染组件,这意味着重新执行组件函数的函数体: ```js -(props) => { +() => { const [ counter, setCounter ] = useState(0) setTimeout( @@ -364,24 +375,24 @@ setTimeout( } ``` - - -第二次执行组件函数时,它调用了 useState 函数返回的新状态值: 1。 再次执行函数体还会对 setTimeout 进行一次新的函数调用,它会执行一秒钟的超时并再次递增计数器状态。 由于counter变量的值现在是1,所以将该值增加1本质上等同于将计数器的状态值设置为2。 + +第二次执行组件函数时,它会调用_useState_函数并返回状态的新值:1。再次执行函数体的时候,也会再一次调用_setTimeout_,等待一秒钟后再次增加状态_counter_的值。因为变量_counter_的值是1,将值增加1等价于将_counter_的值设为2。 ```js () => setCounter(2) ``` -Meanwhile, the old value of _counter_, "1", is rendered to the screen. -与此同时,计数器的旧值“1”被渲染到了屏幕上。 - -每次 setCounter 修改状态时,它都会导致组件重新渲染。 状态的值将在一秒钟后再次递增,并且在应用运行期间循环往复。 + +同时,_counter_的旧值——“1”——被渲染在屏幕上。 + + +每次_setCounter_修改状态都会导致组件被重新渲染。一秒钟后,状态的值将被再次增加,只要应用在运行,这个过程就会一直重复下去。 - -如果组件在该渲染时没有渲染,或者在“错误的时间”进行了渲染,您可以通过将组件变量的值打印到控制台来调试应用。 如果我们在代码中添加了如下内容: + +如果组件在你认为应该渲染的时候没有渲染,或者在“错误的时间”渲染,你可以通过将组件的变量值记录到控制台来调试应用。如果我们对我们的代码做如下补充: ```js -const App = (props) => { +const App = () => { const [ counter, setCounter ] = useState(0) setTimeout( @@ -397,28 +408,31 @@ const App = (props) => { } ``` - -就很容易跟踪并捕获render函数的调用: + +就能轻易跟踪对App组件的渲染函数的调用: ![](../../images/1/4e.png) + +浏览器的控制台开了吗?如果没开,保证这是你最后一次需要提醒了。 + + +### 事件处理 -### Event handling -【事件处理】 - -我们已经在[第0章](/zh/part0)中多次提到事件处理程序,它们(被注册为)在特定事件发生时进行调用。 例如,用户与一个网页的不同元素的交互可能会触发一系列不同类型的事件。 + +我们在[第0章节](/zh/part0)中已经提到了事件处理函数,也就是注册到程序中,让程序在特定事件发生时调用的函数。例如,用户与网页中不同元素的交互会触发一系列不同类型的事件。 - -让我们修改一下应用,这样当用户单击一个按钮时,计数器就会增加,这可以通过[button](https://developer.mozilla.org/en-us/docs/web/html/element/button)-元素实现的。 + +让我们改变应用,让计数器在用户点击按钮时增加,这是用[button](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button)元素实现的。 - -button-元素支持所谓的[鼠标事件](https://developer.mozilla.org/en-us/docs/web/api/mouseevent 事件) ,其中[点击](https://developer.mozilla.org/en-us/docs/web/events/click 事件)是最常见的事件。 + +button元素支持所谓的[鼠标事件](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent),其中[点击](https://developer.mozilla.org/en-US/docs/Web/Events/click)是最常见的一种。尽管名字叫鼠标事件,它也可以通过键盘或触摸屏来触发。 - -在 React 中,将一个事件处理函数注册到click 事件 [发生](https://reactjs.org/docs/handling-events.html) 时,如下: + +在React中,为点击事件[注册一个事件处理函数](https://react.dev/learn/responding-to-events)是这样的: ```js -const App = (props) => { +const App = () => { const [ counter, setCounter ] = useState(0) // highlight-start @@ -440,17 +454,17 @@ const App = (props) => { } ``` - -我们将按钮的onClick-属性 的值设置为 handleClick 函数的引用。 + +我们将按钮的onClick属性的值设为对代码中定义的_handleClick_函数的引用。 - -现在,每次单击plus 按钮都会调用 handleClick 函数,这意味着每次单击事件都会将clicked 消息打印到浏览器控制台。 + +现在每次点击plus按钮都会调用_handleClick_函数,这意味着每次点击事件都会向浏览器控制台记录一条clicked消息。 - -事件处理函数也可以在 onClick-属性的值中直接定义: + +事件处理函数也可以直接在onClick属性的赋值中定义: ```js -const App = (props) => { +const App = () => { const [ counter, setCounter ] = useState(0) return ( @@ -464,8 +478,8 @@ const App = (props) => { } ``` - -将事件处理程序更改为如下形式: + +通过改变事件处理函数为以下形式 ```js ``` - -我们实现了预期,也就是计数器的值增加了1,而且组件被重新渲染。 + +我们实现了期望的行为,也就是说,_counter_的值增加了1并且组件被重新渲染了。 - -让我们再添加一个重置计数器的按钮: + +我们还可以添加一个按钮来重置计数器: ```js -const App = (props) => { +const App = () => { const [ counter, setCounter ] = useState(0) return ( @@ -490,7 +504,7 @@ const App = (props) => { plus // highlight-start - // highlight-end @@ -499,90 +513,76 @@ const App = (props) => { } ``` - -现在我们的应用已经准备好了! - - - + +我们的应用现在已经准备好了! + +### 事件处理函数是一个函数 -### Event handler is a function -【事件处理是一个函数】 - -我们为按钮定义事件处理程序,声明它们的 onClick 属性: + +我们为在声明我们的按钮的onClick属性的地方定义事件处理函数: ```js - ``` - - -如果我们尝试以更简单的形式定义事件处理,应该怎样定义呢? + +如果我们试图以更简单的形式来定义事件处理函数呢? ```js - ``` + +这将完全破坏我们的应用: - -们的应用崩了: - -![](../../images/1/5b.png) +![](../../images/1/5c.png) - - - -怎么回事?事件处理程序应该是一个函数 或一个函数引用,当我们编写时 + +这是怎么回事?一个事件处理函数应该是一个函数或者一个函数引用,而当我们写: ```js ``` - -现在,按钮的属性定义了单击按钮时发生的事情,onClick的值为 _() => setCounter(counter +1)_。 - - -只有当用户单击按钮时才会调用 setCounter 函数。 - - + +现在按钮的属性定义了当按钮被点击时发生的事情 —— onClick —— 其值为 _() => setCounter(counter + 1)_。 + +只有当用户点击按钮时,才会调用setCounter函数。 - -通常在 JSX-模板 中定义事件处理程序并不是一个好的实践。 + +通常在JSX模板中定义事件处理函数并不是一个好主意。 + +这里可以,因为我们的事件处理函数非常简单。 - -但这里没问题,因为我们的事件处理程序非常简单。 - - -但无论如何,让我们将事件处理程序分离成单独的函数: + +无论如何,让我们把事件处理函数分离成专门的函数: ```js -const App = (props) => { +const App = () => { const [ counter, setCounter ] = useState(0) // highlight-start const increaseByOne = () => setCounter(counter + 1) - + const setToZero = () => setCounter(0) // highlight-end @@ -600,33 +600,32 @@ const App = (props) => { } ``` - - - -这里就正确定义了事件处理。onClick 属性的值是一个包含函数引用的变量: + +在这里,事件处理函数已经被正确定义。onClick属性的值是一个包含对一个函数的引用的变量: ```js - ``` -### Passing state to child components -【将状态传递给子组件】 - -十分建议编写跨应用甚至跨项目的、小型且可重用的 React 组件。 让我们重构我们的应用,使它由三个较小的组件组成,一个组件用于显示计数器,两个组件用于显示按钮。 + +### 向子组件传递状态 - -让我们首先实现一个Display 组件,它负责显示计数器的值。 + +建议编写的React组件要小,并且可以在整个应用甚至项目中重用。让我们重构我们的应用,使其由三个小的组件组成,一个组件用于显示计数器,两个组件用于按钮。 - -在 React 中的一个最佳实践是将 [状态提升](https://reactjs.org/docs/lifting-state-up.html) ,提升到组件层次结构中足够高的位置,文档中是这么说的: + +让我们首先实现一个Display组件,负责显示计数器的值。 -> Often, several components need to reflect the same changing data. We recommend lifting the shared state up to their closest common ancestor. -通常,几个组件需要反映相同的变化数据。 我们建议将共享状态提升到它们最接近的共同祖先。 + +React的一个最佳做法是在组件结构中[提升状态](https://react.dev/learn/sharing-state-between-components)。文档中说: - -因此,让我们将应用的状态放在App 组件中,并通过props 将其传递给Display 组件: + +> 通常,几个组件需要反映相同的变化数据。我们建议将共享状态提升到它们最接近的共同祖先。 + + +所以让我们把应用的状态放在App组件中,并通过props把它传递给Display组件: ```js const Display = (props) => { @@ -636,12 +635,11 @@ const Display = (props) => { } ``` - - -使用组件很简单,因为我们只需要将计数器的状态传递给组件即可: + +组件的使用很简单,因为我们只需要将_counter_的状态传递给它: ```js -const App = (props) => { +const App = () => { const [ counter, setCounter ] = useState(0) const increaseByOne = () => setCounter(counter + 1) @@ -653,7 +651,7 @@ const App = (props) => { -
    @@ -661,32 +659,33 @@ const App = (props) => { } ``` - -一切仍然正常。 当单击按钮并重新渲染App 时,其所有子元素(包括Display 组件)也将重新渲染。 - - + +一切仍在运作。当按钮被点击,App被重新渲染时,它所有的子节点包括Display组件也被重新渲染。 -接下来,让我们为应用的按钮制作一个Button 组件。 我们必须通过组件的props传递事件处理程序以及按钮的标题: + +接下来,让我们为我们应用的按钮制作一个Button组件。我们必须通过组件的props来传递事件处理函数以及按钮的标题。 ```js const Button = (props) => { return ( - ) } ``` - -我们的App 组件现在看起来像这样: + +我们的App组件现在是这样: ```js -const App = (props) => { +const App = () => { const [ counter, setCounter ] = useState(0) const increaseByOne = () => setCounter(counter + 1) + //highlight-start const decreaseByOne = () => setCounter(counter - 1) + //highlight-end const setToZero = () => setCounter(0) return ( @@ -694,66 +693,104 @@ const App = (props) => { // highlight-start ) } ``` - - - -我们可以使用解构,只从props 获取所需的字段,并使用更紧凑的箭头函数: + +我们可以使用解构来只取props中所需的字段,并使用更紧凑的箭头函数形式: ```js -const Button = ({ handleClick, text }) => ( - -) +const Button = ({ onClick, text }) => ``` - + +这样处理也能运行,因为组件只包含一条return语句,所以可以使用更简洁的箭头函数语法。 + diff --git a/src/content/1/zh/part1d.md b/src/content/1/zh/part1d.md index 1a67ced9fc7..cf6f8b099f3 100644 --- a/src/content/1/zh/part1d.md +++ b/src/content/1/zh/part1d.md @@ -7,48 +7,43 @@ lang: zh
    -### Complex state -【复杂状态】 - + +### 复杂状态 -在之前的示例中,应用状态很简单,因为它仅由单个整数组成。 如果我们的应用需要一个更复杂的状态怎么办? + +在我们之前的例子中,应用的状态比较简单,只包括一个整数。如果我们的应用需要更复杂的状态呢? - + +在大多数情况下,最简单但也是最好的方法是通过多次使用_useState_函数来创建专门的状态“片段”。 -在大多数情况下,实现这一点的最简单和最好的方法是多次使用 useState 函数来创建单独的状态“片段”。 - - - -在下面的代码中,我们为应用创建了两个名为 left 和 right 的初始值为0的状态: + +在下面的代码中,我们为应用创建了两个名为_left_和_right_的状态片段,它们的初始值都是0: ```js -const App = (props) => { +const App = () => { const [left, setLeft] = useState(0) const [right, setRight] = useState(0) return (
    -
    - {left} - - - {right} -
    + {left} + + + {right}
    ) } ``` - -组件获得对 setLeft 和 setRight 函数的访问权,可以使用这两个函数更新这两个状态。 + +该组件可以通过调用函数_setLeft_和_setRight_来更新这两个状态片段。 - - -组件的状态或其状态的一部分可以是任何类型。 我们可以通过将leftright 按钮的单击次数保存到一个对象中来实现相同的功能: + +组件的状态或状态的一部分可以是任何类型。我们可以通过将leftright按钮的点击次数保存在单个对象中来实现同样的功能: ```js { @@ -57,63 +52,60 @@ const App = (props) => { } ``` - -在这种情况下,应用应该是这样的: + +在这种情况下,应用将如下所示: ```js -const App = (props) => { +const App = () => { const [clicks, setClicks] = useState({ left: 0, right: 0 }) const handleLeftClick = () => { - const newClicks = { - left: clicks.left + 1, - right: clicks.right + const newClicks = { + left: clicks.left + 1, + right: clicks.right } setClicks(newClicks) } const handleRightClick = () => { - const newClicks = { - left: clicks.left, - right: clicks.right + 1 + const newClicks = { + left: clicks.left, + right: clicks.right + 1 } setClicks(newClicks) } return (
    -
    - {clicks.left} - - - {clicks.right} -
    + {clicks.left} + + + {clicks.right}
    ) } ``` - - -现在组件只有一个状态片段,事件处理程序必须负责更改整个应用的状态。 + +现在这个组件只有一个状态,事件处理函数必须处理整个应用的状态。 - -事件处理程序看起来有点凌乱。当单击左键时,会调用下面的函数: + +事件处理函数看起来有点乱。当左键被点击时,以下函数被调用: ```js const handleLeftClick = () => { - const newClicks = { - left: clicks.left + 1, - right: clicks.right + const newClicks = { + left: clicks.left + 1, + right: clicks.right } setClicks(newClicks) } ``` - -下面的对象被设置为应用的新状态: + +应用的新状态被设为: ```js { @@ -122,47 +114,45 @@ const handleLeftClick = () => { } ``` - - left 属性的新值现在与前一状态的left + 1 的值相同,而right 属性的值与前一状态的right 属性的值相同。 + +现在left属性的新值等同于之前状态的left的值+ 1,而right属性的新值等同于之前状态right属性的值。 - -我们可以通过使用对象的[展开语法](https://developer.mozilla.org/en-us/docs/web/javascript/reference/operators/spread_syntax)更加整洁地定义新的状态对象 - -该语法在2018年夏天添加到了语言规范中的: + +我们可以通过使用2018年夏添加到语言规范中的[对象传播](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax)语法来更整齐地定义新的状态对象: ```js const handleLeftClick = () => { - const newClicks = { - ...clicks, - left: clicks.left + 1 + const newClicks = { + ...clicks, + left: clicks.left + 1 } setClicks(newClicks) } const handleRightClick = () => { - const newClicks = { - ...clicks, - right: clicks.right + 1 + const newClicks = { + ...clicks, + right: clicks.right + 1 } setClicks(newClicks) } ``` - -语法一开始可能看起来有点奇怪。 实际上, { ...clicks } 创建了一个新对象,该对象是具有 _clicks_ 对象的所有属性的副本。 当我们向对象添加新属性时,例如 { ...clicks, right: 1 },新对象中right属性的值将为1。 + +这种语法第一眼看去可能有点奇怪。实际上{ ...clicks }创建了一个_clicks_对象的所有属性副本的新对象。当我们指定一个特定的属性——如{ ...clicks, right: 1 }中的right,新对象中的_right_属性值将是1。 - -在上面的例子中,下面代码: + +在上面的例子中: ```js { ...clicks, right: clicks.right + 1 } ``` - -创建了 _clicks_ 对象的副本,其中 _right_ 属性的值增加了1。 + +创建一个_clicks_对象的副本,其中_right_属性的值增加了1。 - -将对象分配给事件处理中的变量是没有必要的,我们可以将函数简化为如下形式: + +没有必要在事件处理函数中把对象赋值给一个变量,我们可以把函数简化为以下形式: ```js const handleLeftClick = () => @@ -172,8 +162,8 @@ const handleRightClick = () => setClicks({ ...clicks, right: clicks.right + 1 }) ``` - -一些读者可能想知道为什么我们不直接更新状态,像这样: + +有些读者可能想,为什么我们不像这样直接更新状态: ```js const handleLeftClick = () => { @@ -182,23 +172,23 @@ const handleLeftClick = () => { } ``` - -这个应用似乎可以工作。 但是,这违反了React 中状态不可直接修改的原则,因为它会导致意想不到的副作用。 必须始终通过将状态设置为新对象来更改状态。 如果来自前一个状态对象的属性只想简单地复制,则必须通过将这些属性复制到新对象中来完成。 + +该应用似乎可以运行。然而,在React中是禁止直接改变状态的,因为[这可能导致意想不到的副作用](https://stackoverflow.com/a/40309023)。改变状态必须始终通过将状态设置为一个新的对象来完成。如果前一个状态对象的属性没有改变,那么只需要复制它们。这可以通过将这些属性复制到一个新的对象中,并将其设置为新的状态来完成。 - -对于这个特定的应用来说,将所有状态存储在单个状态对象中是一个糟糕的选择; 没有明显的好处,还会导致产生的应用要复杂得多。 在这种情况下,将点击计数器存储到单独的状态块中是一个更合适的选择。 + +对这个应用来说,将所有的状态存储在单个状态对象中是一个糟糕的选择;没有明显的好处,还会使应用变得更复杂。在这种情况下,将点击计数器存储在专门的状态片段中是一个更合适的选择。 - + +有些情况下,将一段应用的状态存储在一个更复杂的数据结构中会有好处。[React的官方文档](https://react.dev/learn/choosing-the-state-structure)包含了一些关于这个主题的有用指导。 -在某些情况下,将一段应用状态存储在更复杂的数据结构中是有益的。 官方的React[文档](https://reactjs.org/docs/hooks-faq.html#should-i-use-one-or-many-state-variables)包含了一些关于这个话题的有用的指导。 + +### 处理数组 -### Handling arrays -【处理数组】 - -让我们向应用添加一个状态,该状态包含一个数组 _allClicks_ ,该数组记录应用中发生的每次单击记录。 + +让我们为我们的应用添加一个数组_allClicks_的状态片段来记录应用中发生的每一次点击。 ```js -const App = (props) => { +const App = () => { const [left, setLeft] = useState(0) const [right, setRight] = useState(0) const [allClicks, setAll] = useState([]) // highlight-line @@ -208,38 +198,36 @@ const App = (props) => { setAll(allClicks.concat('L')) setLeft(left + 1) } -// highlight-end +// highlight-end // highlight-start const handleRightClick = () => { setAll(allClicks.concat('R')) setRight(right + 1) } -// highlight-end +// highlight-end return (
    -
    - {left} - - - {right} -

    {allClicks.join(' ')}

    // highlight-line -
    + {left} + + + {right} +

    {allClicks.join(' ')}

    // highlight-line
    ) } ``` - -每次单击都会被存储到一个叫 _allClicks_ 的单独的状态单元中,这个状态单元被初始化为一个空数组: + +每一次点击都被存储在一个专门的状态片段_allClicks_中。_allClicks_一开始是一个空数组: ```js const [allClicks, setAll] = useState([]) ``` - -当单击left 按钮时,我们将字母 L 添加到 allClicks 数组中: + +当left按钮被点击时,我们将字母L添加到_allClicks_数组中: ```js const handleLeftClick = () => { @@ -248,12 +236,11 @@ const handleLeftClick = () => { } ``` - - -存储在 allClicks 中的状态块现在被设置为一个数组,该数组包含前一个状态数组的所有项以及字母 L。 向数组中添加新元素是通过[concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat)方法完成的,该方法不改变现有数组,而是返回数组 新副本,并将元素添加到该数组中。 + +存储在_allClicks_中的状态片段现在被设为了一个包含之前状态数组的所有项目加上字母L的新数组。将新项添加到数组中是通过[concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat)方法完成的,该方法并不改变当前的数组,而是返回一个添加了新项的数组的新副本。 - -正如前面提到的,在 JavaScript 中也可以使用[push](https://developer.mozilla.org/en-us/docs/web/JavaScript/reference/global_objects/array/push)方法将元素添加到数组中。 如果我们通过将元素push到 allClicks 数组,然后更新状态这种方法来添加元素,应用看起来仍然可以工作: + +之前提到过,在JavaScript中也可以用[push](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push)方法向数组中添加项。如果我们通过把新项“push”到_allClicks_数组中,然后更新状态来添加项,这个应用看起来仍然可以运行。 ```js const handleLeftClick = () => { @@ -263,39 +250,155 @@ const handleLeftClick = () => { } ``` - -但是,不要这样做。 如前所述,React 组件(如 _allClicks_ )的状态不能直接更改。 即使改变状态在某些情况下可以工作,也可能导致很难注意到的问题。 + +然而,__不要__这样做。之前提到过,像_allClicks_这样的React组件的状态是不能直接更改的。即使改变状态在某些情况下看起来是有效的,它也会导致一些很难调试的问题。 - -让我们仔细看看点击历史是如何渲染在页面上的: + +让我们仔细看一下点击的情况是如何被渲染到页面上的: ```js -const App = (props) => { +const App = () => { // ... return (
    -
    - {left} - - - {right} -

    {allClicks.join(' ')}

    // highlight-line -
    + {left} + + + {right} +

    {allClicks.join(' ')}

    // highlight-line +
    + ) +} +``` + + +我们在_allClicks_数组上调用[join](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/join)方法,将所有项连接成单个字符串,并用函数参数传递的字符串(在这个的例子中是一个空格)分开。 + + +### 状态的更新是异步的 + + +让我们给我们的应用添加一个记录按钮点击总次数的状态_total_。每次点击按钮的时候,都更新_total_的值。 + +```js +const App = () => { + const [left, setLeft] = useState(0) + const [right, setRight] = useState(0) + const [allClicks, setAll] = useState([]) + const [total, setTotal] = useState(0) // highlight-line + + const handleLeftClick = () => { + setAll(allClicks.concat('L')) + setLeft(left + 1) + setTotal(left + right) // highlight-line + } + + const handleRightClick = () => { + setAll(allClicks.concat('R')) + setRight(right + 1) + setTotal(left + right) // highlight-line + } + + return ( +
    + {left} + + + {right} +

    {allClicks.join(' ')}

    +

    total {total}

    // highlight-line
    ) } ``` - -我们为 allClicks 数组调用[join](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/array/join)方法,该数组将所有项目连接到一个字符串中,由作为函数参数传递的字符串分隔,在我们的例子中,该字符串是一个空格。 + +这样写不大对: + +![](../../images/1/33.png) + + +出于什么原因,按钮点击次数的总数总是比实际点击的次数少一次。 -### Conditional rendering -【条件渲染】 - -让我们修改我们的应用,使得单击历史的渲染由一个新的 History 组件处理: + +让我们向事件处理函数里添加一些console.log语句。 ```js +const App = () => { + // ... + const handleLeftClick = () => { + setAll(allClicks.concat('L')) + console.log('left before', left) // highlight-line + setLeft(left + 1) + console.log('left after', left) // highlight-line + setTotal(left + right) + } + + // ... +} +``` + + +控制台揭示了问题 + +![](../../images/1/32.png) + + +即使已经调用_setLeft(left + 1)_来更新_left_的值,_left_的值还是旧的,并没有更新。结果,尝试计算按钮点击总次数的结果总是比实际值要小: + +```js +setTotal(left + right) +``` + + +原因在于在React中,状态的更新是[异步](https://react.dev/learn/queueing-a-series-of-state-updates)进行的,也就是说,状态的更新并非立即生效,而是在组件再次渲染前的“某一刻”进行的。 + + +我们可以这样修代码: + +```js +const App = () => { + // ... + const handleLeftClick = () => { + setAll(allClicks.concat('L')) + const updatedLeft = left + 1 + setLeft(updatedLeft) + setTotal(updatedLeft + right) + } + + // ... +} +``` + + +现在点击按钮的次数和实际按左侧按钮的次数一样了。 + + +我们对右侧按钮也用同样的方式处理异步更新: + +```js +const App = () => { + // ... + const handleRightClick = () => { + setAll(allClicks.concat('R')); + const updatedRight = right + 1; + setRight(updatedRight); + setTotal(left + updatedRight); + }; + + // ... +} +``` + + +### 条件渲染 + + +让我们修改我们的应用,让点击历史的渲染由一个新的History组件来处理。 + +```js +// highlight-start const History = (props) => { if (props.allClicks.length === 0) { return ( @@ -311,34 +414,32 @@ const History = (props) => {
    ) } +// highlight-end -const App = (props) => { +const App = () => { // ... return (
    -
    - {left} - - - {right} - // highlight-line -
    + {left} + + + {right} + // highlight-line
    ) } ``` - - -现在,组件的行为取决于是否单击了任何按钮。 如果没有,这意味着 allClicks 数组是空的,那么该组件将渲染一个带有如下说明的 div 组件: + +现在,该组件的行为取决于是否有任何按钮被点击过。如果没有,意味着allClicks数组是空的,那么组件会渲染一个带有使用说明的div元素: ```js
    the app is used by pressing the buttons
    ``` - -在其他情况下,该组件渲染单击历史记录: + +而在其他所有情况下,该组件会渲染点击历史: ```js
    @@ -346,14 +447,14 @@ const App = (props) => {
    ``` - - History 组件根据应用的状态渲染完全不同的 React-元素。 + +History组件根据应用的状态渲染完全不同的React元素。这被称为条件渲染。 - -React 还提供了许多其他的方法来实现[条件渲染](https://reactjs.org/docs/conditional-rendering.html)。 我们将在[第2章节](/zh/part2)中进一步研究这个问题。 + +React还提供了许多其他的方法来进行[条件渲染](https://react.dev/learn/conditional-rendering)。我们将在[第2章节](/zh/part2)中仔细研究。 - -让我们对我们的应用进行最后一次修改,重构它,用上我们前面定义的 Button 组件: + +让我们对我们的应用做最后一次修改,使用我们先前定义的_Button_组件重构它: ```js const History = (props) => { @@ -372,15 +473,9 @@ const History = (props) => { ) } -// highlight-start -const Button = ({ onClick, text }) => ( - -) -// highlight-end +const Button = ({ onClick, text }) => // highlight-line -const App = (props) => { +const App = () => { const [left, setLeft] = useState(0) const [right, setRight] = useState(0) const [allClicks, setAll] = useState([]) @@ -397,85 +492,75 @@ const App = (props) => { return (
    -
    - {left} - // highlight-start -
    + {left} + // highlight-start +
    ) } ``` -### Old React -【老版本的React】 - - - -在这个过程中,我们使用了状态Hook [state hook](https://reactjs.org/docs/hooks-state.html) 来添加状态到我们的 React 组件,这是 React 的新版本的一部分,可以从版本[16.8.0](https://www.npmjs.com/package/React/v/16.8.0)开始使用。 在添加Hook之前,没有办法将状态添加到 React 函数组件。 需要状态的组件必须使用 JavaScript 类语法定义为 React 的 [class](https://reactjs.org/docs/react-component.html) 组件。 + +### 旧React - + +在本课程中,我们使用[状态Hook](https://react.dev/learn/state-a-components-memory)来向我们的React组件添加状态,这是较新版本React的一部分,从[16.8.0](https://www.npmjs.com/package/react/v/16.8.0)起才可以使用。在增加Hook之前,无法向函数式组件添加状态。需要状态的组件必须被定义为[类式](https://react.dev/reference/react/Component)组件,使用JavaScript类的语法。 -在这个课程中,我们做了一个稍微激进的决定,从第一天开始就完全使用Hook,以确保我们正在学习未来的React风格。 尽管函数式组件是 React 的未来,但学习类语法仍然很重要,因为有数十亿行旧的 React 代码可能会在某一天需要维护。 同样的道理,你可能在互联网上偶然发现React的文档和例子也使用了这些旧代码。 + +在这个课程中,我们做了一个略显激进的决定,从第一天开始就只使用Hook,以确保我们学习React的当前和未来形式。即使函数式组件是React的未来,学习类的语法仍然很重要,因为有数十亿行的React遗留代码,你有一天可能会维护它们。这同样适用于你在互联网上意外发现的React的文档和例子。 - -我们将在稍后的课程中学习更多关于 React 类组件的知识。 + +我们将在后面的课程进一步学习关于React的类式组件。 -### Debugging React applications -【调试React应用】 - + +### 调试React应用 -典型的开发人员的大部分时间都花在调试和读取现有代码上。 我们时不时地会写一两行新代码,但是我们的大部分时间都花在试图弄明白为什么有些东西坏了,或者某些东西是如何工作的上面。 出于这个原因,良好的调试实践和工具非常重要。 + +典型开发者的大部分时间都花在调试和阅读现有的代码上。我们确实时不时会写一两行新代码,但我们的大部分时间都花在试图弄清楚为什么某段代码运行不了,或者某段代码是如何运行的。因此,良好的调试实践和工具是非常重要的。 - -幸运的是,在调试方面来说,React 对开发者是非常友好的。 + +幸运的是,在调试方面,React是一个对开发者极其友好的库。 - -在我们继续之前,让我们重新提醒自己 web 开发最重要的规则之一。 + +在我们继续之前,让我们提醒自己网络开发中最重要的规矩之一。 -

    The first rule of web development web开发第一原则

    + +

    web开发的第一规矩

    -> **Keep the browser's developer console open at all times.** + +> **始终保持浏览器的开发者控制台是打开的**。 + > -> 始终打开浏览器的开发控制台 - - ->The Console tab in particular should always be open, unless there is a specific reason to view another tab. ->尤其是Console 选项卡应该始终处于打开状态,除非有特定的原因需要查看另一个选项卡。 - - -保持你的代码和网页同时打开,一直同时打开。 + +> 特别是控制台标签页应该一直打开,除非有特别的原因要查看其他标签页。 - -如果你的代码编译失败,你的浏览器就会像圣诞树一样亮起来: + +**始终、同时**打开你的代码和网页。 -![](../../images/1/6e.png) + +如果当你的代码无法编译,你的浏览器像圣诞树一样亮起来的时候: - +![](../../images/1/6x.png) -不要继续编写更多的代码,而是立即找到并修复问题。 在编码的历史上,还没有哪一次编译失败的代码在编写了大量额外的代码之后奇迹般地开始工作。这样的事情在这个课程中也不会发生。 + +不要写更多的代码,而是要**立即**找到并解决这个问题。在编程的历史上,还没有出现过在编写了大量的额外代码之后,无法编译的代码会奇迹般地开始运行的事情。我非常怀疑在这个课程中是否会发生这样的事情。 - -老派的,基于打印的调试总是一个好主意。如果组件如下所示 + +传统的、基于打印的调试总是一个好主意。如果组件 ```js -const Button = ({ onClick, text }) => ( - -) +const Button = ({ onClick, text }) => ``` - - -不能正常工作时,开始将其变量输出到控制台是很有用的。 为了有效地做到这一点,我们必须将我们的函数转换成不那么紧凑的形式,接收整个props对象而不是解构它: + +不能按预期运行,开始将其变量打印到控制台是很有用的。为了有效地做到这一点,我们必须将我们的函数转化为不太紧凑的形式,并接收整个props对象,而不是立即解构: ```js -const Button = (props) => { +const Button = (props) => { console.log(props) // highlight-line const { onClick, text } = props return ( @@ -486,77 +571,64 @@ const Button = (props) => { } ``` - -这将立即揭示,例如,是否有一个属性在使用组件时拼写错误。 - - + +这将立即显示出,例如,在使用该组件时,其中一个属性被拼错了。 -**注意**:当您使用 console.log 进行调试时,不要使用“加号”,像类似于 java 的方式组合对象。 即不要写: + +**注意** 当你使用_console.log_进行调试时,不要像Java那样用加号运算符组合_objects_: ```js -console.log('props value is' + props) +console.log('props value is ' + props) ``` - -而应用逗号分隔需要打印到控制台的内容: + +如果这样做的话,你最终会得到一个信息量很小日志信息: ```js -console.log('props value is', props) +props value is [Object object] ``` - -如果你使用类似于 java 的方式将一个字符串与一个对象结合起来,你最终会得到一个相当无用的日志消息: + +而是用逗号把你想记录到控制台的东西分开: ```js -props value is [Object object] +console.log('props value is', props) ``` - -而用逗号分隔的项目都可以在浏览器控制台中进行进一步检查。 + +这样,用逗号分隔的项目将在浏览器控制台中全部可用,以供进一步检查。 - + +将输出记录到控制台决不是调试我们应用的唯一方法。你可以在Chrome开发者控制台的调试器中暂停执行应用代码,方法是在代码的任何地方写下[debugger](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/debugger)命令。 -将日志记录到控制台绝不是调试应用的唯一方法。 你可以在 Chrome 开发者控制台的debugger 中暂停应用代码的执行,只需在代码中的任何地方写入命令[debugger](https://developer.mozilla.org/en-us/docs/web/javascript/reference/statements/debugger)即可。 - - -一旦到达调试器命令执行的地方,执行就会暂停: + +一旦执行到_debugger_命令,就会暂停执行。 ![](../../images/1/7a.png) - - -通过访问Console 选项卡,可以很容易地检查变量的当前状态: + +通过进入Console标签页,很容易检查变量当前的状态。 ![](../../images/1/8a.png) - -一旦发现 bug 的原因,您可以删除 _debugger_ 命令并刷新页面。 + +一旦发现问题的原因,你就可以删除_debugger_命令并刷新页面。 - - _debugger_ 还允许我们使用在Source 选项卡右侧找到控件一行一行地执行代码。 + +调试器也使我们能够通过Sources标签页右侧的控件来逐行执行我们的代码。 - -通过在Sources 选项卡中添加断点,您还可以在不使用 _debugger_ 命令的情况下访问调试器。 检查组件变量的值可以在 Scope-部分 中完成: + +你也可以不用_debugger_命令,而通过在Sources标签页中添加断点来访问调试器。检查组件的变量值可以在_Scope_部分完成: ![](../../images/1/9a.png) - -强烈建议在 Chrome 中添加 [React developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi)扩展。 它为开发工具增加了一个新的 React 选项卡: - -![](../../images/1/10e.png) - - -新的 React developer tools 选项卡可用于检查应用中的不同 React 元素,以及它们的状态和属性。 - - -不幸的是,当前版本的 React developer 工具在用 hooks 时显示创建的组件状态,有一些不足之处: - -![](../../images/1/11e.png) - + +强烈建议在Chrome浏览器中添加[React开发者工具](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi)扩展。它在开发者工具中增加了一个新的_Components_标签页,可以用来检查应用中不同的React元素,以及它们的状态和props。 +![](../../images/1/10ea.png) - -组件状态的定义如下: + +_App_组件的状态是这样定义的: ```js const [left, setLeft] = useState(0) @@ -564,24 +636,31 @@ const [right, setRight] = useState(0) const [allClicks, setAll] = useState([]) ``` - -开发工具按照定义顺序显示hook的状态: + +Dev tools按照定义的顺序显示Hook的状态。 -![](../../images/1/11be.png) +![](../../images/1/11ea.png) -### Rules of Hooks -【Hook的规则】 - -为了确保应用正确地使用基于Hook的状态函数,我们必须遵循一些限制和规则。 + +第一个State包含left状态的值,第二个包含right状态的值,最后一个包含allClicks状态的值。 - -不能从循环、条件表达式或任何不是定义组件的函数的地方调用 _useState_ (同样的还有 _useEffect_ 函数,将在后面的课程中介绍)。 这样做是为了确保Hook总是以相同的顺序调用,如果不是这样,应用的行为就会不规则。 + +你还可以在Chrome中学习调试JavaScript,比如通过[Chrome开发者工具指导视频](https://developer.chrome.com/docs/devtools/javascript)。 - -回顾一下,hook 只能从定义 React component 的函数体内部调用: + +### Hook的规则 + + +为了确保我们的应用正确使用基于Hook的状态函数,必须遵循一些限制和[规则](https://react.dev/warnings/invalid-hook-call-warning#breaking-rules-of-hooks)。 + + +不能在循环、条件表达式或任何不是定义组件的函数的地方调用_useState_函数(以及课程后面介绍的_useEffect_函数)。这样做是为了确保Hook总是以相同的顺序被调用,如果不这样做的话,应用将表现得不稳定。 + + +简而言之,只能在定义React组件的函数体内调用Hook: ```js -const App = (props) => { +const App = () => { // these are ok const [age, setAge] = useState(0) const [name, setName] = useState('Juha Tauriainen') @@ -607,18 +686,20 @@ const App = (props) => { } ``` -### Event Handling Revisited -【复习事件处理】 - -事件处理已被证明是本课程前面的迭代中比较难的一块。 + +### 重提事件处理函数 + + +前几期的课程显示事件处理是一个难点。 - -出于这个原因,我们将再次讨论这个话题。 + +因此,我们将重提这个话题。 + + +让我们假设我们在使用如下组件App开发这个简单的应用: - -假设我们正在开发这个简单的应用: ```js -const App = (props) => { +const App = () => { const [value, setValue] = useState(10) return ( @@ -628,31 +709,26 @@ const App = (props) => { ) } - -ReactDOM.render( - , - document.getElementById('root') -) ``` - -我们希望单击按钮来重置存储在 value 变量中的状态。 + +我们希望点击按钮能重置存储在_value_变量中的状态。 - -为了使按钮对单击事件作出反应,我们必须向其添加一个事件处理程序。 + +为了使按钮对点击事件作出反应,我们必须给它添加一个事件处理函数。 - -事件处理程序必须始终是函数或对函数的引用。 如果将事件处理程序设置为任何其他类型的变量,则按钮将不起作用。 + +事件处理函数必须始终是一个函数或对一个函数的引用。如果事件处理函数被设置为任何其他类型的变量,按钮将无法运行。 - -如果我们将事件处理程序定义为一个字符串: + +如果我们将事件处理函数定义为一个字符串: ```js - + ``` - -React会在控制台中警告我们: + +React会在控制台警告我们: ```js index.js:2178 Warning: Expected `onClick` listener to be a function, instead got a value of `string` type. @@ -661,32 +737,32 @@ index.js:2178 Warning: Expected `onClick` listener to be a function, instead got in App (at index.js:27) ``` - -下列尝试也不会奏效: + +下面的尝试也不会成功: ```js ``` - -我们尝试将事件处理程序设置为 value + 1,它只返回操作的结果。 在控制台中会友好地警告我们: + +我们试图将事件处理函数设置为_value + 1_,也就是返回操作的结果。React会在控制台中善意地警告我们: ```js index.js:2178 Warning: Expected `onClick` listener to be a function, instead got a value of `number` type. ``` - -以下这种尝试也不会奏效: + +这种尝试也不会成功: ```js ``` - -事件处理程序不是一个函数,而是一个变量赋值,React 将再次向控制台发出警告。 这种尝试也是有缺陷的,因为我们绝不能在React中直接改变状态。 + +事件处理函数不是一个函数,而是一个变量赋值语句,React将再次向控制台发出警告。这种尝试也是错的,因为我们决不能在React中直接改变状态。 - -下面的内容会发生什么: + +那这样呢: ```js ``` - -消息被打印到控制台一次,但是当我们第二次单击按钮时什么也没有发生。 为什么即使我们的事件处理程序包含一个函数 console.log 也不能工作? + +当组件被渲染时,信息被打印到控制台一次,但当我们点击按钮时,什么也没有发生。为什么即使当我们的事件处理函数包含一个函数_console.log_时,也不能运行呢? + + +这里的问题是我们的事件处理函数被定义为一个函数调用,这意味着事件处理函数实际上是函数的返回值,_console.log_的返回值是undefined。 - -这里的问题是,我们的事件处理被定义为function call,这意味着事件处理程序实际上被分配了函数返回的值,而 console.log 的返回值是undefined。 + +当组件被渲染时,_console.log_函数调用被执行,也因此会打印一次信息到控制台。 - - _console.log_ 函数调用在渲染组件时执行,因此它只在控制台中打印一次。 + +下面的尝试也是错的: - -下面的尝试也是有缺陷的: ```js ``` - -我们再次尝试将函数调用设置为事件处理程序。 这行不通。 这种特殊的尝试也引起了另一个问题。 在渲染组件时,执行函数 setValue (0) ,从而导致重新渲染组件。 依次重新渲染将再次调用 setValue (0) ,从而导致无限递归。 + +我们再次尝试将一个函数调用设置为事件处理函数。这并不奏效。这个尝试也导致了另一个问题。当组件被渲染时,函数_setValue(0)_被执行,这反过来导致组件被重新渲染。重新渲染又会再次调用_setValue(0)_,从而导致无限的递归。 - -当按钮被点击时,执行一个特定的函数调用可以这样完成: + +当按钮被点击时,执行一个特定的函数调用可以这样完成: ```js ``` - -现在,事件处理程序是一个使用箭头函数 _() => console.log('clicked the button')_.定义的函数。 在渲染组件时,不调用任何函数,只将对箭头函数的引用设置为事件处理程序。 只有单击按钮时才调用该函数。 + +现在事件处理函数是一个用箭头函数语法_() => console.log('clicked the button')_定义的函数。当组件被渲染时,没有函数被调用,只有箭头函数的引用被设为事件处理函数。只有当按钮被点击时才会调用该函数。 - -我们可以使用相同的技术在应用中实现重置状态: + +我们可以用同样的方法在我们的应用中实现重置状态: ```js ``` - -事件处理程序现在是函数 _() => setValue(0)_。 + +现在事件处理函数是_() => setValue(0)_。 - -在按钮的属性中直接定义事件处理程序不一定是最好的方法。 + +直接在按钮的属性中定义事件处理函数,并不是最好的做法。 - -您经常会看到事件处理程序定义在一个单独的位置。 在下面的应用中,我们定义了一个函数,然后将其赋值给组件函数体中的 _handleClick_ 变量: + +你经常会看到事件处理函数被定义在一个单独的地方。在我们应用的以下版本中,我们定义了一个函数,然后将其赋值给组件函数主体中的_handleClick_变量: ```js -const App = (props) => { +const App = () => { const [value, setValue] = useState(10) const handleClick = () => @@ -756,18 +833,18 @@ const App = (props) => { } ``` - -现在, _handleClick_ 变量被分配成对函数的引用。 引用作为onClick 属性传递给按钮: + +_handleClick_变量现在是一个函数定义的引用。我们将这个引用传递给按钮的onClick属性: ```js ``` - -当然,我们的事件处理可以由多个命令组成。 在这种情况下,我们对箭头函数,使用较长的大括号语法: + +当然,我们的事件处理函数可以由多个命令组成。在这种情况下,我们对箭头函数使用较长的大括号语法: ```js -const App = (props) => { +const App = () => { const [value, setValue] = useState(10) // highlight-start @@ -786,19 +863,20 @@ const App = (props) => { } ``` -### Function that returns a function -【返回函数的函数】 - -定义事件处理程序的另一种方法是使用返回函数的函数。 + +### 返回函数的函数 + + +另一种定义事件处理函数的方法是使用返回函数的函数。 - -在本课程的任何练习中,您可能不需要使用返回函数的函数。 如果这个议题看起来特别令人困惑,您可以跳过这一章节,稍后再回过头看它。 + +在本课程的任何练习中,你可能都不需要使用返回函数的函数。 如果这个话题看起来特别令人困惑,你可以暂时跳过这一节,以后再来讨论。 - -让我们对我们的代码进行如下修改: + +让我们将我们的代码修改如下: ```js -const App = (props) => { +const App = () => { const [value, setValue] = useState(10) // highlight-start @@ -818,21 +896,21 @@ const App = (props) => { } ``` - -尽管代码看起来很复杂,但它能正常工作。 + +尽管代码看起来很复杂,但它能正确运行。 - -事件处理程序现在设置为函数调用: + +事件处理函数现在被设置为一个函数调用: ```js ``` - -前面我们说过,事件处理程序不能是对函数的调用,它必须是函数或对函数的引用。 那么为什么函数调用在这种情况下会起作用呢? + +先前我们说过,一个事件处理函数不能是对一个函数的调用,它必须是一个函数或对一个函数的引用。那为什么在这种情况下,函数调用就能正确运行了呢? - -在渲染组件时,执行如下函数: + +当组件被渲染时,下面的函数被执行: ```js const hello = () => { @@ -842,18 +920,18 @@ const hello = () => { } ``` - -函数的返回值 是分配给处理程序变量的另一个函数。 + +该函数的返回值是另一个函数,这另一个函数被赋值给_handler_变量。 - -当 React 渲染行时: + +当React渲染这一行时: ```js ``` - -它将 hello ()的返回值赋给 onClick-属性,本质上该行被转换成: + +它把_hello()_的返回值赋值给onClick属性。本质上,这一行被转化为: ```js ``` - -因为 hello 函数返回一个函数,所以事件处理程序现在是一个函数。 + +由于_hello_函数返回一个函数,事件处理函数现在是一个函数。 - -这个概念的意义是什么? + +这个概念的意义在哪儿? - -让我们稍微修改一下代码: + +让我们稍微改变一下代码: ```js -const App = (props) => { +const App = () => { const [value, setValue] = useState(10) // highlight-start @@ -882,33 +960,33 @@ const App = (props) => { return handler } - // highlight-end + // highlight-end return (
    {value} - // highlight-start + // highlight-start - // highlight-end + // highlight-end
    ) } ``` - -现在,应用有三个按钮,事件处理程序由接受参数的 hello 函数定义。 + +现在这个应用有三个按钮,其事件处理函数由接受一个参数的_hello_函数定义。 - -第一个按钮定义为 + +第一个按钮的定义是 ```js ``` - -事件处理程序由执行 函数调用 _hello('world')_创建,函数 call 返回函数: + +事件处理函数是通过执行函数调用_hello('world')_创建的。该函数调用返回函数: ```js () => { @@ -916,15 +994,15 @@ const App = (props) => { } ``` - -第二个按钮定义为: + +第二个按钮的定义是: ```js ``` - -创建事件处理程序的函数 _hello('react')_返回: + +创建事件处理函数的函数调用_hello('react')_返回: ```js () => { @@ -932,14 +1010,14 @@ const App = (props) => { } ``` - -两个按钮都有自己的单独事件处理程序。 + +两个按钮都得到了自己的个性化的事件处理函数。 - -返回函数的函数可用于定义可以使用参数自定义的通用函数。 可以将创建事件处理程序的 hello 函数视为一个生成用户的定制事件处理的工厂。 + +返回的函数的函数可以用在定义通过参数个性化的通用功能。创建事件处理函数的_hello_函数可以被想象成一个产生向用户问好的个性化的事件处理函数的工厂。 - -我们目前的定义有点冗长: + +我们目前的定义略显冗长: ```js const hello = (who) => { @@ -951,8 +1029,8 @@ const hello = (who) => { } ``` - -让我们消除辅助变量,直接返回创建的函数: + +让我们去掉辅助变量,直接返回创建的函数: ```js const hello = (who) => { @@ -962,8 +1040,8 @@ const hello = (who) => { } ``` - -因为 hello 函数是由一个单独的返回命令组成的,所以我们可以省略大括号,对箭头函数使用更紧凑的语法: + +由于我们的_hello_函数由单个返回命令组成,我们可以省略大括号,并使用箭头函数的更紧凑的语法: ```js const hello = (who) => @@ -972,8 +1050,8 @@ const hello = (who) => } ``` - -最后,让我们把所有的箭头写在同一行上: + +最后,让我们把所有的箭头写在同一行: ```js const hello = (who) => () => { @@ -981,19 +1059,20 @@ const hello = (who) => () => { } ``` - -我们可以使用相同的技巧来定义将组件状态,设置为给定值的事件处理程序。 让我们对我们的代码进行如下修改: + +我们可以使用同样的技巧来定义事件处理函数,将组件的状态设置为一个给定的值。让我们对我们的代码做如下修改: ```js -const App = (props) => { +const App = () => { const [value, setValue] = useState(10) - + // highlight-start const setToValue = (newValue) => () => { + console.log('value now', newValue) // print the new value to console setValue(newValue) } // highlight-end - + return (
    {value} @@ -1007,46 +1086,49 @@ const App = (props) => { } ``` - -在渲染组件时,创建thousand 按钮: + +当组件被渲染时,thousand按钮被创建: ```js ``` - -事件处理程序设置为 setToValue (1000)的返回值,该返回值是如下函数: + +事件处理函数被设置为_setToValue(1000)_的返回值,也就是这个函数: ```js () => { + console.log('value now', 1000) setValue(1000) } ``` - -为 increase 按钮生成的代码行如下: + +“加1”按钮的声明如下: ```js ``` - -事件处理程序由函数调用_setToValue(value + 1)_ 创建,该函数接收状态变量值的当前值,并将变量值增加1作为参数。 如果值为10,那么创建的事件处理程序就是函数: + +事件处理函数由函数调用_setToValue(value + 1)_创建,该函数接收状态变量_value_的当前值加一作为其参数。如果_value_的值是10,那么创建的事件处理函数将是这个函数: ```js () => { + console.log('value now', 11) setValue(11) } ``` - -使用返回函数的函数不是实现此功能所必需的。 让我们将负责更新状态的 _setToValue_ 函数返回到一个普通函数: + +实现这个功能不需要使用返回函数的函数。让我们把负责更新状态的_setToValue_函数,改回普通的函数: ```js -const App = (props) => { +const App = () => { const [value, setValue] = useState(10) const setToValue = (newValue) => { + console.log('value now', newValue) setValue(newValue) } @@ -1067,58 +1149,74 @@ const App = (props) => { } ``` - -现在,我们可以将事件处理程序定义为一个函数,该函数使用适当的参数调用 setToValue 函数。 用于重置应用状态的事件处理程序如下: + +我们现在可以把事件处理函数定义为一个函数,该函数用一个适当的参数调用_setToValue_函数。重置应用状态的事件处理函数将是: ```js ``` - -可以在这两种定义事件处理程序的方式中进行选择,这主要取决于个人喜好。 + +在所介绍的两种定义事件处理函数的方式中,选择哪一种主要取决于自己的品味。 -### Passing Event Handlers to Child Components -【将事件处理传递给子组件】 - -让我们将按钮提取到它自己的组件中: + +### 向子组件传递事件处理函数 + + +让我们把按钮提取到它自己的组件中: ```js const Button = (props) => ( - ) ``` - -该组件从 _handleClick_ 属性获取事件处理函数,从text 属性获取按钮的文本。 + +这个组件从_onClick_ prop中获得事件处理函数,从_text_ prop中获得按钮的文本。让我们使用这个新组件: - -使用Button 组件很简单,尽管我们必须确保在向组件传递props时使用正确的属性名。 +```js +const App = (props) => { + // ... + return ( +
    + {value} +
    + ) +} +``` + + +使用Button组件很简单,尽管我们必须确保在向组件传递prop时使用正确的属性名称。 -![](../../images/1/12e.png) +![](../../images/1/12f.png) -### Do Not Define Components Within Components -【不要在组件中定义组件】 + +### 不要在组件中定义组件 - -让我们开始将应用的值显示到它自己的Display 组件中。 + +让我们开始把显示应用值的功能放到它自己的Display组件中。 - -我们将通过在App-组件中定义一个新组件来更改应用。 + +我们将通过在App组件内定义一个新的组件来改变应用。 ```js // This is the right place to define a component const Button = (props) => ( - ) -const App = props => { +const App = () => { const [value, setValue] = useState(10) const setToValue = newValue => { + console.log('value now', newValue) setValue(newValue) } @@ -1127,113 +1225,212 @@ const App = props => { return (
    - -
    ) } ``` - + +应用似乎仍在运行,但**不要这样实现组件!**永远不要在其他组件中定义组件。这种方法没有任何好处,而且会导致许多不愉快的问题。最大的问题是由于React在每次渲染时都将定义在另一个组件内的组件视为一个新的组件。这使得React无法优化该组件。 -应用看起来仍然可以工作,但是 **不要像这样实现组件!**不要在其他组件内部定义组件。 这种方法没有任何好处,而且会导致许多不愉快的问题。 让我们把Display 组件函数移动到正确的位置,这个位置在App 组件函数之外: + +让我们把Display组件函数移到它的正确位置,也就是App组件函数之外: ```js const Display = props =>
    {props.value}
    const Button = (props) => ( - ) -const App = props => { +const App = () => { const [value, setValue] = useState(10) const setToValue = newValue => { + console.log('value now', newValue) setValue(newValue) } return (
    -
    ) } ``` -### Useful Reading -【有用的阅读材料】 - -互联网上充满了React相关的材料。 然而,我们使用了这样一种新的React方式,以至于网上发现的绝大多数材料对我们的目的来说都已经过时了。 - -你可在如下链接中找到有用的资料: + +### 阅读资料 - + +互联网上有很多React相关的资料。然而,因为我们使用的是React的新风格,所以对于我们来说,网上发现的大部分材料已经过时。 -- React[官方文档](https://reactjs.org/docs/hello-world.html)在某种程度上值得一读,尽管其中大部分只有在课程后期才会变得有意义。 此外,所有与类组件相关的内容都与我们无关。 - -- 注意官方的React[教程](https://reactjs.org/tutorial/tutorial.html) ,它不是很好。 - -- 一些关于[Egghead.io](https://Egghead.io)的课程,如[开始学习React](https://Egghead.io/courses/Start-learning-React) ,质量很高,稍新一点的[初学者React指南](https://Egghead.io/courses/The-Beginner-s-guide-to-reactjs)也相对不错; 这两门课程都介绍了一些概念,这些概念也将在本课程后面介绍。 然而,这两门课程都使用了 Class 组件,而不是本课程中使用的新的函数式组件。 + +你可能会发现下面的链接很有用: -
    + +- [React官方文档](https://react.dev/learn)值得在某些时候查看,尽管它的大部分内容在课程的后期才会变得相关。另外,基于类的组件有关的一切都与我们无关; + +- [Egghead.io](https://egghead.io)上的一些课程,比如[Start learning React](https://egghead.io/courses/start-learning-react)的质量很高,最近更新的[Beginner's Guide to React](https://egghead.io/courses/the-beginner-s-guide-to-reactjs)也比较好;两个课程介绍的概念也将在本课程的后面介绍。**注意**前者使用类式组件,后者使用新的函数式组件。 -
    -

    Exercises 1.6.-1.14.

    + +### web开发者誓言 + + +编程不易,因此我们要通过一切方法让它变得容易 + + +- 我会始终打开我的浏览器开发者控制台 + +- 我小步前进 + +- 我会写大量的_console.log_语句来确保我理解代码是怎么运行的,并借此准确找到问题 + +- 如果我的代码出问题了,我不会写更多的代码。而是删除代码直到它能运行,或者直接回到之前代码能运行的状态 + +- 当我在课程的Discord群或者其他地方寻求帮助时,我会准确表达我的问题,点[此](http://fullstackopen.com/en/part0/general_info#how-to-get-help-in-discord)了解如何寻求帮助。 + + +### 利用大语言模型 + + +大语言模型,比如[ChatGPT](https://chat.openai.com/auth/login)、[Claude](https://claude.ai/)和[GitHub Copilot](https://github.com/features/copilot)非常有助于软件开发。 + + +我个人常用的是GitHub Copilot,它现在[原生集成进Visual Studio Code中](https://code.visualstudio.com/docs/copilot/overview) + +提醒一下,如果你是在校大学生的话,你可以通过[GitHub Student Developer Pack](https://education.github.com/pack)来免费试用Copilot pro。 + + +Copilot在许多场合下都有用。你可以在打开的文件里用文本描述你想要的功能,然后让Copilot编写代码: + +![](../../images/1/gpt1.png) + + +如果代码看起来是对的,Copilot会将它加到文件里: + +![](../../images/1/gpt2.png) + + +在我们的例子中,Copilot只是创建了一个按钮,事件处理函数_handleResetClick_没有定义。 + + +事件处理函数也可以由Copilot生成。写完函数的第一行后,Copilot会提供生成的功能: + +![](../../images/1/gpt3.png) + + +在Copilot的聊天窗口,还可以让Copilot解释选中的代码区域。 +![](../../images/1/gpt4.png) + +Copilot在处理错误上也很有用,将错误信息复制到Copilot的聊天窗口,你就能得到对问题的解释还有建议的修复方式: - -提交你的解决方案,首先把你的代码推送到 GitHub,然后把[练习提交系统](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)完成的练习标记为已完成。 +![](../../images/1/gpt5.png) - -记住,在一次提交中提交一章节的所有练习。 一旦你提交了一章节的解决方案,你就不能再向这个章节提交更多的练习了。 + +Copilot的聊天窗口还能为我们创建更多功能 + +![](../../images/1/gpt6.png) + + +Copilot以及其他大语言模型提供的提示的有用程度各不相同。但也许大语言模型最大的问题是[幻觉](https://en.wikipedia.org/wiki/Hallucination_(artificial_intelligence)),它们有时会生成看起来完全有说服力的答案,但这些答案完全是错的。当然在编程中,幻觉代码只要运行不了就能查出来。更麻烦的是大语言模型生成的代码有时看起来能运行,但是隐藏了更难以发现的问题,或者诸如缺乏安全性的问题。 + + + +在软件开发中使用大语言模型还有另一个问题,语言模型很难“理解”大型项目,也难以生成比如需要修改多个文件的功能。目前大语言模型也无法很好地泛化代码,比如如果要实现的新功能只需要稍微修改项目中已有的函数或组件,语言模型不会利用这些已有的内容。这可能导致代码库变得越来越冗余,因为语言模型会生成大量重复代码,详见[这里](https://visualstudiomagazine.com/articles/2024/01/25/copilot-research.aspx)。 + + +使用语言模型时,责任始终在程序员自己。 + + +语言模型的快速发展让编程学习者面临挑战:既然几乎所有内容都能从语言模型中获得现成的,是否还值得,以及是否还有必要深入学习编程呢? + +此时,值得回忆一下[Brian Kernighan](https://en.wikipedia.org/wiki/Brian_Kernighan),《C程序设计语言》作者之一的古老智慧: + +![](../../images/1/kerningham.png) +> 每个人都知道,调试代码比从头写代码难两倍。所以如果你在编写时就足够聪明,那你将来又怎么调试呢? + +换句话说,既然调试比编程难两倍,就不应该写那些自己都勉强能看懂的代码。如果编程都外包给了大语言模型,要调试的代码开发者自己都不理解,那又怎么可能调试呢? + +目前为止,大语言模型和AI的发展还没有达到自给自足的阶段,最难的问题还是要靠人类来解决。因此,即使是初学者,也必须学好编程,以备不时之需。也许语言模型的发展,反而需要我们学得更深。人工智能可以处理简单的事情,但AI造成的最复杂的烂摊子还是要人类来收拾。GitHub Copilot这个名字非常贴切,它是副驾驶,只是在飞机上帮助主驾驶的驾驶员。程序员仍然是主驾驶,是机长,是扛起最终责任的人。 + +也许,在这门课程中默认关闭Copilot,只有在真正紧急情况时才依赖它,会对你更有利。 + +
    + +
    -Some of the exercises work on the same application. In these cases, it is sufficient to submit just the final version of the application. If you wish, you can make a commit after every finished exercise, but it is not mandatory. -有些练习是针对同一个应用的。 在这些情况下,只提交应用的最终版本就足够了。 如果您愿意,您可以在每次完成练习后进行commit,但这不是强制性的。 + +

    练习1.6.~1.14.

    - + +要上交练习的解答,先将你的代码推送到GitHub,然后在[上交应用](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)的“my submissions”标签页将练习标记为已完成。 -警告: create-react-app 会自动使项目成为一个 git 仓库,除非应用是在已有仓库中创建的。 而您很可能不希望项目成为一个存储库,因此可以在项目的根目录中运行命令 *_rm -rf .git_* + +记住,**一次性上交**一个章节的**所有**练习。一旦你上交了一个章节的解答,**你就不能再上交该章节的练习了**。 - -在某些情况下,您可能还必须从项目的根目录运行如下命令: + +有些练习开发的是同一个应用。对于这些练习,只要上交应用的最终版本就够了。你也可以每完成一道练习就在git中提交一次,但并不强制。 -``` + +在某些情况下,你可能还得从项目的根目录运行以下命令: + +```bash rm -rf node_modules/ && npm i ``` -

    1.6: unicafe 步骤1

    + +如果你遇到了以下报错信息 + +> Objects are not valid as a React child - + +记住[这里](/zh/part1/react简介#不要渲染对象)讲过的内容。 - -像大多数公司一样, [Unicafe](https://www.unicafe.fi/#/9/4)收集来自客户的反馈。 您的任务是实现一个收集客户反馈的 web 应用。 反馈只有三种选择:goodneutralbad。 + +

    1.6:unicafe 第1步

    - -应用必须显示每个类别收集的反馈总数。最终的应用可以是这样的: + +和大多数公司一样,赫尔辛基大学的学生餐厅[Unicafe](https://www.unicafe.fi)从客户那里收集反馈。你的任务是实现一个收集客户反馈的web应用。反馈只有三个选项:,和。 + + +应用必须显示每个类别收集到的反馈的总数。你的最终应用可以是这样的: ![](../../images/1/13e.png) + +注意你的应用只需要在一个浏览器会话中运行。一旦你刷新页面,所收集的反馈消失也不要紧。 + + +建议使用和教材与之前练习中相同的结构。文件main.js如下: - -请注意,您的应用只需要在单个浏览器会话期间工作。 一旦刷新页面,收集到的反馈信息就会消失。 +```js +import ReactDOM from 'react-dom/client' + +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render() +``` - -您可以在一个index.js 文件中实现该应用。 您可以使用下面的代码作为应用的起点。 + +你可以用下面的代码作为App.jsx文件的起点: ```js -import React, { useState } from 'react' -import ReactDOM from 'react-dom' +import { useState } from 'react' const App = () => { - // save clicks of each button to own state + // save clicks of each button to its own state const [good, setGood] = useState(0) const [neutral, setNeutral] = useState(0) const [bad, setBad] = useState(0) @@ -1245,27 +1442,25 @@ const App = () => { ) } -ReactDOM.render(, - document.getElementById('root') -) +export default App ``` -

    1.7: unicafe 步骤2

    - + +

    1.7:unicafe 第2步

    - - -扩展您的应用,以便它显示更多关于收集到的反馈的统计数据: 收集到的反馈总数、平均分数(好: 1,中性: 0,坏:-1)和正反馈的百分比。 + +扩展你的应用,使其显示更多关于收集到的反馈的统计数据:收集到的反馈总数、平均分(“好”1分,“中”0分,“差”-1分)和反馈“好”的百分比。 ![](../../images/1/14e.png) -

    1.8: unicafe 步骤3

    + +

    1.8:unicafe 第3步

    - -重构应用,以便将显示统计信息提取到它自己的Statistics 组件中。 应用的状态应该保留在App 根组件中。 + +重构你的应用,将显示统计数据的功能提取到它自己的Statistics组件。应用的状态应该保留在App根组件中。 - -记住组件不应该在其他组件中定义: + +记住组件不应该被定义在其他组件里面: ```js // a proper place to define a component @@ -1289,33 +1484,37 @@ const App = () => { } ``` -

    1.9: unicafe 步骤4

    - -只有在收集到反馈之后,才能将应用更改为显示统计信息。 + +

    1.9:unicafe 第4步

    -![](../../images/1/15e.png) + +改变你的应用,只有在收集到反馈后才显示统计信息。 -

    1.10: unicafe 步骤5

    +![](../../images/1/15e.png) + +

    1.10:unicafe 第5步

    + +让我们继续重构应用。提取以下两个组件: - -让我们继续重构这个应用,提取如下两个组件: + +- Button处理提交每个反馈按钮的功能。 -- 按钮 用于定义用于提交反馈的按钮 -- 显示单一统计数字的 statistics,例如平均分数。 + +- StatisticLine用于显示单个统计数据,例如平均分。 - -需要明确的是:statistics 组件总是显示一个统计信息,这意味着应用需要使用多个组件来渲染所有的统计信息: + +明确一下:StatisticLine组件总是显示单个统计数字,这意味着应用使用多个组件来渲染所有的统计数字: ```js const Statistics = (props) => { /// ... return(
    - - - + + + // ...
    ) @@ -1323,133 +1522,126 @@ const Statistics = (props) => { ``` - -应用的状态仍然应该保存在App 根组件中。 - -

    1.11*: unicafe 步骤6

    - -在 HTML [表格](https://developer.mozilla.org/en-us/docs/learn/HTML/tables/basics)中显示统计信息 ,这样你的应用看起来大致如下: + +应用的状态仍应保存在根App组件中。 -![](../../images/1/16e.png) + +

    1.11*:unicafe 第6步

    + +在一个HTML[表格](https://developer.mozilla.org/en-US/docs/Learn/HTML/Tables/Basics)中显示统计数据,这样你的应用看起来大致是这样的: +![](../../images/1/16e.png) - -请记住始终打开控制台。如果在控制台中看到如下警告: + +记住要一直保持你的控制台开启。如果你在你的控制台看到这个警告: ![](../../images/1/17a.png) - -执行必要的操作使警告消失。如果卡住了,尝试用谷歌搜索错误消息。 - - -典型的错误来用来源: `Unchecked runtime.lastError: Could not establish connection. Receiving end does not exist.` 是 Chrome 扩展导致的。 尝试在 `chrome://extensions/`中 ,逐个禁用它们并刷新 React app 页面; 错误最终应该会消失。 + +然后执行必要的操作以使警告消失。如果你被卡住了,试着把错误信息粘贴到搜索引擎上。 - + +错误_Unchecked runtime.lastError: Could not establish connection. Receiving end does not exist._的源头一般是某个Chrome扩展。尝试去_chrome://extensions/_试着一个一个关掉它们,然后刷新React应用页面;错误最终应该会消失的。 -确保从现在开始,你在控制台上看不到任何警告! + +**确保从现在开始,你在控制台中不会看到任何警告!** -

    1.12*: anecdotes 步骤1

    + +

    1.12*:名言警句 第1步

    - -在软件工程的世界里,充满了从我们这个领域提炼出永恒真理 [箴言anecdotes](http://www.comp.nus.edu.sg/~damithch/pages/SE-quotes.htm) 。 + +软件工程的世界充满了将我们领域中永恒的真理提炼成短短一句话的[名言警句](http://www.comp.nus.edu.sg/~damithch/pages/SE-quotes.htm)。 - - -通过添加一个点击按钮来显示软件工程领域的随机箴言,扩展如下应用: + +扩展下面的应用,添加一个可以点击来随机显示一条软件工程领域的名言警句的按钮: ```js -import React, { useState } from 'react' -import ReactDOM from 'react-dom' +import { useState } from 'react' + +const App = () => { + const anecdotes = [ + 'If it hurts, do it more often.', + 'Adding manpower to a late software project makes it later!', + 'The first 90 percent of the code accounts for the first 90 percent of the development time...The remaining 10 percent of the code accounts for the other 90 percent of the development time.', + 'Any fool can write code that a computer can understand. Good programmers write code that humans can understand.', + 'Premature optimization is the root of all evil.', + 'Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it.', + 'Programming without an extremely heavy use of console.log is same as if a doctor would refuse to use x-rays or blood tests when diagnosing patients.', + 'The only way to go fast, is to go well.' + ] -const App = (props) => { const [selected, setSelected] = useState(0) return (
    - {props.anecdotes[selected]} + {anecdotes[selected]}
    ) } -const anecdotes = [ - 'If it hurts, do it more often', - 'Adding manpower to a late software project makes it later!', - 'The first 90 percent of the code accounts for the first 90 percent of the development time...The remaining 10 percent of the code accounts for the other 90 percent of the development time.', - 'Any fool can write code that a computer can understand. Good programmers write code that humans can understand.', - 'Premature optimization is the root of all evil.', - 'Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it.' -] - -ReactDOM.render( - , - document.getElementById('root') -) +export default App ``` - -谷歌会告诉你如何在 JavaScript 中生成随机数。 记住,你可以在浏览器的控制台中测试随机数的生成。 + +文件main.js的内容与之前的练习相同。 - -你完成的应用可以是这样的: - -![](../../images/1/18a.png) + +找出如何在JavaScript中生成随机数,例如通过搜索引擎或去[Mozilla Developer Network](https://developer.mozilla.org)。记住,你可以直接在浏览器的控制台测试生成随机数等。 + +你完成的应用可能如下所示: - +![](../../images/1/18a.png) -**警告**: create-react-app 会自动使项目成为一个 git 仓库,除非应用是在已有仓库中创建的。 而您很可能不希望项目成为一个存储库,因此可以在项目的根目录中运行命令 *_rm -rf .git_* 。 + +

    1.13*: 名言警句 第2步

    -

    1.13*: anecdotes 步骤2

    - -扩展您的应用,以便您可以为显示箴言的投票。 + +扩展你的应用,让你可以为显示的名言警句投票。 ![](../../images/1/19a.png) + +**注意**将每个名言警句的投票存入组件状态中的一个数组或对象。记住,更新存储在对象和数组等复杂数据结构中的状态的正确方法是制作状态的副本。 - - -注意, 将对每个箴言的投票存储到组件状态的数组或对象中。 记住,更新存储在对象和数组等复杂数据结构中的状态,正确方法是复制状态。 - - -你可以像这样创建一个对象的副本: + +你可以像这样复制一个对象: ```js -const points = { 0: 1, 1: 3, 2: 4, 3: 2 } +const votes = { 0: 1, 1: 3, 2: 4, 3: 2 } -const copy = { ...points } +const copy = { ...votes } // increment the property 2 value by one -copy[2] += 1 +copy[2] += 1 ``` - -或者一个数组的副本: + +或者像这样复制一个数组: ```js -const points = [1, 4, 6, 3] +const votes = [1, 4, 6, 3] -const copy = [...points] +const copy = [...votes] // increment the value in position 2 by one -copy[2] += 1 +copy[2] += 1 ``` - -在这种情况下,使用数组可能是更简单的选择。 在 google 上搜索会给你提供很多关于如何创建一个具有期望长度的、零填充的数组,比如[这一篇](https://stackoverflow.com/questions/20222501/how-to-create-a-zero-filled-javascript-array-of-arbitrary-length/22209781)。 - -

    1.14*: anecdotes 步骤3

    + +在这种情况下,使用一个数组可能是更简单的选择。互联网上可以搜索到很多关于如何[创建一个所需长度的零填充数组](https://stackoverflow.com/questions/20222501/how-to-create-a-zero-filled-javascript-array-of-arbitrary-length/22209781)的提示。 - + +

    1.14*: 名言警句 第3步

    - -现在实现这个应用的最终版本,显示得票最多的警句: + +现在实现应用的最终版本,显示拥有最多票的名言警句: ![](../../images/1/20a.png) - -如果有多个箴言并列第一,那么只要展示其中一个就足够了。 + +如果多个名言警句并列第一,只显示其中一个就足够了。 - -这是本课程这一章节的最后一个练习,现在是时候把你的代码推送到 GitHub,并将所有完成的练习标记到[练习提交系统](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)。 + +这是本章节课程的最后一道练习,是时候将你的代码推送到GitHub,然后在[上交应用](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)的“my submissions”标签页中标记所有完成的练习了。
    - diff --git a/src/content/10/en/part10.md b/src/content/10/en/part10.md new file mode 100644 index 00000000000..bcf55cbf8af --- /dev/null +++ b/src/content/10/en/part10.md @@ -0,0 +1,15 @@ +--- +mainImage: ../../../images/part-10.svg +part: 10 +lang: en +--- + +
    + +In this part, we will learn how to build native Android and iOS mobile applications with JavaScript and React using the React Native framework. We will dive into the React Native ecosystem by developing an entire mobile application from scratch. Along the way, we will learn concepts such as how to render native user interface components with React Native, how to create beautiful user interfaces, how to communicate with a server, and how to test a React Native application. + +Part updated 26th Feb 2024 +- New Node 20 version of rate-repository-api +- Material updated + +
    diff --git a/src/content/10/en/part10a.md b/src/content/10/en/part10a.md new file mode 100644 index 00000000000..4dcd40788cb --- /dev/null +++ b/src/content/10/en/part10a.md @@ -0,0 +1,212 @@ +--- +mainImage: ../../../images/part-10.svg +part: 10 +letter: a +lang: en +--- + +
    + +Traditionally, developing native iOS and Android applications has required the developer to use platform-specific programming languages and development environments. For iOS development, this means using Objective C or Swift and for Android development using JVM-based languages such as Java, Scala or Kotlin. Releasing an application for both these platforms technically requires developing two separate applications with different programming languages. This requires lots of development resources. + +One of the popular approaches to unify the platform-specific development has been to utilize the browser as the rendering engine. [Cordova](https://cordova.apache.org/) is one of the most popular platforms for building cross-platform applications. It allows for developing multi-platform applications using standard web technologies - HTML5, CSS3, and JavaScript. However, Cordova applications are running within an embedded browser window in the user's device. That is why these applications can not achieve the performance nor the look-and-feel of native applications that utilize actual native user interface components. + +[React Native](https://reactnative.dev/) is a framework for developing native Android and iOS applications using JavaScript and React. It provides a set of cross-platform components that behind the scenes utilize the platform's native components. Using React Native allows us to bring all the familiar features of React such as JSX, components, props, state, and hooks into native application development. On top of that, we can utilize many familiar libraries in the React ecosystem such as [React Redux](https://react-redux.js.org/), [Apollo](https://github.com/apollographql/react-apollo), [React Router](https://reactrouter.com/en/main) and many more. + +The speed of development and gentle learning curve for developers familiar with React is one of the most important benefits of React Native. Here's a motivational quote from Coinbase's article [Onboarding thousands of users with React Native](https://benbronsteiny.wordpress.com/2020/02/27/onboarding-thousands-of-users-with-react-native/) on the benefits of React Native: + +> If we were to reduce the benefits of React Native to a single word, it would be “velocity”. On average, our team was able to onboard engineers in less time, share more code (which we expect will lead to future productivity boosts), and ultimately deliver features faster than if we had taken a purely native approach. + +### About this part + +During this part, we will learn how to build an actual React Native application from the bottom up. We will learn concepts such as what are React Native's core components, how to create beautiful user interfaces, how to communicate with a server and how to test a React Native application. + +We will be developing an application for rating [GitHub](https://github.com/) repositories. Our application will have features such as, sorting and filtering reviewed repositories, registering a user, logging in and creating a review for a repository. The back end for the application will be provided for us so that we can solely focus on the React Native development. The final version of our application will look something like this: + +![Application preview](../../images/10/4.png) + +All the exercises in this part have to be submitted into a single GitHub repository which will eventually contain the entire source code of your application. There will be model solutions available for each section of this part which you can use to fill in incomplete submissions. This part is structured based on the idea that you develop your application as you progress in the material. So do not wait until the exercises to start the development. Instead, develop your application at the same pace as the material progresses. + +This part will heavily rely on concepts covered in the previous parts. Before starting this part you will need basic knowledge of JavaScript, React and GraphQL. Deep knowledge of server-side development is not required and all the server-side code is provided for you. However, we will be making network requests from your React Native applications, for example, using GraphQL queries. The recommended parts to complete before this part are [part 1](/en/part1), [part 2](/en/part2), [part 5](/en/part5), [part 7](/en/part7) and [part 8](/en/part8). + +### Submitting exercises and earning credits + +Exercises are submitted via the [submissions system](https://studies.cs.helsinki.fi/stats/courses/fs-react-native-2020) just like in the previous parts. Note that, exercises in this part are submitted to a different course instance than in parts 0-9. Parts 1-4 in the submission system refer to sections a-d in this part. This means that you will be submitting exercises a single section at a time starting with this section, "Introduction to React Native", which is part 1 in the submission system. + +During this part, you will earn credits based on the number of exercises you complete. Completing at least 25 exercises in this part will earn you 2 credits. Once you have completed the exercises and want to get the credits, let us know through the exercise submission system that you have completed the course: + +![Submitting exercises for credits](../../images/10/23.png) + +**Note** that you need a registration to the corresponding course part for getting the credits registered, see [here](/en/part0/general_info#parts-and-completion) for more information. + +You can download the certificate for completing this part by clicking one of the flag icons. The flag icon corresponds to the certificate's language. Note that you must have completed at least one credit worth of exercises before you can download the certificate. + +### Initializing the application + +To get started with our application we need to set up our development environment. We have learned from previous parts that there are useful tools for setting up React applications quickly such as Create React App. Luckily React Native has these kinds of tools as well. + +For the development of our application, we will be using [Expo](https://docs.expo.io/versions/latest/). Expo is a platform that eases the setup, development, building, and deployment of React Native applications. Let's get started with Expo by initializing our project with create-expo-app: + +```shell +npx create-expo-app rate-repository-app --template expo-template-blank@sdk-50 +``` + +Note, that the @sdk-50 sets the project's Expo SDK version to 50, which supports React Native version 0.73. Using other Expo SDK versions might cause you trouble while following this material. Also, Expo has a [few limitations](https://docs.expo.dev/faq/#limitations) when compared to plain React Native CLI. However, these limitations do not affect the application implemented in the material. + +Next, let's navigate to the created rate-repository-app directory with the terminal and install a few dependencies we'll be needing soon: + +```shell +npx expo install react-native-web@~0.19.6 react-dom@18.2.0 @expo/metro-runtime@~3.1.1 +``` + +Now that our application has been initialized, open the created rate-repository-app directory with an editor such as [Visual Studio Code](https://code.visualstudio.com/). The structure should be more or less the following: + +![Project structure](../../images/10/1.png) + +We might spot some familiar files and directories such as package.json and node_modules. On top of those, the most relevant files are the app.json file which contains Expo-related configuration and App.js which is the root component of our application. Do not rename or move the App.js file because by default Expo imports it to [register the root component](https://docs.expo.io/versions/latest/sdk/register-root-component/). + +Let's look at the scripts section of the package.json file which has the following scripts: + +```javascript +{ + // ... + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web" + }, + // ... +} +``` + +Let us now run the script *npm start* + +![metro bundler console output](../../images/10/25new.png) + +> If the script fails with error +> the problem is most likely your Node version. In case of problems, switch to version *20*. + +The script starts the [Metro bundler](https://facebook.github.io/metro/) which is a JavaScript bundler for React Native. In addition to the Metro bundler, the Expo command-line interface should be open in the terminal window. The command-line interface has a useful set of commands for viewing the application logs and starting the application in an emulator or in Expo's mobile application. We will get to emulators and Expo's mobile application soon, but first, let's open our application. + +Expo command-line interface suggests a few ways to open our application. Let's press the "w" key in the terminal window to open the application in a browser. We should soon see the text defined in the App.js file in a browser window. Open the App.js file with an editor and make a small change to the text in the Text component. After saving the file you should be able to see that the changes you have made in the code are visible in the browser window after refresh the web page. + +### Setting up the development environment + +We have had the first glance of our application using the Expo's browser view. Although the browser view is quite usable, it is still a quite poor simulation of the native environment. Let's have a look at the alternatives we have regarding the development environment. + +Android and iOS devices such as tablets and phones can be emulated in computers using specific emulators. This is very useful for developing native applications. macOS users can use both Android and iOS emulators with their computers. Users of other operating systems such as Linux or Windows have to settle for Android emulators. Next, depending on your operating system follow one of these instructions on setting up an emulator: + +- [Set up the Android emulator with Android Studio](https://docs.expo.dev/workflow/android-studio-emulator/) (any operating system) +- [Set up the iOS simulator with Xcode](https://docs.expo.dev/workflow/ios-simulator/) (macOS operating system) + +After you have set up the emulator and it is running, start the Expo development tools as we did before, by running npm start. Depending on the emulator you are running either press the corresponding key for the "open Android" or "open iOS simulator". After pressing the key, Expo should connect to the emulator and you should eventually see the application in your emulator. Be patient, this might take a while. + +In addition to emulators, there is one extremely useful way to develop React Native applications with Expo, the Expo mobile app. With the Expo mobile app, you can preview your application using your actual mobile device, which provides a bit more concrete development experience compared to emulators. To get started, install the Expo mobile app by following the instructions in the [Expo's documentation](https://docs.expo.io/get-started/installation/#2-expo-go-app-for-ios-and). Note that the Expo mobile app can only open your application if your mobile device is connected to the same local network (e.g. connected to the same Wi-Fi network) as the computer you are using for development. + +When the Expo mobile app has finished installing, open it up. Next, if the Expo development tools are not already running, start them by running npm start. You should be able to see a QR code at the beginning of the command output. Open the app by scanning the QR code, in Android with Expo app or in iOS with the Camera app. +The Expo mobile app should start building the JavaScript bundle and after it is finished you should be able to see your application. Now, every time you want to reopen your application in the Expo mobile app, you should be able to access the application without scanning the QR code by pressing it in the Recently opened list in the Projects view. + +
    + +
    + +### Exercise 10.1 + +#### Exercise 10.1: initializing the application + +Initialize your application with Expo command-line interface and set up the development environment either using an emulator or Expo's mobile app. It is recommended to try both and find out which development environment is the most suitable for you. The name of the application is not that relevant. You can, for example, go with rate-repository-app. + +To submit this exercise and all future exercises you need to [create a new GitHub repository](https://github.com/new). The name of the repository can be for example the name of the application you initialized with expo init. If you decide to create a private repository, add GitHub user [mluukkai](https://github.com/mluukkai) as a [repository collaborator](https://docs.github.com/en/github/setting-up-and-managing-your-github-user-account/inviting-collaborators-to-a-personal-repository). The collaborator status is only used for verifying your submissions. + +Now that the repository is created, run git init within your application's root directory to make sure that the directory is initialized as a Git repository. Next, to add the created repository as the remote run git remote add origin git@github.com:/.git (remember to replace the placeholder values in the command). Finally, just commit and push your changes into the repository and you are all done. + +
    + +
    + +### ESLint + +Now that we are somewhat familiar with the development environment let's enhance our development experience even further by configuring a linter. We will be using [ESLint](https://eslint.org/) which is already familiar to us from the previous parts. Let's get started by installing the dependencies: + +```shell +npm install --save-dev eslint @babel/eslint-parser eslint-plugin-react eslint-plugin-react-native +``` + +Next, let's add a .eslintrc.json file in the rate-repository-app directory with the ESLint configuration into the following content: + +```javascript +{ + "plugins": ["react", "react-native"], + "settings": { + "react": { + "version": "detect" + } + }, + "extends": ["eslint:recommended", "plugin:react/recommended"], + "parser": "@babel/eslint-parser", + "env": { + "react-native/react-native": true + }, + "rules": { + "react/prop-types": "off", + "react/react-in-jsx-scope": "off" + } +} +``` + +And finally, let's add a lint script to the package.json file to check the linting rules in specific files: + +```javascript +{ + // ... + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "lint": "eslint ./src/**/*.{js,jsx} App.js --no-error-on-unmatched-pattern" // highlight-line + }, + // ... +} +``` + +Now we can check that the linting rules are obeyed in JavaScript files in the src directory and in the App.js file by running npm run lint. We will be adding our future code to the src directory but because we haven't added any files there yet, we need the no-error-on-unmatched-pattern flag. Also if possible integrate ESLint with your editor. If you are using Visual Studio Code you can do that by, going to the extensions section and checking that the ESLint extension is installed and enabled: + +![Visual Studio Code ESLint extensions](../../images/10/3.png) + +The provided ESLint configuration contains only the basis for the configuration. Feel free to improve the configuration and add new plugins if you feel like it. + +
    + +
    + +### Exercise 10.2 + +#### Exercise 10.2: setting up the ESLint + +Set up ESLint in your project so that you can perform linter checks by running npm run lint. To get most of linting it is also recommended to integrate ESLint with your editor. + +This was the last exercise in this section. It's time to push your code to GitHub and mark all of your finished exercises to the [exercise submission system](https://studies.cs.helsinki.fi/stats/courses/fs-react-native-2020). Note that exercises in this section should be submitted to part 1 in the exercise submission system. +
    + +
    + +### Debugging + +When our application doesn't work as intended, we should immediately start debugging it. In practice, this means that we'll need to reproduce the erroneous behavior and monitor the code execution to find out which part of the code behaves incorrectly. During the course, we have already done a bunch of debugging by logging messages, inspecting network traffic, and using specific development tools, such as React Development Tools. In general, debugging isn't that different in React Native, we'll just need the right tools for the job. + +The good old console.log messages appear in the Expo development tools command line: + +![GraphQL structure](../../images/10/27new.png) + +That might actually be enough in most cases, but sometimes we need more. React Native provides an in-app developer menu which offers several debugging options. Read more about [debugging react native applications](https://reactnative.dev/docs/debugging). + +To inspect the React element tree, props, and state you can install React DevTools. + +```shell +npx react-devtools +``` +Read here about [React DevTools](https://reactnative.dev/docs/react-devtools). For more useful React Native application debugging tools, also head out to the Expo's [debugging documentation](https://docs.expo.io/workflow/debugging). + +
    diff --git a/src/content/10/en/part10b.md b/src/content/10/en/part10b.md new file mode 100644 index 00000000000..cff94915f72 --- /dev/null +++ b/src/content/10/en/part10b.md @@ -0,0 +1,926 @@ +--- +mainImage: ../../../images/part-10.svg +part: 10 +letter: b +lang: en +--- + +
    + +Now that we have set up our development environment we can get into React Native basics and get started with the development of our application. In this section, we will learn how to build user interfaces with React Native's core components, how to add style properties to these core components, how to transition between views, and how to manage the form's state efficiently. + +### Core components + +In the previous parts, we have learned that we can use React to define components as functions, which receive props as an argument and returns a tree of React elements. This tree is usually represented with JSX syntax. In the browser environment, we have used the [ReactDOM](https://react.dev/reference/react-dom) library to turn these components into a DOM tree that can be rendered by a browser. Here is a concrete example of a very simple component: + +```javascript +const HelloWorld = props => { + return
    Hello world!
    ; +}; +``` + +The HelloWorld component returns a single div element which is created using the JSX syntax. We might remember that this JSX syntax is compiled into React.createElement method calls, such as this: + +```javascript +React.createElement('div', null, 'Hello world!'); +``` + +This line of code creates a div element without any props and with a single child element which is a string "Hello world". When we render this component into a root DOM element using the ReactDOM.render method the div element will be rendered as the corresponding DOM element. + +As we can see, React is not bound to a certain environment, such as the browser environment. Instead, there are libraries such as ReactDOM that can render a set of predefined components, such as DOM elements, in a specific environment. In React Native these predefined components are called core components. + +[Core components](https://reactnative.dev/docs/intro-react-native-components) are a set of components provided by React Native, which behind the scenes utilize the platform's native components. Let's implement the previous example using React Native: + +```javascript +import { Text } from 'react-native'; // highlight-line + +const HelloWorld = props => { + return Hello world!; // highlight-line +}; +``` + +So we import the [Text](https://reactnative.dev/docs/text) component from React Native and replace the *div* element with a *Text* element. Many familiar DOM elements have their React Native "counterparts". Here are some examples picked from React Native's [Core Components documentation](https://reactnative.dev/docs/components-and-apis): + +- [Text](https://reactnative.dev/docs/text) component is the only React Native component that can have textual children. It is similar to for example the _<strong>_ and the _<h1>_ elements. +- [View](https://reactnative.dev/docs/view) component is the basic user interface building block similar to the _<div>_ element. +- [TextInput](https://reactnative.dev/docs/textinput) component is a text field component similar to the _<input>_ element. +- [Pressable](https://reactnative.dev/docs/pressable) component is for capturing different press events. It is similar to for example the _<button>_ element. + +There are a few notable differences between core components and DOM elements. The first difference is that the Text component is the only React Native component that can have textual children. This means that you can't, for example, replace the Text component with the View component in the previous example. + +The second notable difference is related to the event handlers. While working with the DOM elements we are used to adding event handlers such as onClick to basically any element such as _<div>_ and _<button>_. In React Native we have to carefully read the [API documentation](https://reactnative.dev/docs/components-and-apis) to know what event handlers (as well as other props) a component accepts. For example, the [Pressable](https://reactnative.dev/docs/pressable) component provides props for listening to different kinds of press events. We can for example use the component's [onPress](https://reactnative.dev/docs/pressable) prop for listening to press events: + +```javascript +import { Text, Pressable, Alert } from 'react-native'; + +const PressableText = props => { + return ( + Alert.alert('You pressed the text!')} + > + You can press me + + ); +}; +``` + +Now that we have a basic understanding of the core components, let's start to give our project some structure. Create a src directory in the root directory of your project and in the src directory create a components directory. In the components directory create a file Main.jsx with the following content: + +```javascript +import Constants from 'expo-constants'; +import { Text, StyleSheet, View } from 'react-native'; + +const styles = StyleSheet.create({ + container: { + marginTop: Constants.statusBarHeight, + flexGrow: 1, + flexShrink: 1, + }, +}); + +const Main = () => { + return ( + + Rate Repository Application + + ); +}; + +export default Main; +``` + +Next, let's use the Main component in the App component in the App.js file which is located in our project's root directory. Replace the current content of the file with this: + +```javascript +import Main from './src/components/Main'; + +const App = () => { + return
    ; +}; + +export default App; +``` + +### Manually reloading the application + +As we have seen, Expo will automatically reload the application when we make changes to the code. However, there might be times when automatic reload isn't working and the application has to be reloaded manually. This can be achieved through the in-app developer menu. + +You can access the developer menu by shaking your device or by selecting "Shake Gesture" inside the Hardware menu in the iOS Simulator. You can also use the ⌘D keyboard shortcut when your app is running in the iOS Simulator, or ⌘M when running in an Android emulator on Mac OS and Ctrl+M on Windows and Linux. + +Once the developer menu is open, simply press "Reload" to reload the application. After the application has been reloaded, automatic reloads should work without the need for a manual reload. + +
    + +
    + +### Exercise 10.3 + +#### Exercise 10.3: the reviewed repositories list + +In this exercise, we will implement the first version of the reviewed repositories list. The list should contain the repository's full name, description, language, number of forks, number of stars, rating average and number of reviews. Luckily React Native provides a handy component for displaying a list of data, which is the [FlatList](https://reactnative.dev/docs/flatlist) component. + +Implement components RepositoryList and RepositoryItem in the components directory's files RepositoryList.jsx and RepositoryItem.jsx. The RepositoryList component should render the FlatList component and RepositoryItem a single item on the list (hint: use the FlatList component's [renderItem](https://reactnative.dev/docs/flatlist#required-renderitem) prop). Use this as the basis for the RepositoryList.jsx file: + +```javascript +import { FlatList, View, StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + separator: { + height: 10, + }, +}); + +const repositories = [ + { + id: 'jaredpalmer.formik', + fullName: 'jaredpalmer/formik', + description: 'Build forms in React, without the tears', + language: 'TypeScript', + forksCount: 1589, + stargazersCount: 21553, + ratingAverage: 88, + reviewCount: 4, + ownerAvatarUrl: 'https://avatars2.githubusercontent.com/u/4060187?v=4', + }, + { + id: 'rails.rails', + fullName: 'rails/rails', + description: 'Ruby on Rails', + language: 'Ruby', + forksCount: 18349, + stargazersCount: 45377, + ratingAverage: 100, + reviewCount: 2, + ownerAvatarUrl: 'https://avatars1.githubusercontent.com/u/4223?v=4', + }, + { + id: 'django.django', + fullName: 'django/django', + description: 'The Web framework for perfectionists with deadlines.', + language: 'Python', + forksCount: 21015, + stargazersCount: 48496, + ratingAverage: 73, + reviewCount: 5, + ownerAvatarUrl: 'https://avatars2.githubusercontent.com/u/27804?v=4', + }, + { + id: 'reduxjs.redux', + fullName: 'reduxjs/redux', + description: 'Predictable state container for JavaScript apps', + language: 'TypeScript', + forksCount: 13902, + stargazersCount: 52869, + ratingAverage: 0, + reviewCount: 0, + ownerAvatarUrl: 'https://avatars3.githubusercontent.com/u/13142323?v=4', + }, +]; + +const ItemSeparator = () => ; + +const RepositoryList = () => { + return ( + + ); +}; + +export default RepositoryList; +``` + +Do not alter the contents of the repositories variable, it should contain everything you need to complete this exercise. Render the RepositoryList component in the Main component which we previously added to the Main.jsx file. The reviewed repository list should roughly look something like this: + +![Application preview](../../images/10/5.jpg) + +
    + +
    + +### Style + +Now that we have a basic understanding of how core components work and we can use them to build a simple user interface it is time to add some styles. In [part 2](/en/part2/adding_styles_to_react_app) we learned that in the browser environment we can define React component's style properties using CSS. We had the option to either define these styles inline using the style prop or in a CSS file with a suitable selector. + +There are many similarities in the way style properties are attached to React Native's core components and the way they are attached to DOM elements. In React Native most of the core components accept a prop called style. The style prop accepts an object with style properties and their values. These style properties are in most cases the same as in CSS, however, property names are in camelCase. This means that CSS properties such as padding-top and font-size are written as paddingTop and fontSize. Here is a simple example of how to use the style prop: + +```javascript +import { Text, View } from 'react-native'; + +const BigBlueText = () => { + return ( + + + Big blue text + + + ); +}; +``` + +On top of the property names, you might have noticed another difference in the example. In CSS numerical property values commonly have a unit such as px, %, em or rem. In React Native all dimension-related property values such as width, height, padding, and margin as well as font sizes are unitless. These unitless numeric values represent density-independent pixels. In case you are wondering what are the available style properties for certain core components, check the [React Native Styling Cheat Sheet](https://github.com/vhpoet/react-native-styling-cheat-sheet). + +In general, defining styles directly in the style prop is not considered such a great idea, because it makes components bloated and unclear. Instead, we should define styles outside the component's render function using the [StyleSheet.create](https://reactnative.dev/docs/stylesheet#create) method. The StyleSheet.create method accepts a single argument which is an object consisting of named style objects and it creates a StyleSheet style reference from the given object. Here is an example of how to refactor the previous example using the StyleSheet.create method: + +```javascript +import { Text, View, StyleSheet } from 'react-native'; // highlight-line + +// highlight-start +const styles = StyleSheet.create({ + container: { + padding: 20, + }, + text: { + color: 'blue', + fontSize: 24, + fontWeight: '700', + }, +}); +// highlight-end + +const BigBlueText = () => { + return ( + // highlight-line + // highlight-line + Big blue text + + + ); +}; +``` + +We create two named style objects, styles.container and styles.text. Inside the component, we can access specific style objects the same way we would access any key in a plain object. + +In addition to an object, the style prop also accepts an array of objects. In the case of an array, the objects are merged from left to right so that latter-style properties take precedence. This works recursively, so we can have for example an array containing an array of styles and so forth. If an array contains values that evaluate to false, such as null or undefined, these values are ignored. This makes it easy to define conditional styles for example, based on the value of a prop. Here is an example of conditional styles: + +```javascript +import { Text, View, StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + text: { + color: 'grey', + fontSize: 14, + }, + blueText: { + color: 'blue', + }, + bigText: { + fontSize: 24, + fontWeight: '700', + }, +}); + +const FancyText = ({ isBlue, isBig, children }) => { + const textStyles = [ + styles.text, + isBlue && styles.blueText, + isBig && styles.bigText, + ]; + + return {children}; +}; + +const Main = () => { + return ( + <> + Simple text + Blue text + Big text + + Big blue text + + + ); +}; +``` + +In the example, we use the && operator with the expression condition && exprIfTrue. This expression yields exprIfTrue if the condition evaluates to true, otherwise it will yield condition, which in that case is a value that evaluates to false. This is an extremely widely used and handy shorthand. Another option would be to use the [conditional operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator) like this: + +```js +condition ? exprIfTrue : exprIfFalse +``` + +### Consistent user interface with theming + +Let's stick with the concept of styling but with a bit wider perspective. Most of us have used a multitude of different applications and might agree that one trait that makes a good user interface is consistency. This means that the appearance of user interface components such as their font size, font family and color follows a consistent pattern. To achieve this we have to somehow parametrize the values of different style properties. This method is commonly known as theming. + +Users of popular user interface libraries such as [Bootstrap](https://getbootstrap.com/docs/4.4/getting-started/theming/) and [Material UI](https://material-ui.com/customization/theming/) might already be quite familiar with theming. Even though the theming implementations differ, the main idea is always to use variables such as colors.primary instead of ["magic numbers"]() such as #0366d6 when defining styles. This leads to increased consistency and flexibility. + +Let's see how theming could work in practice in our application. We will be using a lot of text with different variations, such as different font sizes and colors. Because React Native does not support global styles, we should create our own Text component to keep the textual content consistent. Let's get started by adding the following theme configuration object in a theme.js file in the src directory: + +```javascript +const theme = { + colors: { + textPrimary: '#24292e', + textSecondary: '#586069', + primary: '#0366d6', + }, + fontSizes: { + body: 14, + subheading: 16, + }, + fonts: { + main: 'System', + }, + fontWeights: { + normal: '400', + bold: '700', + }, +}; + +export default theme; +``` + +Next, we should create the actual Text component which uses this theme configuration. Create a Text.jsx file in the components directory where we already have our other components. Add the following content to the Text.jsx file: + +```javascript +import { Text as NativeText, StyleSheet } from 'react-native'; + +import theme from '../theme'; + +const styles = StyleSheet.create({ + text: { + color: theme.colors.textPrimary, + fontSize: theme.fontSizes.body, + fontFamily: theme.fonts.main, + fontWeight: theme.fontWeights.normal, + }, + colorTextSecondary: { + color: theme.colors.textSecondary, + }, + colorPrimary: { + color: theme.colors.primary, + }, + fontSizeSubheading: { + fontSize: theme.fontSizes.subheading, + }, + fontWeightBold: { + fontWeight: theme.fontWeights.bold, + }, +}); + +const Text = ({ color, fontSize, fontWeight, style, ...props }) => { + const textStyle = [ + styles.text, + color === 'textSecondary' && styles.colorTextSecondary, + color === 'primary' && styles.colorPrimary, + fontSize === 'subheading' && styles.fontSizeSubheading, + fontWeight === 'bold' && styles.fontWeightBold, + style, + ]; + + return ; +}; + +export default Text; +``` + +Now we have implemented our text component. This text component has consistent color, font size and font weight variants that we can use anywhere in our application. We can get different text variations using different props like this: + +```javascript +import Text from './Text'; + +const Main = () => { + return ( + <> + Simple text + Text with custom style + + Bold subheading + + Text with secondary color + + ); +}; + +export default Main; +``` + +Feel free to extend or modify this component if you feel like it. It might also be a good idea to create reusable text components such as Subheading which use the Text component. Also, keep on extending and modifying the theme configuration as your application progresses. + +### Using flexbox for layout + +The last concept we will cover related to styling is implementing layouts with [flexbox](https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Flexbox). Those who are more familiar with CSS know that flexbox is not related only to React Native, it has many use cases in web development as well. Those who know how flexbox works in web development won't probably learn that much from this section. Nevertheless, let's learn or revise the basics of flexbox. + +Flexbox is a layout entity consisting of two separate components: a flex container and inside it a set of flex items. A Flex container has a set of properties that control the flow of its items. To make a component a flex container it must have the style property display set as flex which is the default value for the display property. Here is an example of a flex container: + +```javascript +import { View, StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + flexContainer: { + flexDirection: 'row', + }, +}); + +const FlexboxExample = () => { + return {/* ... */}; +}; +``` + +Perhaps the most important properties of a flex container are the following: + +- [flexDirection](https://css-tricks.com/almanac/properties/f/flex-direction/) property controls the direction in which the flex items are laid out within the container. Possible values for this property are row, row-reverse, column (default value) and column-reverse. Flex direction row will lay out the flex items from left to right, whereas column from top to bottom. \*-reverse directions will just reverse the order of the flex items. + +- [justifyContent](https://css-tricks.com/almanac/properties/j/justify-content/) property controls the alignment of flex items along the main axis (defined by the flexDirection property). Possible values for this property are flex-start (default value), flex-end, center, space-between, space-around and space-evenly. +- [alignItems](https://css-tricks.com/almanac/properties/a/align-items/) property does the same as justifyContent but for the opposite axis. Possible values for this property are flex-start, flex-end, center, baseline and stretch (default value). + +Let's move on to flex items. As mentioned, a flex container can contain one or many flex items. Flex items have properties that control how they behave in respect of other flex items in the same flex container. To make a component a flex item all you have to do is to set it as an immediate child of a flex container: + +```javascript +import { View, Text, StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + flexContainer: { + display: 'flex', + }, + flexItemA: { + flexGrow: 0, + backgroundColor: 'green', + }, + flexItemB: { + flexGrow: 1, + backgroundColor: 'blue', + }, +}); + +const FlexboxExample = () => { + return ( + + + Flex item A + + + Flex item B + + + ); +}; +``` + +One of the most commonly used properties of flex items is the [flexGrow](https://css-tricks.com/almanac/properties/f/flex-grow/) property. It accepts a unitless value which defines the ability for a flex item to grow if necessary. If all flex items have a flexGrow of 1, they will share all the available space evenly. If a flex item has a flexGrow of 0, it will only use the space its content requires and leave the rest of the space for other flex items. + +Here you can find how to simplify layouts with Flexbox gap: [Flexbox gap](https://reactnative.dev/blog/2023/01/12/version-071#simplifying-layouts-with-flexbox-gap). + +Next, read the article [A Complete Guide to Flexbox](https://css-tricks.com/snippets/css/a-guide-to-flexbox/) which has comprehensive visual examples of flexbox. It is also a good idea to play around with the flexbox properties in the [Flexbox Playground](https://flexbox.tech/) to see how different flexbox properties affect the layout. Remember that in React Native the property names are the same as the ones in CSS except for the camelCase naming. However, the property values such as flex-start and space-between are exactly the same. + +**NB:** React Native and CSS has some differences regarding the flexbox. The most important difference is that in React Native the default value for the flexDirection property is column. It is also worth noting that the flex shorthand doesn't accept multiple values in React Native. More on React Native's flexbox implementation can be read in the [documentation](https://reactnative.dev/docs/flexbox). + +
    + +
    + +### Exercises 10.4-10.5 + +#### Exercise 10.4: the app bar + +We will soon need to navigate between different views in our application. That is why we need an [app bar](https://material.io/components/app-bars-top/) to display tabs for switching between different views. Create a file AppBar.jsx in the components folder with the following content: + +```javascript +import { View, StyleSheet } from 'react-native'; +import Constants from 'expo-constants'; + +const styles = StyleSheet.create({ + container: { + paddingTop: Constants.statusBarHeight, + // ... + }, + // ... +}); + +const AppBar = () => { + return {/* ... */}; +}; + +export default AppBar; +``` + +Now that the AppBar component will prevent the status bar from overlapping the content, you can remove the marginTop style we added for the Main component earlier in the Main.jsx file. The AppBar component should currently contain a tab with the text "Repositories". Make the tab pressable by using the [Pressable](https://reactnative.dev/docs/pressable) component but you don't have to handle the onPress event in any way. Add the AppBar component to the Main component so that it is the uppermost component on the screen. The AppBar component should look something like this: + +![Application preview](../../images/10/6.jpg) + +The background color of the app bar in the image is #24292e but you can use any other color as well. It might be a good idea to add the app bar's background color into the theme configuration so that it is easy to change it if needed. Another good idea might be to separate the app bar's tab into a component like AppBarTab so that it is easy to add new tabs in the future. + +#### Exercise 10.5: polished reviewed repositories list + +The current version of the reviewed repositories list looks quite grim. Modify the RepositoryItem component so that it also displays the repository author's avatar image. You can implement this by using the [Image](https://reactnative.dev/docs/image) component. Counts, such as the number of stars and forks, larger than or equal to 1000 should be displayed in thousands with the precision of one decimal and with a "k" suffix. This means that for example fork count of 8439 should be displayed as "8.4k". Also, polish the overall look of the component so that the reviewed repositories list looks something like this: + +![Application preview](../../images/10/7.jpg) + +In the image, the Main component's background color is set to #e1e4e8 whereas RepositoryItem component's background color is set to white. The language tag's background color is #0366d6 which is the value of the colors.primary variable in the theme configuration. Remember to exploit the Text component we implemented earlier. Also when needed, split the RepositoryItem component into smaller components. + +
    + +
    + +### Routing + +When we start to expand our application we will need a way to transition between different views such as the repositories view and the sign-in view. In [part 7](/en/part7/react_router) we got familiar with [React router](https://reactrouter.com/) library and learned how to use it to implement routing in a web application. + +Routing in a React Native application is a bit different from routing in a web application. The main difference is that we can't reference pages with URLs, which we type into the browser's address bar, and can't navigate back and forth through the user's history using the browser's [history API](https://developer.mozilla.org/en-US/docs/Web/API/History_API). However, this is just a matter of the router interface we are using. + +With React Native we can use the entire React router's core, including the hooks and components. The only difference to the browser environment is that we must replace the BrowserRouter with React Native compatible [NativeRouter](https://reactrouter.com/en/6.4.5/router-components/native-router), provided by the [react-router-native](https://www.npmjs.com/package/react-router-native) library. Let's get started by installing the react-router-native library: + +```shell +npm install react-router-native +``` + +Next, open the App.js file and add the NativeRouter component to the App component: + +```javascript +import { StatusBar } from 'expo-status-bar'; +import { NativeRouter } from 'react-router-native'; // highlight-line + +import Main from './src/components/Main'; + +const App = () => { + return ( + // highlight-start + <> + +
    + + + + // highlight-end + ); +}; + +export default App; +``` + +Once the router is in place, let's add our first route to the Main component in the Main.jsx file: + +```javascript +import { StyleSheet, View } from 'react-native'; +import { Route, Routes, Navigate } from 'react-router-native'; // highlight-line + +import RepositoryList from './RepositoryList'; +import AppBar from './AppBar'; +import theme from '../theme'; + +const styles = StyleSheet.create({ + container: { + backgroundColor: theme.colors.mainBackground, + flexGrow: 1, + flexShrink: 1, + }, +}); + +const Main = () => { + return ( + + + // highlight-start + + } /> + } /> + + // highlight-end + + ); +}; + +export default Main; +``` + +That's it! The last Route inside the Routes is for catching paths that don't match any previously defined path. In this case, we want to navigate to the home view. + +
    + +
    + +### Exercises 10.6-10.7 + +#### Exercise 10.6: the sign-in view + +We will soon implement a form, that a user can use to sign in to our application. Before that, we must implement a view that can be accessed from the app bar. Create a file SignIn.jsx in the components directory with the following content: + +```javascript +import Text from './Text'; + +const SignIn = () => { + return The sign-in view; +}; + +export default SignIn; +``` + +Set up a route for this SignIn component in the Main component. Also, add a tab with the text "Sign in" to the app bar next to the "Repositories" tab. Users should be able to navigate between the two views by pressing the tabs (hint: you can use the React router's [Link](https://reactrouter.com/6.4.5/components/link-native) component). + +#### Exercise 10.7: scrollable app bar + +As we are adding more tabs to our app bar, it is a good idea to allow horizontal scrolling once the tabs won't fit the screen. The [ScrollView](https://reactnative.dev/docs/scrollview) component is just the right component for the job. + +Wrap the tabs in the AppBar component's tabs with a ScrollView component: + +```javascript +const AppBar = () => { + return ( + + {/* ... */} // highlight-line + + ); +}; +``` + +Setting the [horizontal](https://reactnative.dev/docs/scrollview#horizontal) prop true will cause the ScrollView component to scroll horizontally once the content won't fit the screen. Note that, you will need to add suitable style properties to the ScrollView component so that the tabs will be laid in a row inside the flex container. You can make sure that the app bar can be scrolled horizontally by adding tabs until the last tab won't fit the screen. Just remember to remove the extra tabs once the app bar is working as intended. + +
    + +
    + +### Form state management + +Now that we have a placeholder for the sign-in view the next step would be to implement the sign-in form. Before we get to that let's talk about forms from a wider perspective. + +Implementation of forms relies heavily on state management. Using React's useState hook for state management might get the job done for smaller forms. However, it will quickly make state management for more complex forms quite tedious. Luckily there are many good libraries in the React ecosystem that ease the state management of forms. One of these libraries is [Formik](https://formik.org/). + +The main concepts of Formik are the context and the field. However, the easiest way to do a simple form submit is by using useFormik(). It is a custom React hook that will return all Formik state and helpers directly. + +There are some restrictions concerning the use of UseFormik(). Read this to become familiar with [useFormik()](https://formik.org/docs/api/useFormik) + + +Let's see how this works by creating a form for calculating the [body mass index](https://en.wikipedia.org/wiki/Body_mass_index): + +```javascript +import { Text, TextInput, Pressable, View } from 'react-native'; +import { useFormik } from 'formik'; + +const initialValues = { + mass: '', + height: '', +}; + +const getBodyMassIndex = (mass, height) => { + return Math.round(mass / Math.pow(height, 2)); +}; + +const BodyMassIndexForm = ({ onSubmit }) => { + const formik = useFormik({ + initialValues, + onSubmit, + }); + + return ( + + + + + Calculate + + + ); +}; + +const BodyMassIndexCalculator = () => { + const onSubmit = values => { + const mass = parseFloat(values.mass); + const height = parseFloat(values.height); + + if (!isNaN(mass) && !isNaN(height) && height !== 0) { + console.log(`Your body mass index is: ${getBodyMassIndex(mass, height)}`); + } + }; + + return ; +}; + +export default BodyMassIndexCalculator; + +``` + +This example is not part of our application, so you don't need to add this code to the application. You can however try it out for example in [Expo Snack](https://snack.expo.io/). Expo Snack is an online editor for React Native, similar to [JSFiddle](https://jsfiddle.net/) and [CodePen](https://codepen.io/). It is a useful platform for quickly trying out code. You can share Expo Snacks with others using a link or embedding them as a Snack Player on a website. You might have bumped into Snack Players for example in this material and React Native documentation. + + + + + +
    + +
    + +### Exercise 10.8 + +#### Exercise 10.8: the sign-in form + +Implement a sign-in form to the SignIn component we added earlier in the SignIn.jsx file. The sign-in form should include two text fields, one for the username and one for the password. There should also be a button for submitting the form. You don't need to implement an onSubmit callback function, it is enough that the form values are logged using console.log when the form is submitted: + +```javascript +const onSubmit = (values) => { + console.log(values); +}; +``` + +The first step is to install Formik: + +```shell +npm install formik +``` + + +You can use the [secureTextEntry](https://reactnative.dev/docs/textinput#securetextentry) prop in the TextInput component to obscure the password input. + +The sign-in form should look something like this: + +![Application preview](../../images/10/19.jpg) + +
    + +
    + +### Form validation + +Formik offers two approaches to form validation: a validation function or a validation schema. A validation function is a function provided for the Formik component as the value of the [validate](https://formik.org/docs/guides/validation#validate) prop. It receives the form's values as an argument and returns an object containing possible field-specific error messages. + +The second approach is the validation schema which is provided for the Formik component as the value of the [validationSchema](https://formik.org/docs/guides/validation#validationschema) prop. This validation schema can be created with a validation library called [Yup](https://github.com/jquense/yup). Let's get started by installing Yup: + +```shell +npm install yup +``` + +Next, as an example, let's create a validation schema for the body mass index form we implemented earlier. We want to validate that both mass and height fields are present and they are numeric. Also, the value of mass should be greater or equal to 1 and the value of height should be greater or equal to 0.5. Here is how we define the schema: + + + + +```javascript +import * as yup from 'yup'; // highlight-line + +// ... + +// highlight-start +const validationSchema = yup.object().shape({ + mass: yup + .number() + .min(1, 'Weight must be greater or equal to 1') + .required('Weight is required'), + height: yup + .number() + .min(0.5, 'Height must be greater or equal to 0.5') + .required('Height is required'), +}); +// highlight-end + +const BodyMassIndexForm = ({ onSubmit }) => { + const formik = useFormik({ + initialValues, + // highlight-start + validationSchema, + // highlight-end + onSubmit, + }); + + return ( + + + {formik.touched.mass && formik.errors.mass && ( + {formik.errors.mass} + )} + + {formik.touched.height && formik.errors.height && ( + {formik.errors.height} + )} + + Calculate + + + ); +}; + +const BodyMassIndexCalculator = () => { + // ... + + +``` + +Be aware that you need to include these Text components within the View returned by the form to display the validation errors: + +``` + {formik.touched.mass && formik.errors.mass && ( + {formik.errors.mass} + )} +``` + +``` + {formik.touched.height && formik.errors.height && ( + {formik.errors.height} + )} +``` + +The validation is performed by default every time a field's value changes and when the handleSubmit function is called. If the validation fails, the function provided for the onSubmit prop of the Formik component is not called. + + + +
    + +
    + +### Exercise 10.9 + +#### Exercise 10.9: validating the sign-in form + +Validate the sign-in form so that both username and password fields are required. Note that the onSubmit callback implemented in the previous exercise, should not be called if the form validation fails. + +The current implementation of the TextInput component should display an error message if a touched field has an error. Emphasize this error message by giving it a red color. + +On top of the red error message, give an invalid field a visual indication of an error by giving it a red border color. Remember that if a field has an error, the TextInput component sets the TextInput component's error prop as true. You can use the value of the error prop to attach conditional styles to the TextInput component. + +Here's what the sign-in form should roughly look like with an invalid field: + +![Application preview](../../images/10/8.jpg) + +The red color used in this implementation is #d73a4a. + +
    + +
    + +### Platform-specific code + +A big benefit of React Native is that we don't need to worry about whether the application is run on an Android or iOS device. However, there might be cases where we need to execute platform-specific code. Such cases could be for example using a different implementation of a component on a different platform. + +We can access the user's platform through the Platform.OS constant: + +```javascript +import { React } from 'react'; +import { Platform, Text, StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + text: { + color: Platform.OS === 'android' ? 'green' : 'blue', + }, +}); + +const WhatIsMyPlatform = () => { + return Your platform is: {Platform.OS}; +}; +``` + +Possible values for the Platform.OS constants are android and ios. Another useful way to define platform-specific code branches is to use the Platform.select method. Given an object where keys are one of ios, android, native and default, the Platform.select method returns the most fitting value for the platform the user is currently running on. We can rewrite the styles variable in the previous example using the Platform.select method like this: + +```javascript +const styles = StyleSheet.create({ + text: { + color: Platform.select({ + android: 'green', + ios: 'blue', + default: 'black', + }), + }, +}); +``` + +We can even use the Platform.select method to require a platform-specific component: + +```javascript +const MyComponent = Platform.select({ + ios: () => require('./MyIOSComponent'), + android: () => require('./MyAndroidComponent'), +})(); + +; +``` + +However, a more sophisticated method for implementing and importing platform-specific components (or any other piece of code) is to use the .ios.jsx and .android.jsx file extensions. Note that the .jsx extension could also be another extension recognized by the bundler, such as .js. We can for example have files Button.ios.jsx and Button.android.jsx which we can import like this: + +```javascript +import Button from './Button'; + +const PlatformSpecificButton = () => { + return
    + +
    + +### Exercise 10.10 + +#### Exercise 10.10: a platform-specific font + +Currently, the font family of our application is set to System in the theme configuration located in the theme.js file. Instead of the System font, use a platform-specific [Sans-serif](https://en.wikipedia.org/wiki/Sans-serif) font. On the Android platform, use the Roboto font and on the iOS platform, use the Arial font. The default font can be System. + +This was the last exercise in this section. It's time to push your code to GitHub and mark all of your finished exercises to the [exercise submission system](https://studies.cs.helsinki.fi/stats/courses/fs-react-native-2020). Note that exercises in this section should be submitted to the section named part 2 in the exercise submission system. +
    diff --git a/src/content/10/en/part10c.md b/src/content/10/en/part10c.md new file mode 100644 index 00000000000..46a866475a3 --- /dev/null +++ b/src/content/10/en/part10c.md @@ -0,0 +1,829 @@ +--- +mainImage: ../../../images/part-10.svg +part: 10 +letter: c +lang: en +--- + +
    + +So far we have implemented features to our application without any actual server communication. For example, the reviewed repositories list we have implemented uses mock data and the sign in form doesn't send the user's credentials to any authentication endpoint. In this section, we will learn how to communicate with a server using HTTP requests, how to use Apollo Client in a React Native application, and how to store data in the user's device. + +Soon we will learn how to communicate with a server in our application. Before we get to that, we need a server to communicate with. For this purpose, we have a completed server implementation in the [rate-repository-api](https://github.com/fullstack-hy2020/rate-repository-api) repository. The rate-repository-api server fulfills all our application's API needs during this part. It uses [SQLite](https://www.sqlite.org/index.html) database which doesn't need any setup and provides an Apollo GraphQL API along with a few REST API endpoints. + +Before heading further into the material, set up the rate-repository-api server by following the setup instructions in the repository's [README](https://github.com/fullstack-hy2020/rate-repository-api/blob/master/README.md). Note that if you are using an emulator for development it is recommended to run the server and the emulator on the same computer. This eases network requests considerably. + +### HTTP requests + +React Native provides [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) for making HTTP requests in our applications. React Native also supports the good old [XMLHttpRequest API](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) which makes it possible to use third-party libraries such as [Axios](https://github.com/axios/axios). These APIs are the same as the ones in the browser environment and they are globally available without the need for an import. + +People who have used both Fetch API and XMLHttpRequest API most likely agree that the Fetch API is easier to use and more modern. However, this doesn't mean that XMLHttpRequest API doesn't have its uses. For the sake of simplicity, we will be only using the Fetch API in our examples. + +Sending HTTP requests using the Fetch API can be done using the fetch function. The first argument of the function is the URL of the resource: + +```javascript +fetch('https://my-api.com/get-end-point'); +``` + +The default request method is GET. The second argument of the fetch function is an options object, which you can use to for example to specify a different request method, request headers, or request body: + +```javascript +fetch('https://my-api.com/post-end-point', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + firstParam: 'firstValue', + secondParam: 'secondValue', + }), +}); +``` + +Note that these URLs are made up and won't (most likely) send a response to your requests. In comparison to Axios, the Fetch API operates on a bit lower level. For example, there isn't any request or response body serialization and parsing. This means that you have to for example set the Content-Type header by yourself and use JSON.stringify method to serialize the request body. + +The fetch function returns a promise which resolves a [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) object. Note that error status codes such as 400 and 500 are not rejected like for example in Axios. In case of a JSON formatted response we can parse the response body using the Response.json method: + +```javascript +const fetchMovies = async () => { + const response = await fetch('https://reactnative.dev/movies.json'); + const json = await response.json(); + + return json; +}; +``` + +For a more detailed introduction to the Fetch API, read the [Using Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) article in the MDN web docs. + +Next, let's try the Fetch API in practice. The rate-repository-api server provides an endpoint for returning a paginated list of reviewed repositories. Once the server is running, you should be able to access the endpoint at [http://localhost:5000/api/repositories](http://localhost:5000/api/repositories) (unless you have changed the port). The data is paginated in a common [cursor based pagination format](https://graphql.org/learn/pagination/). The actual repository data is behind the node key in the edges array. + +Unfortunately, if we´re using external device, we can't access the server directly in our application by using the http://localhost:5000/api/repositories URL. To make a request to this endpoint in our application we need to access the server using its IP address in its local network. To find out what it is, open the Expo development tools by running npm start. In the console you should be able to see an URL starting with exp:// below the QR code, after the "Metro waiting on" text: + +![metro console output with highlight over exp:// url](../../images/10/26new.png) + +Copy the IP address between the exp:// and :, which is in this example 192.168.1.33. Construct an URL in format :5000/api/repositories and open it in the browser. You should see the same response as you did with the localhost URL. + +Now that we know the end point's URL let's use the actual server-provided data in our reviewed repositories list. We are currently using mock data stored in the repositories variable. Remove the repositories variable and replace the usage of the mock data with this piece of code in the RepositoryList.jsx file in the components directory: + +```javascript +import { useState, useEffect } from 'react'; +// ... + +const RepositoryList = () => { + const [repositories, setRepositories] = useState(); + + const fetchRepositories = async () => { + // Replace the IP address part with your own IP address! + const response = await fetch('http://192.168.1.33:5000/api/repositories'); + const json = await response.json(); + + console.log(json); + + setRepositories(json); + }; + + useEffect(() => { + fetchRepositories(); + }, []); + + // Get the nodes from the edges array + const repositoryNodes = repositories + ? repositories.edges.map(edge => edge.node) + : []; + + return ( + + ); +}; + +export default RepositoryList; +``` + +We are using the React's useState hook to maintain the repository list state and the useEffect hook to call the fetchRepositories function when the RepositoryList component is mounted. We extract the actual repositories into the repositoryNodes variable and replace the previously used repositories variable in the FlatList component's data prop with it. Now you should be able to see actual server-provided data in the reviewed repositories list. + +It is usually a good idea to log the server's response to be able to inspect it as we did in the fetchRepositories function. You should be able to see this log message in the Expo development tools if you navigate to your device's logs as we learned in the [Debugging](/en/part10/introduction_to_react_native#debugging) section. If you are using the Expo's mobile app for development and the network request is failing, make sure that the computer you are using to run the server and your phone are connected to the same Wi-Fi network. If that's not possible either use an emulator in the same computer as the server is running in or set up a tunnel to the localhost, for example, using [Ngrok](https://ngrok.com/). + +The current data fetching code in the RepositoryList component could do with some refactoring. For instance, the component is aware of the network request's details such as the end point's URL. In addition, the data fetching code has lots of reuse potential. Let's refactor the component's code by extract the data fetching code into its own hook. Create a directory hooks in the src directory and in that hooks directory create a file useRepositories.js with the following content: + +```javascript +import { useState, useEffect } from 'react'; + +const useRepositories = () => { + const [repositories, setRepositories] = useState(); + const [loading, setLoading] = useState(false); + + const fetchRepositories = async () => { + setLoading(true); + + // Replace the IP address part with your own IP address! + const response = await fetch('http://192.168.1.33:5000/api/repositories'); + const json = await response.json(); + + setLoading(false); + setRepositories(json); + }; + + useEffect(() => { + fetchRepositories(); + }, []); + + return { repositories, loading, refetch: fetchRepositories }; +}; + +export default useRepositories; +``` + +Now that we have a clean abstraction for fetching the reviewed repositories, let's use the useRepositories hook in the RepositoryList component: + +```javascript +// ... +import useRepositories from '../hooks/useRepositories'; // highlight-line + +const RepositoryList = () => { + const { repositories } = useRepositories(); // highlight-line + + const repositoryNodes = repositories + ? repositories.edges.map(edge => edge.node) + : []; + + return ( + + ); +}; + +export default RepositoryList; +``` + +That's it, now the RepositoryList component is no longer aware of the way the repositories are acquired. Maybe in the future, we will acquire them through a GraphQL API instead of a REST API. We will see what happens. + +### GraphQL and Apollo client + +In [part 8](https://fullstackopen.com/en/part8) we learned about GraphQL and how to send GraphQL queries to an Apollo Server using the [Apollo Client](https://www.apollographql.com/docs/react/) in React applications. The good news is that we can use the Apollo Client in a React Native application exactly as we would with a React web application. + +As mentioned earlier, the rate-repository-api server provides a GraphQL API which is implemented with Apollo Server. Once the server is running, you can access the [Apollo Sandbox](https://www.apollographql.com/docs/studio/explorer/) at [http://localhost:4000](http://localhost:4000). Apollo Sandbox is a tool for making GraphQL queries and inspecting the GraphQL APIs schema and documentation. If you need to send a query in your application always test it with the Apollo Sandbox first before implementing it in the code. It is much easier to debug possible problems in the query in the Apollo Sandbox than in the application. If you are uncertain what the available queries are or how to use them, you can see the documentation next to the operations editor: + +![Apollo Sandbox](../../images/10/11.png) + +In our React Native application, we will be using the same [@apollo/client](https://www.npmjs.com/package/@apollo/client) library as in part 8. Let's get started by installing the library along with the [graphql](https://www.npmjs.com/package/graphql) library which is required as a peer dependency: + +```shell +npm install @apollo/client graphql +``` + +Before we can start using Apollo Client, we will need to slightly configure the Metro bundler so that it handles the .cjs file extensions used by the Apollo Client. First, let's install the @expo/metro-config package which has the default Metro configuration: + +```shell +npm install @expo/metro-config@0.17.4 +``` + +Then, we can add the following configuration to a metro.config.js in the root directory of our project: + +```javascript +const { getDefaultConfig } = require('@expo/metro-config'); + +const defaultConfig = getDefaultConfig(__dirname); + +defaultConfig.resolver.sourceExts.push('cjs'); + +module.exports = defaultConfig; +``` + +Restart the Expo development tools so that changes in the configuration are applied. + +Now that the Metro configuration is in order, let's create a utility function for creating the Apollo Client with the required configuration. Create a utils directory in the src directory and in that utils directory create a file apolloClient.js. In that file configure the Apollo Client to connect to the Apollo Server: + +```javascript +import { ApolloClient, InMemoryCache } from '@apollo/client'; + + +const createApolloClient = () => { + return new ApolloClient({ + uri: 'http://192.168.1.100:4000/graphql', + cache: new InMemoryCache(), + }); +}; + +export default createApolloClient; +``` + +The URL used to connect to the Apollo Server is otherwise the same as the one you used with the Fetch API except the port is 4000 and the path is /graphql. Lastly, we need to provide the Apollo Client using the [ApolloProvider](https://www.apollographql.com/docs/react/api/react/hooks/#the-apolloprovider-component) context. We will add it to the App component in the App.js file: + +```javascript +import { NativeRouter } from 'react-router-native'; +import { ApolloProvider } from '@apollo/client'; // highlight-line + +import Main from './src/components/Main'; +import createApolloClient from './src/utils/apolloClient'; // highlight-line + +const apolloClient = createApolloClient(); // highlight-line + +const App = () => { + return ( + + // highlight-line +
    + // highlight-line + + ); +}; + +export default App; +``` + +### Organizing GraphQL related code + +It is up to you how to organize the GraphQL related code in your application. However, for the sake of a reference structure, let's have a look at one quite simple and efficient way to organize the GraphQL related code. In this structure, we define queries, mutations, fragments, and possibly other entities in their own files. These files are located in the same directory. Here is an example of the structure you can use to get started: + +![GraphQL structure](../../images/10/12.png) + +You can import the gql template literal tag used to define GraphQL queries from @apollo/client library. If we follow the structure suggested above, we could have a queries.js file in the graphql directory for our application's GraphQL queries. Each of the queries can be stored in a variable and exported like this: + +```javascript +import { gql } from '@apollo/client'; + +export const GET_REPOSITORIES = gql` + query { + repositories { + ${/* ... */} + } + } +`; + +// other queries... +``` + +We can import these variables and use them with the useQuery hook like this: + +```javascript +import { useQuery } from '@apollo/client'; + +import { GET_REPOSITORIES } from '../graphql/queries'; + +const Component = () => { + const { data, error, loading } = useQuery(GET_REPOSITORIES); + // ... +}; +``` + +The same goes for organizing mutations. The only difference is that we define them in a different file, mutations.js. It is recommended to use [fragments](https://www.apollographql.com/docs/react/data/fragments/) in queries to avoid retyping the same fields over and over again. + +### Evolving the structure + +Once our application grows larger there might be times when certain files grow too large to manage. For example, we have component A which renders the components B and C. All these components are defined in a file A.jsx in a components directory. We would like to extract components B and C into their own files B.jsx and C.jsx without major refactors. We have two options: + +- Create files B.jsx and C.jsx in the components directory. This results in the following structure: + +```bash +components/ + A.jsx + B.jsx + C.jsx + ... +``` + +- Create a directory A in the components directory and create files B.jsx and C.jsx there. To avoid breaking components that import the A.jsx file, move the A.jsx file to the A directory and rename it to index.jsx. This results in the following structure: + +```bash +components/ + A/ + B.jsx + C.jsx + index.jsx + ... +``` + +The first option is fairly decent, however, if components B and C are not reusable outside the component A, it is useless to bloat the components directory by adding them as separate files. The second option is quite modular and doesn't break any imports because importing a path such as ./A will match both A.jsx and A/index.jsx. + +
    + +
    + +### Exercise 10.11 + +#### Exercise 10.11: fetching repositories with Apollo Client + +We want to replace the Fetch API implementation in the useRepositories hook with a GraphQL query. Open the Apollo Sandbox at [http://localhost:4000](http://localhost:4000) and take a look at the documentation next to the operations editor. Look up the repositories query. The query has some arguments, however, all of these are optional so you don't need to specify them. In the Apollo Sandbox form a query for fetching the repositories with the fields you are currently displaying in the application. The result will be paginated and it contains the up to first 30 results by default. For now, you can ignore the pagination entirely. + +Once the query is working in the Apollo Sandbox, use it to replace the Fetch API implementation in the useRepositories hook. This can be achieved using the [useQuery](https://www.apollographql.com/docs/react/api/react/hooks/#usequery) hook. The gql template literal tag can be imported from the @apollo/client library as instructed earlier. Consider using the structure recommended earlier for the GraphQL related code. To avoid future caching issues, use the *cache-and-network* [fetch policy](https://www.apollographql.com/docs/react/data/queries/#setting-a-fetch-policy) in the query. It can be used with the useQuery hook like this: + +```javascript +useQuery(MY_QUERY, { + fetchPolicy: 'cache-and-network', + // Other options +}); +``` + +The changes in the useRepositories hook should not affect the RepositoryList component in any way. + +
    + +
    + +### Environment variables + +Every application will most likely run in more than one environment. Two obvious candidates for these environments are the development environment and the production environment. Out of these two, the development environment is the one we are running the application right now. Different environments usually have different dependencies, for example, the server we are developing locally might use a local database whereas the server that is deployed to the production environment uses the production database. To make the code environment independent we need to parametrize these dependencies. At the moment we are using one very environment dependant hardcoded value in our application: the URL of the server. + +We have previously learned that we can provide running programs with environment variables. These variables can be defined in the command line or using environment configuration files such as .env files and third-party libraries such as Dotenv. Unfortunately, React Native doesn't have direct support for environment variables. However, we can access the Expo configuration defined in the app.json file at runtime from our JavaScript code. This configuration can be used to define and access environment dependant variables. + +The configuration can be accessed by importing the Constants constant from the expo-constants module as we have done a few times before. Once imported, the Constants.expoConfig property will contain the configuration. Let's try this by logging Constants.expoConfig in the App component: + +```javascript +import { NativeRouter } from 'react-router-native'; +import { ApolloProvider } from '@apollo/client'; +import Constants from 'expo-constants'; // highlight-line + +import Main from './src/components/Main'; +import createApolloClient from './src/utils/apolloClient'; + +const apolloClient = createApolloClient(); + +const App = () => { + console.log(Constants.expoConfig); // highlight-line + + return ( + + +
    + + + ); +}; + +export default App; +``` + +You should now see the configuration in the logs. + +The next step is to use the configuration to define environment dependant variables in our application. Let's get started by renaming the app.json file to app.config.js. Once the file is renamed, we can use JavaScript inside the configuration file. Change the file contents so that the previous object: + +```javascript +{ + "expo": { + "name": "rate-repository-app", + // rest of the configuration... + } +} +``` + +Is turned into an export, which contains the contents of the expo property: + +```javascript +export default { + name: 'rate-repository-app', + // rest of the configuration... +}; +``` + +Expo has reserved an [extra](https://docs.expo.dev/guides/environment-variables/#using-app-manifest--extra) property in the configuration for any application-specific configuration. + To see how this works, let's add an env variable into our application's configuration. Note, that the older versions used (now deprecated) manifest instead of expoConfig. + +```javascript +export default { + name: 'rate-repository-app', + // rest of the configuration... + // highlight-start + extra: { + env: 'development' + }, + // highlight-end +}; +``` + + + +If you make changes in configuration, the restart may not be enough. You may need to start the application with cache cleared by command: + +```javascript +npx expo start --clear +``` + +Now, restart Expo development tools to apply the changes and you should see that the value of Constants.expoConfig property has changed and now includes the extra property containing our application-specific configuration. Now the value of the env variable is accessible through the Constants.expoConfig.extra.env property. + +Because using hard coded configuration is a bit silly, let's use an environment variable instead: + +```javascript +export default { + name: 'rate-repository-app', + // rest of the configuration... + // highlight-start + extra: { + env: process.env.ENV, + }, + // highlight-end +}; +``` + +As we have learned, we can set the value of an environment variable through the command line by defining the variable's name and value before the actual command. As an example, start Expo development tools and set the environment variable ENV as test like this: + +```shell +ENV=test npm start +``` + +If you take a look at the logs, you should see that the Constants.expoConfig.extra.env property has changed. + +We can also load environment variables from an .env file as we have learned in the previous parts. First, we need to install the [Dotenv](https://www.npmjs.com/package/dotenv) library: + +```shell +npm install dotenv +``` + +Next, add a .env file in the root directory of our project with the following content: + +```text +ENV=development +``` + +Finally, import the library in the app.config.js file: + +```javascript +import 'dotenv/config'; // highlight-line + +export default { + name: 'rate-repository-app', + // rest of the configuration... + extra: { + env: process.env.ENV, + }, +}; +``` + +You need to restart Expo development tools to apply the changes you have made to the .env file. + +Note that it is never a good idea to put sensitive data into the application's configuration. The reason for this is that once a user has downloaded your application, they can, at least in theory, reverse engineer your application and figure out the sensitive data you have stored into the code. + +
    + +
    + +### Exercise 10.12 + +#### Exercise 10.12: environment variables + +Instead of the hardcoded Apollo Server's URL, use an environment variable defined in the .env file when initializing the Apollo Client. You can name the environment variable for example APOLLO_URI. + +Do not try to access environment variables like process.env.APOLLO_URI outside the app.config.js file. Instead use the Constants.expoConfig.extra object like in the previous example. In addition, do not import the dotenv library outside the app.config.js file or you will most likely face errors. + +
    + +
    + +### Storing data in the user's device + +There are times when we need to store some persisted pieces of data in the user's device. One such common scenario is storing the user's authentication token so that we can retrieve it even if the user closes and reopens our application. In web development, we have used the browser's localStorage object to achieve such functionality. React Native provides similar persistent storage, the [AsyncStorage](https://react-native-async-storage.github.io/async-storage/docs/usage/). + +We can use the npx expo install command to install the version of the @react-native-async-storage/async-storage package that is suitable for our Expo SDK version: + +```shell +npx expo install @react-native-async-storage/async-storage +``` + +The API of the AsyncStorage is in many ways same as the localStorage API. They are both key-value storages with similar methods. The biggest difference between the two is that, as the name implies, the operations of AsyncStorage are asynchronous. + +Because AsyncStorage operates with string keys in a global namespace it is a good idea to create a simple abstraction for its operations. This abstraction can be implemented for example using a [class](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes). As an example, we could implement a shopping cart storage for storing the products user wants to buy: + +```javascript +import AsyncStorage from '@react-native-async-storage/async-storage'; + +class ShoppingCartStorage { + constructor(namespace = 'shoppingCart') { + this.namespace = namespace; + } + + async getProducts() { + const rawProducts = await AsyncStorage.getItem( + `${this.namespace}:products`, + ); + + return rawProducts ? JSON.parse(rawProducts) : []; + } + + async addProduct(productId) { + const currentProducts = await this.getProducts(); + const newProducts = [...currentProducts, productId]; + + await AsyncStorage.setItem( + `${this.namespace}:products`, + JSON.stringify(newProducts), + ); + } + + async clearProducts() { + await AsyncStorage.removeItem(`${this.namespace}:products`); + } +} + +const doShopping = async () => { + const shoppingCartA = new ShoppingCartStorage('shoppingCartA'); + const shoppingCartB = new ShoppingCartStorage('shoppingCartB'); + + await shoppingCartA.addProduct('chips'); + await shoppingCartA.addProduct('soda'); + + await shoppingCartB.addProduct('milk'); + + const productsA = await shoppingCartA.getProducts(); + const productsB = await shoppingCartB.getProducts(); + + console.log(productsA, productsB); + + await shoppingCartA.clearProducts(); + await shoppingCartB.clearProducts(); +}; + +doShopping(); +``` + +Because AsyncStorage keys are global, it is usually a good idea to add a namespace for the keys. In this context, the namespace is just a prefix we provide for the storage abstraction's keys. Using the namespace prevents the storage's keys from colliding with other AsyncStorage keys. In this example, the namespace is defined as the constructor's argument and we are using the namespace:key format for the keys. + +We can add an item to the storage using the [AsyncStorage.setItem](https://react-native-async-storage.github.io/async-storage/docs/api#setitem) method. The first argument of the method is the item's key and the second argument its value. The value must be a string, so we need to serialize non-string values as we did with the JSON.stringify method. The [AsyncStorage.getItem](https://react-native-async-storage.github.io/async-storage/docs/api/#getitem) method can be used to get an item from the storage. The argument of the method is the item's key, of which value will be resolved. The [AsyncStorage.removeItem](https://react-native-async-storage.github.io/async-storage/docs/api/#removeitem) method can be used to remove the item with the provided key from the storage. + +**NB:** [SecureStore](https://docs.expo.dev/versions/latest/sdk/securestore/) is similar persisted storage as the AsyncStorage but it encrypts the stored data. This makes it more suitable for storing more sensitive data such as the user's credit card number. + +
    + +
    + +### Exercises 10.13. - 10.14 + +#### Exercise 10.13: the sign in form mutation + +The current implementation of the sign in form doesn't do much with the submitted user's credentials. Let's do something about that in this exercise. First, read the rate-repository-api server's [authentication documentation](https://github.com/fullstack-hy2020/rate-repository-api#-authentication) and test the provided queries and mutations in the Apollo Sandbox. If the database doesn't have any users, you can populate the database with some seed data. Instructions for this can be found in the [getting started](https://github.com/fullstack-hy2020/rate-repository-api#-getting-started) section of the README. + +Once you have figured out how the authentication works, create a file *useSignIn.js* file in the hooks directory. In that file implement a useSignIn hook that sends the authenticate mutation using the [useMutation](https://www.apollographql.com/docs/react/api/react/hooks/#usemutation) hook. Note that the authenticate mutation has a single argument called credentials, which is of type AuthenticateInput. This [input type](https://graphql.org/graphql-js/mutations-and-input-types) contains username and password fields. + +The return value of the hook should be a tuple [signIn, result] where result is the mutations result as it is returned by the useMutation hook and signIn a function that runs the mutation with a { username, password } object argument. Hint: don't pass the mutation function to the return value directly. Instead, return a function that calls the mutation function like this: + +```javascript +const useSignIn = () => { + const [mutate, result] = useMutation(/* mutation arguments */); + + const signIn = async ({ username, password }) => { + // call the mutate function here with the right arguments + }; + + return [signIn, result]; +}; +``` + +Once the hook is implemented, use it in the SignIn component's onSubmit callback for example like this: + +```javascript +const SignIn = () => { + const [signIn] = useSignIn(); + + const onSubmit = async (values) => { + const { username, password } = values; + + try { + const { data } = await signIn({ username, password }); + console.log(data); + } catch (e) { + console.log(e); + } + }; + + // ... +}; +``` + +This exercise is completed once you can log the user's authenticate mutations result after the sign in form has been submitted. The mutation result should contain the user's access token. + +#### Exercise 10.14: storing the access token step1 + +Now that we can obtain the access token we need to store it. Create a file authStorage.js in the utils directory with the following content: + +```javascript +import AsyncStorage from '@react-native-async-storage/async-storage'; + +class AuthStorage { + constructor(namespace = 'auth') { + this.namespace = namespace; + } + + getAccessToken() { + // Get the access token for the storage + } + + setAccessToken(accessToken) { + // Add the access token to the storage + } + + removeAccessToken() { + // Remove the access token from the storage + } +} + +export default AuthStorage; +``` + +Next, implement the methods AuthStorage.getAccessToken, AuthStorage.setAccessToken and AuthStorage.removeAccessToken. Use the namespace variable to give your keys a namespace like we did in the previous example. + +
    + +
    + +### Enhancing Apollo Client's requests + +Now that we have implemented storage for storing the user's access token, it is time to start using it. Initialize the storage in the App component: + +```javascript +import { NativeRouter } from 'react-router-native'; +import { ApolloProvider } from '@apollo/client'; + +import Main from './src/components/Main'; +import createApolloClient from './src/utils/apolloClient'; +import AuthStorage from './src/utils/authStorage'; // highlight-line + +const authStorage = new AuthStorage(); // highlight-line +const apolloClient = createApolloClient(authStorage); // highlight-line + +const App = () => { + return ( + + +
    + + + ); +}; + +export default App; +``` + +We also provided the storage instance for the createApolloClient function as an argument. This is because next, we will send the access token to Apollo Server in each request. The Apollo Server will expect that the access token is present in the Authorization header in the format Bearer . We can enhance the Apollo Client's request by using the [setContext](https://www.apollographql.com/docs/react/api/link/apollo-link-context) function. Let's send the access token to the Apollo Server by modifying the createApolloClient function in the apolloClient.js file: + +```javascript +import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client'; +import Constants from 'expo-constants'; +import { setContext } from '@apollo/client/link/context'; // highlight-line + +// You might need to change this depending on how you have configured the Apollo Server's URI +const { apolloUri } = Constants.expoConfig.extra; + +const httpLink = createHttpLink({ + uri: apolloUri, +}); + +// highlight-start +const createApolloClient = (authStorage) => { + const authLink = setContext(async (_, { headers }) => { + try { + const accessToken = await authStorage.getAccessToken(); + + return { + headers: { + ...headers, + authorization: accessToken ? `Bearer ${accessToken}` : '', + }, + }; + } catch (e) { + console.log(e); + + return { + headers, + }; + } + }); + + return new ApolloClient({ + link: authLink.concat(httpLink), + cache: new InMemoryCache(), + }); +}; +// highlight-end + +export default createApolloClient; +``` + +### Using React Context for dependency injection + +The last piece of the sign-in puzzle is to integrate the storage to the useSignIn hook. To achieve this the hook must be able to access token storage instance we have initialized in the App component. React [Context](https://react.dev/learn/passing-data-deeply-with-context) is just the tool we need for the job. Create a directory contexts in the src directory. In that directory create a file AuthStorageContext.js with the following content: + +```javascript +import { createContext } from 'react'; + +const AuthStorageContext = createContext(); + +export default AuthStorageContext; +``` + +Now we can use the AuthStorageContext.Provider to provide the storage instance to the descendants of the context. Let's add it to the App component: + +```javascript +import { NativeRouter } from 'react-router-native'; +import { ApolloProvider } from '@apollo/client'; + +import Main from './src/components/Main'; +import createApolloClient from './src/utils/apolloClient'; +import AuthStorage from './src/utils/authStorage'; +import AuthStorageContext from './src/contexts/AuthStorageContext'; // highlight-line + +const authStorage = new AuthStorage(); +const apolloClient = createApolloClient(authStorage); + +const App = () => { + return ( + + + // highlight-line +
    + // highlight-line + + + ); +}; + +export default App; +``` + +Accessing the storage instance in the useSignIn hook is now possible using the React's [useContext](https://react.dev/reference/react/useContext) hook like this: + +```javascript +// ... +import { useContext } from 'react'; // highlight-line + +import AuthStorageContext from '../contexts/AuthStorageContext'; //highlight-line + +const useSignIn = () => { + const authStorage = useContext(AuthStorageContext); //highlight-line + // ... +}; +``` + +Note that accessing a context's value using the useContext hook only works if the useContext hook is used in a component that is a descendant of the [Context.Provider](https://react.dev/reference/react/createContext#provider) component. + +Accessing the AuthStorage instance with useContext(AuthStorageContext) is quite verbose and reveals the details of the implementation. Let's improve this by implementing a useAuthStorage hook in a useAuthStorage.js file in the hooks directory: + +```javascript +import { useContext } from 'react'; +import AuthStorageContext from '../contexts/AuthStorageContext'; + +const useAuthStorage = () => { + return useContext(AuthStorageContext); +}; + +export default useAuthStorage; +``` + +The hook's implementation is quite simple but it improves the readability and maintainability of the hooks and components using it. We can use the hook to refactor the useSignIn hook like this: + +```javascript +// ... +import useAuthStorage from '../hooks/useAuthStorage'; // highlight-line + +const useSignIn = () => { + const authStorage = useAuthStorage(); //highlight-line + // ... +}; +``` + +The ability to provide data to component's descendants opens tons of use cases for React Context, as we already saw in the [last chapter](/en/part6/react_query_use_reducer_and_the_context) of part 6. + +To learn more about these use cases, read Kent C. Dodds' enlightening article [How to use React Context effectively](https://kentcdodds.com/blog/how-to-use-react-context-effectively) to find out how to combine the [useReducer](https://react.dev/reference/react/useReducer) hook with the context to implement state management. You might find a way to use this knowledge in the upcoming exercises. + +
    + +
    + +### Exercises 10.15. - 10.16 + +#### Exercise 10.15: storing the access token step2 + +Improve the useSignIn hook so that it stores the user's access token retrieved from the authenticate mutation. The return value of the hook should not change. The only change you should make to the SignIn component is that you should redirect the user to the reviewed repositories list view after a successful sign in. You can achieve this by using the [useNavigate](https://api.reactrouter.com/v7/functions/react_router.useNavigate.html) hook. + +After the authenticate mutation has been executed and you have stored the user's access token to the storage, you should reset the Apollo Client's store. This will clear the Apollo Client's cache and re-execute all active queries. You can do this by using the Apollo Client's [resetStore](https://www.apollographql.com/docs/react/api/core/ApolloClient/#ApolloClient.resetStore) method. You can access the Apollo Client in the useSignIn hook using the [useApolloClient](https://www.apollographql.com/docs/react/api/react/hooks/#useapolloclient) hook. Note that the order of the execution is crucial and should be the following: + +```javascript +const { data } = await mutate(/* options */); +await authStorage.setAccessToken(/* access token from the data */); +apolloClient.resetStore(); +``` + +#### Exercise 10.16: sign out + +The final step in completing the sign in feature is to implement a sign out feature. The me query can be used to check the authenticated user's information. If the query's result is null, that means that the user is not authenticated. Open the Apollo Sandbox and run the following query: + +```javascript +{ + me { + id + username + } +} +``` + +You will probably end up with the null result. This is because the Apollo Sandbox is not authenticated, meaning that it doesn't send a valid access token with the request. Revise the [authentication documentation](https://github.com/fullstack-hy2020/rate-repository-api#-authentication) and retrieve an access token using the authenticate mutation. Use this access token in the *Authorization* header as instructed in the documentation. Now, run the me query again and you should be able to see the authenticated user's information. + +Open the AppBar component in the AppBar.jsx file where you currently have the tabs "Repositories" and "Sign in". Change the tabs so that if the user is signed in the tab "Sign out" is displayed, otherwise show the "Sign in" tab. You can achieve this by using the me query with the [useQuery](https://www.apollographql.com/docs/react/api/react/hooks/#usequery) hook. + +Pressing the "Sign out" tab should remove the user's access token from the storage and reset the Apollo Client's store with the [resetStore](https://www.apollographql.com/docs/react/api/core/ApolloClient/#ApolloClient.resetStore) method. Calling the resetStore method should automatically re-execute all active queries which means that the me query should be re-executed. Note that the order of execution is crucial: access token must be removed from the storage before the Apollo Client's store is reset. + +This was the last exercise in this section. It's time to push your code to GitHub and mark all of your finished exercises to the [exercise submission system](https://studies.cs.helsinki.fi/stats/courses/fs-react-native-2020). Note that exercises in this section should be submitted to the part 3 in the exercise submission system. +
    diff --git a/src/content/10/en/part10d.md b/src/content/10/en/part10d.md new file mode 100644 index 00000000000..05555b83731 --- /dev/null +++ b/src/content/10/en/part10d.md @@ -0,0 +1,1127 @@ +--- +mainImage: ../../../images/part-10.svg +part: 10 +letter: d +lang: en +--- + +
    + +Now that we have established a good foundation for our project, it is time to start expanding it. In this section you can put to use all the React Native knowledge you have gained so far. Along with expanding our application we will cover some new areas, such as testing, and additional resources. + +### Testing React Native applications + +To start testing code of any kind, the first thing we need is a testing framework, which we can use to run a set of test cases and inspect their results. For testing a JavaScript application, [Jest](https://jestjs.io/) is a popular candidate for such testing framework. For testing an Expo based React Native application with Jest, Expo provides a set of Jest configuration in a form of [jest-expo](https://github.com/expo/expo/tree/master/packages/jest-expo) preset. In order to use ESLint in the Jest's test files, we also need the [eslint-plugin-jest](https://www.npmjs.com/package/eslint-plugin-jest) plugin for ESLint. Let's get started by installing the packages: + +```shell +npm install --save-dev jest jest-expo eslint-plugin-jest +``` + +To use the jest-expo preset in Jest, we need to add the following Jest configuration to the package.json file along with the test script: + +```javascript +{ + // ... + "scripts": { + // other scripts... + "test": "jest" // highlight-line + }, + // highlight-start + "jest": { + "preset": "jest-expo", + "transform": { + "^.+\\.jsx?$": "babel-jest" + }, + "transformIgnorePatterns": [ + "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|react-router-native)" + ] + }, + // highlight-end + // ... +} +``` + +The transform option tells Jest to transform .js and .jsx files with the [Babel](https://babeljs.io/) compiler. The transformIgnorePatterns option is for ignoring certain directories in the node_modules directory while transforming files. This Jest configuration is almost identical to the one proposed in the Expo's [documentation](https://docs.expo.dev/develop/unit-testing/). + +To use the eslint-plugin-jest plugin in ESLint, we need to include it in the plugins and extensions array in the .eslintrc.json file: + +```javascript +{ + "plugins": ["react", "react-native"], + "settings": { + "react": { + "version": "detect" + } + }, + "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:jest/recommended"], // highlight-line + "parser": "@babel/eslint-parser", + "env": { + "react-native/react-native": true + }, + "rules": { + "react/prop-types": "off", + "react/react-in-jsx-scope": "off" + } +} +``` + +To see that the setup is working, create a directory \_\_tests\_\_ in the src directory and in the created directory create a file example.test.js. In that file, add this simple test: + +```javascript +describe('Example', () => { + it('works', () => { + expect(1).toBe(1); + }); +}); +``` + +Now, let's run our example test by running npm test. The command's output should indicate that the test located in the src/\_\_tests\_\_/example.test.js file is passed. + +### Organizing tests + +Organizing test files in a single \_\_tests\_\_ directory is one approach in organizing the tests. When choosing this approach, it is recommended to put the test files in their corresponding subdirectories just like the code itself. This means that for example tests related to components are in the components directory, tests related to utilities are in the utils directory, and so on. This will result in the following structure: + +```bash +src/ + __tests__/ + components/ + AppBar.js + RepositoryList.js + ... + utils/ + authStorage.js + ... + ... +``` + +Another approach is to organize the tests near the implementation. This means that for example, the test file containing tests for the AppBar component is in the same directory as the component's code. This will result in the following structure: + +```bash +src/ + components/ + AppBar/ + AppBar.test.jsx + index.jsx + ... + ... +``` + +In this example, the component's code is in the index.jsx file and the test in the AppBar.test.jsx file. Note that in order for Jest to find your test files you either have to put them into a \_\_tests\_\_ directory, use the .test or .spec suffix, or [manually configure](https://jestjs.io/docs/en/configuration#testmatch-arraystring) the global patterns. + +### Testing components + +Now that we have managed to set up Jest and run a very simple test, it is time to find out how to test components. As we know, testing components requires a way to serialize a component's render output and simulate firing different kind of events, such as pressing a button. For these purposes, there is the [Testing Library](https://testing-library.com/docs/intro) family, which provides libraries for testing user interface components in different platforms. All of these libraries share similar API for testing user interface components in a user-centric way. + +In [part 5](/en/part5/testing_react_apps) we got familiar with one of these libraries, the [React Testing Library](https://testing-library.com/docs/react-testing-library/intro). Unfortunately, this library is only suitable for testing React web applications. Luckily, there exists a React Native counterpart for this library, which is the [React Native Testing Library](https://callstack.github.io/react-native-testing-library/). This is the library we will be using while testing our React Native application's components. The good news is, that these libraries share a very similar API, so there aren't too many new concepts to learn. In addition to the React Native Testing Library, we need a set of React Native specific Jest matchers such as toHaveTextContent and toHaveProp. These matchers are provided by the [jest-native](https://github.com/testing-library/jest-native) library. Before getting into the details, let's install these packages: + +```shell +npm install --save-dev --legacy-peer-deps react-test-renderer@18.2.0 @testing-library/react-native @testing-library/jest-native +``` + +**NB:** If you face peer dependency issues, make sure that the react-test-renderer version matches the project's React version in the npm install command above. You can check the React version by running npm list react --depth=0. + +If the installation fails due to peer dependency issues, try again using the --legacy-peer-deps flag with the npm install command. + +To be able to use these matchers we need to extend the Jest's expect object. This can be done by using a global setup file. Create a file setupTests.js in the root directory of your project, that is, the same directory where the package.json file is located. In that file add the following line: + +```javascript +import '@testing-library/jest-native/extend-expect'; +``` + +Next, configure this file as a setup file in the Jest's configuration in the package.json file (note that the \ in the path is intentional and there is no need to replace it): + +```javascript +{ + // ... + "jest": { + "preset": "jest-expo", + "transform": { + "^.+\\.jsx?$": "babel-jest" + }, + "transformIgnorePatterns": [ + "node_modules/(?!(jest-)?react-native|react-clone-referenced-element|@react-native-community|expo(nent)?|@expo(nent)?/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|@sentry/.*|react-router-native)" + ], + "setupFilesAfterEnv": ["/setupTests.js"] // highlight-line + } + // ... +} +``` + +The main concepts of the React Native Testing Library are the [queries](https://callstack.github.io/react-native-testing-library/docs/api/queries) and [firing events](https://callstack.github.io/react-native-testing-library/docs/api#fireevent). Queries are used to extract a set of nodes from the component that is rendered using the [render](https://callstack.github.io/react-native-testing-library/docs/api#render) function. Queries are useful in tests where we expect for example some text, such as the name of a repository, to be present in the rendered component. Here's an example how to use the [ByText](https://callstack.github.io/react-native-testing-library/docs/api/queries/#bytext) query to check if the component's Text element has the correct textual content: + +```javascript +import { Text, View } from 'react-native'; +import { render, screen } from '@testing-library/react-native'; + +const Greeting = ({ name }) => { + return ( + + Hello {name}! + + ); +}; + +describe('Greeting', () => { + it('renders a greeting message based on the name prop', () => { + render(); + + screen.debug(); + + expect(screen.getByText('Hello Kalle!')).toBeDefined(); + }); +}); +``` + +Tests use the object [screen](https://callstack.github.io/react-native-testing-library/docs/api#screen) to do the queries to the rendered component. + +We acquire the Text node containing certain text by using the getByText function. The Jest matcher [toBeDefined](https://jestjs.io/docs/expect#tobedefined) is used to ensure that the query has found the element. + +React Native Testing Library's documentation has some good hints on [how to query different kinds of elements](https://callstack.github.io/react-native-testing-library/docs/guides/how-to-query). Another guide worth reading is Kent C. Dodds article [Making your UI tests resilient to change](https://kentcdodds.com/blog/making-your-ui-tests-resilient-to-change). + +The object [screen](https://callstack.github.io/react-native-testing-library/docs/api#screen) also has a helper method [debug](https://callstack.github.io/react-native-testing-library/docs/api#debug) that prints the rendered React tree in a user-friendly format. Use it if you are unsure what the React tree rendered by the render function looks like. + +For all available queries, check the React Native Testing Library's [documentation](https://callstack.github.io/react-native-testing-library/docs/api/queries). The full list of available React Native specific matchers can be found in the [documentation](https://github.com/testing-library/jest-native#matchers) of the jest-native library. Jest's [documentation](https://jestjs.io/docs/en/expect) contains every universal Jest matcher. + +The second very important React Native Testing Library concept is firing events. We can fire an event in a provided node by using the [fireEvent](https://callstack.github.io/react-native-testing-library/docs/api#fireevent) object's methods. This is useful for example typing text into a text field or pressing a button. Here is an example of how to test submitting a simple form: + +```javascript +import { useState } from 'react'; +import { Text, TextInput, Pressable, View } from 'react-native'; +import { render, fireEvent, screen } from '@testing-library/react-native'; + +const Form = ({ onSubmit }) => { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + + const handleSubmit = () => { + onSubmit({ username, password }); + }; + + return ( + + + setUsername(text)} + placeholder="Username" + /> + + + setPassword(text)} + placeholder="Password" + /> + + + + Submit + + + + ); +}; + +describe('Form', () => { + it('calls function provided by onSubmit prop after pressing the submit button', () => { + const onSubmit = jest.fn(); + render(
    ); + + fireEvent.changeText(screen.getByPlaceholderText('Username'), 'kalle'); + fireEvent.changeText(screen.getByPlaceholderText('Password'), 'password'); + fireEvent.press(screen.getByText('Submit')); + + expect(onSubmit).toHaveBeenCalledTimes(1); + + // onSubmit.mock.calls[0][0] contains the first argument of the first call + expect(onSubmit.mock.calls[0][0]).toEqual({ + username: 'kalle', + password: 'password', + }); + }); +}); +``` + +In this test, we want to test that after filling the form's fields using the fireEvent.changeText method and pressing the submit button using the fireEvent.press method, the onSubmit callback function is called correctly. To inspect whether the onSubmit function is called and with which arguments, we can use a [mock function](https://jestjs.io/docs/en/mock-function-api). Mock functions are functions with preprogrammed behavior such as a specific return value. In addition, we can create expectations for the mock functions such as "expect the mock function to have been called once". The full list of available expectations can be found in the Jest's [expect documentation](https://jestjs.io/docs/en/expect). + +Before heading further into the world of testing React Native applications, play around with these examples by adding a test file in the \_\_tests\_\_ directory we created earlier. + +### Handling dependencies in tests + +Components in the previous examples are quite easy to test because they are more or less pure. Pure components don't depend on side effects such as network requests or using some native API such as the AsyncStorage. The Form component is much less pure than the Greeting component because its state changes can be counted as a side effect. Nevertheless, testing it isn't too difficult. + +Next, let's have a look at a strategy for testing components with side effects. Let's pick the RepositoryList component from our application as an example. At the moment the component has one side effect, which is a GraphQL query for fetching the reviewed repositories. The current implementation of the RepositoryList component looks something like this: + +```javascript +const RepositoryList = () => { + const { repositories } = useRepositories(); + + const repositoryNodes = repositories + ? repositories.edges.map((edge) => edge.node) + : []; + + return ( + + ); +}; + +export default RepositoryList; +``` + +The only side effect is the use of the useRepositories hook, which sends a GraphQL query. There are a few ways to test this component. One way is to mock the Apollo Client's responses as instructed in the Apollo Client's [documentation](https://www.apollographql.com/docs/react/development-testing/testing/). A more simple way is to assume that the useRepositories hook works as intended (preferably through testing it) and extract the components "pure" code into another component, such as the RepositoryListContainer component: + +```javascript +export const RepositoryListContainer = ({ repositories }) => { + const repositoryNodes = repositories + ? repositories.edges.map((edge) => edge.node) + : []; + + return ( + + ); +}; + +const RepositoryList = () => { + const { repositories } = useRepositories(); + + return ; +}; + +export default RepositoryList; +``` + +Now, the RepositoryList component contains only the side effects and its implementation is quite simple. We can test the RepositoryListContainer component by providing it with paginated repository data through the repositories prop and checking that the rendered content has the correct information. + +
    + +
    + +### Exercises 10.17. - 10.18 + +#### Exercise 10.17: testing the reviewed repositories list + +Implement a test that ensures that the RepositoryListContainer component renders repository's name, description, language, forks count, stargazers count, rating average, and review count correctly. One approach in implementing this test is to add a [testID](https://reactnative.dev/docs/view#testid) prop for the element wrapping a single repository's information: + +```javascript +const RepositoryItem = (/* ... */) => { + // ... + + return ( + + {/* ... */} + + ) +}; +``` + +Once the testID prop is added, you can use the [getAllByTestId](https://callstack.github.io/react-native-testing-library/docs/api/queries#getallby) query to get those elements: + +```javascript +const repositoryItems = screen.getAllByTestId('repositoryItem'); +const [firstRepositoryItem, secondRepositoryItem] = repositoryItems; + +// expect something from the first and the second repository item +``` + +Having those elements you can use the [toHaveTextContent](https://github.com/testing-library/jest-native#tohavetextcontent) matcher to check whether an element has certain textual content. You might also find the [Querying Within Elements](https://testing-library.com/docs/dom-testing-library/api-within/) guide useful. If you are unsure what is being rendered, use the [debug](https://callstack.github.io/react-native-testing-library/docs/api#debug) function to see the serialized rendering result. + +Use this as a base for your test: + +```javascript +describe('RepositoryList', () => { + describe('RepositoryListContainer', () => { + it('renders repository information correctly', () => { + const repositories = { + totalCount: 8, + pageInfo: { + hasNextPage: true, + endCursor: + 'WyJhc3luYy1saWJyYXJ5LnJlYWN0LWFzeW5jIiwxNTg4NjU2NzUwMDc2XQ==', + startCursor: 'WyJqYXJlZHBhbG1lci5mb3JtaWsiLDE1ODg2NjAzNTAwNzZd', + }, + edges: [ + { + node: { + id: 'jaredpalmer.formik', + fullName: 'jaredpalmer/formik', + description: 'Build forms in React, without the tears', + language: 'TypeScript', + forksCount: 1619, + stargazersCount: 21856, + ratingAverage: 88, + reviewCount: 3, + ownerAvatarUrl: + 'https://avatars2.githubusercontent.com/u/4060187?v=4', + }, + cursor: 'WyJqYXJlZHBhbG1lci5mb3JtaWsiLDE1ODg2NjAzNTAwNzZd', + }, + { + node: { + id: 'async-library.react-async', + fullName: 'async-library/react-async', + description: 'Flexible promise-based React data loader', + language: 'JavaScript', + forksCount: 69, + stargazersCount: 1760, + ratingAverage: 72, + reviewCount: 3, + ownerAvatarUrl: + 'https://avatars1.githubusercontent.com/u/54310907?v=4', + }, + cursor: + 'WyJhc3luYy1saWJyYXJ5LnJlYWN0LWFzeW5jIiwxNTg4NjU2NzUwMDc2XQ==', + }, + ], + }; + + // Add your test code here + }); + }); +}); +``` + +You can put the test file where you please. However, it is recommended to follow one of the ways of organizing test files introduced earlier. Use the repositories variable as the repository data for the test. There should be no need to alter the variable's value. Note that the repository data contains two repositories, which means that you need to check that both repositories' information is present. + +#### Exercise 10.18: testing the sign in form + +Implement a test that ensures that filling the sign in form's username and password fields and pressing the submit button will call the onSubmit handler with correct arguments. The first argument of the handler should be an object representing the form's values. You can ignore the other arguments of the function. Remember that the [fireEvent](https://callstack.github.io/react-native-testing-library/docs/api#fireevent) methods can be used for triggering events and a [mock function](https://jestjs.io/docs/en/mock-function-api) for checking whether the onSubmit handler is called or not. + +You don't have to test any Apollo Client or AsyncStorage related code which is in the useSignIn hook. As in the previous exercise, extract the pure code into its own component and test it in the test. + +Note that Formik's form submissions are asynchronous so expecting the onSubmit function to be called immediately after pressing the submit button won't work. You can get around this issue by making the test function an async function using the async keyword and using the React Native Testing Library's [waitFor](https://callstack.github.io/react-native-testing-library/docs/api#waitfor) helper function. The waitFor function can be used to wait for expectations to pass. If the expectations don't pass within a certain period, the function will throw an error. Here is a rough example of how to use it: + +```javascript +import { render, screen, fireEvent, waitFor } from '@testing-library/react-native'; +// ... + +describe('SignIn', () => { + describe('SignInContainer', () => { + it('calls onSubmit function with correct arguments when a valid form is submitted', async () => { + // render the SignInContainer component, fill the text inputs and press the submit button + + await waitFor(() => { + // expect the onSubmit function to have been called once and with a correct first argument + }); + }); + }); +}); +``` + +
    + +
    + +### Extending our application + +It is time to put everything we have learned so far to good use and start extending our application. Our application still lacks a few important features such as reviewing a repository and registering a user. The upcoming exercises will focus on these essential features. + +
    + +
    + +### Exercises 10.19. - 10.26 + +#### Exercise 10.19: the single repository view + +Implement a view for a single repository, which contains the same repository information as in the reviewed repositories list but also a button for opening the repository in GitHub. It would be a good idea to reuse the RepositoryItem component used in the RepositoryList component and display the GitHub repository button for example based on a boolean prop. + +The repository's URL is in the url field of the Repository type in the GraphQL schema. You can fetch a single repository from the Apollo server with the repository query. The query has a single argument, which is the id of the repository. Here's a simple example of the repository query: + +```javascript +{ + repository(id: "jaredpalmer.formik") { + id + fullName + url + } +} +``` + +As always, test your queries in the Apollo Sandbox first before using them in your application. If you are unsure about the GraphQL schema or what are the available queries, take a look at the documentation next to the operations editor. If you have trouble using the id as a variable in the query, take a moment to study the Apollo Client's [documentation](https://www.apollographql.com/docs/react/data/queries/) on queries. + +To learn how to open a URL in a browser, read the Expo's [Linking API documentation](https://docs.expo.dev/versions/latest/sdk/linking/). You will need this feature while implementing the button for opening the repository in GitHub. Hint: [Linking.openURL](https://docs.expo.dev/versions/latest/sdk/linking/#linkingopenurlurl) method will come in handy. + +The view should have its own route. It would be a good idea to define the repository's id in the route's path as a path parameter, which you can access by using the [useParams](https://reactrouter.com/6.14.2/hooks/use-params) hook. The user should be able to access the view by pressing a repository in the reviewed repositories list. You can achieve this by for example wrapping the RepositoryItem with a [Pressable](https://reactnative.dev/docs/pressable) component in the RepositoryList component and using navigate function to change the route in an onPress event handler. You can access the navigate function with the [useNavigate](https://api.reactrouter.com/v7/functions/react_router.useNavigate.html) hook. + +The final version of the single repository view should look something like this: + +![Application preview](../../images/10/13.jpg) + +**Note** if the peer depencendy issues prevent installing the library, try the *--legacy-peer-deps* option: + +```bash +npm install expo-linking --legacy-peer-deps +``` + +#### Exercise 10.20: repository's review list + +Now that we have a view for a single repository, let's display repository's reviews there. Repository's reviews are in the reviews field of the Repository type in the GraphQL schema. reviews is a similar paginated list as in the repositories query. Here's an example of getting reviews of a repository: + +```javascript +{ + repository(id: "jaredpalmer.formik") { + id + fullName + reviews { + edges { + node { + id + text + rating + createdAt + user { + id + username + } + } + } + } + } +} +``` + +Review's text field contains the textual review, rating field a numeric rating between 0 and 100, and createdAt the date when the review was created. Review's user field contains the reviewer's information, which is of type User. + +We want to display reviews as a scrollable list, which makes [FlatList](https://reactnative.dev/docs/flatlist) a suitable component for the job. To display the previous exercise's repository's information at the top of the list, you can use the FlatList component's [ListHeaderComponent](https://reactnative.dev/docs/flatlist#listheadercomponent) prop. You can use the [ItemSeparatorComponent](https://reactnative.dev/docs/flatlist#itemseparatorcomponent) to add some space between the items like in the RepositoryList component. Here's an example of the structure: + +```javascript +const RepositoryInfo = ({ repository }) => { + // Repository's information implemented in the previous exercise +}; + +const ReviewItem = ({ review }) => { + // Single review item +}; + +const SingleRepository = () => { + // ... + + return ( + } + keyExtractor={({ id }) => id} + ListHeaderComponent={() => } + // ... + /> + ); +}; + +export default SingleRepository; +``` + +The final version of the repository's reviews list should look something like this: + +![Application preview](../../images/10/14.jpg) + +The date under the reviewer's username is the creation date of the review, which is in the createdAt field of the Review type. The date format should be user-friendly such as day.month.year. You can for example install the [date-fns](https://date-fns.org/) library and use the [format](https://date-fns.org/v2.28.0/docs/format) function for formatting the creation date. + +The round shape of the rating's container can be achieved with the borderRadius style property. You can make it round by fixing the container's width and height style property and setting the border-radius as width / 2. + +#### Exercise 10.21: the review form + +Implement a form for creating a review using Formik. The form should have four fields: repository owner's GitHub username (for example "jaredpalmer"), repository's name (for example "formik"), a numeric rating, and a textual review. Validate the fields using Yup schema so that it contains the following validations: + +- Repository owner's username is a required string +- Repository's name is a required string +- Rating is a required number between 0 and 100 +- Review is a optional string + +Explore Yup's [documentation](https://github.com/jquense/yup#yup) to find suitable validators. Use sensible error messages with the validators. The validation message can be defined as the validator method's message argument. You can make the review field expand to multiple lines by using TextInput component's [multiline](https://reactnative.dev/docs/textinput#multiline) prop. + +You can create a review using the createReview mutation. Check this mutation's arguments in the Apollo Sandbox. You can use the [useMutation](https://www.apollographql.com/docs/react/api/react/hooks/#usemutation) hook to send a mutation to the Apollo Server. + +After a successful createReview mutation, redirect the user to the repository's view you implemented in the previous exercise. This can be done with the navigate function after you have obtained it using the [useNavigate](https://reactrouter.com/en/main/hooks/use-navigate) hook. The created review has a repositoryId field which you can use to construct the route's path. + +To prevent getting cached data with the repository query in the single repository view, use the *cache-and-network* [fetch policy](https://www.apollographql.com/docs/react/data/queries/#setting-a-fetch-policy) in the query. It can be used with the useQuery hook like this: + +```javascript +useQuery(GET_REPOSITORY, { + fetchPolicy: 'cache-and-network', + // Other options +}); +``` + +Note that only an existing public GitHub repository can be reviewed and a user can review the same repository only once. You don't have to handle these error cases, but the error payload includes specific codes and messages for these errors. You can try out your implementation by reviewing one of your own public repositories or any other public repository. + +The review form should be accessible through the app bar. Create a tab to the app bar with a label "Create a review". This tab should only be visible to users who have signed in. You will also need to define a route for the review form. + +The final version of the review form should look something like this: + +![Application preview](../../images/10/15.jpg) + +This screenshot has been taken after invalid form submission to present what the form should look like in an invalid state. + +#### Exercise 10.22: the sign up form + +Implement a sign up form for registering a user using Formik. The form should have three fields: username, password, and password confirmation. Validate the form using Yup schema so that it contains the following validations: + +- Username is a required string with a length between 5 and 30 +- Password is a required string with a length between 5 and 50 +- Password confirmation matches the password + +The password confirmation field's validation can be a bit tricky, but it can be done for example by using the [oneOf](https://github.com/jquense/yup#schemaoneofarrayofvalues-arrayany-message-string--function-schema-alias-equals) and [ref](https://github.com/jquense/yup#refpath-string-options--contextprefix-string--ref) methods like suggested in [this issue](https://github.com/jaredpalmer/formik/issues/90#issuecomment-354873201). + +You can create a new user by using the createUser mutation. Find out how this mutation works by exploring the documentation in the Apollo Sandbox. After a successful createUser mutation, sign the created user in by using the useSignIn hook as we did in the sign in the form. After the user has been signed in, redirect the user to the reviewed repositories list view. + +The user should be able to access the sign-up form through the app bar by pressing a "Sign up" tab. This tab should only be visible to users that aren't signed in. + +The final version of the sign up form should look something like this: + +![Application preview](../../images/10/16.jpg) + +This screenshot has been taken after invalid form submission to present what the form should look like in an invalid state. + +#### Exercise 10.23: sorting the reviewed repositories list + +At the moment repositories in the reviewed repositories list are ordered by the date of repository's first review. Implement a feature that allows users to select the principle, which is used to order the repositories. The available ordering principles should be: + +- Latest repositories. The repository with the latest first review is on the top of the list. This is the current behavior and should be the default principle. +- Highest rated repositories. The repository with the highest average rating is on the top of the list. +- Lowest rated repositories. The repository with the lowest average rating is on the top of the list. + +The repositories query used to fetch the reviewed repositories has an argument called orderBy, which you can use to define the ordering principle. The argument has two allowed values: CREATED\_AT (order by the date of repository's first review) and RATING\_AVERAGE, (order by the repository's average rating). The query also has an argument called orderDirection which can be used to change the order direction. The argument has two allowed values: ASC (ascending, smallest value first) and DESC (descending, biggest value first). + +The selected ordering principle state can be maintained for example using the React's [useState](https://react.dev/reference/react/useState) hook. The variables used in the repositories query can be given to the useRepositories hook as an argument. + +You can use for example [@react-native-picker/picker](https://docs.expo.io/versions/latest/sdk/picker/) library, or [React Native Paper](https://callstack.github.io/react-native-paper/) library's [Menu](https://callstack.github.io/react-native-paper/docs/components/Menu/) component to implement the ordering principle's selection. You can use the FlatList component's [ListHeaderComponent](https://reactnative.dev/docs/flatlist#listheadercomponent) prop to provide the list with a header containing the selection component. + +The final version of the feature, depending on the selection component in use, should look something like this: + +![Application preview](../../images/10/17.jpg) + +#### Exercise 10.24: filtering the reviewed repositories list + +The Apollo Server allows filtering repositories using the repository's name or the owner's username. This can be done using the searchKeyword argument in the repositories query. Here's an example of how to use the argument in a query: + +```javascript +{ + repositories(searchKeyword: "ze") { + edges { + node { + id + fullName + } + } + } +} +``` + +Implement a feature for filtering the reviewed repositories list based on a keyword. Users should be able to type in a keyword into a text input and the list should be filtered as the user types. You can use a simple TextInput component or something a bit fancier such as React Native Paper's [Searchbar](https://callstack.github.io/react-native-paper/docs/components/Searchbar/) component as the text input. Put the text input component in the FlatList component's header. + +To avoid a multitude of unnecessary requests while the user types the keyword fast, only pick the latest input after a short delay. This technique is often referred to as [debouncing](https://lodash.com/docs/4.17.15#debounce). [use-debounce](https://www.npmjs.com/package/use-debounce) library is a handy hook for debouncing a state variable. Use it with a sensible delay time, such as 500 milliseconds. Store the text input's value by using the useState hook and then pass the debounced value to the query as the value of the searchKeyword argument. + +You probably face an issue that the text input component loses focus after each keystroke. This is because the content provided by the ListHeaderComponent prop is constantly unmounted. This can be fixed by turning the component rendering the FlatList component into a class component and defining the header's render function as a class property like this: + +```javascript +export class RepositoryListContainer extends React.Component { + renderHeader = () => { + // this.props contains the component's props + const props = this.props; + + // ... + + return ( + + ); + }; + + render() { + return ( + + ); + } +} +``` + +The final version of the filtering feature should look something like this: + +![Application preview](../../images/10/18.jpg) + +#### Exercise 10.25: the user's reviews view + +Implement a feature which allows user to see their reviews. Once signed in, the user should be able to access this view by pressing a "My reviews" tab in the app bar. Here is what the review list view should roughly look like: + +![Application preview](../../images/10/20.jpg) + +Remember that you can fetch the authenticated user from the Apollo Server with the me query. This query returns a User type, which has a field reviews. If you have already implemented a reusable me query in your code, you can customize this query to fetch the reviews field conditionally. This can be done using GraphQL's [include](https://graphql.org/learn/queries/#directives) directive. + +Let's say that the current query is implemented roughly in the following manner: + +```javascript +const GET_CURRENT_USER = gql` + query { + me { + # user fields... + } + } +`; +``` + +You can provide the query with an includeReviews argument and use that with the include directive: + +```javascript +const GET_CURRENT_USER = gql` + query getCurrentUser($includeReviews: Boolean = false) { + me { + # user fields... + reviews @include(if: $includeReviews) { + edges { + node { + # review fields... + } + } + } + } + } +`; +``` + +The includeReviews argument has a default value of false, because we don't want to cause additional server overhead unless we explicitly want to fetch authenticated user's reviews. The principle of the include directive is quite simple: if the value of the if argument is true, include the field, otherwise omit it. + +#### Exercise 10.26: review actions + +Now that user can see their reviews, let's add some actions to the reviews. Under each review on the review list, there should be two buttons. One button is for viewing the review's repository. Pressing this button should take the user to the single repository view implemented in one of the earlier exercises. The other button is for deleting the review. Pressing this button should delete the review. Here is what the actions should roughly look like: + +![Application preview](../../images/10/21.jpg) + +Pressing the delete button should be followed by a confirmation alert. If the user confirms the deletion, the review is deleted. Otherwise, the deletion is discarded. You can implement the confirmation using the [Alert](https://reactnative.dev/docs/alert) module. Note that calling the Alert.alert method won't open any window in Expo web preview. Use either Expo mobile app or an emulator to see the what the alert window looks like. + +Here is the confirmation alert that should pop out once the user presses the delete button: + +![Application preview](../../images/10/22.jpg) + +You can delete a review using the deleteReview mutation. This mutation has a single argument, which is the id of the review to be deleted. After the mutation has been performed, the easiest way to update the review list's query is to call the [refetch](https://www.apollographql.com/docs/react/data/queries/#refetching) function. + +
    + +
    + +### Cursor-based pagination + +When an API returns an ordered list of items from some collection, it usually returns a subset of the whole set of items to reduce the required bandwidth and to decrease the memory usage of the client applications. The desired subset of items can be parameterized so that the client can request for example the first twenty items on the list after some index. This technique is commonly referred to as pagination. When items can be requested after a certain item defined by a cursor, we are talking about cursor-based pagination. + +So cursor is just a serialized presentation of an item in an ordered list. Let's have a look at the paginated repositories returned by the repositories query using the following query: + +```javascript +{ + repositories(first: 2) { + totalCount + edges { + node { + id + fullName + createdAt + } + cursor + } + pageInfo { + endCursor + startCursor + hasNextPage + } + } +} +``` + +The first argument tells the API to return only the first two repositories. Here's an example of a result of the query: + +```javascript +{ + "data": { + "repositories": { + "totalCount": 10, + "edges": [ + { + "node": { + "id": "zeit.next.js", + "fullName": "zeit/next.js", + "createdAt": "2020-05-15T11:59:57.557Z" + }, + "cursor": "WyJ6ZWl0Lm5leHQuanMiLDE1ODk1NDM5OTc1NTdd" + }, + { + "node": { + "id": "zeit.swr", + "fullName": "zeit/swr", + "createdAt": "2020-05-15T11:58:53.867Z" + }, + "cursor": "WyJ6ZWl0LnN3ciIsMTU4OTU0MzkzMzg2N10=" + } + ], + "pageInfo": { + "endCursor": "WyJ6ZWl0LnN3ciIsMTU4OTU0MzkzMzg2N10=", + "startCursor": "WyJ6ZWl0Lm5leHQuanMiLDE1ODk1NDM5OTc1NTdd", + "hasNextPage": true + } + } + } +} +``` + +The format of the result object and the arguments are based on the [Relay's GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm), which has become a quite common pagination specification and has been widely adopted for example in the [GitHub's GraphQL API](https://docs.github.com/en/graphql). In the result object, we have the edges array containing items with node and cursor attributes. As we know, the node contains the repository itself. The cursor on the other hand is a Base64 encoded representation of the node. In this case, it contains the repository's id and date of repository's creation as a timestamp. This is the information we need to point to the item when they are ordered by the creation time of the repository. The pageInfo contains information such as the cursor of the first and the last item in the array. + +Let's say that we want to get the next set of items after the last item of the current set, which is the "zeit/swr" repository. We can set the after argument of the query as the value of the endCursor like this: + +```javascript +{ + repositories(first: 2, after: "WyJ6ZWl0LnN3ciIsMTU4OTU0MzkzMzg2N10=") { + totalCount + edges { + node { + id + fullName + createdAt + } + cursor + } + pageInfo { + endCursor + startCursor + hasNextPage + } + } +} +``` + +Now that we have the next two items and we can keep on doing this until the hasNextPage has the value false, meaning that we have reached the end of the list. To dig deeper into cursor-based pagination, read Shopify's article [Pagination with Relative Cursors](https://shopify.engineering/pagination-relative-cursors). It provides great details on the implementation itself and the benefits over the traditional index-based pagination. + +### Infinite scrolling + +Vertically scrollable lists in mobile and desktop applications are commonly implemented using a technique called infinite scrolling. The principle of infinite scrolling is quite simple: + +- Fetch the initial set of items +- When the user reaches the last item, fetch the next set of items after the last item + +The second step is repeated until the user gets tired of scrolling or some scrolling limit is exceeded. The name "infinite scrolling" refers to the way the list seems to be infinite - the user can just keep on scrolling and new items keep on appearing on the list. + +Let's have a look at how this works in practice using the Apollo Client's useQuery hook. Apollo Client has a great [documentation](https://www.apollographql.com/docs/react/pagination/cursor-based/) on implementing the cursor-based pagination. Let's implement infinite scrolling for the reviewed repositories list as an example. + +First, we need to know when the user has reached the end of the list. Luckily, the FlatList component has a prop [onEndReached](https://reactnative.dev/docs/virtualizedlist#onendreached), which will call the provided function once the user has scrolled to the last item on the list. You can change how early the onEndReach callback is called using the [onEndReachedThreshold](https://reactnative.dev/docs/virtualizedlist#onendreachedthreshold) prop. Alter the RepositoryList component's FlatList component so that it calls a function once the end of the list is reached: + +```javascript +export const RepositoryListContainer = ({ + repositories, + onEndReach, + /* ... */, +}) => { + const repositoryNodes = repositories + ? repositories.edges.map((edge) => edge.node) + : []; + + return ( + + ); +}; + +const RepositoryList = () => { + // ... + + const { repositories } = useRepositories(/* ... */); + + const onEndReach = () => { + console.log('You have reached the end of the list'); + }; + + return ( + + ); +}; + +export default RepositoryList; +``` + +Try scrolling to the end of the reviewed repositories list and you should see the message in the logs. + +Next, we need to fetch more repositories once the end of the list is reached. This can be achieved using the [fetchMore](https://www.apollographql.com/docs/react/pagination/core-api/#the-fetchmore-function) function provided by the useQuery hook. To describe to Apollo Client how to merge the existing repositories in the cache with the next set of repositories, we can use a [field policy](https://www.apollographql.com/docs/react/caching/cache-field-behavior/). In general, field policies can be used to customize the cache behavior during read and write operations with [read](https://www.apollographql.com/docs/react/caching/cache-field-behavior/#the-read-function) and [merge](https://www.apollographql.com/docs/react/caching/cache-field-behavior/#the-merge-function) functions. + +Let's add a field policy for the repositories query in the apolloClient.js file: + +```javascript +import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client'; +import { setContext } from '@apollo/client/link/context'; +import Constants from 'expo-constants'; +import { relayStylePagination } from '@apollo/client/utilities'; // highlight-line + +const { apolloUri } = Constants.manifest.extra; + +const httpLink = createHttpLink({ + uri: apolloUri, +}); + +// highlight-start +const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + repositories: relayStylePagination(), + }, + }, + }, +}); +// highlight-end + +const createApolloClient = (authStorage) => { + const authLink = setContext(async (_, { headers }) => { + try { + const accessToken = await authStorage.getAccessToken(); + + return { + headers: { + ...headers, + authorization: accessToken ? `Bearer ${accessToken}` : '', + }, + }; + } catch (e) { + console.log(e); + + return { + headers, + }; + } + }); + + return new ApolloClient({ + link: authLink.concat(httpLink), + cache, // highlight-line + }); +}; + +export default createApolloClient; +``` + +As mentioned earlier, the format of the pagination's result object and the arguments are based on the Relay's pagination specification. Luckily, Apollo Client provides a predefined field policy, relayStylePagination, which can be used in this case. + +Next, let's alter the useRepositories hook so that it returns a decorated fetchMore function, which calls the actual fetchMore function with appropriate arguments so that we can fetch the next set of repositories: + +```javascript +const useRepositories = (variables) => { + const { data, loading, fetchMore, ...result } = useQuery(GET_REPOSITORIES, { + variables, + // ... + }); + + const handleFetchMore = () => { + const canFetchMore = !loading && data?.repositories.pageInfo.hasNextPage; + + if (!canFetchMore) { + return; + } + + fetchMore({ + variables: { + after: data.repositories.pageInfo.endCursor, + ...variables, + }, + }); + }; + + return { + repositories: data?.repositories, + fetchMore: handleFetchMore, + loading, + ...result, + }; +}; +``` + +Make sure you have the pageInfo and the cursor fields in your repositories query as described in the pagination examples. You will also need to include the after and first arguments for the query. + +The handleFetchMore function will call the Apollo Client's fetchMore function if there are more items to fetch, which is determined by the hasNextPage property. We also want to prevent fetching more items if fetching is already in process. In this case, loading will be true. In the fetchMore function we are providing the query with an after variable, which receives the latest endCursor value. + +The final step is to call the fetchMore function in the onEndReach handler: + +```javascript +const RepositoryList = () => { + // ... + + const { repositories, fetchMore } = useRepositories({ + first: 8, + // ... + }); + + const onEndReach = () => { + fetchMore(); + }; + + return ( + + ); +}; + +export default RepositoryList; +``` + +Use a relatively small first argument value such as 3 while trying out the infinite scrolling. This way you don't need to review too many repositories. You might face an issue that the onEndReach handler is called immediately after the view is loaded. This is most likely because the list contains so few repositories that the end of the list is reached immediately. You can get around this issue by increasing the value of first argument. Once you are confident that the infinite scrolling is working, feel free to use a larger value for the first argument. + +
    + +
    + +### Exercise 10.27 + +#### Exercise 10.27: infinite scrolling for the repository's reviews list + +Implement infinite scrolling for the repository's reviews list. The Repository type's reviews field has the first and after arguments similar to the repositories query. ReviewConnection type also has the pageInfo field just like the RepositoryConnection type. + +Here's an example query: + +```javascript +{ + repository(id: "jaredpalmer.formik") { + id + fullName + reviews(first: 2, after: "WyIxYjEwZTRkOC01N2VlLTRkMDAtODg4Ni1lNGEwNDlkN2ZmOGYuamFyZWRwYWxtZXIuZm9ybWlrIiwxNTg4NjU2NzUwMDgwXQ==") { + totalCount + edges { + node { + id + text + rating + createdAt + repositoryId + user { + id + username + } + } + cursor + } + pageInfo { + endCursor + startCursor + hasNextPage + } + } + } +} +``` + +The cache's field policy can be similar as with the repositories query: + +```javascript +const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + repositories: relayStylePagination(), + }, + }, + // highlight-start + Repository: { + fields: { + reviews: relayStylePagination(), + }, + }, + // highlight-end + }, +}); +``` + +As with the reviewed repositories list, use a relatively small first argument value while you are trying out the infinite scrolling. You might need to create a few new users and use them to create a few new reviews to make the reviews list long enough to scroll. Set the value of the first argument high enough so that the onEndReach handler isn't called immediately after the view is loaded, but low enough so that you can see that more reviews are fetched once you reach the end of the list. Once everything is working as intended you can use a larger value for the first argument. + +This was the last exercise in this section. It's time to push your code to GitHub and mark all of your finished exercises to the [exercise submission system](https://studies.cs.helsinki.fi/stats/courses/fs-react-native-2020). Note that exercises in this section should be submitted to the part 4 in the exercise submission system. + +
    + +
    + +### Additional resources + +As we are getting closer to the end of this part, let's take a moment to look at some additional React Native related resources. [Awesome React Native](https://github.com/jondot/awesome-react-native) is an extremely encompassing curated list of React Native resources such as libraries, tutorials, and articles. Because the list is exhaustively long, let's have a closer look at few of its highlights + +#### React Native Paper + +> Paper is a collection of customizable and production-ready components for React Native, following Google’s Material Design guidelines. + +[React Native Paper](https://callstack.github.io/react-native-paper/) is for React Native what [Material-UI](https://material-ui.com/) is for React web applications. It offers a wide range of high-quality UI components, support for [custom themes](https://callstack.github.io/react-native-paper/docs/guides/theming/) and a fairly simple [setup](https://callstack.github.io/react-native-paper/docs/guides/getting-started) for Expo based React Native applications. + +#### Styled-components + +> Utilising tagged template literals (a recent addition to JavaScript) and the power of CSS, styled-components allows you to write actual CSS code to style your components. It also removes the mapping between components and styles – using components as a low-level styling construct could not be easier! + +[Styled-components](https://styled-components.com/) is a library for styling React components using [CSS-in-JS](https://en.wikipedia.org/wiki/CSS-in-JS) technique. In React Native we are already used to defining component's styles as a JavaScript object, so CSS-in-JS is not so uncharted territory. However, the approach of styled-components is quite different from using the StyleSheet.create method and the style prop. + +In styled-components components' styles are defined with the component using a feature called [tagged template literal](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#Tagged_templates) or a plain JavaScript object. Styled-components makes it possible to define new style properties for component based on its props *at runtime*. This brings many possibilities, such as seamlessly switching between a light and a dark theme. It also has a full [theming support](https://styled-components.com/docs/advanced#theming). Here is an example of creating a Text component with style variations based on props: + +```javascript +import styled from 'styled-components/native'; +import { css } from 'styled-components'; + +const FancyText = styled.Text` + color: grey; + font-size: 14px; + + ${({ isBlue }) => + isBlue && + css` + color: blue; + `} + + ${({ isBig }) => + isBig && + css` + font-size: 24px; + font-weight: 700; + `} +`; + +const Main = () => { + return ( + <> + Simple text + Blue text + Big text + + Big blue text + + + ); +}; +``` + +Because styled-components processes the style definitions, it is possible to use CSS-like snake case syntax with the property names and units in property values. However, units don't have any effect because property values are internally unitless. For more information on styled-components, head out to the [documentation](https://styled-components.com/docs). + +#### React-spring + +> react-spring is a spring-physics based animation library that should cover most of your UI related animation needs. It gives you tools flexible enough to confidently cast your ideas into moving interfaces. + +[React-spring](https://www.react-spring.io/) is a library that provides a clean [API](https://react-spring.io/basics) for animating React Native components. + +#### React Navigation + +> Routing and navigation for your React Native apps + +[React Navigation](https://reactnavigation.org/) is a routing library for React Native. It shares some similarities with the React Router library we have been using during this and earlier parts. However, unlike React Router, React Navigation offers more native features such as native gestures and animations to transition between views. + +### Closing words + +That's it, our application is ready. Good job! We have learned many new concepts during our journey such as setting up our React Native application using Expo, using React Native's core components and adding style to them, communicating with the server, and testing React Native applications. The final piece of the puzzle would be to deploy the application to the Apple App Store and Google Play Store. + +Deploying the application is entirely optional and it isn't quite trivial, because you also need to fork and deploy the [rate-repository-api](https://github.com/fullstack-hy2020/rate-repository-api). For the React Native application itself, you first need to create either iOS or Android builds by following Expo's [documentation](https://docs.expo.io/distribution/building-standalone-apps/). Then you can upload these builds to either Apple App Store or Google Play Store. Expo has [documentation](https://docs.expo.dev/submit/introduction/) for this as well. + +
    diff --git a/src/content/10/es/part10.md b/src/content/10/es/part10.md new file mode 100644 index 00000000000..6dde8ef1ffe --- /dev/null +++ b/src/content/10/es/part10.md @@ -0,0 +1,11 @@ +--- +mainImage: ../../../images/part-10.svg +part: 10 +lang: es +--- + +
    + +En esta parte, aprenderemos cómo compilar Android nativo y aplicaciones móviles iOS con JavaScript y React usando el marco React Native. Nos sumergiremos en el ecosistema React Native desarrollando una aplicación móvil completa desde cero. En el camino, aprenderemos conceptos tales como cómo renderizar componentes de interfaz de usuario nativos con React Native, cómo crear hermosas interfaces de usuario, cómo comunicarse con un servidor y cómo probar una aplicación React Native. + +
    diff --git a/src/content/10/es/part10a.md b/src/content/10/es/part10a.md new file mode 100644 index 00000000000..fb10f4b24e1 --- /dev/null +++ b/src/content/10/es/part10a.md @@ -0,0 +1,218 @@ +--- +mainImage: ../../../images/part-10.svg +part: 10 +letter: a +lang: es +--- + +
    + +Tradicionalmente, el desarrollo de aplicaciones nativas de iOS y Android ha requirido que el desarrollador utilizara lenguajes de programación y entornos de desarrollo específicos de la plataforma. Para el desarrollo de iOS, esto significa usar Objective C o Swift y para el desarrollo de Android usando lenguajes basados ​​en JVM como Java, Scala o Kotlin. El lanzamiento de una aplicación para ambas plataformas requiere técnicamente desarrollar dos aplicaciones separadas con diferentes lenguajes de programación. Esto requiere muchos recursos de desarrollo. + +Uno de los enfoques populares para unificar el desarrollo específico de la plataforma ha sido utilizar el navegador como motor de renderizado. [Cordova](https://cordova.apache.org/) es una de las plataformas más populares para crear aplicaciones multiplataforma. Permite desarrollar aplicaciones multiplataforma utilizando tecnologías web estándar: HTML5, CSS3 y JavaScript. Sin embargo, las aplicaciones de Cordova se ejecutan dentro de una ventana de navegador integrada en el dispositivo del usuario. Es por eso que estas aplicaciones no pueden lograr el rendimiento ni la apariencia de las aplicaciones nativas que utilizan componentes de interfaz de usuario nativos reales. + +[React Native](https://reactnative.dev/) es un marco para desarrollar aplicaciones nativas de Android e iOS usando JavaScript y React. Proporciona un conjunto de componentes multiplataforma que, entre bastidores, utilizan los componentes nativos de la plataforma. El uso de React Native nos permite incorporar todas las características familiares de React, como JSX, componentes, accesorios, estado y enlaces al desarrollo de aplicaciones nativas. Además de eso, podemos utilizar muchas bibliotecas conocidas en el ecosistema React, como [react-redux](https://react-redux.js.org/), [react-apollo](https://github.com/apollographql/react-apollo), [react-router](https://reacttraining.com/react-router/core/guides/quick-start) y muchos más. + +La velocidad de desarrollo y la curva de aprendizaje suave para los desarrolladores familiarizados con React es uno de los beneficios más importantes de React Native. Aquí hay una cita motivacional del artículo de Coinbase [Incorporación de miles de usuarios con React Native](https://blog.coinbase.com/onboarding-thousands-of-users-with-react-native-361219066df4) sobre los beneficios de React Native: + +> Si tuviéramos que reducir los beneficios de React Native a una sola palabra, sería “velocidad”. En promedio, nuestro equipo pudo incorporar ingenieros en menos tiempo, compartir más código (lo que esperamos que conduzca a aumentos de productividad futuros) y, en última instancia, ofrecer funciones más rápido que si hubiéramos adoptado un enfoque puramente nativo. + +### Acerca de esta parte + +Durante esta parte, aprenderemos cómo construir una aplicación React Native real de abajo hacia arriba. Aprenderemos conceptos tales como cuáles son los componentes centrales de React Native, cómo crear hermosas interfaces de usuario, cómo comunicarse con un servidor y cómo probar una aplicación React Native. + +Desarrollaremos una aplicación para calificar repositorios de [GitHub](https://github.com/). Nuestra aplicación tendrá características como ordenar y filtrar los repositorios revisados, registrar un usuario, iniciar sesión y crear una revisión para un repositorio. Se nos proporcionará el back-end de la aplicación para que podamos centrarnos únicamente en el desarrollo de React Native. La versión final de nuestra aplicación se verá así: + +![Vista previa de la aplicación](../../images/10/4.png) + +Todos los ejercicios de esta parte deben enviarse a un único repositorio de GitHub que eventualmente contendrá el código fuente completo de su aplicación. Habrá soluciones modelo disponibles para cada sección de esta parte que puede utilizar para completar las presentaciones incompletas. Esta parte está estructurada en base a la idea de que usted desarrolla su aplicación a medida que avanza en el material. Así que no espere hasta los ejercicios para comenzar el desarrollo. En su lugar, desarrolle su aplicación al mismo ritmo que avanza el material. + +Esta parte se basará en gran medida en los conceptos cubiertos en las partes anteriores. Antes de comenzar esta parte, necesitará conocimientos básicos de JavaScript, React y GraphQL. No se requiere un conocimiento profundo del desarrollo del lado del servidor y se le proporciona todo el código del lado del servidor. Sin embargo, realizaremos solicitudes de red desde sus aplicaciones React Native, por ejemplo, utilizando consultas GraphQL. Las partes recomendadas para completar antes de esta parte son [parte 1](/es/part1), [parte 2](/es/part2), [parte 5](/es/part5), [parte 7] (/es/part7) y [parte 8](/es/part8). + +### Envío de ejercicios y obtención de créditos + +Los ejercicios se envían a través del [sistema de presentaciones](https://studies.cs.helsinki.fi/stats/courses/fs-react-native-2020) al igual que en las partes anteriores. Tenga en cuenta que los ejercicios de esta parte se envían a una instancia de curso diferente que en las partes 0-9. Las partes 1 a 4 en el sistema de presentación se refieren a las secciones a-d en esta parte. Esto significa que enviará los ejercicios de una sola sección a la vez, comenzando con esta sección, "Introducción a React Native", que es la parte 1 del sistema de envío. + +Durante esta parte, obtendrá créditos en función de la cantidad de ejercicios que complete. Si completa al menos 19 ejercicios en esta parte, obtendrá 1 crédito. Si completa al menos 26 ejercicios en esta parte, obtendrá 2 créditos. + +Una vez que haya completado los ejercicios y desee obtener los créditos, háganos saber a través del sistema de envío de ejercicios que ha completado el curso: + +![Envío de ejercicios para créditos](../../images/10/23.png) + +Tenga en cuenta que la nota "examen realizado en Moodle" se refiere al [examen del curso Full Stack Open](https://fullstackopen.com/en/part0/general_info#sign-up-for-the-exam), que debe completarse antes de poder obtener créditos de esta parte. + +Puede descargar el certificado por completar esta parte haciendo clic en uno de los iconos de bandera. El icono de la bandera corresponde al idioma del certificado. Tenga en cuenta que debe haber completado al menos un crédito de ejercicios antes de poder descargar el certificado. + +### Inicializando la aplicación + +Para comenzar con nuestra aplicación, necesitamos configurar nuestro entorno de desarrollo. Hemos aprendido de las partes anteriores que existen herramientas útiles para configurar aplicaciones React rápidamente, como Create React App. Afortunadamente, React Native también tiene este tipo de herramientas. + +Para el desarrollo de nuestra aplicación, usaremos [Expo](https://docs.expo.io/versions/latest/). Expo es una plataforma que facilita la configuración, el desarrollo, la construcción y la implementación de aplicaciones React Native. Comencemos con Expo instalando la interfaz de línea de comandos expo-cli: + +```shell +npm install --global expo-cli +``` + +A continuación, podemos inicializar nuestro proyecto en un directorio rate-repository-app ejecutando el siguiente comando: + +```shell +expo init rate-repository-app --template expo-template-blank@sdk-38 +``` + +Tenga en cuenta que @sdk-38 establece la versión Expo SDK del proyecto en 38, que admite React Native versión 0.62. El uso de otra versión de Expo SDK puede causarle problemas al seguir este material. Además, Expo tiene pocas limitaciones en comparación con React Native CLI, más sobre ellas [aquí](https://docs.expo.dev/faq/#limitations). Sin embargo, estas limitaciones no tienen ningún efecto sobre la aplicación implementada en el material. + +Ahora que nuestra aplicación se ha inicializado, abra el directorio rate-repository-app creado con un editor como [Visual Studio Code](https://code.visualstudio.com/). La estructura debería ser más o menos la siguiente: + +![Project structure](../../images/10/1.png) + +Podríamos ver algunos archivos y directorios familiares como package.json y node_modules. Además de esos, los archivos más relevantes son el archivo app.json que contiene la configuración relacionada con Expo y App.js que es el componente raíz de nuestra aplicación. No cambie el nombre ni mueva el archivo App.js porque, de forma predeterminada, Expo lo importa a [registrar el componente raíz](https://docs.expo.io/versions/latest/sdk/register-root-component/). + +Veamos la sección scripts del archivo package.json que tiene los siguientes scripts: + +```javascript +{ + // ... + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "eject": "expo eject" + }, + // ... +} +``` + +Al ejecutar el script npm start se inicia el [paquete de Metro](https://facebook.github.io/metro/), que es un paquete de JavaScript para React Native. Puede describirse como el [Webpack](https://webpack.js.org/) del ecosistema React Native. Además del paquete Metro, las herramientas de desarrollo de Expo deben estar abiertas en una ventana del navegador en [http://localhost:19002](http://localhost:19002). Las herramientas de desarrollo de Expo son un conjunto útil de herramientas para ver los registros de la aplicación e iniciar la aplicación en un emulador o en la aplicación móvil de Expo. Pronto llegaremos a los emuladores y la aplicación móvil de Expo, pero primero, iniciemos nuestra aplicación en un navegador web haciendo clic en el enlace Ejecutar en el navegador web: + +![Expo DevTools](../../images/10/2.png) + +Después de hacer clic en el enlace, pronto deberíamos ver el texto definido en el archivo App.js en una ventana del navegador. Abra el archivo App.js con un editor y realice un pequeño cambio en el texto en el componente Text. Después de guardar el archivo, debería poder ver que los cambios que ha realizado en el código son visibles en la ventana del navegador. + +### Configuración del entorno de desarrollo + +Hemos visto por primera vez nuestra aplicación usando la vista del navegador de Expo. Aunque la vista del navegador es bastante utilizable, sigue siendo una simulación bastante pobre del entorno nativo. Echemos un vistazo a las alternativas que tenemos con respecto al entorno de desarrollo. + +Los dispositivos Android e iOS, como tabletas y teléfonos, se pueden emular en computadoras mediante emuladores específicos. Esto es muy útil para desarrollar aplicaciones nativas. Los usuarios de macOS pueden usar emuladores de Android e iOS con sus computadoras. Los usuarios de otros sistemas operativos como Linux o Windows tienen que conformarse con emuladores de Android. A continuación, dependiendo de su sistema operativo, siga una de estas instrucciones para configurar un emulador: + +- [Configurar el emulador de Android con Android Studio](https://docs.expo.io/versions/v37.0.0/workflow/android-studio-emulator/) (cualquier sistema operativo) +- [Configurar el simulador de iOS con Xcode](https://docs.expo.io/versions/v37.0.0/workflow/ios-simulator/) (sistema operativo macOS) + +Una vez que haya configurado el emulador y se esté ejecutando, inicie las herramientas de desarrollo de Expo como lo hicimos antes, ejecutando npm start. Dependiendo del emulador que esté ejecutando, haga clic en el enlace Ejecutar en dispositivo/emulador Android o Ejecutar en simulador de iOS. Después de hacer clic en el enlace, Expo debería conectarse al emulador y eventualmente debería ver la aplicación en su emulador. Tenga paciencia, esto puede llevar un tiempo. + +Además de los emuladores, existe una forma extremadamente útil de desarrollar aplicaciones React Native con Expo, la aplicación móvil Expo. Con la aplicación móvil Expo, puede obtener una vista previa de su aplicación utilizando su dispositivo móvil real, lo que proporciona una experiencia de desarrollo un poco más concreta en comparación con los emuladores. Para comenzar, instale la aplicación móvil Expo siguiendo las instrucciones en la [documentación de Expo](https://docs.expo.io/versions/latest/get-started/installation/#2-mobile-app-expo-client-para-ios). Tenga en cuenta que la aplicación móvil Expo solo puede abrir su aplicación si su dispositivo móvil está conectado a la misma red local (por ejemplo, conectado a la misma red Wi-Fi) que la computadora que está utilizando para el desarrollo. + +Cuando la aplicación móvil Expo haya terminado de instalarse, ábrala. A continuación, si las herramientas de desarrollo de Expo aún no se están ejecutando, inícielo ejecutando npm start. En la esquina inferior izquierda de las herramientas de desarrollo, debería poder ver un código QR. Dentro de la aplicación móvil Expo, presione Escanear código QR y escanee el código QR que se muestra en las herramientas de desarrollo. La aplicación móvil de Expo debería comenzar a crear el paquete de JavaScript y, una vez finalizado, debería poder ver su aplicación. Ahora, cada vez que desee volver a abrir su aplicación en la aplicación móvil de Expo, debería poder acceder a la aplicación sin escanear el código QR presionándolo en la lista Recientemente abiertos en la vista Proyectos.. + +
    + +
    + +#### Ejercicio 10.1: inicialización de la aplicación + +Inicialice su aplicación con la interfaz de línea de comandos de Expo y configure el entorno de desarrollo utilizando un emulador o la aplicación móvil de Expo. Se recomienda probar ambos y averiguar qué entorno de desarrollo es el más adecuado para usted. El nombre de la aplicación no es tan relevante. Puede, por ejemplo, usar rate-repository-app. + +Para enviar este ejercicio y todos los ejercicios futuros, debe [crear un nuevo repositorio de GitHub](https://github.com/new). El nombre del repositorio puede ser, por ejemplo, el nombre de la aplicación que inicializó con expo init. Si decide crear un repositorio privado, agregue al usuario de GitHub [mluukkai](https://github.com/mluukkai) como [colaborador del repositorio](https://docs.github.com/en/github/setting-up-and-managing-your-github-user-account/inviting-collaborators-to-a-personal-repository). El estado de colaborador solo se utiliza para verificar sus envíos. + +Ahora que se creó el repositorio, ejecute git init dentro del directorio raíz de su aplicación para asegurarse de que el directorio se inicialice como un repositorio Git. A continuación, para agregar el repositorio creado como la ejecución remota <git remote add origin git@github.com:/.git (recuerde reemplazar los valores de marcador de posición en el comando). Finalmente, simplemente confirme e inserte sus cambios en el repositorio y ya está. + +
    + +
    + +### ESLint + +Ahora que estamos algo familiarizados con el entorno de desarrollo, mejoremos aún más nuestra experiencia de desarrollo configurando un linter. Usaremos [ESLint] (https://eslint.org/) que ya nos es familiar de las partes anteriores. Comencemos instalando las dependencias: + +```shell +npm install --save-dev eslint babel-eslint eslint-plugin-react +``` + +A continuación, agreguemos la configuración de ESLint en un archivo .eslintrc en el directorio rate-repository-app con el siguiente contenido: + +```javascript +{ + "plugins": ["react"], + "settings": { + "react": { + "version": "detect" + } + }, + "extends": ["eslint:recommended", "plugin:react/recommended"], + "parser": "babel-eslint", + "env": { + "browser": true + }, + "rules": { + "react/prop-types": "off", + "semi": "error" + } +} +``` + +Y finalmente, agreguemos un script lint al archivo package.json para verifique las reglas de linting en archivos específicos: + +```javascript +{ + // ... + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "eject": "expo eject", + "lint": "eslint ./src/**/*.{js,jsx} App.js --no-error-on-unmatched-pattern" + }, + // ... +} +``` + +En contraste con las partes 1-8, estamos usando punto y coma para terminar líneas ahora, así que hemos agregado la regla [semi](https://eslint.org/docs/rules/semi) para verificar eso. + +Ahora podemos verificar que las reglas de linting se obedezcan en los archivos JavaScript en el directorio src y en el archivo App.js ejecutando npm run lint . Agregaremos nuestro código futuro al directorio src pero como no hemos agregado ningún archivo allí todavía, necesitamos el indicador no-error-on-unmatched-pattern. Además, si es posible, integre ESLint con su editor. Si está utilizando Visual Studio Code, puede hacerlo yendo a la sección de extensiones y verificando que la extensión ESLint esté instalada y habilitada: + +![Visual Studio Code ESLint extensions](../../images/10/3.png) + +La configuración de ESLint proporcionada contiene solo la base para la configuración. Siéntase libre de mejorar la configuración y agregar nuevos complementos si lo desea. + +
    + +
    + +### Ejercicio 10.2 + +#### Ejercicio 10.2: configuración de ESLint + +Configure ESLint en su proyecto para que pueda realizar comprobaciones de linter ejecutando npm run lint. Para aprovechar al máximo el linting, también se recomienda integrar ESLint con su editor. + +Este fue el último ejercicio de esta sección. Es hora de enviar tu código a GitHub y marcar todos tus ejercicios terminados en el [sistema de envío de ejercicios](https://studies.cs.helsinki.fi/stats/courses/fs-react-native-2020). Tenga en cuenta que los ejercicios de esta sección deben enviarse a la parte 1 del sistema de envío de ejercicios. + +
    + +
    + +### Visualización de registros + +Las herramientas de desarrollo de Exposición se pueden utilizar para mostrar los mensajes de registro de la aplicación en ejecución. Los mensajes de nivel de error y advertencia también son visibles en el emulador y en la interfaz de la aplicación móvil. Los mensajes de error aparecerán como una superposición roja, mientras que los mensajes de advertencia se pueden expandir presionando el cuadro de diálogo de alerta amarillo en la parte inferior de la pantalla. Para propósitos de depuración, podemos usar el conocido método console.log para escribir mensajes de depuración en el registro. + +Probemos esto en la práctica. Inicie las herramientas de desarrollo de Expo ejecutando npm start y abra la aplicación con el emulador o la aplicación móvil. Cuando se ejecuta la aplicación debe ser capaz de ver los dispositivos conectados en el marco del "Metro Bundler" en la esquina superior izquierda de las herramientas de desarrollos: + +![Expo development tools](../../images/10/9.png) + +Haga clic en el dispositivo para abrir sus registros. A continuación, abra el archivo App.js y agregue un mensaje console.log al componente App. Después de guardar el archivo, debería poder ver su mensaje en los registros. + +### Usando el depurador + +La inspección de los mensajes registrados desde el código con el método console.log puede ser útil, pero a veces encontrar errores o entender cómo funciona la aplicación requiere que veamos el panorama general. Podríamos, por ejemplo, estar interesados ​​en cuál es el estado y los accesorios de un determinado componente, o cuál es la respuesta de una determinada solicitud de red. En las partes anteriores, hemos utilizado las herramientas de desarrollo del navegador para este tipo de depuración. [React Native Debugger](https://docs.expo.io/workflow/debugging/#react-native-debugger) es una herramienta que ofrece un conjunto similar de funciones de depuración para las aplicaciones React Native. + +Comencemos instalando React Native Debugger con la ayuda de las [instrucciones de instalación](https://github.com/jhen0409/react-native-debugger#installation). Una vez que se complete la instalación, inicie React Native Debugger, abra una nueva ventana del depurador (accesos directos: Command + T en macOS, Ctrl + T en Linux / Windows) y configure el puerto del empaquetador React Native a 19001. + +A continuación, debemos iniciar nuestra aplicación y conectarnos al depurador. Inicie la aplicación ejecutando npm start. Una vez que la aplicación se esté ejecutando, ábrala con un emulador o la aplicación móvil Expo. Dentro del emulador o la aplicación móvil de Expo, abra el menú del desarrollador siguiendo las [instrucciones](https://docs.expo.io/workflow/debugging/#developer-menu) en la documentación de Expo. En el menú del desarrollador, seleccione Depurar JS remoto para conectarse al depurador. Ahora, debería poder ver el árbol de componentes de la aplicación en el depurador: + +![React Native Debugger](../../images/10/24.png) + +Puede utilizar el depurador para inspeccionar el estado y los accesorios del componente, así como para cambiarlos. Intente encontrar el componente Text representado por el componente App utilizando el depurador. Puede utilizar la búsqueda o recorrer el árbol de componentes. Una vez que haya encontrado el componente Text en el árbol, haga clic en él y cambie el valor del prop children. El cambio debería ser visible automáticamente en la vista previa de la aplicación. + +Para obtener herramientas de depuración de aplicaciones React Native más útiles, diríjase a la [documentación de depuración](https://docs.expo.io/workflow/debugging) de Expo. + +
    diff --git a/src/content/10/es/part10b.md b/src/content/10/es/part10b.md new file mode 100644 index 00000000000..27207206f6a --- /dev/null +++ b/src/content/10/es/part10b.md @@ -0,0 +1,1023 @@ +--- +mainImage: ../../../images/part-10.svg +part: 10 +letter: b +lang: es +--- + +
    + +Ahora que hemos configurado nuestro entorno de desarrollo podemos adentrarnos en los conceptos básicos de React Native y comenzar con el desarrollo de nuestra aplicación. En esta sección, aprenderemos cómo construir interfaces de usuario con los componentes centrales de React Native, cómo agregar propiedades de estilo a estos componentes centrales, cómo hacer la transición entre vistas y cómo administrar el estado del formulario de manera eficiente. + +### Componentes principales + +En las partes anteriores, hemos aprendido que podemos usar React para definir componentes como funciones que reciben props como argumento y devuelven un árbol de elementos React. Este árbol generalmente se representa con sintaxis JSX. En el entorno del navegador, hemos utilizado la biblioteca [ReactDOM](https://reactjs.org/docs/react-dom.html) para convertir estos componentes en un árbol DOM que puede ser renderizado por un navegador. Aquí hay un ejemplo concreto de un componente muy simple: + +```javascript +import React from 'react'; + +const HelloWorld = props => { + return
    Hello world!
    ; +}; +``` + +El componente HelloWorld devuelve un único elemento div que se crea utilizando la sintaxis JSX. Podríamos recordar que esta sintaxis JSX se compila en llamadas al método React.createElement, como esta: + +```javascript +React.createElement('div', null, 'Hello world!'); +``` + +Esta línea de código crea un elemento div sin ningún prop y con un solo elemento hijo que es una cadena "Hello World". Cuando renderizamos este componente en un elemento DOM raíz usando el método ReactDOM.render, el elemento div se renderizará como el elemento DOM correspondiente. + +Como podemos ver, React no está vinculado a un entorno determinado, como el entorno del navegador. En cambio, existen bibliotecas como ReactDOM que pueden representar un conjunto de componentes predefinidos, como elementos DOM, en un entorno específico. En React Native, estos componentes predefinidos se denominan componentes principales. + +Los [componentes principales](https://reactnative.dev/docs/intro-react-native-components) son un conjunto de componentes proporcionados por React Native que, entre bastidores, utilizan los componentes nativos de la plataforma. Implementemos el ejemplo anterior usando React Native: + +```javascript +import React from 'react'; +import { Text } from 'react-native'; // highlight-line + +const HelloWorld = props => { + return Hello world!; // highlight-line +}; +``` + +Así que importamos el componente [Text](https://reactnative.dev/docs/text) de React Native y reemplazamos el elemento div con un elemento Text . Muchos elementos DOM familiares tienen sus "contrapartes" de React Native. Aquí hay algunos ejemplos seleccionados de la [documentación de los componentes principales](https://reactnative.dev/docs/components-and-apis) de React Native: + +- El componente [Texto](https://reactnative.dev/docs/text) es el único componente de React Native que puede tener hijos textuales. Es similar, por ejemplo, a los elementos <strong> y <h1>. +- El componente [View](https://reactnative.dev/docs/view) es el componente básico de la interfaz de usuario similar al <div> +- El componente [TextInput](https://reactnative.dev/docs/textinput) es un componente de campo de texto similar al elemento <input>. +- El componente [TouchableWithoutFeedback](https://reactnative.dev/docs/touchablewithoutfeedback) (y otros componentes Touchable*) sirve para capturar diferentes eventos de prensa. Es similar, por ejemplo, al elemento <button>. + +Hay algunas diferencias notables entre los componentes principales y los elementos DOM. La primera diferencia es que el componente Text es el único componente React Native que puede tener hijos textuales. Esto significa que no puede, por ejemplo, reemplazar el componente Texto con el Ver + +La segunda diferencia notable está relacionada con los controladores de eventos. Mientras trabajamos con los elementos DOM, estamos acostumbrados a agregar controladores de eventos como onClick básicamente a cualquier elemento como <div> y <button>. En React Native tenemos que leer detenidamente la [documentación de la API](https://reactnative.dev/docs/components-and-apis) para saber qué controladores de eventos (así como otros accesorios) acepta un componente. Por ejemplo, la familia de [componentes "Tocables"](https://reactnative.dev/docs/handling-touches#touchables) proporciona la capacidad de capturar gestos táctiles y puede mostrar comentarios cuando se reconoce un gesto. Uno de estos componentes es el componente [TouchableWithoutFeedback](https://reactnative.dev/docs/touchablewithoutfeedback), que acepta la propiedad onPress: + +```javascript +import React from 'react'; +import { Text, TouchableWithoutFeedback, Alert } from 'react-native'; + +const TouchableText = props => { + return ( + Alert.alert('You pressed the text!')} + > + You can press me + + ); +}; +``` + +Ahora que tenemos una comprensión básica de los componentes centrales, comencemos a darle a nuestro proyecto algo de estructura. Cree un directorio src en el directorio raíz de su proyecto y en el directorio src cree un directorio components. En el directorio components cree un archivo Main.jsx con el siguiente contenido: + +```javascript +import React from 'react'; +import Constants from 'expo-constants'; +import { Text, StyleSheet, View } from 'react-native'; + +const styles = StyleSheet.create({ + container: { + marginTop: Constants.statusBarHeight, + flexGrow: 1, + flexShrink: 1, + }, +}); + +const Main = () => { + return ( + + Rate Repository Application + + ); +}; + +export default Main; +``` + +A continuación, usemos el componente Main en el componente App en el archivo App.js que se encuentra en el directorio raíz de nuestro proyecto. Reemplace el contenido actual del archivo con esto: + +```javascript +import React from 'react'; + +import Main from './src/components/Main'; + +const App = () => { + return
    ; +}; + +export default App; +``` + +### Recarga manual de la aplicación + +Como hemos visto, Expo recargará automáticamente la aplicación cuando hagamos cambios en el código. Sin embargo, puede haber ocasiones en las que la recarga automática no funcione y la aplicación deba recargarse manualmente. Esto se puede lograr a través del menú de desarrollador de la aplicación. + +Puede acceder al menú del desarrollador agitando su dispositivo o seleccionando "Shake Gesture" dentro del menú Hardware en el simulador de iOS. También puedes usar el método abreviado de teclado ⌘D cuando tu aplicación se está ejecutando en el simulador de iOS, o ⌘M cuando se ejecuta en un emulador de Android en Mac OS y Ctrl + M en Windows y Linux. + +Una vez que el menú del desarrollador esté abierto, simplemente presione "Recargar" para volver a cargar la aplicación. Una vez que se ha recargado la aplicación, las recargas automáticas deberían funcionar sin la necesidad de una recarga manual. + +
    + +
    + +### Ejercicio 10.3. + +#### Ejercicio 10.3: la lista de repositorios revisados + +En este ejercicio, implementaremos la primera versión de la lista de repositorios revisados. La lista debe contener el nombre completo del repositorio, la descripción, el idioma, la cantidad de bifurcaciones, la cantidad de estrellas, la calificación promedio y la cantidad de reseñas. Afortunadamente, React Native proporciona un componente útil para mostrar una lista de datos, que es el componente [FlatList](https://reactnative.dev/docs/flatlist). + +Implemente los componentes RepositoryList y RepositoryItem en los archivos del directorio components RepositoryList.jsx y RepositoryItem.jsx. El componente RepositoryList debe representar el componente FlatList y RepositoryItem como un solo elemento en la lista (pista: use el prop del componente de FlatList [renderItem](https://reactnative.dev/docs/flatlist#renderitem)). Use esto como base para el archivo RepositoryList.jsx: + +```javascript +import React from 'react'; +import { FlatList, View, StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + separator: { + height: 10, + }, +}); + +const repositories = [ + { + id: 'jaredpalmer.formik', + fullName: 'jaredpalmer/formik', + description: 'Build forms in React, without the tears', + language: 'TypeScript', + forksCount: 1589, + stargazersCount: 21553, + ratingAverage: 88, + reviewCount: 4, + ownerAvatarUrl: 'https://avatars2.githubusercontent.com/u/4060187?v=4', + }, + { + id: 'rails.rails', + fullName: 'rails/rails', + description: 'Ruby on Rails', + language: 'Ruby', + forksCount: 18349, + stargazersCount: 45377, + ratingAverage: 100, + reviewCount: 2, + ownerAvatarUrl: 'https://avatars1.githubusercontent.com/u/4223?v=4', + }, + { + id: 'django.django', + fullName: 'django/django', + description: 'The Web framework for perfectionists with deadlines.', + language: 'Python', + forksCount: 21015, + stargazersCount: 48496, + ratingAverage: 73, + reviewCount: 5, + ownerAvatarUrl: 'https://avatars2.githubusercontent.com/u/27804?v=4', + }, + { + id: 'reduxjs.redux', + fullName: 'reduxjs/redux', + description: 'Predictable state container for JavaScript apps', + language: 'TypeScript', + forksCount: 13902, + stargazersCount: 52869, + ratingAverage: 0, + reviewCount: 0, + ownerAvatarUrl: 'https://avatars3.githubusercontent.com/u/13142323?v=4', + }, +]; + +const ItemSeparator = () => ; + +const RepositoryList = () => { + return ( + + ); +}; + +export default RepositoryList; +``` + +No altere el contenido de la variable repositories, debe contener todo lo que necesita para completar este ejercicio. Renderice el componente RepositoryList en el componente Main que agregamos previamente al archivo Main.jsx. La lista de repositorios revisada debería verse más o menos así: + +![Vista previa de la aplicación](../../images/10/5.jpg) + +
    + +
    + +### Estilo + +Ahora que tenemos una comprensión básica de cómo funcionan los componentes centrales y podemos usarlos para construir una interfaz de usuario simple, es hora de agregar algo de estilo. En la [parte 2](/es/part2/added_styles_to_react_app) aprendimos que en el entorno del navegador podemos definir las propiedades de estilo del componente React usando CSS. Teníamos la opción de definir estos estilos en línea usando el accesorio style o en un archivo CSS con un selector adecuado. + +Hay muchas similitudes en la forma en que las propiedades de estilo se adjuntan a los componentes centrales de React Native y en la forma en que se adjuntan a los elementos DOM. En React Native, la mayoría de los componentes principales aceptan un prop llamado style. El prop style acepta un objeto con propiedades de estilo y sus valores. Estas propiedades de estilo son en la mayoría de los casos las mismas que en CSS, sin embargo, los nombres de las propiedades están en camelCase. Esto significa que las propiedades CSS como padding-top y font-size se escriben como paddingTop y fontSize. Aquí hay un ejemplo simple de cómo usar el style prop: + +```javascript +import React from 'react'; +import { Text, View } from 'react-native'; + +const BigBlueText = () => { + return ( + + + Big blue text + + + ); +}; +``` + +Además de los nombres de las propiedades, es posible que haya notado otra diferencia en el ejemplo. En CSS, los valores de las propiedades numéricas suelen tener una unidad como px, %, em o rem. En React Native, todos los valores de propiedad relacionados con la dimensión, como width, height, padding y margin, así como la fuente los tamaños son sin unidades. Estos valores numéricos sin unidades representan píxeles independientes de la densidad. En caso de que se esté preguntando cuáles son las propiedades de estilo disponibles para cierto componente principal, consulte la [Hoja de trucos de estilo nativo de React](https://github.com/vhpoet/react-native-styling-cheat-sheet). + +En general, definir estilos directamente en el objeto style no se considera una gran idea, porque hace que los componentes se vuelvan inflados y poco claros. En cambio, deberíamos definir estilos fuera de la función de renderización del componente usando el método [StyleSheet.create](https://reactnative.dev/docs/0.53/stylesheet#create). El método StyleSheet.create acepta un único argumento que es un objeto que consta de objetos de estilo con nombre y crea una referencia de estilo StyleSheet a partir del objeto dado. Aquí hay un ejemplo de cómo refactorizar el ejemplo anterior usando el método StyleSheet.create: + +```javascript +import React from 'react'; +import { Text, View, StyleSheet } from 'react-native'; // highlight-line + +// highlight-start +const styles = StyleSheet.create({ + container: { + padding: 20, + }, + text: { + color: 'blue', + fontSize: 24, + fontWeight: '700', + }, +}); +// highlight-end + +const BigBlueText = () => { + return ( + // highlight-line + // highlight-line + Big blue text + + + ); +}; +``` + +Creamos dos objetos de estilo con nombre, styles.container y styles.text. Dentro del componente, podemos acceder a un objeto de estilo específico de la misma manera que accederíamos a cualquier clave en un objeto simple. + +Además de un objeto, el prop style también acepta una matriz de objetos. En el caso de una matriz, los objetos se fusionan de izquierda a derecha para que las últimas propiedades de estilo tengan prioridad. Esto funciona de forma recursiva, por lo que podemos tener, por ejemplo, una matriz que contenga una matriz de estilos, etc. Si una matriz contiene valores que se evalúan como falsos, como null o undefined, estos valores se ignoran. Esto facilita la definición de estilos condicionales, por ejemplo, basándose en el valor de una propiedad. Aquí hay un ejemplo de estilos condicionales: + +```javascript +import React from 'react'; +import { Text, View, StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + text: { + color: 'grey', + fontSize: 14, + }, + blueText: { + color: 'blue', + }, + bigText: { + fontSize: 24, + fontWeight: '700', + }, +}); + +const FancyText = ({ isBlue, isBig, children }) => { + const textStyles = [ + styles.text, + isBlue && styles.blueText, + isBig && styles.bigText, + ]; + + return {children}; +}; + +const Main = () => { + return ( + <> + Simple text + Blue text + Big text + + Big blue text + + + ); +}; +``` + +En el ejemplo usamos el operador && con la instrucción condition && exprIfTrue. Esta declaración produce exprIfTrue si la condition se evalúa como verdadera; de lo contrario, producirá condition, que en ese caso es un valor que se evalúa como falso. Se trata de una abreviatura práctica y muy utilizada. Otra opción sería usar, por ejemplo, el [operador condicional](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator), condition ? exprIfTrue : exprIfFalse. + +### Interfaz de usuario coherente con temas + +Sigamos con el concepto de estilo, pero con una perspectiva un poco más amplia. La mayoría de nosotros hemos utilizado una multitud de aplicaciones diferentes y podríamos estar de acuerdo en que una característica que hace que una buena interfaz de usuario sea la coherencia. Esto significa que la apariencia de los componentes de la interfaz de usuario, como el tamaño de fuente, la familia de fuentes y el color, sigue un patrón constante. Para lograr esto, tenemos que parametrizar de alguna manera los valores de diferentes propiedades de estilo. Este método se conoce comúnmente como tematización. + +Los usuarios de librerías de interfaces de usuario populares como [Bootstrap](https://getbootstrap.com/docs/4.4/getting-started/theming/) y [Material UI](https://material-ui.com/customization/theming/) puede que ya esté bastante familiarizado con la temática. Aunque las implementaciones de temas son diferentes, la idea principal es siempre usar variables como colors.primary en lugar de ["números mágicos"]() como #0366d6 al definir estilos. Esto conduce a una mayor consistencia y flexibilidad. + +Veamos cómo la tematización podría funcionar en la práctica en nuestra aplicación. Usaremos mucho texto con diferentes variaciones, como diferentes tamaños de fuente y colores. Debido a que React Native no admite estilos globales, deberíamos crear nuestro propio componente Text para mantener la coherencia del contenido textual. Comencemos agregando el siguiente objeto de configuración de tema en un archivo theme.js en el directorio src: + +```javascript +const theme = { + colors: { + textPrimary: '#24292e', + textSecondary: '#586069', + primary: '#0366d6', + }, + fontSizes: { + body: 14, + subheading: 16, + }, + fonts: { + main: 'System', + }, + fontWeights: { + normal: '400', + bold: '700', + }, +}; + +export default theme; +``` + +A continuación, deberíamos crear el componente actual Text que utiliza esta configuración de tema. Cree un archivo Text.jsx en el directorio components donde ya tenemos nuestros otros componentes. Agregue el siguiente contenido al archivo Text.jsx: + +```javascript +import React from 'react'; +import { Text as NativeText, StyleSheet } from 'react-native'; + +import theme from '../theme'; + +const styles = StyleSheet.create({ + text: { + color: theme.colors.textPrimary, + fontSize: theme.fontSizes.body, + fontFamily: theme.fonts.main, + fontWeight: theme.fontWeights.normal, + }, + colorTextSecondary: { + color: theme.colors.textSecondary, + }, + colorPrimary: { + color: theme.colors.primary, + }, + fontSizeSubheading: { + fontSize: theme.fontSizes.subheading, + }, + fontWeightBold: { + fontWeight: theme.fontWeights.bold, + }, +}); + +const Text = ({ color, fontSize, fontWeight, style, ...props }) => { + const textStyle = [ + styles.text, + color === 'textSecondary' && styles.colorTextSecondary, + color === 'primary' && styles.colorPrimary, + fontSize === 'subheading' && styles.fontSizeSubheading, + fontWeight === 'bold' && styles.fontWeightBold, + style, + ]; + + return ; +}; + +export default Text; +``` + +Ahora hemos implementado nuestro propio componente de texto con variantes de color, tamaño de fuente y peso de fuente consistentes que podemos usar en cualquier lugar de nuestra aplicación. Podemos obtener diferentes variaciones de texto usando diferentes props como este: + +```javascript +import React from 'react'; + +import Text from './Text'; + +const Main = () => { + return ( + <> + Simple text + Text with custom style + + Bold subheading + + Text with secondary color + + ); +}; + +export default Main; +``` + +No dude en ampliar o modificar este componente si lo desea. También puede ser una buena idea crear componentes de texto reutilizables como Subheading que utilizan el componente Text. Además, siga ampliando y modificando la configuración del tema a medida que avanza su aplicación. + +### Usando flexbox para el diseño + +El último concepto que cubriremos relacionado con el estilo es implementar diseños con [flexbox](https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Flexbox). Aquellos que están más familiarizados con CSS saben que flexbox no está relacionado solo con React Native, también tiene muchos casos de uso en el desarrollo web. De hecho, aquellos que saben cómo funciona flexbox en el desarrollo web probablemente no aprenderán mucho de esta sección. Sin embargo, aprendamos o revisemos los conceptos básicos de flexbox. + +Flexbox es una entidad de diseño que consta de dos componentes separados: un contenedor flexible y dentro de él un conjunto de elementos flexibles. El contenedor flexible tiene un conjunto de propiedades que controlan el flujo de sus artículos. Para convertir un componente en un contenedor flexible, debe tener la propiedad de estilo display establecida como flex, que es el valor predeterminado para la propiedad display. Aquí hay un ejemplo de un contenedor flexible: + +```javascript +import React from 'react'; +import { View, StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + flexContainer: { + flexDirection: 'row', + }, +}); + +const FlexboxExample = () => { + return {/* ... */}; +}; +``` + +Quizás las propiedades más importantes de un contenedor flexible son las siguientes: + +- la propiedad [flexDirection](https://css-tricks.com/almanac/properties/f/flex-direction/) controla la dirección en la que los artículos flex se colocan dentro del contenedor. Los valores posibles para esta propiedad son row, row-reverse, column (valor predeterminado) y reverse-column. La dirección de flex row colocará los elementos flexibles de izquierda a derecha, mientras que la column de arriba a abajo. Las direcciones *-reverse simplemente invertirán el orden de los elementos flexibles. + +- La propiedad [justifyContent](https://css-tricks.com/almanac/properties/j/justify-content/) controla la alineación de los elementos flexibles a lo largo del eje principal (definido por la propiedad flexDirection). Los valores posibles para esta propiedad son flex-start (valor predeterminado), flex-end, center, space-between, space-around y space-evenly. + +- La propiedad [alignItems](https://css-tricks.com/almanac/properties/a/align-items/) hace lo mismo que justifyContent pero para el eje opuesto. Los valores posibles para esta propiedad son flex-start, flex-end, center, baseline y stretch (valor predeterminado). + +Pasemos a los elementos flexibles. Como se mencionó, un contenedor flexible puede contener uno o varios elementos flexibles. Los elementos flexibles tienen propiedades que controlan cómo se comportan con respecto a otros elementos flexibles en el mismo contenedor flexible. Para convertir un componente en un elemento flexible, todo lo que tiene que hacer es configurarlo como hijo inmediato de un contenedor flexible: + +```javascript +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + flexContainer: { + display: 'flex', + }, + flexItemA: { + flexGrow: 0, + backgroundColor: 'green', + }, + flexItemB: { + flexGrow: 1, + backgroundColor: 'blue', + }, +}); + +const FlexboxExample = () => { + return ( + + + Flex item A + + + Flex item B + + + ); +}; +``` + +Una de las propiedades más utilizadas del elemento flexible es la propiedad [flexGrow](https://css-tricks.com/almanac/properties/f/flex-grow/). Acepta un valor sin unidades que define la capacidad de un artículo flexible para crecer si es necesario. Si todos los elementos flexibles tienen un flexGrow de 1, compartirán todo el espacio disponible de manera uniforme. Si un elemento flexible tiene un flexGrow de 0, solo usará el espacio que requiere su contenido y dejará el resto del espacio para otros elementos flexibles. + +Aquí hay un ejemplo más interactivo y concreto de cómo usar flexbox para implementar un componente de tarjeta simple con encabezado, cuerpo y pie de página: [ejemplo de Flexbox](https://snack.expo.io/@kalleilv/3d045d). + +A continuación, lea el artículo [Una guía completa de Flexbox](https://css-tricks.com/snippets/css/a-guide-to-flexbox/) que tiene ejemplos visuales completos de flexbox. También es una buena idea jugar con las propiedades de flexbox en [Flexbox Playground](https://demos.scotch.io/visual-guide-to-css3-flexbox-flexbox-playground/demos/) para ver cómo diferentes propiedades de flexbox afectan el diseño. Recuerde que en React Native los nombres de las propiedades son los mismos que los de CSS excepto por el nombre en camelCase. Sin embargo, los valores de propiedad como flex-start y space-between son exactamente iguales. + +**NB:** React Native y CSS tienen algunas diferencias con respecto al flexbox. La diferencia más importante es que en React Native el valor predeterminado para la propiedad flexDirection es column. También vale la pena señalar que la abreviatura flex no acepta múltiples valores en React Native. Se puede leer más sobre la implementación de Flexbox de React Native en la [documentación](https://reactnative.dev/docs/flexbox). + +
    + +
    + +### Ejercicios 10.4. - 10,5. + +#### Ejercicio 10.4: la barra de aplicaciones + +Pronto necesitaremos navegar entre diferentes vistas en nuestra aplicación. Es por eso que necesitamos una [barra de aplicaciones](https://material.io/components/app-bars-top/) para mostrar pestañas para cambiar entre diferentes vistas. Cree un archivo AppBar.jsx en la carpeta components con el siguiente contenido: + +```javascript +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import Constants from 'expo-constants'; + +const styles = StyleSheet.create({ + container: { + paddingTop: Constants.statusBarHeight, + // ... + }, + // ... +}); + +const AppBar = () => { + return {/* ... */}; +}; + +export default AppBar; +``` + +Ahora que el componente AppBar evitará que la barra de estado se superponga al contenido, puede eliminar el estilo marginTop que agregamos anteriormente para el componente Main en el archivo Main.jsx. El componente AppBar debería contener actualmente una pestaña con el texto "Repositorios". Haga que la pestaña sea táctil usando el componente [TouchableWithoutFeedback](https://reactnative.dev/docs/touchablewithoutfeedback) pero no tiene que manejar el evento onPress de ninguna manera. Agregue el componente AppBar al componente Main para que sea el componente superior en la pantalla. El componente AppBar debería verse algo como esto: + +![Application preview](../../images/10/6.jpg) + +El color de fondo de la barra de la aplicación en la imagen es #24292e, pero también puede usar cualquier otro color. Puede ser una buena idea agregar el color de fondo de la barra de la aplicación en la configuración del tema para que sea fácil cambiarlo si es necesario. Otra buena idea podría ser separar la pestaña de la barra de la aplicación en su propio componente, como AppBarTab para que sea fácil agregar nuevas pestañas en el futuro. + +#### Ejercicio 10.5: lista de repositorios revisados ​​y pulida + +La versión actual de la lista de repositorios revisada parece bastante sombría. Modifique el componente RepositoryListItem para que también muestre la imagen de avatar del autor del repositorio. Puede implementar esto utilizando el componente [Image](https://reactnative.dev/docs/image). Los recuentos, como el número de estrellas y forks, mayores o iguales a 1000 deben mostrarse en miles con una precisión de un decimal y con un sufijo "k". Esto significa que, por ejemplo, el recuento de bifurcaciones de 8439 debería mostrarse como "8.4k". También pule el aspecto general del componente para que la lista de repositorios revisados ​​se vea así: + +![Vista previa de la aplicación](../../images/10/7.jpg) + +En la imagen, el color de fondo del componente Main se establece en #e1e4e8 mientras que el color de fondo del componente RepositoryListItem se establece en white. El color de fondo de la etiqueta de idioma es #0366d6, que es el valor de la variable colors.primary en la configuración del tema. Recuerde explotar el componente Text que implementamos anteriormente. Además, cuando sea necesario, divida el componente RepositoryListItem en componentes más pequeños. + +
    + +
    + +### Enrutamiento + +Cuando comencemos a expandir nuestra aplicación, necesitaremos una forma de transición entre diferentes vistas, como la vista de repositorios y la vista de inicio de sesión. En la [parte 7](/es/part7/react_router) nos familiarizamos con la biblioteca [React router](https://reacttraining.com/react-router/native/guides/quick-start) y aprendimos cómo usarla para implementar el enrutamiento en una aplicación web. + +El enrutamiento en una aplicación React Native es un poco diferente al enrutamiento en una aplicación web. La principal diferencia es que no podemos hacer referencia a páginas con URL, que escribimos en la barra de direcciones del navegador, y no podemos navegar hacia adelante y hacia atrás a través del historial del usuario usando los navegadores [API de historial](https://developer.mozilla.org/en-US/docs/Web/API/History_API). Sin embargo, esto es solo cuestión de la interfaz del enrutador que estamos usando. + +Con React Native podemos usar todo el núcleo del enrutador React, incluidos los ganchos y componentes. La única diferencia con el entorno del navegador es que debemos reemplazar el BrowserRouter con [NativeRouter](https://reacttraining.com/react-router/native/api/NativeRouter) compatible con React Native , proporcionado por la biblioteca [react-router-native](https://reacttraining.com/react-router/native/guides/quick-start). Comencemos instalando la librería react-router-native: + +```shell +npm install react-router-native +``` + +El uso de la librería react-router-native romperá la vista previa del navegador web de Expo. Sin embargo, otras vistas previas funcionarán igual que antes. Podemos solucionar el problema ampliando la configuración del Webpack de la Expo para que transpile las fuentes de la biblioteca react-router-native con Babel. Para extender la configuración de Webpack, necesitamos instalar la librería @expo/webpack-config: + +```shell +npm install @expo/webpack-config --save-dev +``` + +A continuación, cree un archivo webpack.config.js en el directorio raíz de su proyecto con el siguiente contenido: + +```javascript +const path = require('path'); +const createExpoWebpackConfigAsync = require('@expo/webpack-config'); + +module.exports = async function(env, argv) { + const config = await createExpoWebpackConfigAsync(env, argv); + + config.module.rules.push({ + test: /\.js$/, + loader: 'babel-loader', + include: [path.join(__dirname, 'node_modules/react-router-native')], + }); + + return config; +}; +``` + +Finalmente, reinicie las herramientas de desarrollo de Expo para que se aplique nuestra nueva configuración de Webpack. + +Ahora que la vista previa del navegador web de la Expo está arreglada, abra el archivo App.js y agregue el componente NativeRouter al componente App: + + + +```javascript +import React from 'react'; +import { NativeRouter } from 'react-router-native'; // highlight-line + +import Main from './src/components/Main'; + +const App = () => { + return ( + // highlight-line +
    + // highlight-line + ); +}; + +export default App; +``` + +Una vez que el enrutador esté en su lugar, agreguemos nuestra primera ruta al componente Main en el archivo Main.jsx: + +```javascript +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Route, Switch, Redirect } from 'react-router-native'; // highlight-line + +import RepositoryList from './RepositoryList'; +import AppBar from './AppBar'; +import theme from '../theme'; + +const styles = StyleSheet.create({ + container: { + backgroundColor: theme.colors.mainBackground, + flexGrow: 1, + flexShrink: 1, + }, +}); + +const Main = () => { + return ( + + + // highlight-start + + + + + + + // highlight-end + + ); +}; + +export default Main; +``` + +
    + +
    + +### Ejercicios 10.6. - 10,7. + +#### Ejercicio 10.6: la vista de inicio de sesión + +Pronto implementaremos un formulario, que un usuario puede usar para iniciar sesión en nuestra aplicación. Antes de eso, debemos implementar una vista a la que se pueda acceder desde la barra de aplicaciones. Cree un archivo SignIn.jsx en el directorio components con el siguiente contenido: + +```javascript +import React from 'react'; + +import Text from './Text'; + +const SignIn = () => { + return The sign in view; +}; + +export default SignIn; +``` + + +Configure una ruta para este componente SignIn en el componente Main. También agregue una pestaña con el texto "Sign In" en la barra de la aplicación junto a la pestaña "Repositories". Los usuarios deben poder navegar entre las dos vistas presionando las pestañas (pista: use el componente [Link](https://reacttraining.com/react-router/native/api/Link) y su prop [component](https://reacttraining.com/react-router/native/api/Link/component-func)). + +#### Ejercicio 10.7: barra de aplicaciones desplazable + +Como estamos agregando más pestañas a nuestra barra de aplicaciones, es una buena idea permitir el desplazamiento horizontal una vez que las pestañas no quepan en la pantalla. El componente [ScrollView](https://reactnative.dev/docs/scrollview) es el componente adecuado para el trabajo. + +Envuelva las pestañas en las pestañas del componente AppBar con un componente ScrollView: + +```javascript +const AppBar = () => { + return ( + + {/* ... */} // highlight-line + + ); +}; +``` + +Establecer el prop [horizontal](https://reactnative.dev/docs/scrollview#horizontal) en true hará que el componente ScrollView se desplace horizontalmente una vez que el contenido no encaje en la pantalla. Tenga en cuenta que deberá agregar propiedades de estilo adecuadas al componente ScrollView para que las pestañas se coloquen en una fila dentro del contenedor flexible. Puede asegurarse de que la barra de la aplicación pueda desplazarse horizontalmente agregando pestañas hasta que la última pestaña no se ajuste a la pantalla. Solo recuerde eliminar las pestañas adicionales una vez que la barra de la aplicación esté funcionando según lo previsto. + +
    + +
    + +### Gestión del estado del formulario + +Ahora que tenemos un marcador de posición para la vista de inicio de sesión, el siguiente paso sería implementar el formulario de inicio de sesión. Antes de llegar a eso, hablemos de las formas en una perspectiva más amplia. + +La implementación de formularios depende en gran medida de la gestión del estado. Usar el hook useState de React para la administración del estado podría hacer el trabajo para formularios más pequeños. Sin embargo, rápidamente hará que la gestión de estado sea bastante tediosa con formas más complejas. Afortunadamente, hay muchas bibliotecas buenas en el ecosistema React que facilitan la gestión de estado de formularios. Una de estas bibliotecas es [Formik](https://jaredpalmer.com/formik/). + +Los conceptos principales de Formik son el contexto y un campo. El contexto de Formik lo proporciona el componente [Formik](https://jaredpalmer.com/formik/docs/api/formik) que contiene el estado del formulario. El estado consta de información de los campos del formulario. Esta información incluye, por ejemplo, el valor y los errores de validación de cada campo. Se puede hacer referencia a los campos del estado por su nombre utilizando el hook [useField](https://jaredpalmer.com/formik/docs/api/useField) o el componente [Field](https://jaredpalmer.com/formik/docs/api/field). + +Veamos cómo funciona esto realmente creando un formulario para calcular el [índice de masa corporal](https://en.wikipedia.org/wiki/Body_mass_index): + +```javascript +import React from 'react'; +import { Text, TextInput, TouchableWithoutFeedback, View } from 'react-native'; +import { Formik, useField } from 'formik'; + +const initialValues = { + mass: '', + height: '', +}; + +const getBodyMassIndex = (mass, height) => { + return Math.round(mass / Math.pow(height, 2)); +}; + +const BodyMassIndexForm = ({ onSubmit }) => { + const [massField, massMeta, massHelpers] = useField('mass'); + const [heightField, heightMeta, heightHelpers] = useField('height'); + + return ( + + massHelpers.setValue(text)} + /> + heightHelpers.setValue(text)} + /> + + Calculate + + + ); +}; + +const BodyMassIndexCalculator = () => { + const onSubmit = values => { + const mass = parseFloat(values.mass); + const height = parseFloat(values.height); + + if (!isNaN(mass) && !isNaN(height) && height !== 0) { + console.log(`Your body mass index is: ${getBodyMassIndex(mass, height)}`); + } + }; + + return ( + + {({ handleSubmit }) => } + + ); +}; +``` + +Este ejemplo no es parte de nuestra aplicación, por lo que no es necesario que agregue este código a la aplicación. Sin embargo, puede probarlo, por ejemplo, en [Expo Snack](https://snack.expo.io/). Expo Snack es un editor en línea para React Native, similar a [JSFiddle](https://jsfiddle.net/) y [CodePen](https://codepen.io/). Es una plataforma útil para probar código rápidamente. Puede compartir Expo Snacks con otros usando un enlace o incrustándolos como un Snack Player en un sitio web. Es posible que se haya topado con Snack Players, por ejemplo, en este material y en la documentación de React Native. + +En el ejemplo, definimos el contexto Formik en el componente BodyMassIndexCalculator y le proporcionamos valores iniciales y una devolución de llamada de envío. Los valores iniciales se proporcionan a través de la propiedad [initialValues](https://jaredpalmer.com/formik/docs/api/formik#initialvalues-values) como un objeto con nombres de campo como claves y los valores iniciales correspondientes como valores. La devolución de llamada de envío se proporciona a través del prop [onSubmit](https://jaredpalmer.com/formik/docs/api/formik#onsubmit-values-values-formikbag-formikbag--void--promiseany) y se llama cuando el la función handleSubmit es llamada, con la condición de que no haya errores de validación. Los hijos del componente Formik son una función que se llama con [props](https://jaredpalmer.com/formik/docs/api/formik#formik-render-methods-and-props) incluyendo información relacionada con el estado y acciones como la función handleSubmit. + +El componente BodyMassIndexForm contiene los enlaces de estado entre el contexto y las entradas de texto. Usamos el hook [useField](https://jaredpalmer.com/formik/docs/api/useField) para obtener el valor de un campo y cambiarlo. El hook _useField_ tiene un argumento que es el nombre del campo y devuelve una matriz con tres valores, [field, meta, helpers]. El [objeto de campo](https://jaredpalmer.com/formik/docs/api/useField#fieldinputpropsvalue) contiene el valor del campo, el [metaobjeto](https://jaredpalmer.com/formik/docs/api/useField#fieldmetapropsvalue) contiene metainformación del campo, como un posible mensaje de error y el [objeto de ayuda](https://jaredpalmer.com/formik/docs/api/useField#fieldhelperprops) contiene diferentes acciones para cambiar el estado del campo, como la función setValue. Tenga en cuenta que el componente que usa el hook useField tiene que estar _dentro del contexto de Formik_. Esto significa que el componente debe ser descendiente del componente Formik. + +Aquí hay una versión interactiva de nuestro ejemplo anterior: [Ejemplo de Formik](https://snack.expo.io/@kalleilv/formik-example). + +En el ejemplo anterior, el uso del hook useField con el componente TextInput provoca un código repetitivo. Extraigamos este código repetitivo en un componente FormikTextInput y creemos un componente TextInput personalizado para hacer que las entradas de texto sean un poco más agradables visualmente. Primero, instalemos Formik: + +```shell +npm install formik +``` + +A continuación, cree un archivo TextInput.jsx en el directorio components con el siguiente contenido: + +```javascript +import React from 'react'; +import { TextInput as NativeTextInput, StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({}); + +const TextInput = ({ style, error, ...props }) => { + const textInputStyle = [style]; + + return ; +}; + +export default TextInput; +``` + +Pasemos al componente FormikTextInput que agrega los enlaces de estado de Formik al componente TextInput. Cree un archivo FormikTextInput.jsx en el directorio components con el siguiente contenido: + +```javascript +import React from 'react'; +import { StyleSheet } from 'react-native'; +import { useField } from 'formik'; + +import TextInput from './TextInput'; +import Text from './Text'; + +const styles = StyleSheet.create({ + errorText: { + marginTop: 5, + }, +}); + +const FormikTextInput = ({ name, ...props }) => { + const [field, meta, helpers] = useField(name); + const showError = meta.touched && meta.error; + + return ( + <> + helpers.setValue(value)} + onBlur={() => helpers.setTouched(true)} + value={field.value} + error={showError} + {...props} + /> + {showError && {meta.error}} + + ); +}; + +export default FormikTextInput; +``` + +Al usar el componente FormikTextInput podríamos refactorizar el componente BodyMassIndexForm en el ejemplo anterior de esta manera: + +```javascript +const BodyMassIndexForm = ({ onSubmit }) => { + return ( + + // highlight-line + //highlight-line + + Calculate + + + ); +}; +``` + +Como podemos ver, la implementación del componente FormikTextInput que maneja los enlaces de Formik del componente TextInput ahorra mucho código. Si sus formularios de Formik utilizan otros componentes de entrada, es una buena idea implementar abstracciones similares para ellos también. + +
    + +
    + +### Ejercicio 10.8. + +#### Ejercicio 10.8: el formulario de inicio de sesión + +Implemente un formulario de inicio de sesión en el componente SignIn que agregamos anteriormente en el archivo SignIn.jsx. El formulario de inicio de sesión debe incluir dos campos de texto, uno para el nombre de usuario y otro para la contraseña. También debería haber un botón para enviar el formulario. No es necesario implementar una función de devolución de llamada onSubmit, es suficiente que los valores del formulario se registren usando console.log cuando se envía el formulario: + +```javascript +const onSubmit = (values) => { + console.log(values); +}; +``` + +Recuerde utilizar el componente FormikTextInput que implementamos anteriormente. Puede utilizar el prop [secureTextEntry](https://reactnative.dev/docs/textinput#securetextentry) en el componente TextInput para ocultar la entrada de la contraseña. + +El formulario de inicio de sesión debería verse así: + +![Application preview](../../images/10/19.jpg) + +
    + +
    + +### Validación del formulario + +Formik ofrece dos enfoques para la validación de formularios: una función de validación o un esquema de validación. Una función de validación es una función proporcionada para el componente Formik como el valor del prop [validate](https://jaredpalmer.com/formik/docs/guides/validation#validate). Recibe los valores del formulario como argumento y devuelve un objeto que contiene posibles mensajes de error específicos del campo. + +El segundo enfoque es el esquema de validación que se proporciona para el componente Formik como el valor del prop [validationSchema](https://jaredpalmer.com/formik/docs/guides/validation#validationschema). Este esquema de validación se puede crear con una librería de validación llamada [Yup](https://github.com/jquense/yup). Comencemos instalando Yup: + +```shell +npm install yup +``` + +A continuación, como ejemplo, creemos un esquema de validación para el formulario de índice de masa corporal que implementamos anteriormente. Queremos validar que los campos mass y height estén presentes y sean numéricos. Además, el valor de mass debe ser mayor o igual a 1 y el valor de height debe ser mayor o igual a 0.5. Así es como definimos el esquema: + +```javascript +import React from 'react'; +import * as yup from 'yup'; // highlight-line + +// ... + +// highlight-start +const validationSchema = yup.object().shape({ + mass: yup + .number() + .min(1, 'Weight must be greater or equal to 1') + .required('Weight is required'), + height: yup + .number() + .min(0.5, 'Height must be greater or equal to 0.5') + .required('Height is required'), +}); +// highlight-end + +const BodyMassIndexCalculator = () => { + // ... + + return ( + + {({ handleSubmit }) => } + + ); +}; +``` + +La validación se realiza de forma predeterminada cada vez que cambia el valor de un campo y cuando se llama a la función handleSubmit. Si la validación falla, no se llama a la función provista para la propiedad onSubmit del componente Formik. + +El componente FormikTextInput que implementamos anteriormente muestra el mensaje de error del campo si está presente y el campo está "tocado", lo que significa que el campo ha recibido y perdido el foco: + +```javascript +const FormikTextInput = ({ name, ...props }) => { + const [field, meta, helpers] = useField(name); + + // Check if the field is touched and the error message is present + const showError = meta.touched && meta.error; + + return ( + <> + helpers.setValue(value)} + onBlur={() => helpers.setTouched(true)} + value={field.value} + error={showError} + {...props} + /> + {/* Show the error message if the value of showError variable is true */} + {showError && {meta.error}} + + ); +}; +``` + +
    + +
    + +### Ejercicio 10.9. + +#### Ejercicio 10.9: validación del formulario de inicio de sesión + +Valide el formulario de inicio de sesión para que se requieran los campos de nombre de usuario y contraseña. Tenga en cuenta que la devolución de llamada onSubmit implementada en el ejercicio anterior, no debe llamarse si falla la validación del formulario. + +La implementación actual del componente FormikTextInput debería mostrar un mensaje de error si un campo tocado tiene un error. Enfatice este mensaje de error dándole un color rojo. + +En la parte superior del mensaje de error rojo, dé a un campo no válido una indicación visual de un error dándole un color de borde rojo. Recuerde que si un campo tiene un error, el componente FormikTextInput establece el prop error del componente TextInput como verdadero. Puede utilizar el valor del prop error para adjuntar estilos condicionales al componente TextInput. + +Así es como debería verse el formulario de inicio de sesión con un campo no válido: + +![Application preview](../../images/10/8.jpg) + +El color rojo utilizado en esta implementación es #d73a4a. + +
    + +
    + +### Código específico de la plataforma + +Una gran ventaja de React Native es que no tenemos que preocuparnos por si la aplicación se ejecuta en un dispositivo Android o iOS. Sin embargo, puede haber casos en los que necesitemos ejecutar código específico de la plataforma. Tal caso podría ser, por ejemplo, el uso de una implementación diferente de un componente en una plataforma diferente. + +Podemos acceder a la plataforma del usuario a través de la constante Platform.OS: + +```javascript +import { React } from 'react'; +import { Platform, Text, StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + text: { + color: Platform.OS === 'android' ? 'green' : 'blue', + }, +}); + +const WhatIsMyPlatform = () => { + return Your platform is: {Platform.OS}; +}; +``` + +Los valores posibles para la constante Platform.OS son android y ios. Otra forma útil de definir ramas de código específicas de la plataforma es utilizar el método Platform.select. Dado un objeto cuyas claves son ios, android, native y default, El método Platform.select devuelve el valor más adecuado para la plataforma en la que se está ejecutando el usuario. Podemos reescribir la variable styles en el ejemplo anterior usando el método Platform.select como este: + +```javascript +const styles = StyleSheet.create({ + text: { + color: Platform.select({ + android: 'green', + ios: 'blue', + default: 'black', + }), + }, +}); +``` + +Incluso podemos usar el método Platform.select para requerir un componente específico de la plataforma: + +```javascript +const MyComponent = Platform.select({ + ios: () => require('./MyIOSComponent'), + android: () => require('./MyAndroidComponent'), +})(); + +; +``` + +Sin embargo, un método más sofisticado para implementar e importar componentes específicos de la plataforma (o cualquier otro fragmento de código) es utilizar las extensiones de archivo .io.jsx y .android.jsx. Tenga en cuenta que la extensión .jsx también puede ser cualquier extensión reconocida por el paquete, como .js. Por ejemplo, podemos tener archivos Button.ios.jsx que podemos importar así: + +```javascript +import React from 'react'; + +import Button from './Button'; + +const PlatformSpecificButton = () => { + return
    + +
    + +### Ejercicio 10.10. + +#### Ejercicio 10.10: una fuente específica de la plataforma + +Actualmente, la familia de fuentes de nuestra aplicación está configurada en System en la configuración del tema ubicada en el archivo theme.js. En lugar de la fuente System, utilice una fuente [Sans-serif](https://en.wikipedia.org/wiki/Sans-serif) específica de la plataforma. En la plataforma Android use la fuente Roboto y en la plataforma iOS use la fuente Arial . La fuente predeterminada puede ser System. + +Este fue el último ejercicio de esta sección. Es hora de enviar tu código a GitHub y marcar todos tus ejercicios terminados en el [sistema de envío de ejercicios](https://studies.cs.helsinki.fi/stats/courses/fs-react-native-2020). Tenga en cuenta que los ejercicios de esta sección deben enviarse a la parte 2 del sistema de envío de ejercicios. + +
    diff --git a/src/content/10/es/part10c.md b/src/content/10/es/part10c.md new file mode 100644 index 00000000000..6536da64507 --- /dev/null +++ b/src/content/10/es/part10c.md @@ -0,0 +1,755 @@ +--- +mainImage: ../../../images/part-10.svg +part: 10 +letter: c +lang: es +--- + +
    + +Hasta ahora hemos implementado funciones en nuestra aplicación sin ninguna comunicación real con el servidor. Por ejemplo, la lista de repositorios revisados ​​que hemos implementado utiliza datos simulados y el formulario de inicio de sesión no envía las credenciales del usuario a ningún endpoint de autorización. En esta sección, aprenderemos cómo comunicarnos con un servidor mediante solicitudes HTTP, cómo usar Apollo Client en una aplicación React Native y cómo almacenar datos en el dispositivo del usuario. + +Pronto aprenderemos a comunicarnos con un servidor en nuestra aplicación. Antes de llegar a eso, necesitamos un servidor con el que comunicarnos. Para ello, tenemos una implementación de servidor completa en el repositorio [rate-repository-api](https://github.com/fullstack-hy2020/rate-repository-api). El servidor rate-repository-api satisface todas las necesidades de API de nuestra aplicación durante esta parte. Utiliza la base de datos [SQLite](https://www.sqlite.org/index.html) que no necesita ninguna configuración y proporciona una API Apollo GraphQL junto con algunos endpoints de la API REST. + +Antes de profundizar más en el material, configure el servidor rate-repository-api siguiendo las instrucciones de configuración en el [README](https://github.com/fullstack-hy2020/rate-repository-api/blob/master/README.md) del repositorio . Tenga en cuenta que si está utilizando un emulador para el desarrollo, se recomienda ejecutar el servidor y el emulador en la misma computadora. Esto facilita considerablemente las solicitudes de red. + +### solicitudes HTTP + +React Native proporciona [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) para realizar solicitudes HTTP en nuestras aplicaciones. React Native también es compatible con la antigua [API XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) que hace posible el uso de bibliotecas de terceros como [Axios](https://github.com/axios/axios). Estas API son las mismas que las del entorno del navegador y están disponibles globalmente sin necesidad de importarlas. + +Las personas que han utilizado tanto la API Fetch como la API XMLHttpRequest probablemente estén de acuerdo en que la API Fetch es más fácil de usar y más moderna. Sin embargo, esto no significa que XMLHttpRequest API no tenga sus usos. En aras de la simplicidad, solo usaremos la API Fetch en nuestros ejemplos. + +El envío de solicitudes HTTP mediante la API Fetch se puede realizar mediante la función fetch. El primer argumento de la función es la URL del recurso: + +```javascript +fetch('https://my-api.com/get-end-point'); +``` + +El método de solicitud predeterminado es GET. El segundo argumento de la función fetch es un objeto de opciones, que puede utilizar, por ejemplo, para especificar un método de solicitud diferente, encabezados de solicitud o cuerpo de solicitud: + +```javascript +fetch('https://my-api.com/post-end-point', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + firstParam: 'firstValue', + secondParam: 'secondValue', + }), +}); +``` + +Tenga en cuenta que estas URL están inventadas y (lo más probable) no enviarán una respuesta a sus solicitudes. En comparación con Axios, la API Fetch opera en un nivel un poco más bajo. Por ejemplo, no hay ninguna serialización ni análisis del cuerpo de solicitud o respuesta. Esto significa que, por ejemplo, debe configurar el encabezado Content-Type usted mismo y usar el método JSON.stringify para serializar el cuerpo de la solicitud. + +La función fetch devuelve una promesa que resuelve un objeto [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response). Tenga en cuenta que los códigos de estado de error como 400 y 500 no se rechazan como, por ejemplo, en Axios. En el caso de una respuesta con formato JSON, podemos analizar el cuerpo de la respuesta utilizando el método Response.json: + +```javascript +const fetchMovies = async () => { + const response = await fetch('https://reactnative.dev/movies.json'); + const json = await response.json(); + + return json; +}; +``` + +Para una introducción más detallada a la API de Fetch, lea el artículo [Using Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) en los documentos web de MDN. + +A continuación, probemos la API Fetch en la práctica. El servidor rate-repository-api proporciona un punto final para devolver una lista paginada de repositorios revisados. Una vez que el servidor se esté ejecutando, debería poder acceder al endpoint en [http://localhost:5000/api/repositories](http://localhost:5000/api/repositories). Los datos están paginados en un [formato de paginación basado en cursor](https://graphql.org/learn/pagination/) común. Los datos reales del repositorio están detrás de la clave node en la matriz edges. + +Desafortunadamente, no podemos acceder al servidor directamente en nuestra aplicación usando la URL http://localhost:5000/api/repositories. Para realizar una solicitud a este endpoint en nuestra aplicación, necesitamos acceder al servidor usando su dirección IP en su red local. Para saber cuál es, abra las herramientas de desarrollo de Expo ejecutando npm start. En las herramientas de desarrollo, debería poder ver una URL que comience con exp:// encima del código QR: + +![Development tools](../../images/10/10.png) + +Copie la dirección IP entre exp:// y :, que en este ejemplo es 192.168.100.16. Construya una URL en formato http://:5000/api/repositories y ábrala en el navegador. + +Ahora que conocemos la URL del endpoint, usemos los datos reales proporcionados por el servidor en nuestra lista de repositorios revisados. Actualmente estamos usando datos simulados almacenados en la variable repositories. Elimine la variable repositories y reemplace el uso de los datos simulados con este fragmento de código en el archivo RepositoryList.jsx en el directorio components: + +```javascript +import React, { useState, useEffect } from 'react'; +// ... + +const RepositoryList = () => { + const [repositories, setRepositories] = useState(); + + const fetchRepositories = async () => { + // Replace the IP address part with your own IP address! + const response = await fetch('http://192.168.100.16:5000/api/repositories'); + const json = await response.json(); + + console.log(json); + + setRepositories(json); + }; + + useEffect(() => { + fetchRepositories(); + }, []); + + // Get the nodes from the edges array + const repositoryNodes = repositories + ? repositories.edges.map(edge => edge.node) + : []; + + return ( + + ); +}; + +export default RepositoryList; +``` + +Estamos usando el hook useState de React para mantener el estado de la lista de repositorios y el hook useEffect para llamar a la función fetchRepositories cuando el componente RepositoryList está montado. Extraemos los repositorios reales en la variable repositoryNodes y reemplazamos la variable repositories utilizada anteriormente en la propiedad data del componente FlatList con eso. Ahora debería poder ver los datos reales proporcionados por el servidor en la lista de repositorios revisados. + +Por lo general, es una buena idea registrar la respuesta del servidor para poder inspeccionarlo como hicimos en la función fetchRepositories. Debería poder ver este mensaje de registro en las herramientas de desarrollo de la Expo si navega a los registros de su dispositivo como aprendimos en la sección [Visualización de registros](/es/part10/introduction_to_react_native#view-logs). Si está utilizando la aplicación móvil de la Expo para el desarrollo y la solicitud de red falla, asegúrese de que la computadora que está usando para ejecutar el servidor y su teléfono estén conectados a la misma red Wi-Fi. Si eso no es posible, use un emulador en la misma computadora en la que se ejecuta el servidor o configure un túnel al localhost, por ejemplo, usando [Ngrok](https://ngrok.com/). + +El código actual de obtención de datos en el componente RepositoryList podría refactorizarse. Por ejemplo, el componente conoce los detalles de la solicitud de red, como la URL del punto final. Además, el código de recuperación de datos tiene un gran potencial de reutilización. Refactoricemos el código del componente extrayendo el código de obtención de datos en su propio gancho. Cree un directorio hooks en el directorio src y en ese directorio hooks cree un archivo useRepositories.js con el siguiente contenido: + +```javascript +import { useState, useEffect } from 'react'; + +const useRepositories = () => { + const [repositories, setRepositories] = useState(); + const [loading, setLoading] = useState(false); + + const fetchRepositories = async () => { + setLoading(true); + + // Replace the IP address part with your own IP address! + const response = await fetch('http://192.168.100.16:5000/api/repositories'); + const json = await response.json(); + + setLoading(false); + setRepositories(json); + }; + + useEffect(() => { + fetchRepositories(); + }, []); + + return { repositories, loading, refetch: fetchRepositories }; +}; + +export default useRepositories; +``` + +Ahora que tenemos una abstracción limpia para buscar los repositorios revisados, usemos el hook useRepositories en el componente RepositoryList: + +```javascript +import React from 'react'; +// ... +import useRepositories from '../hooks/useRepositories'; // highlight-line + +const RepositoryList = () => { + const { repositories } = useRepositories(); // highlight-line + + const repositoryNodes = repositories + ? repositories.edges.map(edge => edge.node) + : []; + + return ( + + ); +}; + +export default RepositoryList; +``` + +Eso es todo, ahora el componente RepositoryList ya no es consciente de la forma en que se adquieren los repositorios. Quizás en el futuro, los adquiriremos a través de una API GraphQL en lugar de una API REST. Veremos que sucede. + +### Cliente GraphQL y Apollo + +En la [parte 8](https://fullstackopen.com/en/part8) aprendimos sobre GraphQL y cómo enviar consultas GraphQL a un servidor Apollo utilizando el [Cliente Apollo](https://www.apollographql.com/docs/react/) en aplicaciones React. La buena noticia es que podemos usar Apollo Client en una aplicación React Native exactamente como lo haríamos con una aplicación web React. + +Como se mencionó anteriormente, el servidor rate-repository-api proporciona una API GraphQL que se implementa con Apollo Server. Una vez que el servidor se está ejecutando, puede acceder a [GraphQL Playground](https://www.apollographql.com/docs/apollo-server/testing/graphql-playground/#gatsby-focus-wrapper) en [http://localhost:5000/graphql](http://localhost:5000/graphql). GraphQL Playground es una herramienta de desarrollo para realizar consultas GraphQL e inspeccionar el esquema y la documentación de las API GraphQL. Si necesita enviar una consulta en su aplicación siempre pruébela con GraphQL Playground antes de implementarla en el código. Es mucho más fácil depurar posibles problemas en la consulta en GraphQL Playground que en la aplicación. Si no está seguro de cuáles son las consultas disponibles o cómo utilizarlas, haga clic en la pestaña docs para abrir la documentación: + +![GraphQL Playground](../../images/10/11.png) + +En nuestra aplicación React Native, utilizaremos el [Apollo Boost](https://www.npmjs.com/package/apollo-boost), que es una forma con configuración cero para comenzar a usar Apollo Client. Como integración de React, usaremos la librería [@apollo/react-hooks](https://www.apollographql.com/docs/react/api/react-hooks/), que proporciona enlaces como [useQuery](https://www.apollographql.com/docs/react/api/react-hooks/#usequery) y [useMutation](https://www.apollographql.com/docs/react/api/react-hooks/#usemutation) para usar Apollo Client. Comencemos instalando las dependencias: + +```shell +npm install apollo-boost @apollo/react-hooks graphql +``` + +A continuación, creemos una función de utilidad para crear el cliente Apollo con la configuración requerida. Cree un directorio utils en el directorio src y en ese directorio utils cree un archivo apolloClient.js. En ese archivo, configure el Apollo Client para que se conecte al Apollo Server: + +```javascript +import ApolloClient from 'apollo-boost'; + +const createApolloClient = () => { + return new ApolloClient({ + // Replace the IP address part with your own IP address! + uri: 'http://192.168.100.16:5000/graphql', + }); +}; + +export default createApolloClient; +``` + +Por lo demás, la URL que se usa para conectarse al servidor Apollo es la misma que usó con la API Fetch, pero la ruta es /graphql. Por último, debemos proporcionar el Cliente Apollo utilizando el contexto [ApolloProvider](https://www.apollographql.com/docs/react/api/react-hooks/#apolloprovider). Lo agregaremos al componente App en el archivo App.js: + +```javascript +import React from 'react'; +import { NativeRouter } from 'react-router-native'; +import { ApolloProvider } from '@apollo/react-hooks'; // highlight-line + +import Main from './src/components/Main'; +import createApolloClient from './src/utils/apolloClient'; // highlight-line + +const apolloClient = createApolloClient(); // highlight-line + +const App = () => { + return ( + + // highlight-line +
    + // highlight-line + + ); +}; + +export default App; +``` + +### Organización del código relacionado con GraphQL + +Depende de usted cómo organizar el código relacionado con GraphQL en su aplicación. Sin embargo, por el bien de una estructura de referencia, echemos un vistazo a una forma bastante simple y eficiente de organizar el código relacionado con GraphQL. En esta estructura, definimos consultas, mutaciones, fragmentos y posiblemente otras entidades en sus propios archivos. Estos archivos se encuentran en el mismo directorio. A continuación, se muestra un ejemplo de la estructura que puede usar para comenzar: + +![GraphQL structure](../../images/10/12.png) + +Puede importar el template literal [gql](https://www.apollographql.com/docs/apollo-server/api/apollo-server/ #gql) utilizado para definir consultas GraphQL desde la libería Apollo Boost. Si seguimos la estructura sugerida anteriormente, podríamos tener un archivo queries.js en el directorio graphql para las consultas GraphQL de nuestra aplicación. Cada una de las consultas se puede almacenar en una variable y exportar así: + +```javascript +import { gql } from 'apollo-boost'; + +export const GET_REPOSITORIES = gql` + query { + repositories { + ${/* ... */} + } + } +`; + +// other queries... +``` + +Podemos importar estas variables y usarlas con el hook useQuery como este: + +```javascript +import { useQuery } from '@apollo/react-hooks'; + +import { GET_REPOSITORIES } from '../graphql/queries'; + +const Component = () => { + const { data, error, loading } = useQuery(GET_REPOSITORIES); + // ... +}; +``` + +Lo mismo ocurre con la organización de mutaciones. La única diferencia es que los definimos en un archivo diferente, mutations.js. Se recomienda utilizar [fragmentos](https://www.apollographql.com/docs/react/data/fragments/) en las consultas para evitar volver a escribir los mismos campos una y otra vez. + +### Evolución de la estructura + +Una vez que nuestra aplicación crezca, puede haber ocasiones en las que ciertos archivos crezcan demasiado para administrarlos. Por ejemplo, tenemos el componente A que renderiza los componentes B y C. Todos estos componentes están definidos en un archivo A.jsx en un directorio components. Nos gustaría extraer los componentes B y C en sus propios archivos B.jsx y C.jsx sin mayores refactores. Tenemos dos opciones: + +- Crear archivos B.jsx y C.jsx en el directorio components. Esto da como resultado la siguiente estructura: + +``` +components/ + A.jsx + B.jsx + C.jsx + ... +``` + +- Crear un directorio A en el directorio components y cree los archivos B.jsx y C.jsx allí. Para evitar romper los componentes que importan el archivo A.jsx, mueva el archivo A.jsx al directorio A y cámbiele el nombre a index.jsx. Esto da como resultado la siguiente estructura: + +``` +components/ + A/ + B.jsx + C.jsx + index.jsx + ... +``` + +La primera opción es bastante decente, sin embargo, si los componentes B y C no se pueden reutilizar fuera del componente A, es inútil hinchar el directorio components agregándolos como archivos separados. La segunda opción es bastante modular y no interrumpe ninguna importación porque la importación de una ruta como ./A coincidirá con A.jsx y A/index.jsx. + +
    + +
    + +### Ejercicio 10.11. + +#### Ejercicio 10.11: obtención de repositorios con Apollo Client + +Queremos reemplazar la implementación de la API Fetch en el hook useRepositories con una consulta GraphQL. Abra GraphQL Playground en [http://localhost:5000/graphql](http://localhost:5000/graphql) y abra la documentación haciendo clic en la pestaña docs. Busque la consulta repositorios. La consulta tiene algunos argumentos, sin embargo, todos estos son opcionales, por lo que no es necesario que los especifique. En GraphQL Playground, forme una consulta para buscar los repositorios con los campos que está mostrando actualmente en la aplicación. El resultado se paginará y contiene los primeros 30 resultados de forma predeterminada. Por ahora, puede ignorar la paginación por completo. + +Una vez que la consulta esté funcionando en GraphQL Playground, úsela para reemplazar la implementación de la API Fetch en el hook useRepositories. Esto se puede lograr usando el hook [useQuery](https://www.apollographql.com/docs/react/api/react-hooks/#usequery). La etiqueta literal de plantilla gql se puede importar desde Apollo Boost como se indicó anteriormente. Considere usar la estructura recomendada anteriormente para el código relacionado con GraphQL. Para evitar problemas futuros de almacenamiento en caché, use la [política de recuperación](https://www.apollographql.com/docs/react/data/queries/#configuring-fetch-logic) _cache-and-network_ en la consulta. Se puede usar con el hook useQuery como este: + +```javascript +useQuery(MY_QUERY, { + fetchPolicy: 'cache-and-network', + // Other options +}); +``` + +Los cambios en el hook useRepositories no deberían afectar al componente RepositoryList de ninguna manera. + +
    + +
    + +### Variables de entorno + +Es muy probable que cada aplicación se ejecute en más de un entorno. Dos candidatos obvios para estos entornos son el entorno de desarrollo y el entorno de producción. De estos dos, el entorno de desarrollo es el que estamos ejecutando la aplicación en este momento. Los diferentes entornos generalmente tienen diferentes dependencias, por ejemplo, el servidor que estamos desarrollando localmente puede usar una base de datos local, mientras que el servidor que se implementa en el entorno de producción usa la base de datos de producción. Para hacer que el entorno del código sea independiente, necesitamos parametrizar estas dependencias. Por el momento, estamos usando un valor codificado muy dependiente del entorno en nuestra aplicación: la URL del servidor. + +Hemos aprendido anteriormente que podemos proporcionar programas en ejecución con variables de entorno. Estas variables se pueden definir en la línea de comandos o utilizando archivos de configuración del entorno como archivos .env y bibliotecas de terceros como Dotenv. Desafortunadamente, React Native no tiene soporte directo para variables de entorno. Sin embargo, podemos acceder a la configuración de Expo definida en el archivo app.json en tiempo de ejecución desde nuestro código JavaScript. Esta configuración se puede utilizar para definir y acceder a variables dependientes del entorno. + +Se puede acceder a la configuración importando la constante Constants desde el módulo expo-constants como lo hemos hecho algunas veces antes. Una vez importada, la propiedad Constants.manifest contendrá la configuración. Intentemos esto registrando Constants.manifest en el componente App: + +```javascript +import React from 'react'; +import { NativeRouter } from 'react-router-native'; +import { ApolloProvider } from '@apollo/react-hooks'; +import Constants from 'expo-constants'; // highlight-line + +import Main from './src/components/Main'; +import createApolloClient from './src/utils/apolloClient'; + +const apolloClient = createApolloClient(); + +const App = () => { + console.log(Constants.manifest); // highlight-line + + return ( + + +
    + + + ); +}; + +export default App; +``` + +Ahora debería ver la configuración en los registros. + +El siguiente paso es usar la configuración para definir variables dependientes del entorno en nuestra aplicación. Comencemos cambiando el nombre del archivo app.json a app.config.js. Una vez que se cambia el nombre del archivo, podemos usar JavaScript dentro del archivo de configuración. Cambie el contenido del archivo para que el objeto anterior: + +```javascript +{ + "expo": { + "name": "rate-repository-app", + // rest of the configuration... + } +} +``` + +Se convierte en una exportación, que contiene el contenido de la propiedad expo: + +```javascript +export default { + name: 'rate-repository-app', + // rest of the configuration... +}; +``` + +Expo ha reservado una propiedad [extra](https://docs.expo.io/guides/environment-variables/#using-app-manifest-extra) en la configuración para cualquier configuración específica de la aplicación. Para ver cómo funciona esto, agreguemos una variable env en la configuración de nuestra aplicación: + +```javascript +export default { + name: 'rate-repository-app', + // rest of the configuration... + // highlight-start + extra: { + env: 'development' + }, + // highlight-end +}; +``` + +Reinicie las herramientas de desarrollo de Expo para aplicar los cambios y debería ver que el valor de la propiedad Constants.manifest ha cambiado y ahora incluye la propiedad extra que contiene nuestra configuración específica de la aplicación. Ahora se puede acceder al valor de la variable env a través de la propiedad Constants.manifest.extra.env. + +Debido a que usar una configuración codificada es un poco tonto, usemos una variable de entorno en su lugar: + +```javascript +export default { + name: 'rate-repository-app', + // rest of the configuration... + // highlight-start + extra: { + env: process.env.ENV, + }, + // highlight-end +}; +``` + +Como hemos aprendido, podemos establecer el valor de una variable de entorno a través de la línea de comando definiendo el nombre y el valor de la variable antes del comando real. Como ejemplo, inicie las herramientas de desarrollo de Expo y establezca la variable de entorno ENV como test así: + +```shell +ENV=test npm start +``` + +Si echa un vistazo en los registros, debería ver que la propiedad Constants.manifest.extra.env ha cambiado. + +También podemos cargar variables de entorno desde un archivo .env como hemos aprendido en las partes anteriores. Primero, necesitamos instalar la biblioteca [Dotenv](https://www.npmjs.com/package/dotenv): + +```shell +npm install dotenv +``` + +A continuación, agregue un archivo .env en el directorio raíz de nuestro proyecto con el siguiente contenido: + +``` +ENV=development +``` + +Finalmente, importe la biblioteca en el archivo app.config.js: + +```javascript +import 'dotenv/config'; // highlight-line + +export default { + name: 'rate-repository-app', + // rest of the configuration... + extra: { + env: process.env.ENV, + }, +}; +``` + +Debe reiniciar las herramientas de desarrollo de Expo para aplicar los cambios que ha realizado en el archivo .env. + +Tenga en cuenta que nunca es una buena idea poner datos confidenciales en la configuración de la aplicación. La razón de esto es que una vez que un usuario ha descargado su aplicación, puede, al menos en teoría, aplicar ingeniería inversa a su aplicación y averiguar los datos confidenciales que ha almacenado en el código. + +
    + +
    + +### Ejercicio 10.12. + +#### Ejercicio 10.12: variables de entorno + +En lugar de la URL codificada de Apollo Server, utilice una variable de entorno definida en el archivo .env al inicializar el cliente Apollo. Puede nombrar la variable de entorno, por ejemplo, APOLLO_URI + +No intente acceder a variables de entorno como process.env.APOLLO_URI fuera del archivo app.config.js. En su lugar, utilice el objeto Constants.manifest.extra como en el ejemplo anterior. Además, no importe la biblioteca dotenv fuera del archivo app.config.js o probablemente enfrentará errores. + +
    + +
    + +### Almacenamiento de datos en el dispositivo del usuario + +Hay ocasiones en las que necesitamos almacenar algunos datos persistentes en el dispositivo del usuario. Uno de esos escenarios comunes es almacenar el token de autenticación del usuario para que podamos recuperarlo incluso si el usuario cierra y vuelve a abrir nuestra aplicación. En el desarrollo web, hemos utilizado el objeto localStorage del navegador para lograr dicha funcionalidad. React Native proporciona un almacenamiento persistente similar, el [AsyncStorage](https://react-native-async-storage.github.io/async-storage/docs/usage). + +Podemos usar el comando expo install para instalar la versión del paquete @react-native-community/async-storage que es adecuada para nuestra versión de Expo SDK: + +```shell +expo install @react-native-community/async-storage +``` + +La API de AsyncStorage es en muchos aspectos la misma que la API de localStorage. Ambos son almacenamientos de clave-valor con métodos similares. La mayor diferencia entre los dos es que, como su nombre lo indica, las operaciones de AsyncStorage son asincrónicas. + +Debido a que AsyncStorage opera con claves de cadena en un espacio de nombres global, es una buena idea crear una abstracción simple para sus operaciones. Esta abstracción se puede implementar, por ejemplo, usando una [clase](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes). Como ejemplo, podríamos implementar un almacenamiento de carrito de compras para almacenar los productos que el usuario desea comprar: + +```javascript +import AsyncStorage from '@react-native-community/async-storage'; + +class ShoppingCartStorage { + constructor(namespace = 'shoppingCart') { + this.namespace = namespace; + } + + async getProducts() { + const rawProducts = await AsyncStorage.getItem( + `${this.namespace}:products`, + ); + + return rawProducts ? JSON.parse(rawProducts) : []; + } + + async addProduct(productId) { + const currentProducts = await this.getProducts(); + const newProducts = [...currentProducts, productId]; + + await AsyncStorage.setItem( + `${this.namespace}:products`, + JSON.stringify(newProducts), + ); + } + + async clearProducts() { + await AsyncStorage.removeItem(`${this.namespace}:products`); + } +} + +const doShopping = async () => { + const shoppingCartA = new ShoppingCartStorage('shoppingCartA'); + const shoppingCartB = new ShoppingCartStorage('shoppingCartB'); + + await shoppingCartA.addProduct('chips'); + await shoppingCartA.addProduct('soda'); + + await shoppingCartB.addProduct('milk'); + + const productsA = await shoppingCartA.getProducts(); + const productsB = await shoppingCartB.getProducts(); + + console.log(productsA, productsB); + + await shoppingCartA.clearProducts(); + await shoppingCartB.clearProducts(); +}; + +doShopping(); +``` + +Debido a que las claves AsyncStorage son globales, generalmente es una buena idea agregar un espacio de nombres para las claves. En este contexto, el espacio de nombres es solo un prefijo que proporcionamos para las claves de abstracción de almacenamiento. El uso del espacio de nombres evita que las claves del almacenamiento colisionen con otras claves AsyncStorage. En este ejemplo, el espacio de nombres se define como el argumento del constructor y estamos usando el formato namespace:key para las claves. + +Podemos agregar un elemento al almacenamiento usando el método [AsyncStorage.setItem](https://react-native-async-storage.github.io/async-storage/docs/api#setitem). El primer argumento del método es la clave del elemento y el segundo argumento su valor. El valor debe ser una cadena, por lo que necesitamos serializar valores que no son cadenas como hicimos con el método JSON.stringify. El método [AsyncStorage.getItem](https://react-native-async-storage.github.io/async-storage/docs/api/#getitem) se puede utilizar para obtener un elemento del almacenamiento. El argumento del método es la clave del elemento, cuyo valor se resolverá. El método [AsyncStorage.removeItem](https://react-native-async-storage.github.io/async-storage/docs/api/#removeitem) se puede utilizar para eliminar el elemento con la clave proporcionada del almacenamiento. + +
    + +
    + +### Ejercicios 10.13. - 10.14. + +#### Ejercicio 10.13: la mutación del formulario de inicio de sesión + +La implementación actual del formulario de inicio de sesión no hace mucho con las credenciales del usuario enviado. Hagamos algo al respecto en este ejercicio. Primero, lea la [documentación de autorización](https://github.com/fullstack-hy2020/rate-repository-api#-authorization) del servidor rate-repository-api y pruebe las consultas proporcionadas en GraphQL Playground. Si la base de datos no tiene usuarios, puede completar la base de datos con algunos datos semilla. Las instrucciones para esto se pueden encontrar en la sección [Getting started](https://github.com/fullstack-hy2020/rate-repository-api#-getting-started) del README. + +Una vez que sepa cómo se supone que funcionan las consultas de autorización, cree un archivo _useSignIn.js_ en el directorio hooks. En ese archivo, implemente un hook useSignIn que envíe la mutación authorize usando el hook [useMutation](https://www.apollographql.com/docs/react/api/react-hooks/#usemutation). Tenga en cuenta que la mutación authorize tiene un único argumento llamado credentials, que es de tipo AuthorizeInput. Este [tipo de entrada](https://graphql.org/graphql-js/mutations-and-input-types) contiene los campos username y password. + +El valor de retorno del hook debe ser una tupla [signIn, result] donde result es el resultado de las mutaciones tal como lo devuelve el hook useMutation y una función signIn que ejecuta la mutación con un argumento de objeto { username, password }. Sugerencia: no pase la función de mutación al valor de retorno directamente. En su lugar, devuelva una función que llame a la función de mutación como esta: + +```javascript +const useSignIn = () => { + const [mutate, result] = useMutation(/* mutation arguments */); + + const signIn = async ({ username, password }) => { + // call the mutate function here with the right arguments + }; + + return [signIn, result]; +}; +``` + +Una vez implementado el hook, utilícelo en la devolución de llamada onSubmit del componente SignIn, por ejemplo, como este: + +```javascript +const SignIn = () => { + const [signIn] = useSignIn(); + + const onSubmit = async (values) => { + const { username, password } = values; + + try { + const { data } = await signIn({ username, password }); + console.log(data); + } catch (e) { + console.log(e); + } + }; + + // ... +}; +``` + +Este ejercicio se completa una vez que puede registrar el resultado de las mutaciones authorize del usuario después de que se haya enviado el formulario de inicio de sesión. El resultado de la mutación debe contener el usuario ' + +#### Ejercicio 10.14: almacenando el token de acceso, paso 1 + +Ahora que podemos obtener el token de acceso, necesitamos almacenarlo. Cree un archivo authStorage.js en el directorio utils con el siguiente contenido: + +```javascript +import AsyncStorage from '@react-native-community/async-storage'; + +class AuthStorage { + constructor(namespace = 'auth') { + this.namespace = namespace; + } + + getAccessToken() { + // Get the access token for the storage + } + + setAccessToken(accessToken) { + // Add the access token to the storage + } + + removeAccessToken() { + // Remove the access token from the storage + } +} + +export default AuthStorage; +``` + +A continuación, implemente los métodos AuthStorage.getAccessToken, AuthStorage.setAccessToken y AuthStorage.removeAccessToken. Utilice la variable namespace para dar a sus claves un espacio de nombres como hicimos en el ejemplo anterior. + +
    + +
    + +### Mejora de las solicitudes del cliente Apollo + +Ahora que hemos implementado el almacenamiento para almacenar el token de acceso del usuario, es hora de comenzar a usarlo. Inicialice el almacenamiento en el componente App: + +```javascript +import React from 'react'; +import { NativeRouter } from 'react-router-native'; +import { ApolloProvider } from '@apollo/react-hooks'; + +import Main from './src/components/Main'; +import createApolloClient from './src/utils/apolloClient'; +import AuthStorage from './src/utils/authStorage'; // highlight-line + +const authStorage = new AuthStorage(); // highlight-line +const apolloClient = createApolloClient(authStorage); // highlight-line + +const App = () => { + return ( + + +
    + + + ); +}; + +export default App; +``` + +También proporcionamos la instancia de almacenamiento para la función createApolloClient como argumento. Esto se debe a que a continuación, enviaremos el token de acceso a Apollo Server en cada solicitud. Apollo Server esperará que el token de acceso esté presente en el encabezado Authorization en el formato Bearer . Podemos mejorar el funcionamiento del cliente Apollo utilizando la opción [request](https://www.apollographql.com/docs/react/get-started/#configuration-options). Enviemos el token de acceso al servidor Apollo en nuestro cliente Apollo modificando la función createApolloClient en el archivo apolloClient.js: + +```javascript +const createApolloClient = (authStorage) => { // highlight-line + return new ApolloClient({ + // highlight-start + request: async (operation) => { + try { + const accessToken = await authStorage.getAccessToken(); + + operation.setContext({ + headers: { + authorization: accessToken ? `Bearer ${accessToken}` : '', + }, + }); + } catch (e) { + console.log(e); + } + }, + // highlight-end + // uri and other options... + }); +}; +``` + +### Usando React Context para inyección de dependencia + +La última pieza del rompecabezas de inicio de sesión es integrar el almacenamiento en el gancho useSignIn. Para lograr esto, el gancho debe poder acceder a la instancia de almacenamiento de tokens que hemos inicializado en el componente App. React [Context](https://reactjs.org/docs/context.html) es solo la herramienta que necesitamos para el trabajo. Cree un directorio context en el directorio src. En ese directorio cree un archivo AuthStorageContext.js con el siguiente contenido: + +```javascript +import React from 'react'; + +const AuthStorageContext = React.createContext(); + +export default AuthStorageContext; +``` + +Ahora podemos usar el AuthStorageContext.Provider para proporcionar la instancia de almacenamiento a los descendientes del contexto. Agreguémoslo al componente App: + +```javascript +import React from 'react'; +import { NativeRouter } from 'react-router-native'; +import { ApolloProvider } from '@apollo/react-hooks'; + +import Main from './src/components/Main'; +import createApolloClient from './src/utils/apolloClient'; +import AuthStorage from './src/utils/authStorage'; +import AuthStorageContext from './src/contexts/AuthStorageContext'; // highlight-line + +const authStorage = new AuthStorage(); +const apolloClient = createApolloClient(authStorage); + +const App = () => { + return ( + + + // highlight-line +
    + // highlight-line + + + ); +}; + +export default App; +``` + +Ahora es posible acceder a la instancia de almacenamiento en el hook useSignIn usando el hook [useContext](https://reactjs.org/docs/hooks-reference.html#usecontext) de React como este: + +```javascript +import { useContext } from 'react'; // highlight-line +// ... + +import AuthStorageContext from '../contexts/AuthStorageContext'; //highlight-line + +const useSignIn = () => { + const authStorage = useContext(AuthStorageContext); //highlight-line + // ... +}; +``` +Tenga en cuenta que acceder al valor de un contexto mediante el hook useContext solo funciona si el hook useContext se usa en un componente que es descendiente del componente [Context.Provider](https://reactjs.org/docs/context.html#contextprovider). + +La capacidad de proporcionar datos a los descendientes de componentes abre toneladas de casos de uso para React Context. Para obtener más información sobre estos casos de uso, lea el esclarecedor artículo de Kent C. Dodds [Cómo usar React Context de manera efectiva](https://kentcdodds.com/blog/how-to-use-react-context-effectively) para descubrir cómo combinar el hook [useReducer](https://reactjs.org/docs/hooks-reference.html#usereducer) con el contexto para implementar la gestión del estado. Puede encontrar una forma de utilizar este conocimiento en los próximos ejercicios. + +
    + +
    + +### Ejercicios 10.15. - 10.16. + +#### Ejercicio 10.15: almacenamiento del token de acceso paso 2 + +Mejore el hook useSignIn para que almacene el token de acceso del usuario recuperado de la mutación authorize. El valor de retorno del hook no debería cambiar. El único cambio que debe realizar en el componente SignIn es que debe redirigir al usuario a la vista de lista de repositorios revisados ​​después de un inicio de sesión exitoso. Puede lograrlo usando [useHistory](https://reacttraining.com/react-router/native/api/Hooks/usehistory) y el método [push](https://reacttraining.com/react-router/native/api/history) del historial. + +Después de que se haya ejecutado la mutación authorize y haya almacenado el token de acceso del usuario en el almacenamiento, debe restablecer la tienda del cliente Apollo. Esto borrará la memoria caché del cliente Apollo y volverá a ejecutar todas las consultas activas. Puede hacer esto usando el método [resetStore](https://www.apollographql.com/docs/react/v2.5/api/apollo-client/#ApolloClient.resetStore) del cliente Apollo. Puede acceder al cliente Apollo en el hook useSignIn usando el hook [useApolloClient](https://www.apollographql.com/docs/react/api/react-hooks/#useapolloclient). Tenga en cuenta que el orden de ejecución es crucial y debe ser el siguiente: + +```javascript +const { data } = await mutate(/* options */); +await authStorage.setAccessToken(/* access token from the data */); +apolloClient.resetStore(); +``` + +#### Ejercicio 10.16: cerrar sesión + +El paso final para completar la función de inicio de sesión es implementar una función de cierre de sesión. La consulta me se puede utilizar para verificar la información del usuario autorizado. Si el resultado de la consulta es null, eso significa que el usuario no está autorizado. Abra el patio de juegos de GraphQL y ejecute la siguiente consulta: + +```javascript +{ + me { + id + username + } +} +``` + +Probablemente terminará con el resultado null. Esto se debe a que GraphQL Playground no está autorizado, lo que significa que no envía un token de acceso válido con la solicitud. Revise la [documentación de autorización](https://github.com/fullstack-hy2020/rate-repository-api#-authorization) y recupere un token de acceso utilizando la mutación authorize. Utilice este token de acceso en el encabezado _Authorization_ como se indica en la documentación. Ahora, ejecute la consulta me nuevamente y debería poder ver la información del usuario autorizado. + +Abra el componente AppBar en el archivo AppBar.jsx donde actualmente tiene las pestañas "Repositories" e "Sign in". Cambie las pestañas para que si el usuario está registrado en la pestaña "Sign out" se muestra, de lo contrario, muestre la pestaña "Sign in". Puede lograr esto usando la consulta me con el hook [useQuery](https://www.apollographql.com/docs/react/api/react-hooks/#usequery). + +Al presionar la pestaña "Sign out" se debe eliminar el token de acceso del usuario del almacenamiento y restablecer la tienda del Cliente Apollo con [resetStore](https://www.apollographql.com/docs/react/v2.5/api/apollo-client/#ApolloClient.resetStore). Llamar al método resetStore debería volver a ejecutar automáticamente todas las consultas activas, lo que significa que la consulta me debería volver a ejecutarse. Tenga en cuenta que el orden de ejecución es crucial: el token de acceso debe eliminarse del almacenamiento antes que se restablezca la tienda del cliente Apollo. + +Este fue el último ejercicio de esta sección. Es hora de enviar tu código a GitHub y marcar todos tus ejercicios terminados en el [sistema de envío de ejercicios](https://studies.cs.helsinki.fi/stats/courses/fs-react-native-2020). Tenga en cuenta que los ejercicios de esta sección deben enviarse a la parte 3 del sistema de envío de ejercicios. + +
    diff --git a/src/content/10/es/part10d.md b/src/content/10/es/part10d.md new file mode 100644 index 00000000000..50b86102f6f --- /dev/null +++ b/src/content/10/es/part10d.md @@ -0,0 +1,1032 @@ +--- +mainImage: ../../../images/part-10.svg +part: 10 +letter: d +lang: es +--- + +
    + +Ahora que hemos establecido una buena base para nuestro proyecto, es hora de empezar a ampliarlo. En esta sección puede poner en práctica todo el conocimiento de React Native que ha adquirido hasta ahora. Además de expandir nuestra aplicación, cubriremos algunas áreas nuevas, como pruebas y recursos adicionales. + +### Prueba de aplicaciones React Native + +Para comenzar a probar código de cualquier tipo, lo primero que necesitamos es un marco de prueba, que podemos usar para ejecutar un conjunto de casos de prueba e inspeccionar sus resultados. Para probar una aplicación de JavaScript, [Jest](https://jestjs.io/) es un candidato popular para dicho marco de prueba. Para probar una aplicación React Native basada en Expo con Jest, Expo proporciona un conjunto de configuración de Jest en forma de [jest-expo](https://github.com/expo/expo/tree/master/packages/jest-expo) Preestablecido. Para usar ESLint en los archivos de prueba de Jest, también necesitamos el complemento [eslint-plugin-jest](https://www.npmjs.com/package/eslint-plugin-jest) para ESLint. Comencemos instalando los paquetes: + +```shell +npm install --save-dev jest jest-expo eslint-plugin-jest +``` + +Para usar el ajuste preestablecido de jest-expo en Jest, necesitamos agregar la siguiente configuración de Jest al archivo package.json junto con el script test: + +```javascript +{ + // ... + "scripts": { + // other scripts... + "test": "jest" // highlight-line + }, + // highlight-start + "jest": { + "preset": "jest-expo", + "transform": { + "^.+\\.jsx?$": "babel-jest" + }, + "transformIgnorePatterns": [ + "node_modules/(?!(jest-)?react-native|react-clone-referenced-element|@react-native-community|expo(nent)?|@expo(nent)?/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|@sentry/.*|react-router-native)" + ] + }, + // highlight-end + // ... +} +``` + +La opción transform le dice a Jest que transforme los archivos .js y .jsx con compilador [Babel](https://babeljs.io/). La opción transformIgnorePatterns es para ignorar ciertos directorios en el directorio node_modules mientras se transforman archivos. Esta configuración de Jest es casi idéntica a la propuesta en la [documentación](https://docs.expo.dev/develop/unit-testing/) de Expo. +Para usar el complemento eslint-plugin-jest en ESLint, debemos incluirlo en la matriz de complementos y extensiones en el archivo .eslintrc: + +```javascript +{ + "plugins": ["react", "jest"], + "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:jest/recommended"], // highlight-line + "parser": "babel-eslint", + "env": { + "browser": true + }, + "rules": { + "react/prop-types": "off" + } +} +``` + +Para ver que la configuración está funcionando, cree un directorio \_\_tests_\_ en el directorio src y en el directorio creado cree un archivo example.js. En ese archivo, agregue esta simple prueba: + +```javascript +describe('Example', () => { + it('works', () => { + expect(1).toBe(1); + }); +}); +``` + +Ahora, ejecutemos nuestra prueba de ejemplo ejecutando npm test. La salida del comando debe indicar que se pasó la prueba ubicada en el archivo src/\_\_tests_\_/example.js. + +### Organización de pruebas + +Organizar archivos de prueba en un solo directorio \_\_tests\_\_ es un método para organizar las pruebas. Al elegir este enfoque, se recomienda colocar los archivos de prueba en sus subdirectorios correspondientes al igual que el código en sí. Esto significa que, por ejemplo, las pruebas relacionadas con los componentes están en el directorio components, las pruebas relacionadas con las utilidades están en las utils directorio, y así sucesivamente. Esto dará como resultado la siguiente estructura: + +``` +src/ + __tests__/ + components/ + AppBar.js + RepositoryList.js + ... + utils/ + authStorage.js + ... + ... +``` + +Otro enfoque es organizar las pruebas cerca de la implementación. Esto significa que, por ejemplo, el archivo de prueba que contiene las pruebas para el componente AppBar está en el mismo directorio que el código del componente. Esto dará como resultado la siguiente estructura: + +``` +src/ + components/ + AppBar/ + AppBar.test.jsx + index.jsx + ... + ... +``` + +En este ejemplo, el código del componente está en el archivo index.jsx y la prueba en el archivo AppBar.test.jsx. Tenga en cuenta que para Jest encontrar sus archivos de prueba, debe colocarlos en un directorio \_\_tests\_\_, use .test o .spec sufijo, o [configurar manualmente](https://jestjs.io/docs/en/configuration#testmatch-arraystring) los patrones globales. + +### Prueba de componentes + +Ahora que hemos logrado configurar Jest y ejecutar una prueba muy simple, es hora de descubrir cómo probar los componentes. Como sabemos, probar componentes requiere una forma de serializar la salida de render de un componente y simular la activación de diferentes tipos de eventos, como presionar un botón. Para estos propósitos, existe la familia [Testing Library](https://testing-library.com/docs/intro), que proporciona bibliotecas para probar componentes de interfaz de usuario en diferentes plataformas. Todas estas bibliotecas comparten una API similar para probar los componentes de la interfaz de usuario de una manera centrada en el usuario. + +En la [parte 5](/es/part5/testing_react_apps) nos familiarizamos con una de estas liberías, la [React Testing Library](https://testing-library.com/docs/react-testing-library/intro). Desafortunadamente, esta librería solo es adecuada para probar aplicaciones web React. Afortunadamente, existe una contraparte de React Native para esta librería, que es la [React Native Testing Library](https://callstack.github.io/react-native-testing-library/). Esta es la librería que usaremos mientras probamos los componentes de nuestra aplicación React Native. La buena noticia es que estas bibliotecas comparten una API muy similar, por lo que no hay demasiados conceptos nuevos que aprender. Además de la biblioteca de pruebas React Native, necesitamos un conjunto de comparadores Jest específicos de React Native, como toHaveTextContent y toHaveProp. Estos comparadores los proporciona la librería [jest-native](https://github.com/testing-library/jest-native). Antes de entrar en detalles, instalemos estos paquetes: + +```shell +npm install --save-dev @testing-library/react-native @testing-library/jest-native +``` + +Para poder usar estos comparadores necesitamos extender el objeto expect de Jest. Esto se puede hacer usando un archivo de configuración global. Cree un archivo setupTests.js en el directorio raíz de su proyecto, es decir, el mismo directorio donde se encuentra el archivo package.json. En ese archivo agregue la siguiente línea: + +```javascript +import '@testing-library/jest-native/extend-expect'; +``` + +A continuación, configure este archivo como un archivo de instalación en la configuración de Jest en el archivo package.json (tenga en cuenta que el \ en la ruta es intencional y no es necesario reemplazarlo): + +```javascript +{ + // ... + "jest": { + "preset": "jest-expo", + "transform": { + "^.+\\.jsx?$": "babel-jest" + }, + "transformIgnorePatterns": [ + "node_modules/(?!(jest-)?react-native|react-clone-referenced-element|@react-native-community|expo(nent)?|@expo(nent)?/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|@sentry/.*|react-router-native)" + ], + "setupFilesAfterEnv": ["/setupTests.js"] // highlight-line + } + // ... +} +``` + +Los conceptos principales de React Native Testing Library son las [consultas](https://callstack.github.io/react-native-testing-library/docs/api/queries) y [disparando eventos](https://callstack.github.io/react-native-testing-library/docs/api#fireevent). Las consultas se utilizan para extraer un conjunto de nodos del componente que se representa mediante la función [render](https://callstack.github.io/react-native-testing-library/docs/api#render). Las consultas son útiles en las pruebas donde esperamos, por ejemplo, que algún texto, como el nombre de un repositorio, esté presente en el componente renderizado. Para obtener fácilmente nodos específicos, puede etiquetar los nodos con la propiedad testID y consultarla con la función [getByTestId](https://callstack.github.io/react-native-testing-library/docs/api/queries#bytestid). Cada componente principal de React Native acepta el prop testID. A continuación, se muestra un ejemplo de cómo utilizar las consultas: + +```javascript +import React from 'react'; +import { Text, View } from 'react-native'; +import { render } from '@testing-library/react-native'; + +const Greeting = ({ name }) => { + return ( + + {/* This node is tagged with the testID prop */} + Hello {name}! + + ); +}; + +describe('Greeting', () => { + it('renders a greeting message based on the name prop', () => { + const { debug, getByTestId } = render(); + + debug(); + + expect(getByTestId('greetingText')).toHaveTextContent('Hello Kalle!'); + }); +}); +``` + +La función render devuelve las consultas y ayudantes adicionales, como la función debug. La función [debug](https://callstack.github.io/react-native-testing-library/docs/api#debug) imprime el árbol React renderizado en un formato fácil de usar. Úselo si no está seguro de cómo se ve el árbol de React generado por la función render. Adquirimos el nodo Text etiquetado con el prop testID usando la función getByTestId. Para todas las consultas disponibles, consulte la [documentación](https://callstack.github.io/react-native-testing-library/docs/api/queries) de React Native Testing Library . El comparador toHaveTextContent se usa para afirmar que el contenido textual del nodo es correcto. La lista completa de comparadores específicos de React Native disponibles se puede encontrar en la [documentación](https://github.com/testing-library/jest-native#matchers) de la biblioteca jest-native. La [documentación](https://jestjs.io/docs/en/expect) de Jest contiene todos los comparadores de Jest universales. + +El segundo concepto muy importante de React Native Testing Library es la activación de eventos. Podemos disparar un evento en un nodo provisto usando los métodos del objeto [fireEvent](https://callstack.github.io/react-native-testing-library/docs/api#fireevent). Esto es útil, por ejemplo, para escribir texto en un campo de texto o presionar un botón. Aquí hay un ejemplo de cómo probar el envío de un formulario simple: + +```javascript +import React, { useState } from 'react'; +import { Text, TextInput, TouchableWithoutFeedback, View } from 'react-native'; +import { render, fireEvent } from '@testing-library/react-native'; + +const Form = ({ onSubmit }) => { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + + const handleSubmit = () => { + onSubmit({ username, password }); + }; + + return ( + + + setUsername(text)} + placeholder="Username" + testID="usernameField" + /> + + + setPassword(text)} + placeholder="Password" + testID="passwordField" + /> + + + + Submit + + + + ); +}; + +describe('Form', () => { + it('calls function provided by onSubmit prop after pressing the submit button', () => { + const onSubmit = jest.fn(); + const { getByTestId } = render(); + + fireEvent.changeText(getByTestId('usernameField'), 'kalle'); + fireEvent.changeText(getByTestId('passwordField'), 'password'); + fireEvent.press(getByTestId('submitButton')); + + expect(onSubmit).toHaveBeenCalledTimes(1); + + // onSubmit.mock.calls[0][0] contains the first argument of the first call + expect(onSubmit.mock.calls[0][0]).toEqual({ + username: 'kalle', + password: 'password', + }); + }); +}); +``` + +En esta prueba, queremos probar que después de completar los campos del formulario usando el método fireEvent.changeText y presionar el botón enviar usando el método fireEvent.press, La función de devolución de llamada onSubmit se llama correctamente. Para inspeccionar si se llama a la función onSubmit y con qué argumentos, podemos usar una [función simulada](https://jestjs.io/docs/en/mock-function-api). Las funciones simuladas son funciones con un comportamiento preprogramado, como un valor de retorno específico. Además, podemos crear expectativas para las funciones simuladas como "esperar que la función simulada se haya llamado una vez". La lista completa de expectativas disponibles se puede encontrar en la [documentación de espera](https://jestjs.io/docs/en/expect) de Jest. + +Antes de adentrarse más en el mundo de las pruebas de aplicaciones React Native, juegue con estos ejemplos agregando un archivo de prueba en el directorio \_\_tests\_\_ que creamos anteriormente. + +### Manejo de dependencias en pruebas + +Los componentes de los ejemplos anteriores son bastante fáciles de probar porque son más o menos puros. Los componentes puros no dependen de efectos secundarios como las solicitudes de red o el uso de alguna API nativa como AsyncStorage. El componente Form es mucho menos puro que el componente Greeting porque sus cambios de estado pueden contarse como un efecto secundario. Sin embargo, probarlo no es demasiado difícil. + +A continuación, echemos un vistazo a una estrategia para probar componentes con efectos secundarios. Escojamos el componente RepositoryList de nuestra aplicación como ejemplo. Por el momento, el componente tiene un efecto secundario, que es una consulta GraphQL para obtener los repositorios revisados. La implementación actual del componente RepositoryList se parece a esto: + +```javascript +const RepositoryList = () => { + const { repositories } = useRepositories(); + + const repositoryNodes = repositories + ? repositories.edges.map((edge) => edge.node) + : []; + + return ( + + ); +}; + +export default RepositoryList; +``` + +El único efecto secundario es el uso del hook useRepositories, que envía una consulta GraphQL. Hay varias formas de probar este componente. Una forma es burlarse de las respuestas del Cliente Apollo como se indica en la [documentación](https://www.apollographql.com/docs/react/development-testing/testing/) del Cliente Apollo. Una forma más sencilla es asumir que el hook useRepositories funciona según lo previsto (preferiblemente probándolo) y extraer el código "puro" de los componentes en otro componente, como el RepositoryListContainer componente: + +```javascript +export const RepositoryListContainer = ({ repositories }) => { + const repositoryNodes = repositories + ? repositories.edges.map((edge) => edge.node) + : []; + + return ( + + ); +}; + +const RepositoryList = () => { + const { repositories } = useRepositories(); + + return ; +}; + +export default RepositoryList; +``` + +Ahora, el componente RepositoryList contiene solo los efectos secundarios y su implementación es bastante simple. Podemos probar el componente RepositoryListContainer proporcionándole datos de repositorio paginados a través de la propiedad repositories y comprobando que el contenido renderizado tenga la información correcta. Esto se puede lograr etiquetando los nodos del componente RepositoryItem requerido con los props testID. + +
    + +
    + +### Ejercicios 10.17. - 10.18. + +#### Ejercicio 10.17: prueba de la lista de repositorios revisados + +Implemente una prueba que garantice que el componente RepositoryListContainer muestre el nombre del repositorio, la descripción, el idioma, el recuento de bifurcaciones, el recuento de observadores de estrellas, el promedio de calificación y el recuento de reseñas correctamente. Recuerde que puede usar el comparador [toHaveTextContent](https://github.com/testing-library/jest-native#tohavetextcontent) para verificar si un nodo tiene cierto contenido textual. Puede usar la consulta [getAllByTestId](https://callstack.github.io/react-native-testing-library/docs/api/queries#getallby) para obtener todos los nodos con un determinado prop testID como una matriz. Si no está seguro de lo que se está procesando, use la función [debug](https://callstack.github.io/react-native-testing-library/docs/api#debug) para ver el resultado de la representación serializada. + +Use esto como base para su prueba: + +```javascript +describe('RepositoryList', () => { + describe('RepositoryListContainer', () => { + it('renders repository information correctly', () => { + const repositories = { + totalCount: 8, + pageInfo: { + hasNextPage: true, + endCursor: + 'WyJhc3luYy1saWJyYXJ5LnJlYWN0LWFzeW5jIiwxNTg4NjU2NzUwMDc2XQ==', + startCursor: 'WyJqYXJlZHBhbG1lci5mb3JtaWsiLDE1ODg2NjAzNTAwNzZd', + }, + edges: [ + { + node: { + id: 'jaredpalmer.formik', + fullName: 'jaredpalmer/formik', + description: 'Build forms in React, without the tears', + language: 'TypeScript', + forksCount: 1619, + stargazersCount: 21856, + ratingAverage: 88, + reviewCount: 3, + ownerAvatarUrl: + 'https://avatars2.githubusercontent.com/u/4060187?v=4', + }, + cursor: 'WyJqYXJlZHBhbG1lci5mb3JtaWsiLDE1ODg2NjAzNTAwNzZd', + }, + { + node: { + id: 'async-library.react-async', + fullName: 'async-library/react-async', + description: 'Flexible promise-based React data loader', + language: 'JavaScript', + forksCount: 69, + stargazersCount: 1760, + ratingAverage: 72, + reviewCount: 3, + ownerAvatarUrl: + 'https://avatars1.githubusercontent.com/u/54310907?v=4', + }, + cursor: + 'WyJhc3luYy1saWJyYXJ5LnJlYWN0LWFzeW5jIiwxNTg4NjU2NzUwMDc2XQ==', + }, + ], + }; + + // Add your test code here + }); + }); +}); +``` + +Puede poner el archivo de prueba donde desee. Sin embargo, se recomienda seguir una de las formas de organizar los archivos de prueba presentados anteriormente. Utilice la variable repositories como datos del repositorio para la prueba. No debería ser necesario modificar el valor de la variable. Tenga en cuenta que los datos del repositorio contienen dos repositorios, lo que significa que debe comprobar que la información de ambos repositorios esté presente. + +#### Ejercicio 10.18: probar el formulario de inicio de sesión + +Implemente una prueba que garantice que al completar los campos de nombre de usuario y contraseña del formulario de inicio de sesión y presionar el botón de envío se llamará al controlador onSubmit con argumentos correctos. El primer argumento del controlador debe ser un objeto que represente los valores del formulario. Puede ignorar los otros argumentos de la función. Recuerde que los métodos [fireEvent](https://callstack.github.io/react-native-testing-library/docs/api#fireevent) se pueden usar para activar eventos y una [función simulada](https://jestjs.io/docs/en/mock-function-api) para comprobar si se llama o no al controlador onSubmit. + +No tiene que probar ningún código relacionado con Apollo Client o AsyncStorage que se encuentre en el enlace useSignIn. Como en el ejercicio anterior, extraiga el código puro en su propio componente y pruébelo en la prueba. + +Tenga en cuenta que los envíos de formularios de Formik son asíncronos, por lo que esperar que se llame a la función onSubmit inmediatamente después de presionar el botón enviar no funcionará. Puede solucionar este problema haciendo que la función de prueba sea una función asíncrona utilizando la palabra clave async y utilizando [waitFor](https://callstack.github.io/react-native-testing-library/docs/api#waitfor) función auxiliar. La función waitFor se puede utilizar para esperar a que pasen las expectativas. Si las expectativas no pasan dentro de un período determinado, la función arrojará un error. Aquí hay un ejemplo aproximado de cómo usarlo: + +```javascript +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; +// ... + +describe('SignIn', () => { + describe('SignInContainer', () => { + it('calls onSubmit function with correct arguments when a valid form is submitted', async () => { + // render the SignInContainer component, fill the text inputs and press the submit button + + await waitFor(() => { + // expect the onSubmit function to have been called once and with a correct first argument + }); + }); + }); +}); +``` + +Es posible que se enfrente a los siguientes mensajes de advertencia: Warning: An update to Formik inside a test was not wrapped in act(...) [Advertencia: una actualización de Formik dentro de una prueba no se incluyó en act (...)]. Esto sucede porque las llamadas al método fireEvent provocan llamadas asincrónicas en la lógica interna de Formik. Puede deshacerse de estos mensajes envolviendo cada una de las llamadas al método fireEvent con la función [act](https://www.native-testing-library.com/docs/next/api-main# act) como esta: + +```javascript +await act(async () => { + // call the fireEvent method here +}); +``` + +
    + +
    + +### Ampliando nuestra aplicación + +Es hora de poner en práctica todo lo que hemos aprendido hasta ahora y empezar a ampliar nuestra aplicación. Nuestra aplicación aún carece de algunas características importantes, como revisar un repositorio y registrar un usuario. Los próximos ejercicios se centrarán en estas características esenciales. + +
    + +
    + +### Ejercicios 10.19. - 10.24. + +#### Ejercicio 10.19: la vista del repositorio único + +Implemente una vista para un repositorio único, que contiene la misma información del repositorio que en la lista de repositorios revisados, pero también un botón para abrir el repositorio en GitHub. Sería una buena idea reutilizar el componente RepositoryItem utilizado en RepositoryList + +La URL del repositorio está en el campo url del tipo Repository en el esquema GraphQL. Puede obtener un único repositorio del servidor Apollo con la consulta repository. La consulta tiene un solo argumento, que es el id del repositorio. Aquí hay un ejemplo simple de la consulta de repository: + +```javascript +{ + repository(id: "jaredpalmer.formik") { + id + fullName + url + } +} +``` + +Como siempre, primero pruebe sus consultas en el campo de juegos GraphQL antes de usarlas en su aplicación. Si no está seguro sobre el esquema GraphQL o cuáles son las consultas disponibles, abra la pestaña docs o schema en el área de juegos GraphQL. Si tiene problemas para usar el id como una variable en la consulta, tómese un momento para estudiar la [documentación](https://www.apollographql.com/docs/react/data/queries/) de Apollo Client sobre las consultas. + +Para saber cómo abrir una URL en un navegador, lea la [documentación de la API de vinculación](https://docs.expo.io/workflow/linking/) de Expo. Necesitará esta función al implementar el botón para abrir el repositorio en GitHub. + +La vista debe tener su propia ruta. Sería una buena idea definir la identificación del repositorio en la ruta de la dirección como un parámetro del mismo, al que puede acceder utilizando el hook [useParams](https://reacttraining.com/react-router/native/api/Hooks/useparams ). El usuario debería poder acceder a la vista presionando un repositorio en la lista de repositorios revisados. Puede lograr esto, por ejemplo, envolviendo el RepositoryItem con un componente [TouchableOpacity](https://reactnative.dev/docs/touchableopacity) en el componente RepositoryList y usando history.push para cambiar la ruta en un controlador de eventos onPress. Puede acceder al objeto history con el hook [useHistory](https://reacttraining.com/react-router/native/api/Hooks/usehistory). + +La versión final de la vista del repositorio único debería verse así: + +![Application preview](../../images/10/13.jpg) + +#### Ejercicio 10.20: lista de revisión del repositorio + +Ahora que tenemos una vista para un solo repositorio, mostraremos las revisiones del repositorio allí. Las revisiones del repositorio se encuentran en el campo reviews del tipo Repository en el esquema GraphQL. reviews es una lista paginada similar a la de la consulta repositories. Aquí hay un ejemplo de cómo obtener reseñas de un repositorio: + +```javascript +{ + repository(id: "jaredpalmer.formik") { + id + fullName + reviews { + edges { + node { + id + text + rating + createdAt + user { + id + username + } + } + } + } + } +} +``` + +El campo text de la reseña contiene la revisión textual, el campo rating una clasificación numérica entre 0 y 100, y createdAt la fecha en que se creó la revisión. El campo user de la reseña contiene la información del revisor, que es del tipo User. + +Queremos mostrar las reseñas como una lista desplazable, lo que hace que [FlatList](https://reactnative.dev/docs/flatlist) sea un componente adecuado para el trabajo. Para mostrar la información del repositorio del ejercicio anterior en la parte superior de la lista, puede utilizar los componentes FlatList y el prop [ListHeaderComponent](https://reactnative.dev/docs/flatlist#listheadercomponent). Puede usar [ItemSeparatorComponent](https://reactnative.dev/docs/flatlist#itemseparatorcomponent) para agregar algo de espacio entre los elementos como en el componente RepositoryList. Aquí hay un ejemplo de la estructura: + +```javascript +const RepositoryInfo = ({ repository }) => { + // Repository's information implemented in the previous exercise +}; + +const ReviewItem = ({ review }) => { + // Single review item +}; + +const SingleRepository = () => { + // ... + + return ( + } + keyExtractor={({ id }) => id} + ListHeaderComponent={() => } + // ... + /> + ); +}; + +export default SingleRepository; +``` + +La versión final de la lista de reseñas del repositorio debería verse así: + +![Application preview](../../images/10/14.jpg) + +La fecha bajo el nombre de usuario del revisor es la fecha de creación de la revisión, que se encuentra en el campo createdAt del tipo Review. El formato de la fecha debe ser fácil de usar, como date.month.year. Por ejemplo, puede instalar la librería [date-fns](https://date-fns.org/) y usar la función [format](https://date-fns.org/v2.13.0/docs/format) para formatear la fecha de creación. + +La forma redonda del contenedor de la calificación se puede lograr con la propiedad de estilo borderRadius. Puede redondearlo fijando la propiedad de estilo width y height del contenedor y estableciendo el radio del borde como width/2. + +#### Ejercicio 10.21: el formulario de revisión + +Implemente un formulario para crear una revisión usando Formik. El formulario debe tener cuatro campos: nombre de usuario de GitHub del propietario del repositorio (por ejemplo, "jaredpalmer"), nombre del repositorio (por ejemplo, "formik"), una calificación numérica y una revisión textual. Valide los campos utilizando el esquema Yup para que contenga las siguientes validaciones: + +- El nombre de usuario del propietario del repositorio es una cadena obligatoria +- El nombre del repositorio es una cadena obligatoria +- La calificación es un número obligatorio entre 0 y 100 +- La revisión es una cadena opcional + +Explore la [documentación](https://github.com/jquense/yup#yup) de Yup para encontrar validadores adecuados. Utilice mensajes de error sensibles con los validadores. El mensaje de validación se puede definir como el argumento message del método de validación. Puede hacer que el campo de revisión se expanda a varias líneas utilizando el componente [multiline](https://reactnative.dev/docs/textinput#multiline) del componente TextInput. + +Puede crear una revisión mediante la mutación createReview. Verifique los argumentos de esta mutación en la pestaña _docs_ en el campo de juegos de GraphQL. Puede usar el hook [useMutation](https://www.apollographql.com/docs/react/api/react-hooks/#usemutation) para enviar una mutación al servidor Apollo. + +Después de una mutación de createReview exitosa, redirija al usuario a la vista del repositorio que implementó en el ejercicio anterior. Esto se puede hacer con el método history.push después de haber obtenido el objeto de historial usando el hook [useHistory](https://reacttraining.com/react-router/native/api/Hooks/usehistory). La revisión creada tiene un campo repositoryId que puede usar para construir la ruta de la dirección. + +Para evitar la obtención de datos almacenados en caché con la consulta de repository en la vista de repositorio único, use la [política de recuperación](https://www.apollographql.com/docs/react/data/queries/#configuring-fetch-logic) _caché-and-network_ en la consulta. Se puede usar con el hook useQuery como este: + +```javascript +useQuery(GET_REPOSITORY, { + fetchPolicy: 'cache-and-network', + // Other options +}); +``` + +Tenga en cuenta que solo un repositorio público de GitHub se puede revisar y un usuario puede revisar el mismo repositorio solo una vez. No tiene que manejar estos casos de error, pero la carga útil del error incluye códigos y mensajes específicos para estos errores. Puede probar su implementación revisando uno de sus propios repositorios públicos o cualquier otro repositorio público. + +El formulario de revisión debe ser accesible a través de la barra de la aplicación. Cree una pestaña en la barra de la aplicación con la etiqueta "Create a review". Esta pestaña solo debe ser visible para los usuarios que hayan iniciado sesión. También deberá definir una ruta para el formulario de revisión. + +La versión final del formulario de revisión debería verse así: + +![Application preview](../../images/10/15.jpg) + +Esta captura de pantalla se ha tomado después del envío de un formulario no válido para presentar cómo debería verse el formulario en un estado no válido. + +#### Ejercicio 10.22: el formulario de registro + +Implemente un formulario de registro para registrar un usuario mediante Formik. El formulario debe tener tres campos: nombre de usuario, contraseña y confirmación de contraseña. Valide el formulario utilizando el esquema Yup para que contenga las siguientes validaciones: + +- El nombre de usuario es una cadena obligatoria con una longitud entre 1 y 30 +- La contraseña es una cadena obligatoria con una longitud entre 5 y 50 +- La confirmación de la contraseña coincide con la contraseña + +La validación del campo de confirmación de contraseña puede ser un poco complicada, pero se puede hacer, por ejemplo, usando los métodos [oneOf](https://github.com/jquense/yup#mixedoneofarrayofvalues-arrayany-message-string--function-schema-alias-equals) y [ref](https://github.com/jquense/yup#yuprefpath-string-options--contextprefix-string--ref) como se sugiere en [este problema](https://github.com/jaredpalmer/formik/issues/90#issuecomment-354873201). + +Puede crear un nuevo usuario utilizando la mutación createUser. Descubra cómo funciona esta mutación explorando la documentación en el campo de juegos GraphQL. Después de una mutación de createUser exitosa, inicie sesión con el usuario creado utilizando el hook useSignIn como hicimos en el formulario de inicio de sesión. Una vez que el usuario haya iniciado sesión, redirija al usuario a la vista de lista de repositorios revisados. + +El usuario debería poder acceder al formulario de registro a través de la barra de la aplicación presionando la pestaña "Sign up". Esta pestaña solo debe ser visible para los usuarios que no han iniciado sesión. + +La versión final del formulario de registro debe tener un aspecto parecido a esto: + +![Application preview](../../images/10/16.jpg) + +Este Se tomó una captura de pantalla después del envío de un formulario no válido para presentar cómo debería verse el formulario en un estado no válido. + +#### Ejercicio 10.23: clasificación de la lista de repositorios revisados + +En este momento, los repositorios de la lista de repositorios revisados ​​están ordenados por la fecha de la primera revisión del repositorio. Implementar una función que permita a los usuarios seleccionar el principio, que se utiliza para ordenar los repositorios. Los principios de pedido disponibles deben ser: + +- Últimos repositorios. El repositorio con la primera revisión más reciente está en la parte superior de la lista. Este es el comportamiento actual y debería ser el principio predeterminado. +- Repositorios mejor calificados. El repositorio con la calificación promedio más alta está en la parte superior de la lista. +- Repositorios de menor calificación. El repositorio con la calificación promedio más baja está en la parte superior de la lista. + +La consulta repositories que se utiliza para obtener los repositorios revisados ​​tiene un argumento llamado orderBy, que puede utilizar para definir el principio de ordenación. El argumento tiene dos valores permitidos: CREATED_AT (ordenar por la fecha de la primera revisión del repositorio) y RATING_AVERAGE, (ordenar por la calificación promedio del repositorio). La consulta también tiene un argumento llamado orderDirection que puede usarse para cambiar la dirección del pedido. El argumento tiene dos valores permitidos: ASC (ascendente, el valor más pequeño primero) y DESC (descendente, el valor más grande primero). + +El estado del principio de orden seleccionado se puede mantener, por ejemplo, usando el hook [useState](https://reactjs.org/docs/hooks-reference.html#usestate) de React. Las variables utilizadas en la consulta repositories se pueden proporcionar al hook useRepositories como argumento. + +Puede utilizar, por ejemplo, la librería [react-native-picker](https://www.npmjs.com/package/react-native-picker-select) o el componente [React Native Paper](https://callstack.github.io/react-native-paper/) de la libería [Menú](https://callstack.github.io/react-native-paper/menu.html) para implementar la selección del principio de ordenación. Puede utilizar el prop [ListHeaderComponent](https://reactnative.dev/docs/flatlist#listheadercomponent) del componente FlatList para proporcionar a la lista un encabezado que contenga el componente de selección. + +La versión final de la función, dependiendo del componente de selección en uso, debería verse así: + +![Application preview](../../images/10/17.jpg) + +#### Ejercicio 10.24: filtrado de lista de repositorios revisados + +El servidor Apollo permite filtrar repositorios utilizando el nombre del repositorio o el nombre de usuario del propietario. Esto se puede hacer usando el argumento searchKeyword en la consulta repositories. A continuación, se muestra un ejemplo de cómo usar el argumento en una consulta: + +```javascript +{ + repositories(searchKeyword: "ze") { + edges { + node { + id + fullName + } + } + } +} +``` + +Implemente una función para filtrar la lista de repositorios revisados ​​según una palabra clave. Los usuarios deben poder escribir una palabra clave en una entrada de texto y la lista debe filtrarse a medida que el usuario escribe. Puede usar un componente TextInput simple o algo un poco más elegante como el componente [Searchbar](https://callstack.github.io/react-native-paper/searchbar.html) de React Native Paper como la entrada de texto. Coloque el componente de entrada de texto en el encabezado del componente FlatList. + +Para evitar una multitud de solicitudes innecesarias mientras el usuario escribe la palabra clave rápidamente, solo elija la última entrada después de un breve retraso. Esta técnica a menudo se conoce como [debouncing](https://lodash.com/docs/4.17.15#debounce). La biblioteca [use-debounce](https://www.npmjs.com/package/use-debounce) es un hook útil para eliminar el rebote de una variable de estado. Úselo con un tiempo de retardo razonable, como 500 milisegundos. Almacene el valor de la entrada de texto usando el hook useState y pase el valor sin rebote a la consulta como el valor del argumento searchKeyword. + +Probablemente se enfrente al problema de que el componente de entrada de texto pierde el foco después de cada pulsación de tecla. Esto se debe a que el contenido proporcionado por el prop ListHeaderComponent se desmonta constantemente. Esto se puede solucionar convirtiendo el componente que renderiza al componente FlatList en un componente de clase y definiendo la función de representación del encabezado como una propiedad de clase como esta: + +```javascript +export class RepositoryListContainer extends React.Component { + renderHeader = () => { + // this.props contains the component's props + const props = this.props; + + // ... + + return ( + + ); + }; + + render() { + return ( + + ); + } +} +``` + +La versión final de la función de filtrado debería verse así: + +![Application preview](../../images/10/18.jpg) + +
    + +
    + +### Paginación basada en cursor + +Cuando una API devuelve una lista ordenada de elementos de alguna colección, generalmente devuelve un subconjunto de todo el conjunto de elementos para reducir el ancho de banda requerido y disminuir el uso de memoria de las aplicaciones cliente. El subconjunto deseado de elementos se puede parametrizar para que el cliente pueda solicitar, por ejemplo, los primeros veinte elementos de la lista después de algún índice. Esta técnica se conoce comúnmente como paginación. Cuando se pueden solicitar elementos después de un elemento determinado definido por un cursor, estamos hablando de paginación basada en cursor. + +Por tanto, el cursor es solo una presentación serializada de un elemento en una lista ordenada. Echemos un vistazo a los repositorios paginados devueltos por la consulta repositories utilizando la siguiente consulta: + +```javascript +{ + repositories(first: 2) { + totalCount + edges { + node { + id + fullName + createdAt + } + cursor + } + pageInfo { + endCursor + startCursor + hasNextPage + } + } +} +``` + +El primer argumento le dice a la API que devuelva solo los dos primeros repositorios. Aquí hay un ejemplo de un resultado de la consulta: + +```javascript +{ + "data": { + "repositories": { + "totalCount": 10, + "edges": [ + { + "node": { + "id": "zeit.next.js", + "fullName": "zeit/next.js", + "createdAt": "2020-05-15T11:59:57.557Z" + }, + "cursor": "WyJ6ZWl0Lm5leHQuanMiLDE1ODk1NDM5OTc1NTdd" + }, + { + "node": { + "id": "zeit.swr", + "fullName": "zeit/swr", + "createdAt": "2020-05-15T11:58:53.867Z" + }, + "cursor": "WyJ6ZWl0LnN3ciIsMTU4OTU0MzkzMzg2N10=" + } + ], + "pageInfo": { + "endCursor": "WyJ6ZWl0LnN3ciIsMTU4OTU0MzkzMzg2N10=", + "startCursor": "WyJ6ZWl0Lm5leHQuanMiLDE1ODk1NDM5OTc1NTdd", + "hasNextPage": true + } + } + } +} +``` + +En el objeto de resultado, tenemos la matriz edges que contiene elementos con los atributos node y cursor. Como sabemos, el nodo contiene el repositorio en sí. El cursor del otro es una representación codificada en Base64 del nodo. Contiene la identificación del repositorio y la fecha de creación del repositorio como una marca de tiempo. Esta es la información que necesitamos para señalar el elemento cuando se ordenan según el momento de creación del repositorio. pageInfo contiene información como el cursor del primer y último elemento de la matriz. + +Digamos que queremos obtener el siguiente conjunto de elementos después del último elemento del conjunto actual, que es el repositorio "zeit/swr". Podemos establecer el argumento after de la consulta como el valor del endCursor así: + +```javascript +{ + repositories(first: 2, after: "WyJ6ZWl0LnN3ciIsMTU4OTU0MzkzMzg2N10=") { + totalCount + edges { + node { + id + fullName + createdAt + } + cursor + } + pageInfo { + endCursor + startCursor + hasNextPage + } + } +} +``` + +Ahora que tenemos los siguientes dos elementos y podemos seguir haciendo esto hasta que hasNextPage tenga el valor false, lo que significa que hemos llegado al final de la lista. Para profundizar en la paginación basada en el cursor, lee el artículo de Shopify [Paginación con cursores relativos](https://shopify.engineering/pagination-relative-cursors). Proporciona grandes detalles sobre la implementación en sí y los beneficios sobre la paginación tradicional basada en índices. + +### Desplazamiento infinito + +Las listas de desplazamiento vertical en aplicaciones móviles y de escritorio se implementan comúnmente mediante una técnica llamada desplazamiento infinito. El principio del desplazamiento infinito es bastante simple: + +- Obtener el conjunto inicial de elementos +- Cuando el usuario alcanza el último elemento, recupera el siguiente conjunto de elementos después del último elemento. + +El segundo paso se repite hasta que el usuario se cansa de desplazarse o se excede algún límite de desplazamiento. El nombre "desplazamiento infinito" se refiere a la forma en que la lista parece ser infinita: el usuario puede seguir desplazándose y siguen apareciendo nuevos elementos en la lista. + +Echemos un vistazo a cómo funciona esto en la práctica utilizando el hook useQuery del cliente Apollo. Apollo Client tiene una excelente [documentación](https://www.apollographql.com/docs/react/data/pagination/#cursor-based) sobre la implementación de la paginación basada en cursor. Implementemos el desplazamiento infinito para la lista de repositorios revisados ​​como ejemplo. + +Primero, necesitamos saber cuándo el usuario ha llegado al final de la lista. Afortunadamente, el componente FlatList tiene un prop [onEndReached](https://reactnative.dev/docs/virtualizedlist#onendreached), que llamará a la función proporcionada una vez que el usuario se haya desplazado hasta el último elemento de la lista. Puede cambiar la anticipación con la que se llama a la devolución de llamada onEndReach usando el prop [onEndReachedThreshold](https://reactnative.dev/docs/virtualizedlist#onendreachedthreshold). Modifique el componente FlatList del componente RepositoryList para que llame a una función una vez que se alcance el final de la lista: + +```javascript +export const RepositoryListContainer = ({ + repositories, + onEndReach, + /* ... */, +}) => { + const repositoryNodes = repositories + ? repositories.edges.map((edge) => edge.node) + : []; + + return ( + + ); +}; + +const RepositoryList = () => { + // ... + + const { repositories } = useRepositories(/* ... */); + + const onEndReach = () => { + console.log('You have reached the end of the list'); + }; + + return ( + + ); +}; + +export default RepositoryList; +``` + +Intente desplazarse hasta el final de la lista de repositorios revisados ​​y debería ver el mensaje en los registros. + +A continuación, necesitamos buscar más repositorios una vez que se llega al final de la lista. Esto se puede lograr utilizando la función [fetchMore](https://www.apollographql.com/docs/react/data/pagination/#cursor-based) proporcionada por el hook useQuery. Modifiquemos el hook useRepositories para que devuelva una función fetchMore decorada, que llama a la función fetchMore real con el endCursor y actualiza la consulta correctamente con los datos obtenidos: + +```javascript +const useRepositories = (variables) => { + const { data, loading, fetchMore, ...result } = useQuery(GET_REPOSITORIES, { + variables, + // ... + }); + + const handleFetchMore = () => { + const canFetchMore = + !loading && data && data.repositories.pageInfo.hasNextPage; + + if (!canFetchMore) { + return; + } + + fetchMore({ + query: GET_REPOSITORIES, + variables: { + after: data.repositories.pageInfo.endCursor, + ...variables, + }, + updateQuery: (previousResult, { fetchMoreResult }) => { + const nextResult = { + repositories: { + ...fetchMoreResult.repositories, + edges: [ + ...previousResult.repositories.edges, + ...fetchMoreResult.repositories.edges, + ], + }, + }; + + return nextResult; + }, + }); + }; + + return { + repositories: data ? data.repositories : undefined, + fetchMore: handleFetchMore, + loading, + ...result, + }; +}; +``` + +Asegúrese de tener los campos pageInfo y cursor en su consulta de repositories como se describe en los ejemplos de paginación. También deberá incluir los argumentos after y first para la consulta. + +La función handleFetchMore llamará a la función fetchMore del Cliente Apollo si hay más elementos para recuperar, lo cual está determinado por la propiedad hasNextPage. También queremos evitar la recuperación de más elementos si la recuperación ya está en proceso. En este caso, loading será true. En la función fetchMore proporcionamos a la consulta una variable after, que recibe el último valor endCursor. En updateQuery fusionaremos los bordes anteriores con los bordes obtenidos y actualizaremos la consulta para que pageInfo contenga la información más reciente. + +El último paso es llamar a la función fetchMore en el manejador onEndReach: + +```javascript +const RepositoryList = () => { + // ... + + const { repositories, fetchMore } = useRepositories({ + first: 8, + // ... + }); + + const onEndReach = () => { + fetchMore(); + }; + + return ( + + ); +}; + +export default RepositoryList; +``` + +Utilice un valor de argumento first relativamente pequeño, como 8, mientras prueba el desplazamiento infinito. De esta forma, no es necesario revisar demasiados repositorios. Es posible que se enfrente a un problema de que se llame al controlador onEndReach inmediatamente después de que se cargue la vista. Lo más probable es que esto se deba a que la lista contiene tan pocos repositorios que se llega al final de la lista inmediatamente. Puede solucionar este problema aumentando el valor del argumento first. Una vez que esté seguro de que el desplazamiento infinito está funcionando, no dude en utilizar un valor mayor para el argumento first. + +
    + +
    + +### Ejercicios 10.25.-10.27. + +#### Ejercicio 10.25: desplazamiento infinito para la lista de revisiones del repositorio + +Implemente el desplazamiento infinito para la lista de revisiones del repositorio. El campo reviews del tipo Repository tiene los argumentos first y after similares a las consultas repositories. El tipo ReviewConnection también tiene el campo pageInfo al igual que el tipo RepositoryConnection. + +Aquí hay un ejemplo de consulta: + +```javascript +{ + repository(id: "jaredpalmer.formik") { + id + fullName + reviews(first: 2, after: "WyIxYjEwZTRkOC01N2VlLTRkMDAtODg4Ni1lNGEwNDlkN2ZmOGYuamFyZWRwYWxtZXIuZm9ybWlrIiwxNTg4NjU2NzUwMDgwXQ==") { + totalCount + edges { + node { + id + text + rating + createdAt + repositoryId + user { + id + username + } + } + cursor + } + pageInfo { + endCursor + startCursor + hasNextPage + } + } + } +} +``` + +Al igual que con la lista de repositorios revisada, use un valor de argumento first relativamente pequeño mientras está probando el desplazamiento infinito. Es posible que deba crear algunos usuarios nuevos y usarlos para crear algunas reseñas nuevas para que la lista de reseñas sea lo suficientemente larga como para desplazarse. Establezca el valor del argumento first lo suficientemente alto para que el controlador onEndReach no se llame inmediatamente después de que se cargue la vista, pero lo suficientemente bajo para que pueda ver que más reseñas se recuperan una vez que llega al final de la lista. Una vez que todo funcione según lo previsto, puede utilizar un valor mayor para el argumento first. + +#### Ejercicio 10.26: vista de reseñas de usuarios + +Implemente una función que permita al usuario ver sus reseñas. Una vez que haya iniciado sesión, el usuario debería poder acceder a esta vista presionando la pestaña "My reviews" en la barra de la aplicación. La implementación de un desplazamiento infinito para la lista de revisión es _opcional_ en este ejercicio. Así es como debería verse aproximadamente la vista de lista de revisión: + +![Application preview](../../images/10/20.jpg) + +Recuerde que puede buscar al usuario autorizado del servidor Apollo con la consulta autorizadoUser. Esta consulta devuelve un tipo User, que tiene un campo reviews. Si ya ha implementado una consulta me reutilizable en su código, puede personalizar esta consulta para obtener el campo reviews de forma condicional. Esto se puede hacer usando la directiva de GraphQL [include](https://graphql.org/learn/queries/#directives). + +Digamos que la consulta actual se implementa aproximadamente de la siguiente manera: + +```javascript +const GET_CURRENT_USER = gql` + query { + me { + # user fields... + } + } +`; +``` + +Puede proporcionar la consulta con un argumento includeReviews y utilizarlo con la directiva include: + +```javascript +const GET_CURRENT_USER = gql` + query getCurrentUser($includeReviews: Boolean = false) { + me { + # user fields... + reviews @include(if: $includeReviews) { + edges { + node { + # review fields... + } + cursor + } + pageInfo { + # page info fields... + } + } + } + } +`; +``` + +El argumento includeReviews tiene un valor predeterminado de false, porque no queremos causar una sobrecarga adicional del servidor a menos que queramos explícitamente obtener las revisiones de los usuarios autorizados. El principio de la directiva include es bastante simple: si el valor del argumento if es true, incluya el campo, de lo contrario omítalo. + +#### Ejercicio 10.27: revisar acciones + +Ahora que el usuario puede ver sus reseñas, agreguemos algunas acciones a las reseñas. Debajo de cada revisión en la lista de revisión, debe haber dos botones. Un botón es para ver el repositorio de la revisión. Presionar este botón debería llevar al usuario a la revisión del repositorio único implementada en el ejercicio anterior. El otro botón es para eliminar el repositorio. Al presionar este botón se debería eliminar la revisión. Así es como deberían verse las acciones: + +![Application preview](../../images/10/21.jpg) + +Al presionar el botón delete debe seguir una alerta de confirmación. Si el usuario confirma la eliminación, la revisión se elimina. De lo contrario, la eliminación se descarta. Puede implementar la confirmación utilizando el módulo [Alerta](https://reactnative.dev/docs/alert). Tenga en cuenta que llamar al método Alert.alert no abrirá ninguna ventana en la vista previa web de Expo. Use la aplicación móvil Expo o un emulador para ver cómo se ve la ventana de alerta. + +Aquí está la alerta de confirmación que debería aparecer una vez que el usuario presione el botón delete: + +![Application preview](../../images/10/22.jpg) + +Puede eliminar una revisión mediante la mutación deleteReview. Esta mutación tiene un solo argumento, que es el id de la revisión que se eliminará. Una vez realizada la mutación, la forma más sencilla de actualizar la consulta de la lista de revisión es llamar a la función [refetch](https://www.apollographql.com/docs/react/data/queries/#refetching). + +Este fue el último ejercicio de esta sección. Es hora de enviar tu código a GitHub y marcar todos tus ejercicios terminados en el [sistema de envío de ejercicios](https://studies.cs.helsinki.fi/stats/courses/fs-react-native-2020). Tenga en cuenta que los ejercicios de esta sección deben enviarse a la parte 4 del sistema de envío de ejercicios. + +
    + +
    + +### Recursos adicionales + +A medida que nos acercamos al final de esta parte, tomemos un momento para ver algunos recursos adicionales relacionados con React Native. [Awesome React Native](https://github.com/jondot/awesome-react-native) es una lista de recursos de React Native extremadamente completa, como liberías, tutoriales y artículos. Debido a que la lista es exhaustivamente larga, echemos un vistazo más de cerca a algunos de sus aspectos más destacados. + +#### React Native Paper + +> Paper es una colección de componentes personalizables y listos para producción para React Native, siguiendo las pautas de Material Design de Google. + +[React Native Paper](https://callstack.github.io/react-native-paper/) es para React Native lo que [Material-UI](https://material-ui.com/) es para las aplicaciones web de React. Ofrece una amplia gama de componentes de interfaz de usuario de alta calidad y compatibilidad con [temas personalizados](https://callstack.github.io/react-native-paper/theming.html). [Configurar](https://callstack.github.io/react-native-paper/getting-started.html) React Native Paper para las aplicaciones React Native basadas en Expo es bastante simple, lo que hace posible su uso en el próximo ejercicios si quieres intentarlo. + +#### Styled-components + +> Utilizando template literals etiquetados (una adición reciente a JavaScript) y el poder de CSS, los componentes con estilo le permiten escribir código CSS real para diseñar sus componentes. También elimina el mapeo entre componentes y estilos: ¡usar componentes como una construcción de estilo de bajo nivel no podría ser más fácil! + +[Styled-components](https://styled-components.com/) es una librería para diseñar componentes de React usando [CSS-in-JS](https://en.wikipedia.org/wiki/CSS-in-JS ) técnica. En React Native ya estamos acostumbrados a definir los estilos de los componentes como un objeto JavaScript, por lo que CSS-in-JS no es un territorio tan inexplorado. Sin embargo, el enfoque de los componentes con estilo es bastante diferente de usar el método StyleSheet.create y el prop style. + +En los componentes con estilo, los estilos de los componentes se definen con el componente utilizando una función llamada [template litearal etiquetado](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#Tagged_templates) o un Objeto JavaScript. Styled-components hace posible definir nuevas propiedades de estilo para el componente en función de sus props _en tiempo de ejecución_. Esto ofrece muchas posibilidades, como cambiar sin problemas entre un tema claro y uno oscuro. También tiene un [soporte de temas](https://styled-components.com/docs/advanced#theming) completo . Aquí hay un ejemplo de cómo crear un componente Text con variaciones de estilo basadas en props: + +```javascript +import React from 'react'; +import styled from 'styled-components/native'; +import { css } from 'styled-components'; + +const FancyText = styled.Text` + color: grey; + font-size: 14px; + + ${({ isBlue }) => + isBlue && + css` + color: blue; + `} + + ${({ isBig }) => + isBig && + css` + font-size: 24px; + font-weight: 700; + `} +`; + +const Main = () => { + return ( + <> + Simple text + Blue text + Big text + + Big blue text + + + ); +}; +``` + +Debido a que los componentes con estilo procesan las definiciones de estilo, es posible utilizar una sintaxis de mayúsculas y minúsculas similar a CSS con los nombres de propiedad y las unidades en los valores de propiedad. Sin embargo, las unidades no tienen ningún efecto porque los valores de las propiedades no tienen unidades internas. Para obtener más información sobre los componentes con estilo, diríjase a la [documentación](https://styled-components.com/docs). + +#### React-spring + +> react-spring es una librería de animación basada en la física de spring que debería cubrir la mayoría de tus necesidades de animación relacionadas con la interfaz de usuario. Le brinda herramientas lo suficientemente flexibles para transmitir con confianza sus ideas en interfaces móviles. + +[React-spring](https://www.react-spring.io/) es una librería que proporciona una [ hook API](https://www.react-spring.io/docs/hooks/basics) limpia para animar componentes React Native. + +#### Navegación React + +> Enrutamiento y navegación para sus aplicaciones React Native + +[React Navigation](https://reactnavigation.org/) es una librería de enrutamiento para React Native. Comparte algunas similitudes con la biblioteca React Router que hemos estado usando durante esta y partes anteriores. Sin embargo, a diferencia de React Router, React Navigation ofrece más funciones nativas, como gestos nativos y animaciones para la transición entre vistas. + +### Palabras de cierre + +Eso es todo, nuestra aplicación está lista. ¡Buen trabajo! Hemos aprendido muchos conceptos nuevos durante nuestro viaje, como configurar nuestra aplicación React Native usando Expo, usar los componentes centrales de React Native y agregarles estilo, comunicarnos con el servidor y probar aplicaciones React Native. La última pieza del rompecabezas sería implementar la aplicación en Apple App Store y Google Play Store. + +La implementación de la aplicación es completamente opcional y no es del todo trivial, porque también necesitas bifurcar e implementar la [rate-repository-api](https://github.com/fullstack-hy2020/rate-repository-api). Para la propia aplicación React Native, primero debes crear compilaciones de iOS o Android siguiendo la [documentación](https://docs.expo.io/distribution/building-standalone-apps/) de Expo. Luego, puede cargar estas compilaciones en Apple App Store o Google Play Store. Expo tiene una [documentación](https://docs.expo.dev/submit/introduction/) para esto también. + +
    diff --git a/src/content/10/fi/osa10.md b/src/content/10/fi/osa10.md new file mode 100644 index 00000000000..27a23f5aed8 --- /dev/null +++ b/src/content/10/fi/osa10.md @@ -0,0 +1,11 @@ +--- +mainImage: ../../../images/part-10.svg +part: 10 +lang: fi +--- + +
    + +Kurssin kymmenes, React Nativea käsittelevä osa löytyy [englanninkielisestä kurssimateriaalista](/en/part10). + +
    diff --git a/src/content/10/zh/part10.md b/src/content/10/zh/part10.md new file mode 100644 index 00000000000..7956b266433 --- /dev/null +++ b/src/content/10/zh/part10.md @@ -0,0 +1,12 @@ +--- +mainImage: ../../../images/part-10.svg +part: 10 +lang: zh +--- + +
    + + + 在这一部分,我们将学习如何使用React Native框架,用JavaScript和React构建原生的Android和iOS移动应用。我们将通过从头开始开发一个完整的移动应用来深入了解React Native生态系统。在此过程中,我们将学习一些概念,如如何用React Native渲染本地用户界面组件,如何创建漂亮的用户界面,如何与服务器通信,以及如何测试React Native应用。 + +
    diff --git a/src/content/10/zh/part10a.md b/src/content/10/zh/part10a.md new file mode 100644 index 00000000000..a3623a70dfc --- /dev/null +++ b/src/content/10/zh/part10a.md @@ -0,0 +1,268 @@ +--- +mainImage: ../../../images/part-10.svg +part: 10 +letter: a +lang: zh +--- + +
    + + + 传统上,开发原生的iOS和Android应用需要开发人员使用特定平台的编程语言和开发环境。对于iOS开发,这意味着使用Objective C或Swift,对于Android开发,使用基于JVM的语言,如Java、Scala或Kotlin。为这两个平台发布一个应用,在技术上需要用不同的编程语言开发两个独立的应用。这需要大量的开发资源。 + + + 统一特定平台开发的流行方法之一,是利用浏览器作为渲染引擎。[Cordova](https://cordova.apache.org/)是构建跨平台应用的最流行的平台之一。它允许使用标准的网络技术--HTML5、CSS3和JavaScript来开发多平台应用。然而,Cordova应用是在用户设备的嵌入式浏览器窗口中运行。这就是为什么这些应用不能达到使用实际本地用户界面组件的本地应用的性能或外观和感觉的原因。 + + + [React Native](https://reactnative.dev/)是一个使用JavaScript和React开发本地Android和iOS应用的框架。它提供了一套跨平台的组件,在幕后利用平台的本地组件。使用React Native使我们能够将React所有熟悉的功能,如JSX、组件、prop、状态和钩子带入本地应用开发。除此之外,我们还能利用React生态系统中许多熟悉的库,如[react-redux](https://react-redux.js.org/)、[react-apollo](https://github.com/apollographql/react-apollo)、[react-router](https://reacttraining.com/react-router/core/guides/quick-start)等等。 + + + 对于熟悉React的开发者来说,开发速度和温和的学习曲线是React Native最重要的好处之一。以下是Coinbase的文章[Onboarding thousands of users with React Native](https://blog.coinbase.com/onboarding-thousands-of-users-with-react-native-361219066df4)中关于React Native的好处的激励性引用。 + + + > 如果我们要把React Native的好处简化为一个词,那就是 "速度"。平均来说,我们的团队能够在更短的时间内加入工程师,分享更多的代码(我们希望这将导致未来生产力的提升),并最终比我们采取纯粹的原生方法更快地交付功能。 + +### About this part + + + 在这一部分,我们将学习如何自下而上地构建一个实际的React Native应用。我们将学习一些概念,如什么是React Native的核心组件,如何创建漂亮的用户界面,如何与服务器通信以及如何测试React Native应用。 + + + 我们将开发一个用于评定[GitHub](https://github.com/)存储库的应用。我们的应用将有一些功能,如分类和过滤已审查的存储库,注册用户,登录和创建存储库的评论。应用的后端将被提供给我们,这样我们就可以完全专注于React Native的开发。我们的应用的最终版本将如下所示: + +![Application preview](../../images/10/4.png) + + + 本章节的所有练习都必须提交到一个GitHub仓库,该仓库最终将包含你的应用的全部源代码。本章节的每一节都会有模型解决方案,你可以用它来填补不完整的提交。这一部分的结构是基于这样的想法,即随着你在材料中的进展,你会开发你的应用。所以不要等到练习时才开始开发。相反,随着教材的进展,以同样的速度开发你的应用。 + + + 本章节将严重依赖前几部分所涉及的概念。在开始这部分之前,你需要有JavaScript、React和GraphQL的基本知识。不需要深层次的服务器端开发知识,所有的服务器端代码都为你提供。然而,我们将从你的React Native应用中提出网络请求,例如,使用GraphQL查询。在这部分之前推荐完成的部分是[第1章节](/en/part1)、[第2章节](/en/part2)、[第5章节](/en/part5)、[第7章节](/en/part7)和[第8章节](/en/part8) 。 + +### Submitting exercises and earning credits + + + 练习是通过[提交系统](https://studies.cs.helsinki.fi/stats/courses/fs-react-native-2020)提交的,就像前面的部分。请注意,这一部分的习题与第0-9部分相比,是提交给不同的课程实例。提交系统中的1-4部分是指本章节的a-d部分。这意味着,你将从这部分 "React Native简介 "开始,每次提交一个部分的练习,这部分是提交系统中的第1章节。 + + + 在这部分中,你将根据你完成的练习的数量获得学分。在这部分完成至少25个练习将获得2学分。一旦你完成了练习并想获得学分,请通过练习提交系统告诉我们你已经完成了该课程。 + +![Submitting exercises for credits](../../images/10/23.png) + + + 注意,"在Moodle中完成的考试 "说明是指[全栈开放课程''的考试](https://fullstackopen.com/en/part0/general_info#sign-up-for-the-exam),在你从这部分获得学分之前,必须完成。 + + + **注意**你需要注册相应的课程部分来获得学分,更多信息请看[这里](/en/part0/general_info#parts-and-completion)。 + + + 你可以通过点击其中一个旗帜图标下载完成这部分的证书。旗帜图标与证书的语言相对应。注意,你必须至少完成一个学分的练习才能下载证书。 + +### Initializing the application + + + 为了开始使用我们的应用,我们需要设置我们的开发环境。我们从前面的部分了解到,有一些有用的工具可以快速设置React应用,如Create React App。幸运的是React Native也有这类工具。 + + +对于我们的应用的开发,我们将使用[Expo](https://docs.expo.io/versions/latest/)。Expo是一个平台,可以简化React Native应用的设置、开发、构建和部署。让我们通过安装expo-cli命令行界面来开始使用Expo。 + +```shell +npm install --global expo-cli +``` + + + 接下来,我们可以通过运行以下命令在rate-repository-app目录下初始化我们的项目。 + +```shell +expo init rate-repository-app --template expo-template-blank@sdk-44 --npm +``` + + + 注意,@sdk-44设置项目的Expo SDK版本为44,它支持React Native版本0.64。使用其他Expo SDK版本可能会给你带来麻烦,因为你要遵循本材料。另外,与普通的React Native CLI相比,Expo有一些限制,更多关于这些限制[这里](https://docs.expo.dev/faq/#limitations)。然而,这些限制对材料中实现的应用没有影响。 + + + 现在我们的应用已经被初始化了,用一个编辑器如[Visual Studio Code](https://code.visualstudio.com/)打开创建的rate-repository-app目录。其结构应该大致如下。 + +![Project structure](../../images/10/1.png) + + + 我们可能会发现一些熟悉的文件和目录,如package.jsonnode_modules。在这些文件之上,最相关的文件是app.json文件,它包含了Expo相关的配置和App.js,它是我们应用的根组件。不要重命名或移动App.js文件,因为默认情况下,Expo将其导入到[注册根组件](https://docs.expo.io/versions/latest/sdk/register-root-component/)。 + + + 让我们看看package.json文件的scripts部分,其中有以下脚本。 + +```javascript +{ + // ... + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "eject": "expo eject" + }, + // ... +} +``` + + + 运行脚本npm start可以启动[Metro bundler](https://facebook.github.io/metro/),这是一个用于React Native的JavaScript捆绑器。它可以被描述为React Native生态系统的[Webpack](https://webpack.js.org/)。除了Metro捆绑器,Expo开发工具应该在浏览器窗口中打开[http://localhost:19002](http://localhost:19002)。Expo开发工具是一套有用的工具,用于查看应用的日志,以及在模拟器或Expo's移动应用中启动应用。我们将很快讨论模拟器和Expo的移动应用,但首先,让我们通过点击在网络浏览器中运行链接在网络浏览器中启动我们的应用。 + +![Expo DevTools](../../images/10/2.png) + + + 点击该链接后,我们应该很快在浏览器窗口中看到App.js文件中定义的文本。用编辑器打开App.js文件,对Text组件中的文本做一个小改动。保存文件后,你应该能看到你对代码所做的修改在浏览器窗口中是可见的。 + +### Setting up the development environment + + + 我们已经用Expo's的浏览器视图对我们的应用进行了初步的观察。尽管浏览器视图非常可用,但它仍然是对本地环境的一个相当差的模拟。让我们来看看我们在开发环境方面有哪些选择。 + + + 安卓和iOS设备,如平板电脑和手机,可以在电脑中使用特定的模拟器进行模拟。这对开发本地应用非常有用。macOS用户可以在电脑上使用Android和iOS模拟器。其他操作系统的用户,如Linux或Windows,必须解决Android模拟器的问题。接下来,根据你的操作系统,按照这些说明中的一个来设置仿真器。 + + + - [用Android Studio设置安卓模拟器](https://docs.expo.dev/workflow/android-studio-emulator/) (任何操作系统) + + - [用Xcode设置iOS模拟器](https://docs.expo.dev/workflow/ios-simulator/) (macOS操作系统) + + + 在你设置好模拟器并运行后,像我们之前做的那样,通过运行npm start来启动Expo开发工具。根据你正在运行的模拟器,点击在Android设备/模拟器上运行在iOS模拟器上运行链接。点击该链接后,Expo应该连接到模拟器,你最终应该在模拟器中看到该应用。请耐心等待,这可能需要一些时间。 + + + 除了模拟器,还有一个非常有用的方法可以用Expo开发React Native应用,即Expo移动应用。通过Expo移动应用,你可以使用你的实际移动设备预览你的应用,与模拟器相比,它提供了更具体的开发体验。要开始使用,请按照[Expo's documentation](https://docs.expo.io/get-started/installation/#2-expo-go-app-for-ios-and)中的说明来安装Expo移动应用。请注意,只有当你的移动设备与你用于开发的计算机连接到同一个本地网络(如连接到同一个Wi-Fi网络)时,Expo移动应用才能打开你的应用。 + + + 当世博移动应用完成安装后,打开它。接下来,如果Expo开发工具还没有运行,通过运行npm start来启动它。在开发工具的左下角,你应该能看到一个QR码。在Expo移动应用中,按扫描QR码并扫描开发工具中显示的QR码。世博移动应用应该开始构建JavaScript包,完成后你应该能够看到你的应用。现在,每次你想在Expo移动应用中重新打开你的应用,你应该能够在Projects视图中的Recently opened列表中访问该应用,而无需扫描QR码。 + +
    + +
    + +### Exercise 10.1 + +#### Exercise 10.1: initializing the application + + + 用Expo命令行界面初始化你的应用,并使用模拟器或Expo's移动应用设置开发环境。建议同时尝试一下,找出最适合你的开发环境。应用的名称并不那么重要。例如,你可以用rate-repository-app。 + + + 要提交这个练习和所有未来的练习,你需要[创建一个新的 GitHub 仓库](https://github.com/new)。仓库的名字可以是你用expo init初始化的应用的名字。如果你决定创建一个私有仓库,请将 GitHub 用户 [mluukkai](https://github.com/mluukkai) 添加为 [仓库合作者](https://docs.github.com/en/github/setting-up-and-managing-your-github-user-account/inviting-collaborators-to-a-personal-repository) 。合作者的身份只用于验证你的提交。 + + + 现在仓库已经创建,在你的应用的根目录下运行git init,以确保该目录被初始化为一个 Git 仓库。接下来,为了将创建的仓库添加为远程仓库,运行git remote add origin git@github.com:/.git(记住要替换命令中的占位符值)。最后,提交并将你的修改推送到版本库中,你就完成了。 + +
    + +
    + +### ESLint + + + 现在我们已经对开发环境有了一定的了解,让我们通过配置linter来进一步增强我们的开发经验。我们将使用[ESLint](https://eslint.org/),它在前面的部分中已经被我们所熟悉。让我们从安装依赖项开始。 + +```shell +npm install --save-dev eslint @babel/eslint-parser eslint-plugin-react eslint-plugin-react-native +``` + + + 接下来,让我们把ESLint的配置添加到.eslintrc文件中,放入rate-repository-app目录,内容如下。 + +```javascript +{ + "plugins": ["react", "react-native"], + "settings": { + "react": { + "version": "detect" + } + }, + "extends": ["eslint:recommended", "plugin:react/recommended"], + "parser": "@babel/eslint-parser", + "env": { + "react-native/react-native": true + }, + "rules": { + "react/prop-types": "off", + "react/react-in-jsx-scope": "off" + } +} +``` + + + 最后,让我们在package.json文件中添加一个lint脚本,以检查特定文件中的linting规则。 + +```javascript +{ + // ... + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "eject": "expo eject", + "lint": "eslint ./src/**/*.{js,jsx} App.js --no-error-on-unmatched-pattern" // highlight-line + }, + // ... +} +``` + + + 现在我们可以通过运行npm run lint来检查src目录和App.js文件中的JavaScript文件是否遵守了linting规则。我们将把我们未来的代码添加到src目录中,但由于我们还没有在那里添加任何文件,我们需要no-error-on unmatched-pattern标志。此外,如果可能的话,将ESLint与你的编辑器集成。如果你使用的是Visual Studio Code,你可以这样做,进入扩展部分,检查ESLint扩展是否已经安装并启用。 + +![Visual Studio Code ESLint extensions](../../images/10/3.png) + + + 提供的ESLint配置只包含配置的基础。如果你愿意,可以自由地改进配置和添加新的插件。 + +
    + +
    + +### Exercise 10.2 + +#### Exercise 10.2: setting up the ESLint + + + 在你的项目中设置ESLint,这样你就可以通过运行npm run lint来进行linter检查。为了获得大部分的linting,我们还建议将ESLint与你的编辑器集成。 + + + 这是本节的最后一个练习。现在是时候把你的代码推送到GitHub,并把你所有完成的练习标记到[练习提交系统](https://studies.cs.helsinki.fi/stats/courses/fs-react-native-2020)。注意,本节的练习应该提交给练习提交系统中的第1章节。 +
    + +
    + +### Viewing logs + + +世博开发工具可以用来显示运行中的应用的日志信息。错误和警告级别的信息也可以在模拟器和移动应用界面中看到。错误信息会以红色叠加的形式跳出,而警告信息可以通过按屏幕底部的黄色警报对话框展开。为了调试的目的,我们可以使用熟悉的console.log方法,将调试信息写入日志。 + + + 让我们在实践中试试。通过运行npm start启动Expo开发工具,用模拟器或移动应用打开应用。当应用运行时,你应该能够在开发工具左上角的 "Metro Bundler "下看到你的连接设备。 + +![Expo development tools](../../images/10/9.png) + + + 点击设备,打开其日志。接下来,打开App.js文件,在App组件中添加console.log消息。保存文件后,你应该能在日志中看到你的消息。 + +### Using the debugger + + + **NB:**你在尝试使用React Native Debugger时可能会面临以下错误。未捕获的错误。不能添加节点 "1",因为该ID的节点已经在商店里了。在React Native Debugger's repository中,有一个与这个问题相关的[issue](https://github.com/jhen0409/react-native-debugger/issues/668),其中可能包含了修复它的方法。尽管如此,如果这个问题很难解决,也不要拘泥于此。相反,继续学习材料。 + + + 用console.log方法检查从代码中记录的信息可能很方便,但有时发现bug或理解应用如何工作需要我们看到更大的画面。例如,我们可能对某个组件的状态和prop感兴趣,或者对某个网络请求的响应感兴趣。在前面的部分中,我们使用了浏览器的开发工具来进行这种调试。[React Native Debugger](https://docs.expo.io/workflow/debugging/#react-native-debugger)是一个为React Native应用提供类似调试功能的工具。 + + + 让我们在[安装说明](https://github.com/jhen0409/react-native-debugger#installation)的帮助下,开始安装React Native Debugger。如果你不确定选择哪种安装方法,从[release page](https://github.com/jhen0409/react-native-debugger/releases)下载一个预构建的二进制文件可能是最简单的选择。在发布页面,找到支持React Native 0.64版本的最新版本,并在 "Assets "部分下载适合你的操作系统的二进制文件(例如MacOS的.dmg文件和Windows的.exe文件)。一旦安装完成,启动React Native调试器,打开一个新的调试器窗口(快捷键:Command+T在macOS,Ctrl+T在Linux/Windows)并设置React Native打包器端口为19000。 + + + 接下来,我们需要启动我们的应用并连接到调试器。通过运行npm start来启动应用。一旦应用运行,用模拟器或Expo移动应用打开它。在模拟器或Expo移动应用中,按照Expo文档中的[说明](https://docs.expo.io/workflow/debugging/#developer-menu),打开开发者菜单。从开发者菜单中,选择调试远程JS,连接到调试器。现在,你应该能够在调试器中看到应用的组件树。 + +![React Native Debugger](../../images/10/24.png) + + + 你可以使用调试器来检查组件的状态和prop,以及改变它们。尝试用调试器找到由App组件渲染的Text组件。你可以使用搜索或通过组件树。一旦你在树中找到Text组件,点击它,并改变childrenprop的值。这个改变应该在应用的预览中自动可见。 + + + 关于更多有用的React Native应用调试工具,请前往世博会的[调试文档](https://docs.expo.io/workflow/debugging)。 + +
    diff --git a/src/content/10/zh/part10b.md b/src/content/10/zh/part10b.md new file mode 100644 index 00000000000..7cc654b2272 --- /dev/null +++ b/src/content/10/zh/part10b.md @@ -0,0 +1,1065 @@ +--- +mainImage: ../../../images/part-10.svg +part: 10 +letter: b +lang: zh +--- + +
    + + + 现在我们已经建立了我们的开发环境,我们可以进入React Native基础知识并开始开发我们的应用。在这一节中,我们将学习如何用React Native的核心组件构建用户界面,如何为这些核心组件添加样式属性,如何在视图之间转换,以及如何有效地管理表单的状态。 + +### Core components + + + 在前面的部分中,我们已经了解到我们可以使用React将组件定义为接收props作为参数并返回React元素树的函数。这个树通常用JSX语法表示。在浏览器环境中,我们使用了[ReactDOM](https://reactjs.org/docs/react-dom.html)库,将这些组件变成可以被浏览器渲染的DOM树。下面是一个非常简单的组件的具体例子。 + +```javascript +const HelloWorld = props => { + return
    Hello world!
    ; +}; +``` + + + HelloWorld组件返回一个单一的div元素,它是用JJSX语法创建的。我们可能记得,这种JJSX语法被编译成React.createElement方法调用,例如这样。 + +```javascript +React.createElement('div', null, 'Hello world!'); +``` + + + 这行代码创建了一个div元素,没有任何prop,只有一个子元素,是一个字符串"Hello world"。当我们使用ReactDOM.render方法将这个组件渲染成一个根DOM元素时,div元素将被渲染成相应的DOM元素。 + + + 正如我们所看到的,React并不拘泥于某种环境,例如浏览器环境。相反,有一些库,如ReactDOM,可以在特定的环境中渲染一组预定义组件,如DOM元素。在React Native中,这些预定义的组件被称为核心组件。 + + + [核心组件](https://reactnative.dev/docs/intro-react-native-components)是由React Native提供的一组组件,在幕后利用平台的本地组件。让我们用React Native来实现前面的例子。 + +```javascript +import { Text } from 'react-native'; // highlight-line + +const HelloWorld = props => { + return Hello world!; // highlight-line +}; +``` + + + 所以我们从React Native导入[Text](https://reactnative.dev/docs/text)组件,用一个Text元素替换div元素。许多熟悉的DOM元素都有其React Native的 "对应物"。下面是一些从React Native's [Core Components documentation](https://reactnative.dev/docs/components-and-apis)中挑选的例子。 + + + - [Text](https://reactnative.dev/docs/text) 组件是唯一的React Native组件,可以有文本的孩子。它类似于例如<strong><h1>元素。 + + - [View](https://reactnative.dev/docs/view)组件是基本的用户界面构建块,类似于<div>元素。 + + - [TextInput](https://reactnative.dev/docs/textinput)组件是一个类似于<input>元素的文本字段组件。 + + - [Pressable](https://reactnative.dev/docs/pressable)组件是用来捕捉不同的按压事件。它类似于例如<button>元素。 + + + 核心组件和DOM元素之间有几个明显的区别。第一个区别是,Text组件是唯一的React Native组件,可以有文本的孩子。这意味着你不能,例如,用前面的例子中的Text组件替换View组件。 + + + 第二个明显的区别是与事件处理程序有关。当使用DOM元素时,我们习惯于添加事件处理程序,如onClick到基本上任何元素,如<div><button>。在React Native中,我们必须仔细阅读[API文档](https://reactnative.dev/docs/components-and-apis)以了解一个组件接受哪些事件处理程序(以及其他prop)。例如,[Pressable](https://reactnative.dev/docs/pressable)组件提供了用于监听不同类型的按压事件的prop。例如,我们可以使用该组件的[onPress](https://reactnative.dev/docs/pressable)prop来监听新闻事件。 + +```javascript +import { Text, Pressable, Alert } from 'react-native'; + +const PressableText = props => { + return ( + Alert.alert('You pressed the text!')} + > + You can press me + + ); +}; +``` + + + 现在我们对核心组件有了基本的了解,让我们开始给我们的项目一些结构。在你项目的根目录下创建一个src目录,在src目录下创建一个components目录。在components目录中创建一个文件Main.jsx,内容如下。 + +```javascript +import Constants from 'expo-constants'; +import { Text, StyleSheet, View } from 'react-native'; + +const styles = StyleSheet.create({ + container: { + marginTop: Constants.statusBarHeight, + flexGrow: 1, + flexShrink: 1, + }, +}); + +const Main = () => { + return ( + + Rate Repository Application + + ); +}; + +export default Main; +``` + + + 接下来,让我们在App文件中使用Main组件,该文件位于我们项目的根目录下的App.js。将该文件的当前内容替换成这样。 + +```javascript +import Main from './src/components/Main'; + +const App = () => { + return
    ; +}; + +export default App; +``` + +### Manually reloading the application + + + 正如我们所见,当我们对代码进行修改时,Expo会自动重新加载应用。然而,有时自动重载可能不起作用,必须手动重载应用。这可以通过应用内的开发者菜单来实现。 + + + 你可以通过摇动你的设备或在iOS模拟器中选择硬件菜单内的 "摇动手势 "来访问开发者菜单。当你的应用在iOS模拟器中运行时,你也可以使用⌘D键盘快捷键,或在Mac OS上的Android模拟器中运行时使用⌘M,在Windows和Linux上使用Ctrl+M。 + + +一旦开发者菜单打开,只需按下 "重新加载 "就可以重新加载应用。在应用被重新加载后,自动重新加载应该可以工作,而不需要手动重新加载。 + +
    + +
    + +### Exercise 10.3. + +#### Exercise 10.3: the reviewed repositories list + + + 在这个练习中,我们将实现第一个版本的已审查的软件库列表。列表应该包含版本库的全名、描述、语言、分叉数、星级数、平均评分和评论数。幸运的是React Native提供了一个显示数据列表的便捷组件,这就是[FlatList](https://reactnative.dev/docs/flatlist)组件。 + + + 实现组件RepositoryListRepositoryItemcomponents目录'的文件RepositoryList.jsxRepositoryItem.jsxRepositoryList组件应该渲染FlatList组件和RepositoryItem列表中的单个项目(提示:使用FlatList组件的[renderItem](https://reactnative.dev/docs/flatlist#required-renderitem)prop)。用这个作为RepositoryList.jsx文件的基础。 + +```javascript +import { FlatList, View, StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + separator: { + height: 10, + }, +}); + +const repositories = [ + { + id: 'jaredpalmer.formik', + fullName: 'jaredpalmer/formik', + description: 'Build forms in React, without the tears', + language: 'TypeScript', + forksCount: 1589, + stargazersCount: 21553, + ratingAverage: 88, + reviewCount: 4, + ownerAvatarUrl: 'https://avatars2.githubusercontent.com/u/4060187?v=4', + }, + { + id: 'rails.rails', + fullName: 'rails/rails', + description: 'Ruby on Rails', + language: 'Ruby', + forksCount: 18349, + stargazersCount: 45377, + ratingAverage: 100, + reviewCount: 2, + ownerAvatarUrl: 'https://avatars1.githubusercontent.com/u/4223?v=4', + }, + { + id: 'django.django', + fullName: 'django/django', + description: 'The Web framework for perfectionists with deadlines.', + language: 'Python', + forksCount: 21015, + stargazersCount: 48496, + ratingAverage: 73, + reviewCount: 5, + ownerAvatarUrl: 'https://avatars2.githubusercontent.com/u/27804?v=4', + }, + { + id: 'reduxjs.redux', + fullName: 'reduxjs/redux', + description: 'Predictable state container for JavaScript apps', + language: 'TypeScript', + forksCount: 13902, + stargazersCount: 52869, + ratingAverage: 0, + reviewCount: 0, + ownerAvatarUrl: 'https://avatars3.githubusercontent.com/u/13142323?v=4', + }, +]; + +const ItemSeparator = () => ; + +const RepositoryList = () => { + return ( + + ); +}; + +export default RepositoryList; +``` + +Do not alter the contents of the repositories variable, it should contain everything you need to complete this exercise. Render the RepositoryList component in the Main component which we previously added to the Main.jsx file. The reviewed repository list should roughly look something like this: + +![Application preview](../../images/10/5.jpg) + +
    + +
    + +### Style + + + 现在我们对核心组件的工作原理有了基本的了解,我们可以用它们来构建一个简单的用户界面,是时候添加一些样式了。在[第二章节](/en/part2/adding_styles_to_react_app)中,我们了解到在浏览器环境中我们可以使用CSS定义React组件的样式属性。我们可以选择使用styleprop内联定义这些样式,或者在CSS文件中使用合适的选择器来定义。 + + + 样式属性附加到React Native's核心组件的方式和附加到DOM元素的方式有很多相似之处。在React Native中,大多数的核心组件都接受一个名为style的prop。styleprop接受一个带有样式属性及其值的对象。这些样式属性在大多数情况下与CSS相同,但是,属性名称采用camelCase。这意味着CSS属性如padding-topfont-size被写成paddingTopfontSize。下面是一个关于如何使用styleprop的简单例子。 + +```javascript +import { Text, View } from 'react-native'; + +const BigBlueText = () => { + return ( + + + Big blue text + + + ); +}; +``` + + + 在属性名称的基础上,你可能已经注意到这个例子中的另一个不同之处。在CSS中,数字属性值通常有一个单位,如px, %, emrem。在React Native中,所有与尺寸有关的属性值,如width, height, padding, and margin以及字体大小都是无单位。这些无单位的数值代表与强度无关的像素。如果你想知道某些核心组件的可用样式属性是什么,请查看[React Native Styling Cheat Sheet](https://github.com/vhpoet/react-native-styling-cheat-sheet)。 + + + 一般来说,直接在styleprop中定义样式被认为不是一个好主意,因为它使组件变得臃肿和不清晰。相反,我们应该使用[StyleSheet.create](https://reactnative.dev/docs/stylesheet#create)方法在组件的渲染函数之外定义样式。StyleSheet.create方法接受一个单一的参数,它是一个由命名的样式对象组成的对象,它从给定的对象中创建一个StyleSheet样式引用。下面是一个使用StyleSheet.create方法重构前一个例子的例子。 + +```javascript +import { Text, View, StyleSheet } from 'react-native'; // highlight-line + +// highlight-start +const styles = StyleSheet.create({ + container: { + padding: 20, + }, + text: { + color: 'blue', + fontSize: 24, + fontWeight: '700', + }, +}); +// highlight-end + +const BigBlueText = () => { + return ( + // highlight-line + // highlight-line + Big blue text + + + ); +}; +``` + + + 我们创建了两个命名的样式对象,styles.containerstyles.text。在组件内部,我们可以像访问普通对象中的任何键一样,访问特定的样式对象。 + + + 除了一个对象,styleprop还接受一个对象的数组。在数组的情况下,对象从左到右被合并,这样后一个样式属性就会被优先考虑。这样做是递归的,所以我们可以有一个数组,其中包含一个样式数组,如此类推。如果一个数组包含计算为错误的值,如nullundefined,这些值将被忽略。这使得定义条件样式很容易,例如,基于一个prop的值。下面是一个条件性样式的例子。 + +```javascript +import { Text, View, StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + text: { + color: 'grey', + fontSize: 14, + }, + blueText: { + color: 'blue', + }, + bigText: { + fontSize: 24, + fontWeight: '700', + }, +}); + +const FancyText = ({ isBlue, isBig, children }) => { + const textStyles = [ + styles.text, + isBlue && styles.blueText, + isBig && styles.bigText, + ]; + + return {children}; +}; + +const Main = () => { + return ( + <> + Simple text + Blue text + Big text + + Big blue text + + + ); +}; +``` + + + 在这个例子中,我们使用&&操作符,语句为condition && exprIfTrue。如果condition计算为真,这个语句就会产生exprIfTrue,否则就会产生condition,在这种情况下是一个计算为假的值。这是一个使用极为广泛和方便的速记方法。另一个选择是使用例如[条件运算符](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator),condition ? exprIfTrue : exprIfFalse。 + +### Consistent user interface with theming + + + 让我们坚持风格化的概念,但要有更广泛的视角。我们中的大多数人都使用过许多不同的应用,并可能同意,使一个好的用户界面的一个特征是一致性。这意味着用户界面组件的外观,如其字体大小、字体家族和颜色都遵循一个一致的模式。为了实现这一点,我们必须以某种方式参数化不同样式属性的值。这种方法通常被称为主题化。 + + + 流行的用户界面库如[Bootstrap](https://getbootstrap.com/docs/4.4/getting-started/theming/)和[Material UI](https://material-ui.com/customization/theming/)的用户可能已经对主题化相当熟悉。尽管主题化的实现方式不同,但主要的想法是在定义样式时总是使用诸如colors.primary之类的变量,而不是诸如#0366d6之类的["神奇数字"]()。这导致了一致性和灵活性的提高。 + + + 让我们看看主题化在我们的应用中是如何实际运作的。我们将使用大量具有不同变化的文本,例如不同的字体大小和颜色。因为React Native不支持全局样式,我们应该创建自己的Text组件来保持文本内容的一致性。让我们开始吧,在src目录下的theme.js文件中添加以下主题配置对象。 + +```javascript +const theme = { + colors: { + textPrimary: '#24292e', + textSecondary: '#586069', + primary: '#0366d6', + }, + fontSizes: { + body: 14, + subheading: 16, + }, + fonts: { + main: 'System', + }, + fontWeights: { + normal: '400', + bold: '700', + }, +}; + +export default theme; +``` + + + 接下来,我们应该创建实际的Text组件,使用这个主题配置。在我们已经有其他组件的components目录中创建一个Text.jsx文件。在Text.jsx文件中添加以下内容。 + +```javascript +import { Text as NativeText, StyleSheet } from 'react-native'; + +import theme from '../theme'; + +const styles = StyleSheet.create({ + text: { + color: theme.colors.textPrimary, + fontSize: theme.fontSizes.body, + fontFamily: theme.fonts.main, + fontWeight: theme.fontWeights.normal, + }, + colorTextSecondary: { + color: theme.colors.textSecondary, + }, + colorPrimary: { + color: theme.colors.primary, + }, + fontSizeSubheading: { + fontSize: theme.fontSizes.subheading, + }, + fontWeightBold: { + fontWeight: theme.fontWeights.bold, + }, +}); + +const Text = ({ color, fontSize, fontWeight, style, ...props }) => { + const textStyle = [ + styles.text, + color === 'textSecondary' && styles.colorTextSecondary, + color === 'primary' && styles.colorPrimary, + fontSize === 'subheading' && styles.fontSizeSubheading, + fontWeight === 'bold' && styles.fontWeightBold, + style, + ]; + + return ; +}; + +export default Text; +``` + + + 现在我们已经实现了自己的文本组件,具有一致的颜色、字体大小和字体重量的变体,我们可以在应用的任何地方使用。我们可以像这样使用不同的prop获得不同的文本变化。 + +```javascript +import Text from './Text'; + +const Main = () => { + return ( + <> + Simple text + Text with custom style + + Bold subheading + + Text with secondary color + + ); +}; + +export default Main; +``` + + + 如果你喜欢,可以自由地扩展或修改这个组件。创建可重复使用的文本组件,如使用Subheading组件的Text,可能也是一个好主意。另外,随着你的应用的进展,不断地扩展和修改主题配置。 + +### Using flexbox for layout + + + 我们要讲的最后一个与造型有关的概念是用[flexbox](https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Flexbox)实现布局。那些对CSS比较熟悉的人知道,flexbox不仅与React Native有关,它在Web开发中也有很多用例。事实上,那些知道flexbox在web开发中如何工作的人可能不会从这一节中学到那么多。不过,让我们来学习或复习一下Flexbox的基础知识。 + + + Flexbox是一个由两个独立组件组成的布局实体:一个flex container和里面的一组flex items。Flex容器有一组属性来控制其项目的流动。要使一个组件成为柔性容器,它必须将样式属性display设置为flex,这是display属性的默认值。下面是一个柔性容器的例子。 + +```javascript +import { View, StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + flexContainer: { + flexDirection: 'row', + }, +}); + +const FlexboxExample = () => { + return {/* ... */}; +}; +``` + + + 也许柔性容器最重要的属性是以下这些。 + + + - [flexDirection](https://css-tricks.com/almanac/properties/f/flex-direction/)属性控制柔性项目在容器中的布局方向。这个属性的可能值是row, row-reverse, column(默认值)和column-reverse。弹性方向row将从左到右排列弹性项目,而column从上到下。\*-reverse方向将只是颠倒柔性项目的顺序。 + + + - [justifyContent](https://css-tricks.com/almanac/properties/j/justify-content/)属性控制柔性项目沿主轴(由flexDirection属性定义)的对齐。这个属性的可能值是flex-start(默认值),flex-endcenterspace-betweenspace-aroundspace-evenly。 + + - [alignItems](https://css-tricks.com/almanac/properties/a/align-items/) 属性的作用与justifyContent相同,但用于相反的轴。这个属性的可能值是flex-start, flex-end, center, baselinestretch(默认值)。 + + + 让我们继续讨论柔性项目。如前所述,一个柔性容器可以包含一个或多个柔性项目。挠性项目有一些属性,控制它们在同一挠性容器中对其他挠性项目的行为。要使一个组件成为灵活项目,你所要做的就是把它设置为一个灵活容器的直接子项。 + +```javascript +import { View, Text, StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + flexContainer: { + display: 'flex', + }, + flexItemA: { + flexGrow: 0, + backgroundColor: 'green', + }, + flexItemB: { + flexGrow: 1, + backgroundColor: 'blue', + }, +}); + +const FlexboxExample = () => { + return ( + + + Flex item A + + + Flex item B + + + ); +}; +``` + + + 灵活性项目最常用的属性之一是[flexGrow](https://css-tricks.com/almanac/properties/f/flex-grow/) 属性。它接受一个无单位的值,该值定义了在必要的情况下,一个flex项目的增长能力。如果所有灵活项的flexGrow都是1,它们将均匀地分享所有可用空间。如果一个弹性项目的flexGrow0,它将只使用其内容所需的空间,而将剩余的空间留给其他弹性项目。 + + + 这里有一个更加互动和具体的例子,说明如何使用flexbox来实现一个具有页眉、页身和页脚的简单卡片组件:[Flexbox例子](https://snack.expo.io/@kalleilv/3d045d)。 + + + 接下来,请阅读[Flexbox完全指南](https://css-tricks.com/snippets/css/a-guide-to-flexbox/)这篇文章,其中有全面的flexbox的视觉例子。在[Flexbox Playground](https://flexbox.tech/)中玩玩Flexbox属性也是一个好主意,看看不同的Flexbox属性是如何影响布局的。请记住,在React Native中,除了camelCase命名外,属性名称与CSS中的属性名称是一样的。然而,属性值,如flex-startspace-between是完全一样的。 + + + **NB:** React Native和CSS在flexbox方面有一些区别。最重要的区别是,在React Native中,flexDirection属性的默认值是column。同样值得注意的是,flex速记在React Native中不接受多个值。更多关于React Native's flexbox的实现可以在[文档](https://reactnative.dev/docs/flexbox)中阅读。 + +
    + +
    + +### Exercises 10.4. - 10.5. + +#### Exercise 10.4: the app bar + + + 我们很快就会需要在我们的应用中的不同视图之间进行导航。这就是为什么我们需要一个[app bar](https://material.io/components/app-bars-top/)来显示标签,以便在不同的视图之间切换。在components文件夹中创建一个文件AppBar.jsx,内容如下。 + +```javascript +import { View, StyleSheet } from 'react-native'; +import Constants from 'expo-constants'; + +const styles = StyleSheet.create({ + container: { + paddingTop: Constants.statusBarHeight, + // ... + }, + // ... +}); + +const AppBar = () => { + return {/* ... */}; +}; + +export default AppBar; +``` + + + 现在,AppBar组件将防止状态栏与内容重叠,你可以删除我们先前在Main.jsx文件中为Main组件添加的marginTop样式。AppBar组件目前应该包含一个文本为 "Repositories "的标签。通过使用[Pressable](https://reactnative.dev/docs/pressable)组件使标签可被按下,但你不需要以任何方式处理onPress事件。将AppBar组件添加到Main组件中,使其成为屏幕中最上面的组件。AppBar组件应该如下所示: + +![Application preview](../../images/10/6.jpg) + + + 图片中应用栏的背景颜色是#24292e,但你也可以使用任何其他颜色。把应用栏的背景颜色添加到主题配置中可能是一个好主意,这样在需要时就可以很容易地改变它。另一个好主意是将应用栏的标签分离成自己的组件,如AppBarTab,这样将来就很容易添加新的标签。 + +#### Exercise 10.5: polished reviewed repositories list + + + 当前版本的审查库列表看起来相当黯淡。修改RepositoryItem组件,使其同时显示版本库作者的头像。你可以通过使用[Image](https://reactnative.dev/docs/image)组件来实现这一点。大于或等于1000的数字,如星星和叉子的数量,应该以千位数显示,精度为小数点后一位,并加上 "k "的后缀。这意味着,例如8439的叉子数应该显示为 "8.4k"。此外,还要对该组件的整体外观进行修饰,使审查过的软件库列表如下所示: + +![Application preview](../../images/10/7.jpg) + + + 在图片中,Main组件的背景颜色被设置为#e1e4e8RepositoryItem组件的背景颜色被设置为white。语言标签的背景色是#0366d6,这是主题配置中colors.primary的变量值。记住要利用我们之前实现的Text组件。同样在需要的时候,把RepositoryItem组件拆成小的组件。 + +
    + +
    + +### Routing + + + 当我们开始扩展我们的应用时,我们将需要一种方法在不同的视图之间转换,如存储库视图和签到视图。在[第7章节](/en/part7/react_router)中,我们熟悉了[React router](https://reactrouter.com/)库,并学会了如何使用它来实现Web应用中的路由。 + + + 在React Native应用中的路由与Web应用中的路由有些不同。主要的区别是,我们不能用URL来引用页面,我们在浏览器的地址栏里输入URL,也不能用浏览器的[历史API](https://developer.mozilla.org/en-US/docs/Web/API/History_API)来回浏览用户的历史。然而,这只是我们所使用的路由器接口的问题。 + + + 使用React Native,我们可以使用整个React路由器的核心,包括钩子和组件。与浏览器环境的唯一区别是,我们必须用React Native兼容的[NativeRouter](https://reactrouter.com/en/main/router-components/native-router)取代BrowserRouter,该库由[react-router-native](https://www.npmjs.com/package/react-router-native)提供。让我们从安装react-router-native库开始。 + +```shell +npm install react-router-native +``` + + + 接下来,打开App.js文件,将NativeRouter组件添加到App组件。 + +```javascript +import { StatusBar } from 'expo-status-bar'; +import { NativeRouter } from 'react-router-native'; // highlight-line + +import Main from './src/components/Main'; + +const App = () => { + return ( + // highlight-start + <> + +
    + + + + // highlight-end + ); +}; + +export default App; +``` + + + 一旦路由器就位,让我们在Main.jsx文件中的Main组件上添加我们的第一个路由。 + +```javascript +import { StyleSheet, View } from 'react-native'; +import { Route, Routes, Navigate } from 'react-router-native'; // highlight-line + +import RepositoryList from './RepositoryList'; +import AppBar from './AppBar'; +import theme from '../theme'; + +const styles = StyleSheet.create({ + container: { + backgroundColor: theme.colors.mainBackground, + flexGrow: 1, + flexShrink: 1, + }, +}); + +const Main = () => { + return ( + + + // highlight-start + + } exact /> + } /> + + // highlight-end + + ); +}; + +export default Main; +``` + + + 就是这样!Routes里面的最后一个Route是用来捕捉与之前定义的路径不匹配的路径。在这种情况下,我们想导航到主视图。 + +
    + +
    + +### Exercises 10.6. - 10.7. + +#### Exercise 10.6: the sign in view + + + 我们将很快实现一个表单,用户可以用它来登录我们的应用。在此之前,我们必须实现一个可以从应用栏中访问的视图。在components目录下创建一个文件SignIn.jsx,内容如下。 + +```javascript +import Text from './Text'; + +const SignIn = () => { + return The sign in view; +}; + +export default SignIn; +``` + + + 在Main组件中为这个SignIn组件设置一个路由。同时在应用栏中的 "存储库 "标签旁边添加一个带有 "登录 "文字的标签。用户应该能够通过按下标签在两个视图之间导航(提示:你可以使用React路由器's [Link](https://reactrouter.com/en/main/components/link-native) 组件)。 + +#### Exercise 10.7: scrollable app bar + + + 由于我们要在应用栏中添加更多的标签,一旦标签不能适应屏幕,允许水平滚动是个好主意。[ScrollView](https://reactnative.dev/docs/scrollview)组件恰恰是完成这项工作的合适组件。 + + +用一个ScrollView组件来包裹AppBar组件的标签。 + +```javascript +const AppBar = () => { + return ( + + {/* ... */} // highlight-line + + ); +}; +``` + + + 设置[horizontal](https://reactnative.dev/docs/scrollview#horizontal)proptrue将使ScrollView组件在内容无法适应屏幕时水平滚动。注意,你需要给ScrollView组件添加合适的样式属性,这样标签就会以的形式放置在flex容器内。你可以通过添加标签来确保应用栏可以水平滚动,直到最后一个标签无法适应屏幕。只要记得在应用栏如期工作后删除多余的标签。 + +
    + +
    + +### Form state management + + + 现在我们有一个签到视图的占位符,下一步是实现签到表格。在这之前,让我们从更广泛的角度来讨论表单。 + + + 表单的实现在很大程度上依赖于状态管理。使用React的useState钩子来进行状态管理可能会完成较小的表单的工作。然而,对于更复杂的表单,它将很快使状态管理变得相当乏味。幸运的是,在React生态系统中,有许多好的库可以缓解表单的状态管理问题。其中一个库是[Formik](https://jaredpalmer.com/formik/)。 + + + Formik的主要概念是contextfield。Formik's context由[Formik](https://jaredpalmer.com/formik/docs/api/formik)组件提供,它包含表单的状态。状态由表单字段的信息组成。这些信息包括例如每个字段的值和验证错误。状态的字段可以通过使用[useField](https://jaredpalmer.com/formik/docs/api/useField)钩子或[Field](https://jaredpalmer.com/formik/docs/api/field)组件的名称来引用。 + + + 让我们通过创建一个计算[身体质量指数](https://en.wikipedia.org/wiki/Body_mass_index)的表单来看看它的实际效果。 + +```javascript +import { Text, TextInput, Pressable, View } from 'react-native'; +import { Formik, useField } from 'formik'; + +const initialValues = { + mass: '', + height: '', +}; + +const getBodyMassIndex = (mass, height) => { + return Math.round(mass / Math.pow(height, 2)); +}; + +const BodyMassIndexForm = ({ onSubmit }) => { + const [massField, massMeta, massHelpers] = useField('mass'); + const [heightField, heightMeta, heightHelpers] = useField('height'); + + return ( + + massHelpers.setValue(text)} + /> + heightHelpers.setValue(text)} + /> + + Calculate + + + ); +}; + +const BodyMassIndexCalculator = () => { + const onSubmit = values => { + const mass = parseFloat(values.mass); + const height = parseFloat(values.height); + + if (!isNaN(mass) && !isNaN(height) && height !== 0) { + console.log(`Your body mass index is: ${getBodyMassIndex(mass, height)}`); + } + }; + + return ( + + {({ handleSubmit }) => } + + ); +}; +``` + + + 这个例子不是我们应用的一部分,所以你不需要把这个代码添加到应用中。然而,你可以在[Expo Snack](https://snack.expo.io/)中尝试一下这个例子。Expo Snack是一个React Native的在线编辑器,类似于[JSFiddle](https://jsfiddle.net/)和[CodePen](https://codepen.io/)。它是一个快速尝试代码的有用平台。你可以使用链接与他人分享Expo Snacks,或者将它们作为Snack Player嵌入到网站中。你可能在这个材料和React Native文档中撞见了Snack Player的例子。 + + + 在这个例子中,我们在BodyMassIndexCalculator组件中定义了Formik上下文,并为它提供了初始值和一个提交回调。初始值是通过[initialValues](https://jaredpalmer.com/formik/docs/api/formik#initialvalues-values)prop提供的,是一个以字段名为键、以相应的初始值为值的对象。提交回调是通过[onSubmit](https://jaredpalmer.com/formik/docs/api/formik#onsubmit-values-values-formikbag-formikbag--void--promiseany)prop提供的,它在handleSubmit函数被调用时被调用,条件是没有任何验证错误。Formik组件的子女是一个函数,它被调用的[props](https://jaredpalmer.com/formik/docs/api/formik#formik-render-methods-and-props)包括与状态有关的信息和行动,如handleSubmit函数。 + + + BodyMassIndexForm组件包含上下文和文本输入之间的状态绑定。我们使用[useField](https://jaredpalmer.com/formik/docs/api/useField)钩子来获取一个字段的值并改变它。_useField_钩子有一个参数,是字段的名称,它返回一个有三个值的数组,[field, meta, helpers]。[字段对象](https://jaredpalmer.com/formik/docs/api/useField#fieldinputpropsvalue)包含字段的值,[元对象](https://jaredpalmer.com/formik/docs/api/useField#fieldmetapropsvalue)包含字段的元信息,如可能的错误信息,[帮助者对象](https://jaredpalmer.com/formik/docs/api/useField#fieldhelperprops)包含改变字段状态的不同操作,如setValue函数。请注意,使用useField钩子的组件必须在Formik's context_之内。这意味着该组件必须是Formik组件的一个子嗣。 + + + 这里是我们之前例子的互动版本。[Formik例子](https://snack.expo.io/@kalleilv/formik-example)。 + + + 在前面的例子中,使用useField钩子和TextInput组件会导致重复的代码。让我们把这些重复的代码提取到FormikTextInput组件中,并创建一个自定义的TextInput组件,使文本输入在视觉上更悦目。首先,让我们安装Formik。 + +```shell +npm install formik +``` + + + 接下来,在components目录下创建一个文件TextInput.jsx,内容如下。 + +```javascript +import { TextInput as NativeTextInput, StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({}); + +const TextInput = ({ style, error, ...props }) => { + const textInputStyle = [style]; + + return ; +}; + +export default TextInput; +``` + + + 让我们进入FormikTextInput组件,将Formik的状态绑定添加到TextInput组件。在components目录下创建一个文件FormikTextInput.jsx,内容如下。 + +```javascript +import { StyleSheet } from 'react-native'; +import { useField } from 'formik'; + +import TextInput from './TextInput'; +import Text from './Text'; + +const styles = StyleSheet.create({ + errorText: { + marginTop: 5, + }, +}); + +const FormikTextInput = ({ name, ...props }) => { + const [field, meta, helpers] = useField(name); + const showError = meta.touched && meta.error; + + return ( + <> + helpers.setValue(value)} + onBlur={() => helpers.setTouched(true)} + value={field.value} + error={showError} + {...props} + /> + {showError && {meta.error}} + + ); +}; + +export default FormikTextInput; +``` + + + 通过使用FormikTextInput组件,我们可以像这样重构前面例子中的BodyMassIndexForm组件。 + +```javascript +const BodyMassIndexForm = ({ onSubmit }) => { + return ( + + // highlight-line + //highlight-line + + Calculate + + + ); +}; +``` + + + 我们可以看到,实现处理TextInput组件的Formik绑定的TextInput组件可以节省大量的代码。如果你的Formik表单使用了其他的输入组件,为它们实现类似的抽象也是一个好主意。 + +
    + +
    + + +### Exercise 10.8. + +#### Exercise 10.8: the sign in form + + + 为我们之前在SignIn.jsx文件中添加的SignIn组件实现一个登录表单。签到表格应该包括两个文本字段,一个是用户名,一个是密码。还应该有一个提交表单的按钮。你不需要实现一个onSubmit回调函数,只要在表单提交时使用console.log记录表单的值就足够了。 + +```javascript +const onSubmit = (values) => { + console.log(values); +}; +``` + + + 记得利用我们之前实现的FormikTextInput组件。你可以使用TextInput组件中的[secureTextEntry](https://reactnative.dev/docs/textinput#securetextentry)prop来掩盖密码输入。 + + + 签到表格看起来应该是这样的。 + +![Application preview](../../images/10/19.jpg) + +
    + +
    + +### Form validation + + + Formik为表单验证提供了两种方法:一个验证函数或一个验证模式。一个验证函数是为Formik组件提供的一个函数,作为[validate](https://jaredpalmer.com/formik/docs/guides/validation#validate)prop的值。它接收表单的值作为一个参数,并返回一个包含可能的字段特定错误信息的对象。 + + + 第二种方法是验证模式,它作为[validationSchema](https://jaredpalmer.com/formik/docs/guides/validation#validationschema)prop的值提供给Formik组件。这个验证模式可以通过一个叫做[Yup](https://github.com/jquense/yup)的验证库来创建。让我们从安装Yup开始吧。 + +```shell +npm install yup +``` + + + 接下来,作为一个例子,让我们为我们之前实现的体重指数表格创建验证模式。我们要验证massheight这两个字段是否存在,并且是数字。另外,mass的值应该大于或等于1,height的值应该大于或等于0.5。下面是我们定义模式的方式。 + +```javascript +import * as yup from 'yup'; // highlight-line + +// ... + +// highlight-start +const validationSchema = yup.object().shape({ + mass: yup + .number() + .min(1, 'Weight must be greater or equal to 1') + .required('Weight is required'), + height: yup + .number() + .min(0.5, 'Height must be greater or equal to 0.5') + .required('Height is required'), +}); +// highlight-end + +const BodyMassIndexCalculator = () => { + // ... + + return ( + + {({ handleSubmit }) => } + + ); +}; +``` + + + 每次字段的值发生变化时,以及调用handleSubmit函数时,都会默认进行验证。如果验证失败,Formik组件的onSubmitprop所提供的函数不会被调用。 + + + 我们之前实现的FormikTextInput组件会显示字段的错误信息,如果它存在并且字段被 "触摸",意味着字段已经收到并失去焦点。 + +```javascript +const FormikTextInput = ({ name, ...props }) => { + const [field, meta, helpers] = useField(name); + + // Check if the field is touched and the error message is present + const showError = meta.touched && meta.error; + + return ( + <> + helpers.setValue(value)} + onBlur={() => helpers.setTouched(true)} + value={field.value} + error={showError} + {...props} + /> + {/* Show the error message if the value of showError variable is true */} + {showError && {meta.error}} + + ); +}; +``` + +
    + +
    + +### Exercise 10.9. + +#### Exercise 10.9: validating the sign in form + + + 验证签到表单,使用户名和密码字段都是必填的。请注意,在前面的练习中实现的onSubmit回调,不应该被调用如果表单验证失败。 + + + 当前实现的FormikTextInput组件应该在触及的字段有错误时显示错误信息。通过给它一个红色的颜色来强调这个错误信息。 + + + 在红色的错误信息之上,通过给它一个红色的边框颜色,给一个无效的字段一个错误的视觉指示。记住,如果一个字段有错误,FormikTextInput组件将TextInput组件的errorprop设置为true。你可以使用errorprop的值来给TextInput组件附加条件样式。 + + + 下面是签到表单中的无效字段的大致情况。 + +![Application preview](../../images/10/8.jpg) + + + 这个实现中使用的红色是#d73a4a。 + +
    + +
    + +### Platform specific code + + + React Native的一大好处是,我们不需要担心应用是否在Android或iOS设备上运行。然而,在某些情况下,我们可能需要执行平台特定代码。例如,这种情况可能是在不同的平台上使用一个组件的不同实现。 + + + 我们可以通过Platform.OS常量来访问用户的平台。 + +```javascript +import { React } from 'react'; +import { Platform, Text, StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + text: { + color: Platform.OS === 'android' ? 'green' : 'blue', + }, +}); + +const WhatIsMyPlatform = () => { + return Your platform is: {Platform.OS}; +}; +``` + + +Platform.OS常量的可能值是androidios。另一个定义平台特定代码分支的有用方法是使用Platform.select方法。给定一个对象,其键值为iosandroidnativedefault之一,Platform.select方法返回最适合用户当前运行平台的值。我们可以用Platform.select方法重写前面例子中的styles变量,就像这样。 + +```javascript +const styles = StyleSheet.create({ + text: { + color: Platform.select({ + android: 'green', + ios: 'blue', + default: 'black', + }), + }, +}); +``` + + + 我们甚至可以使用Platform.select方法来要求一个特定平台的组件。 + +```javascript +const MyComponent = Platform.select({ + ios: () => require('./MyIOSComponent'), + android: () => require('./MyAndroidComponent'), +})(); + +; +``` + + + 然而,实现和导入平台特定组件(或任何其他代码)的更复杂的方法是使用.ios.jsx.android.jsx文件扩展。请注意,.jsx扩展名也可以是捆绑器识别的任何扩展名,如.js。例如,我们可以有Button.ios.jsxButton.android.jsx文件,我们可以像这样导入。 + +```javascript +import Button from './Button'; + +const PlatformSpecificButton = () => { + return
    + +
    + +### Exercise 10.10. + +#### Exercise 10.10: a platform specific font + + + 目前,在位于theme.js文件的主题配置中,我们应用的字体家族被设置为System。不使用System字体,而使用平台特定的[Sans-serif](https://en.wikipedia.org/wiki/Sans-serif)字体。在Android平台上使用Roboto字体,在iOS平台上使用Arial字体。默认字体可以是System。 + + + 这是本节的最后一个练习。现在是时候把你的代码推送到GitHub,并把你所有完成的练习标记到[练习提交系统](https://studies.cs.helsinki.fi/stats/courses/fs-react-native-2020)。注意,本节的练习应该提交到练习提交系统中的第二章节。 +
    diff --git a/src/content/10/zh/part10c.md b/src/content/10/zh/part10c.md new file mode 100644 index 00000000000..449dd7f3b5d --- /dev/null +++ b/src/content/10/zh/part10c.md @@ -0,0 +1,908 @@ +--- +mainImage: ../../../images/part-10.svg +part: 10 +letter: c +lang: zh +--- + +
    + + + 到目前为止,我们已经在没有任何实际服务器通信的情况下对我们的应用实现了一些功能。例如,我们实现的已审查的存储库列表使用了模拟数据,而且登录表单没有将用户的凭证发送到任何认证终端。在本节中,我们将学习如何使用HTTP请求与服务器通信,如何在React Native应用中使用Apollo客户端,以及如何在用户的设备中存储数据。 + + + 很快我们将学习如何在我们的应用中与服务器通信。在这之前,我们需要一个服务器来进行通信。为此,我们在[rate-repository-api](https://github.com/fullstack-hy2020/rate-repository-api)仓库中有一个完整的服务器实现。在这一部分,rate-repository-api服务器满足了我们应用的所有API需求。它使用[SQLite](https://www.sqlite.org/index.html)数据库,不需要任何设置,并提供一个Apollo GraphQL API和一些REST API端点。 + + + 在进一步了解材料之前,请按照版本库的[README](https://github.com/fullstack-hy2020/rate-repository-api/blob/master/README.md)中的设置说明来设置rate-repository-api服务器。注意,如果你使用模拟器进行开发,建议将服务器和模拟器运行在同一台电脑上。这样可以大大缓解网络请求。 + +### HTTP requests + + + React Native提供了[Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)用于在我们的应用中进行HTTP请求。React Native还支持古老的[XMLHttpRequest API](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest),这使得我们可以使用第三方库,如[Axios](https://github.com/axios/axios)。这些API与浏览器环境中的API是一样的,它们是全局可用的,不需要导入。 + + +同时使用过Fetch API和XMLHttpRequest API的人很可能同意,Fetch API更容易使用,也更现代。然而,这并不意味着XMLHttpRequest API没有它的用途。为了简单起见,我们将在我们的例子中只使用Fetch API。 + + + 使用Fetch API发送HTTP请求可以通过fetch函数完成。该函数的第一个参数是资源的URL。 + +```javascript +fetch('https://my-api.com/get-end-point'); +``` + + + 默认的请求方法是GETfetch函数的第二个参数是一个选项对象,你可以用它来指定一个不同的请求方法、请求头或请求体。 + +```javascript +fetch('https://my-api.com/post-end-point', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + firstParam: 'firstValue', + secondParam: 'secondValue', + }), +}); +``` + + + 注意,这些URL是编造的,不会(很可能)对你的请求发出响应。与Axios相比,Fetch API的操作水平要低一些。例如,没有任何请求或响应体的序列化和解析。这意味着你必须自己设置Content-Type头并使用JSON.stringify方法来序列化请求体。 + + + fetch函数返回一个 promise ,它解决了一个[Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) 对象。请注意,错误状态代码如400和500 不会被拒绝,例如在Axios中。如果是JSON格式的响应,我们可以使用Response.json方法解析响应体。 + +```javascript +const fetchMovies = async () => { + const response = await fetch('https://reactnative.dev/movies.json'); + const json = await response.json(); + + return json; +}; +``` + + + 关于Fetch API的更详细介绍,请阅读MDN网络文档中的[使用Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch)文章。 + + + 接下来,让我们在实践中尝试Fetch API。Rate-repository-api 服务器提供了一个端点,用于返回一个分页的已审核仓库的列表。一旦服务器运行,你应该能够访问[http://localhost:5000/api/repositories](http://localhost:5000/api/repositories)这个端点。数据是以常见的[基于游标的分页格式](https://graphql.org/learn/pagination/)分页的。实际的存储库数据在node键后面的edges阵列中。 + + + 不幸的是,我们不能通过使用http://localhost:5000/api/repositories URL在我们的应用中直接访问该服务器。为了在我们的应用中向这个端点发出请求,我们需要使用其本地网络中的IP地址访问服务器。要知道它是什么,通过运行npm start打开Expo开发工具。在开发工具中,你应该能够看到二维码上面有一个以exp://开头的URL。 + +![Development tools](../../images/10/10.png) + + + 复制exp://:之间的IP地址,在这个例子中是192.168.100.16。构建一个格式为http://:5000/api/repositories的URL,并在浏览器中打开它。你应该看到与localhost URL相同的响应。 + + + 现在我们知道了端点的URL,让我们在审查的存储库列表中使用服务器提供的实际数据。我们目前正在使用存储在repositories变量中的模拟数据。删除repositories变量,用components目录下的RepositoryList.jsx文件中的这段代码替换模拟数据的使用。 + +```javascript +import { useState, useEffect } from 'react'; +// ... + +const RepositoryList = () => { + const [repositories, setRepositories] = useState(); + + const fetchRepositories = async () => { + // Replace the IP address part with your own IP address! + const response = await fetch('http://192.168.100.16:5000/api/repositories'); + const json = await response.json(); + + console.log(json); + + setRepositories(json); + }; + + useEffect(() => { + fetchRepositories(); + }, []); + + // Get the nodes from the edges array + const repositoryNodes = repositories + ? repositories.edges.map(edge => edge.node) + : []; + + return ( + + ); +}; + +export default RepositoryList; +``` + + + 我们使用React的useState钩子来维护存储库列表状态,并使用useEffect钩子在RepositoryList组件被安装时调用fetchRepositories函数。我们将实际的存储库提取到repositoryNodes变量中,并用它替换FlatList组件dataprop中先前使用的repositories变量。现在你应该能够在审查的存储库列表中看到实际的服务器提供的数据。 + + + 记录服务器的响应通常是个好主意,以便能够像我们在fetchRepositories函数中那样检查它。如果你像我们在[查看日志](/en/part10/introduction_to_react_native#viewing-logs)一节中学到的那样,导航到设备的日志,你应该能够在Expo开发工具中看到这个日志信息。如果你使用世博会的移动应用进行开发,并且网络请求失败,请确保你用来运行服务器的电脑和你的手机都连接到同一个Wi-Fi网络。如果这是不可能的,要么在服务器运行的同一台电脑上使用模拟器,要么设置一个隧道到本地主机,例如,使用[Ngrok](https://ngrok.com/)。 + + + 目前在RepositoryList组件中的数据获取代码可以做一些重构。例如,该组件知道网络请求的细节,如终端的URL。此外,获取数据的代码有很多重用的可能性。让我们重构该组件的代码,将数据获取代码提取到它自己的钩子中。在src目录下创建一个hooks目录,在该hooks目录下创建一个useRepositories.js文件,内容如下。 + +```javascript +import { useState, useEffect } from 'react'; + +const useRepositories = () => { + const [repositories, setRepositories] = useState(); + const [loading, setLoading] = useState(false); + + const fetchRepositories = async () => { + setLoading(true); + + // Replace the IP address part with your own IP address! + const response = await fetch('http://192.168.100.16:5000/api/repositories'); + const json = await response.json(); + + setLoading(false); + setRepositories(json); + }; + + useEffect(() => { + fetchRepositories(); + }, []); + + return { repositories, loading, refetch: fetchRepositories }; +}; + +export default useRepositories; +``` + + + 现在我们有了一个干净的抽象来获取审查过的仓库,让我们在RepositoryList组件中使用useRepositories钩子。 + +```javascript +// ... +import useRepositories from '../hooks/useRepositories'; // highlight-line + +const RepositoryList = () => { + const { repositories } = useRepositories(); // highlight-line + + const repositoryNodes = repositories + ? repositories.edges.map(edge => edge.node) + : []; + + return ( + + ); +}; + +export default RepositoryList; +``` + + + 就这样,现在RepositoryList组件不再知道获取存储库的方式。也许在未来,我们会通过GraphQL API而不是REST API来获取它们。我们将看看会发生什么。 + +### GraphQL and Apollo client + + + 在[第8章节](https://fullstackopen.com/en/part8)中,我们了解了GraphQL以及如何在React应用中使用[Apollo客户端](https://www.apollographql.com/docs/react/)将GraphQL查询发送到Apollo服务器。好消息是,我们可以在React Native应用中使用Apollo客户端,就像我们在React Web应用中一样。 + + + 如前所述,rate-repository-api服务器提供了一个GraphQL API,它是用Apollo服务器实现的。一旦服务器运行,你可以在[http://localhost:4000](https://www.apollographql.com/docs/studio/explorer/)访问[Apollo沙盒]。Apollo Sandbox是一个用于进行GraphQL查询和检查GraphQL APIs模式和文档的工具。如果你需要在你的应用中发送一个查询,总是在代码中实施之前先用Apollo Sandbox测试。在Apollo沙盒中调试查询中可能出现的问题要比在应用中调试容易得多。如果你不确定有哪些可用的查询或如何使用它们,你可以查看操作编辑器旁边的文档。 + +![Apollo Sandbox](../../images/10/11.png) + + + 在我们的React Native应用中,我们将使用与第八章节相同的[@apollo/client](https://www.npmjs.com/package/@apollo/client)库。让我们开始安装这个库和[graphql](https://www.npmjs.com/package/graphql)库,它是作为一个对等依赖的需要。 + +```shell +npm install @apollo/client graphql +``` + + + 在我们开始使用Apollo客户端之前,我们需要稍微配置一下Metro捆绑器,以便它能处理Apollo客户端使用的.cjs文件扩展。首先,让我们安装@expo/metro-config包,它有默认的Metro配置。 + +```shell +npm install @expo/metro-config +``` + + + 然后,我们可以在项目根目录下的metro.config.js中添加以下配置。 + +```javascript +const { getDefaultConfig } = require('@expo/metro-config'); + +const defaultConfig = getDefaultConfig(__dirname); + +defaultConfig.resolver.sourceExts.push('cjs'); + +module.exports = defaultConfig; +``` + + + 重新启动Expo开发工具,以便配置中的变化被应用。 + + + 现在Metro配置已经就绪,让我们创建一个实用的函数,用所需的配置创建Apollo客户端。在src目录下创建一个utils目录,在该utils目录下创建一个apolloClient.js文件。在该文件中配置Apollo客户端以连接到Apollo服务器。 + +```javascript +import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client'; + +const httpLink = createHttpLink({ + // Replace the IP address part with your own IP address! + uri: 'http://192.168.100.16:4000/graphql', +}); + +const createApolloClient = () => { + return new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + }); +}; + +export default createApolloClient; +``` + + + 用于连接Apollo服务器的URL与你在Fetch API中使用的URL相同,期望端口为4000,路径为/graphql。最后,我们需要使用[ApolloProvider](https://www.apollographql.com/docs/react/api/react/hooks/#the-apolloprovider-component) 上下文提供Apollo客户端。我们将把它添加到App.js文件中的App组件。 + +```javascript +import { NativeRouter } from 'react-router-native'; +import { ApolloProvider } from '@apollo/client'; // highlight-line + +import Main from './src/components/Main'; +import createApolloClient from './src/utils/apolloClient'; // highlight-line + +const apolloClient = createApolloClient(); // highlight-line + +const App = () => { + return ( + + // highlight-line +
    + // highlight-line + + ); +}; + +export default App; +``` + +### Organizing GraphQL related code + + + 在你的应用中如何组织GraphQL相关的代码,这取决于你。然而,为了有一个参考结构,让我们看看一个相当简单和有效的方法来组织GraphQL相关的代码。在这个结构中,我们在自己的文件中定义查询、改变、片段,可能还有其他实体。这些文件都位于同一个目录下。下面是一个结构的例子,你可以用它来开始。 + +![GraphQL structure](../../images/10/12.png) + + + 你可以从@apollo/client库导入用于定义GraphQL查询的gql模板字面标签。如果我们按照上面建议的结构,我们可以在graphql目录下有一个queries.js文件,用于我们应用的GraphQL查询。每个查询都可以存储在一个变量中,并像这样导出。 + +```javascript +import { gql } from '@apollo/client'; + +export const GET_REPOSITORIES = gql` + query { + repositories { + ${/* ... */} + } + } +`; + +// other queries... +``` + + + 我们可以导入这些变量,并像这样用useQuery挂钩来使用它们。 + +```javascript +import { useQuery } from '@apollo/client'; + +import { GET_REPOSITORIES } from '../graphql/queries'; + +const Component = () => { + const { data, error, loading } = useQuery(GET_REPOSITORIES); + // ... +}; +``` + + + 组织改变的情况也是如此。唯一的区别是我们在一个不同的文件中定义它们,mutations.js。建议在查询中使用[fragments](https://www.apollographql.com/docs/react/data/fragments/),以避免重复输入相同的字段。 + +### Evolving the structure + + + 一旦我们的应用变大,有时可能会出现某些文件变大而无法管理。例如,我们有组件A,它渲染了组件BC。所有这些组件都定义在A.jsx目录下的components文件中。我们想把组件BC提取到它们自己的文件B.jsxC.jsx中,而无需进行重大的重构。我们有两个选择。 + + + - 在components目录下创建文件B.jsxC.jsx。这将导致以下结构。 + +``` +components/ + A.jsx + B.jsx + C.jsx + ... +``` + + + - 在components目录下创建一个目录A,并在那里创建文件B.jsxC.jsx。为了避免破坏导入A.jsx文件的组件,将A.jsx文件移至A目录,并将其重命名为index.jsx。这样就形成了以下结构。 + +``` +components/ + A/ + B.jsx + C.jsx + index.jsx + ... +``` + + + 第一种方案相当得体,然而,如果组件BC在组件A之外不能重复使用,那么将它们作为单独的文件加入到组件目录中,使之膨胀是没有用的。第二种方案是相当模块化的,并且不会破坏任何导入,因为导入诸如./A的路径会同时匹配A.jsxA/index.jsx。 + +
    + +
    + +### Exercise 10.11. + +#### Exercise 10.11: fetching repositories with Apollo Client + + + 我们想用GraphQL查询取代useRepositories钩中的Fetch API实现。在[http://localhost:4000](http://localhost:4000)打开Apollo沙盒,看一下操作编辑器旁边的文档。查一下repositories查询。该查询有一些参数,然而,所有这些参数都是可选的,所以你不需要指定它们。在Apollo沙盒中形成一个查询,以获取你目前在应用中显示的字段的存储库。结果将被分页,默认情况下,它最多包含前30个结果。现在,你可以完全忽略分页。 + + + 一旦查询在Apollo沙盒中工作,用它来替换useRepositories钩中的Fetch API实现。这可以通过[useQuery](https://www.apollographql.com/docs/react/api/react/hooks/#usequery)挂钩实现。gql模板字面标签可以按照前面的指示从@apollo/client库导入。考虑在GraphQL相关代码中使用前面推荐的结构。为了避免将来的缓存问题,在查询中使用_cache-and-network_ [fetch policy](https://www.apollographql.com/docs/react/data/queries/#setting-a-fetch-policy)。它可以像这样与useQuery钩子一起使用。 + +```javascript +useQuery(MY_QUERY, { + fetchPolicy: 'cache-and-network', + // Other options +}); +``` + + + useRepositories钩子的变化不应该以任何方式影响RepositoryList组件。 + +
    + +
    + +### Environment variables + + + 每个应用都很可能在一个以上的环境中运行。这些环境的两个明显的候选者是开发环境和生产环境。在这两个环境中,开发环境是我们现在正在运行的应用。不同的环境通常有不同的依赖性,例如,我们在本地开发的服务器可能使用本地数据库,而部署到生产环境的服务器则使用生产数据库。为了使代码独立于环境,我们需要将这些依赖关系参数化。目前,我们在应用中使用一个非常依赖环境的硬编码值:服务器的URL。 + + + 我们之前已经知道,我们可以为运行中的程序提供环境变量。这些变量可以在命令行中定义,或者使用环境配置文件,如.env文件和第三方库,如Dotenv。不幸的是,React Native没有对环境变量的直接支持。然而,我们可以在运行时从我们的JavaScript代码中访问app.json文件中定义的Expo配置。这个配置可以用来定义和访问与环境有关的变量。 + + +配置可以通过从expo-constants模块导入Constants常量来访问,我们之前已经做过几次。一旦导入,Constants.manifest属性将包含配置。让我们通过在App组件中记录Constants.manifest来试试。 + +```javascript +import { NativeRouter } from 'react-router-native'; +import { ApolloProvider } from '@apollo/client'; +import Constants from 'expo-constants'; // highlight-line + +import Main from './src/components/Main'; +import createApolloClient from './src/utils/apolloClient'; + +const apolloClient = createApolloClient(); + +const App = () => { + console.log(Constants.manifest); // highlight-line + + return ( + + +
    + + + ); +}; + +export default App; +``` + + + 你现在应该在日志中看到配置了。 + + + 下一步是在我们的应用中使用配置来定义环境相关的变量。让我们先把app.json文件重命名为app.config.js。一旦文件被重命名,我们就可以在配置文件中使用JavaScript。改变文件内容,使之前的对象。 + +```javascript +{ + "expo": { + "name": "rate-repository-app", + // rest of the configuration... + } +} +``` + + + 变成一个出口,其中包含expo属性的内容。 + +```javascript +export default { + name: 'rate-repository-app', + // rest of the configuration... +}; +``` + + + Expo在配置中为任何特定应用的配置保留了一个[extra](https://docs.expo.dev/guides/environment-variables/#using-app-manifest--extra)属性。为了了解这一点,让我们在我们应用的配置中添加一个env变量。 + +```javascript +export default { + name: 'rate-repository-app', + // rest of the configuration... + // highlight-start + extra: { + env: 'development' + }, + // highlight-end +}; +``` + + + 重新启动Expo开发工具以应用这些变化,你应该看到Constants.manifest属性的值已经改变,现在包括了包含我们应用特定配置的extra属性。现在,env变量的值可以通过Constants.manifest.extra.env属性访问。 + + + 因为使用硬编码的配置有点傻,所以让我们使用环境变量来代替。 + +```javascript +export default { + name: 'rate-repository-app', + // rest of the configuration... + // highlight-start + extra: { + env: process.env.ENV, + }, + // highlight-end +}; +``` + + + 正如我们所学到的,我们可以通过命令行来设置环境变量的值,方法是在实际命令前定义变量的名称和值。举个例子,启动Expo开发工具,将环境变量ENV设置为test,像这样。 + +```shell +ENV=test npm start +``` + + + 如果你看一下日志,你应该看到Constants.manifest.extra.env属性已经改变。 + + + 我们也可以从.env文件中加载环境变量,正如我们在前面的部分所学到的。首先,我们需要安装[Dotenv](https://www.npmjs.com/package/dotenv)库。 + +```shell +npm install dotenv +``` + + + 接下来,在我们项目的根目录下添加一个.env文件,内容如下。 + +``` +ENV=development +``` + + + 最后,在app.config.js文件中导入该库。 + +```javascript +import 'dotenv/config'; // highlight-line + +export default { + name: 'rate-repository-app', + // rest of the configuration... + extra: { + env: process.env.ENV, + }, +}; +``` + + + 你需要重新启动Expo开发工具来应用你对.env文件所做的修改。 + + + 注意,把敏感数据放到应用的配置中是一个好主意。原因是,一旦用户下载了你的应用,至少在理论上,他们可以对你的应用进行逆向工程,找出你存储在代码中的敏感数据。 + +
    + +
    + +### Exercise 10.12. + +#### Exercise 10.12: environment variables + + + 在初始化Apollo客户端时,使用.env文件中定义的环境变量,而不是硬编码的Apollo服务器的URL。你可以给这个环境变量命名,例如APOLLO_URI。 + +Do not try to access environment variables like process.env.APOLLO_URI outside the app.config.js file. Instead use the Constants.manifest.extra object like in the previous example. In addition, do not import the dotenv library outside the app.config.js file or you will most likely face errors. + +
    + +
    + +### Storing data in the user's device + + + 有的时候我们需要在用户的设备中存储一些持久的数据片段。其中一个常见的场景是存储用户的认证令牌,这样即使用户关闭并重新打开我们的应用,我们也可以检索到它。在Web开发中,我们使用浏览器的localStorage对象来实现这种功能。React Native提供了类似的持久化存储,即[AsyncStorage](https://react-native-async-storage.github.io/async-storage/docs/usage/)。 + + + 我们可以使用expo install命令来安装适合我们Expo SDK版本的@react-native-async-storage/async-storage包。 + +```shell +expo install @react-native-async-storage/async-storage +``` + + + AsyncStorage的API在许多方面与localStorage的API相同。它们都是具有类似方法的键值存储。两者之间最大的区别是,正如其名称所暗示的,AsyncStorage的操作是异步的。 + + + 因为AsyncStorage在全局命名空间中对字符串键进行操作,所以为其操作创建一个简单的抽象是个好主意。这个抽象可以用一个[class](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes)来实现,例如。作为一个例子,我们可以实现一个购物车存储,用于存储用户想要购买的产品。 + +```javascript +import AsyncStorage from '@react-native-async-storage/async-storage'; + +class ShoppingCartStorage { + constructor(namespace = 'shoppingCart') { + this.namespace = namespace; + } + + async getProducts() { + const rawProducts = await AsyncStorage.getItem( + `${this.namespace}:products`, + ); + + return rawProducts ? JSON.parse(rawProducts) : []; + } + + async addProduct(productId) { + const currentProducts = await this.getProducts(); + const newProducts = [...currentProducts, productId]; + + await AsyncStorage.setItem( + `${this.namespace}:products`, + JSON.stringify(newProducts), + ); + } + + async clearProducts() { + await AsyncStorage.removeItem(`${this.namespace}:products`); + } +} + +const doShopping = async () => { + const shoppingCartA = new ShoppingCartStorage('shoppingCartA'); + const shoppingCartB = new ShoppingCartStorage('shoppingCartB'); + + await shoppingCartA.addProduct('chips'); + await shoppingCartA.addProduct('soda'); + + await shoppingCartB.addProduct('milk'); + + const productsA = await shoppingCartA.getProducts(); + const productsB = await shoppingCartB.getProducts(); + + console.log(productsA, productsB); + + await shoppingCartA.clearProducts(); + await shoppingCartB.clearProducts(); +}; + +doShopping(); +``` + + + 因为AsyncStorage键是全局的,所以通常为键添加一个命名空间是个好主意。在这种情况下,命名空间只是我们为存储抽象的键提供的一个前缀。使用命名空间可以防止存储的键与其他AsyncStorage键发生冲突。在这个例子中,命名空间被定义为构造函数的参数,我们使用namespace:key格式来表示键。 + + + 我们可以使用[AsyncStorage.setItem](https://react-native-async-storage.github.io/async-storage/docs/api#setitem)方法向存储添加一个项目。该方法的第一个参数是项目的键,第二个参数是其值。值必须是一个字符串,所以我们需要像使用JSON.stringify方法那样对非字符串值进行序列化。[AsyncStorage.getItem](https://react-native-async-storage.github.io/async-storage/docs/api/#getitem)方法可以用来从存储中获取一个项目。该方法的参数是项目的键,其值将被解析。[AsyncStorage.removeItem](https://react-native-async-storage.github.io/async-storage/docs/api/#removeitem)方法可以用来从存储中移除具有所提供的键的项目。 + + + **NB:** [SecureStore](https://docs.expo.dev/versions/latest/sdk/securestore/)是类似于AsyncStorage的持久化存储,但它对存储的数据进行加密。这使得它更适合于存储更敏感的数据,如用户的信用卡号码。 + +
    + +
    + +### Exercises 10.13. - 10.14. + +#### Exercise 10.13: the sign in form mutation + + + 目前的签到表格的实现对提交的用户凭证没有什么作用。让我们在这个练习中做一些事情。首先,阅读rate-repository-api服务器的[认证文档](https://github.com/fullstack-hy2020/rate-repository-api#-authentication)并在Apollo沙盒中测试所提供的查询和改变。如果数据库没有任何用户,你可以用一些种子数据填充数据库。这方面的说明可以在README的[入门](https://github.com/fullstack-hy2020/rate-repository-api#-getting-started)部分找到。 + + + 一旦你搞清楚了认证的工作方式,在hooks目录下创建一个_useSignIn.js_文件。在该文件中实现一个useSignIn钩子,使用[useMutation](https://www.apollographql.com/docs/react/api/react/hooks/#usemutation)钩子发送authenticate改变。请注意,authenticate改变有一个单一参数,叫做credentials,它的类型是AuthenticateInput。这个[输入类型](https://graphql.org/graphql-js/mutations-and-input-types)包含用户名密码字段。 + + + 钩子的返回值应该是一个元组[signIn, result],其中result是改变的结果,因为它是由useMutation钩子返回的,signIn是运行改变的函数,有{ username, password }对象参数。提示:不要直接将改变函数传递给返回值。相反,返回一个调用改变函数的函数,像这样。 + +```javascript +const useSignIn = () => { + const [mutate, result] = useMutation(/* mutation arguments */); + + const signIn = async ({ username, password }) => { + // call the mutate function here with the right arguments + }; + + return [signIn, result]; +}; +``` + + + 一旦钩子实现,在SignIn组件的onSubmit回调中使用它,例如像这样。 + +```javascript +const SignIn = () => { + const [signIn] = useSignIn(); + + const onSubmit = async (values) => { + const { username, password } = values; + + try { + const { data } = await signIn({ username, password }); + console.log(data); + } catch (e) { + console.log(e); + } + }; + + // ... +}; +``` + + + 一旦你能在签到表格提交后记录用户的authenticate改变结果,这个练习就完成了。改变的结果应该包含用户的访问令牌。 + +#### Exercise 10.14: storing the access token step1 + + + 现在我们可以获得访问令牌,我们需要存储它。在utils目录下创建一个文件authStorage.js,内容如下。 + +```javascript +import AsyncStorage from '@react-native-async-storage/async-storage'; + +class AuthStorage { + constructor(namespace = 'auth') { + this.namespace = namespace; + } + + getAccessToken() { + // Get the access token for the storage + } + + setAccessToken(accessToken) { + // Add the access token to the storage + } + + removeAccessToken() { + // Remove the access token from the storage + } +} + +export default AuthStorage; +``` + + + 接下来,实现方法AuthStorage.getAccessTokenAuthStorage.setAccessTokenAuthStorage.removeAccessToken。使用namespace变量给你的键一个命名空间,就像我们在前面的例子中做的那样。 + +
    + +
    + +### Enhancing Apollo Client's requests + + + 现在我们已经实现了用于存储用户访问令牌的存储,是时候开始使用它了。在App组件中初始化存储。 + +```javascript +import { NativeRouter } from 'react-router-native'; +import { ApolloProvider } from '@apollo/client'; + +import Main from './src/components/Main'; +import createApolloClient from './src/utils/apolloClient'; +import AuthStorage from './src/utils/authStorage'; // highlight-line + +const authStorage = new AuthStorage(); // highlight-line +const apolloClient = createApolloClient(authStorage); // highlight-line + +const App = () => { + return ( + + +
    + + + ); +}; + +export default App; +``` + + + 我们还为createApolloClient函数提供了存储实例作为一个参数。这是因为接下来,我们将在每个请求中向Apollo服务器发送访问令牌。Apollo服务器将期望访问令牌存在于Authorization头中,格式为Bearer 。我们可以通过使用[setContext](https://www.apollographql.com/docs/react/api/link/apollo-link-context/s)函数增强Apollo客户端的请求。让我们通过修改apolloClient.js文件中的createApolloClient函数将访问令牌发送给Apollo服务器。 + +```javascript +import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client'; +import Constants from 'expo-constants'; +import { setContext } from '@apollo/client/link/context'; // highlight-line + +// You might need to change this depending on how you have configured the Apollo Server's URI +const { apolloUri } = Constants.manifest.extra; + +const httpLink = createHttpLink({ + uri: apolloUri, +}); + +// highlight-start +const createApolloClient = (authStorage) => { + const authLink = setContext(async (_, { headers }) => { + try { + const accessToken = await authStorage.getAccessToken(); + + return { + headers: { + ...headers, + authorization: accessToken ? `Bearer ${accessToken}` : '', + }, + }; + } catch (e) { + console.log(e); + + return { + headers, + }; + } + }); + + return new ApolloClient({ + link: authLink.concat(httpLink), + cache: new InMemoryCache(), + }); +}; +// highlight-end + +export default createApolloClient; +``` + +### Using React Context for dependency injection + + + 签到拼图的最后一块是将存储整合到useSignIn挂钩。为了实现这一点,挂钩必须能够访问我们在App组件中初始化的token存储实例。React [Context](https://reactjs.org/docs/context.html)正是我们需要的工作工具。在src目录下创建一个目录contexts。在该目录中创建一个文件AuthStorageContext.js,内容如下。 + +```javascript +import React from 'react'; + +const AuthStorageContext = React.createContext(); + +export default AuthStorageContext; +``` + + + 现在我们可以使用AuthStorageContext.Provider来为上下文的后代提供存储实例。让我们把它添加到App组件中。 + +```javascript +import { NativeRouter } from 'react-router-native'; +import { ApolloProvider } from '@apollo/client'; + +import Main from './src/components/Main'; +import createApolloClient from './src/utils/apolloClient'; +import AuthStorage from './src/utils/authStorage'; +import AuthStorageContext from './src/contexts/AuthStorageContext'; // highlight-line + +const authStorage = new AuthStorage(); +const apolloClient = createApolloClient(authStorage); + +const App = () => { + return ( + + + // highlight-line +
    + // highlight-line + + + ); +}; + +export default App; +``` + + + 在useSignIn钩中访问存储实例,现在可以使用React's [useContext](https://reactjs.org/docs/hooks-reference.html#usecontext)钩,像这样。 + +```javascript +// ... +import { useContext } from 'react'; // highlight-line + +import AuthStorageContext from '../contexts/AuthStorageContext'; //highlight-line + +const useSignIn = () => { + const authStorage = useContext(AuthStorageContext); //highlight-line + // ... +}; +``` + + + 注意,使用useContext钩子访问一个上下文的值,只有当useContext钩子被用于一个[Context.Provider](https://reactjs.org/docs/context.html#contextprovider)组件的后裔的组件时,才会生效。 + + + 用useContext(AuthStorageContext)访问AuthStorage实例是相当啰嗦的,而且会暴露实现的细节。让我们通过在hooks目录下的useAuthStorage.js文件中实现一个useAuthStorage钩子来改进。 + +```javascript +import { useContext } from 'react'; + +import AuthStorageContext from '../contexts/AuthStorageContext'; + +const useAuthStorage = () => { + return useContext(AuthStorageContext); +}; + +export default useAuthStorage; +``` + + + 该钩子的实现相当简单,但它提高了使用它的钩子和组件的可读性和可维护性。我们可以用这个钩子来重构useSignIn钩子,像这样。 + +```javascript +// ... +import useAuthStorage from '../hooks/useAuthStorage'; // highlight-line + +const useSignIn = () => { + const authStorage = useAuthStorage(); //highlight-line + // ... +}; +``` + + + 为组件的后代提供数据的能力为React Context打开了大量的用例。要了解更多关于这些用例的信息,请阅读Kent C. Dodds的启发性文章[How to use React Context effectively](https://kentcdodds.com/blog/how-to-use-react-context-effectively),了解如何将[useReducer](https://reactjs.org/docs/hooks-reference.html#usereducer)钩子与上下文结合起来,实现状态管理。你可能会在接下来的练习中找到使用这些知识的方法。 + +
    + +
    + +### Exercises 10.15. - 10.16. + +#### Exercise 10.15: storing the access token step2 + + + 改进useSignIn钩子,使其存储从authenticate改变中获取的用户访问令牌。该钩子的返回值不应该改变。你应该对SignIn组件做出的唯一改变是,你应该在成功登录后将用户重定向到已审核的存储库列表视图。你可以通过使用[useNavigate](https://reactrouter.com/en/main/hooks/use-navigate)钩子来实现这一点。 + + + 在authenticate改变被执行后,你已经将用户的访问令牌存储到存储区,你应该重置Apollo客户端的存储。这将清除Apollo客户端的缓存并重新执行所有活动的查询。你可以通过使用Apollo客户端的[resetStore](https://www.apollographql.com/docs/react/api/core/ApolloClient/#ApolloClient.resetStore)方法来做到这一点。你可以使用[useApolloClient](https://www.apollographql.com/docs/react/api/react/hooks/#useapolloclient)钩子访问useSignIn钩子中的Apollo客户端。注意,执行的顺序很关键,应该是这样的。 + +```javascript +const { data } = await mutate(/* options */); +await authStorage.setAccessToken(/* access token from the data */); +apolloClient.resetStore(); +``` + +#### Exercise 10.16: sign out + + + 完成签入功能的最后一步是实现签出功能。me查询可以用来检查已认证用户的信息。如果查询的结果是null,这意味着用户没有被认证。打开Apollo沙盒,运行以下查询。 + +```javascript +{ + me { + id + username + } +} +``` + + + 你可能最终会得到null的结果。这是因为Apollo沙盒没有被认证,意味着它没有随请求发送一个有效的访问令牌。修改[认证文档](https://github.com/fullstack-hy2020/rate-repository-api#-authentication)并使用authenticate改变检索一个访问令牌。按照文档中的指示,在_Authorization_标头中使用这个访问令牌。现在,再次运行me查询,你应该能够看到已认证用户的信息。 + + + 打开AppBar.jsx文件中的AppBar组件,目前有 "Repositories "和 "Sign in "标签。改变这些标签,如果用户已经登录,就显示 "退出 "标签,否则就显示 "登录 "标签。你可以通过使用带有[useQuery](https://www.apollographql.com/docs/react/api/react/hooks/#usequery)钩子的me查询来实现这一点。 + + + 按下 "签出 "标签应该从存储中删除用户的访问令牌,并用[resetStore](https://www.apollographql.com/docs/react/api/core/ApolloClient/#ApolloClient.resetStore)方法重置Apollo客户端的存储。调用resetStore方法应该自动重新执行所有活动的查询,这意味着me查询应该被重新执行。请注意,执行的顺序是至关重要的:访问令牌必须在Apollo客户端的存储被重置之前从存储中移除。 + + + 这是本节的最后一个练习。现在是时候把你的代码推送到GitHub,并把你所有完成的练习标记到[练习提交系统](https://studies.cs.helsinki.fi/stats/courses/fs-react-native-2020)。注意,本节的练习应该提交到练习提交系统中的第3章节。 +
    diff --git a/src/content/10/zh/part10d.md b/src/content/10/zh/part10d.md new file mode 100644 index 00000000000..9ecb4549b18 --- /dev/null +++ b/src/content/10/zh/part10d.md @@ -0,0 +1,1248 @@ +--- +mainImage: ../../../images/part-10.svg +part: 10 +letter: d +lang: zh +--- + +
    + + + 现在我们已经为我们的项目建立了一个良好的基础,是时候开始扩展它了。在这一节中,你可以把你到目前为止获得的所有React Native知识运用起来。在扩展我们的应用的同时,我们将涵盖一些新的领域,如测试,和额外的资源。 + +### Testing React Native applications + + + 要开始测试任何类型的代码,我们首先需要的是一个测试框架,我们可以用它来运行一组测试案例并检查其结果。对于测试JavaScript应用,[Jest](https://jestjs.io/)是这种测试框架的一个流行的候选人。对于用Jest测试基于Expo的React Native应用,Expo以[jest-expo](https://github.com/expo/expo/tree/master/packages/jest-expo)预设的形式提供了一套Jest配置。为了在Jest的测试文件中使用ESLint,我们还需要ESLint的[eslint-plugin-jest](https://www.npmjs.com/package/eslint-plugin-jest)插件。让我们开始安装这些软件包。 + +```shell +npm install --save-dev jest jest-expo eslint-plugin-jest +``` + + + 为了在Jest中使用jest-expo预设,我们需要在package.json文件中加入以下Jest配置,同时加入test脚本。 + +```javascript +{ + // ... + "scripts": { + // other scripts... + "test": "jest" // highlight-line + }, + // highlight-start + "jest": { + "preset": "jest-expo", + "transform": { + "^.+\\.jsx?$": "babel-jest" + }, + "transformIgnorePatterns": [ + "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|react-router-native)" + ] + }, + // highlight-end + // ... +} +``` + + + transform选项告诉Jest用[Babel](https://babeljs.io/)编译器来转换.js.jsx文件。transformIgnorePatterns选项用于在转换文件时忽略node_modules目录中的某些目录。这个Jest配置与Expo's [document](https://docs.expo.dev/develop/unit-testing/)中提出的配置几乎相同。 + + + 为了在ESLint中使用eslint-plugin-jest插件,我们需要把它包含在.eslintrc文件的插件和扩展数组中。 + +```javascript +{ + "plugins": ["react", "react-native"], + "settings": { + "react": { + "version": "detect" + } + }, + "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:jest/recommended"], // highlight-line + "parser": "@babel/eslint-parser", + "env": { + "react-native/react-native": true + }, + "rules": { + "react/prop-types": "off", + "react/react-in-jsx-scope": "off" + } +} +``` + + + 要看到这个设置是有效的,在src目录下创建一个目录\_\_tests\_,并在创建的目录下创建一个文件example.js。在该文件中,添加这个简单的测试。 + +```javascript +describe('Example', () => { + it('works', () => { + expect(1).toBe(1); + }); +}); +``` + + + 现在,让我们通过运行npm test来运行我们的测试实例。该命令的输出应该表明,位于src/_\_tests\_/example.js文件中的测试已经通过。 + +### Organizing tests + + + 在一个单一的\__tests\_目录中组织测试文件是组织测试的一种方法。当选择这种方法时,建议把测试文件放在其相应的子目录中,就像代码本身一样。这意味着,例如与组件相关的测试放在components目录下,与实用程序相关的测试放在utils目录下,等等。这将导致以下的结构。 + +``` +src/ + __tests__/ + components/ + AppBar.js + RepositoryList.js + ... + utils/ + authStorage.js + ... + ... +``` + + + 另一种方法是在实现附近组织测试。这意味着,例如,包含AppBar组件测试的测试文件与该组件的代码在同一目录下。这将导致以下的结构。 + +``` +src/ + components/ + AppBar/ + AppBar.test.jsx + index.jsx + ... + ... +``` + + + 在这个例子中,组件的代码在index.jsx文件中,测试在AppBar.test.jsx文件中。注意,为了让Jest找到你的测试文件,你必须把它们放到\__tests\_目录下,使用.test.spec后缀,或者[手动配置](https://jestjs.io/docs/en/configuration#testmatch-arraystring)全局模式。 + +### Testing components + + + 现在我们已经成功地设置了Jest并运行了一个非常简单的测试,现在是时候了解如何测试组件了。正如我们所知,测试组件需要一种方法来序列化一个组件的渲染输出,并模拟发射不同类型的事件,如按下按钮。为了这些目的,有一个[测试库](https://testing-library.com/docs/intro)系列,它提供了用于测试不同平台上的用户界面组件的库。所有这些库都共享类似的API,用于以用户为中心的方式测试用户界面组件。 + + + 在[第五章节](/en/part5/testing_react_apps)中,我们熟悉了这些库中的一个,即[React Testing Library](https://testing-library.com/docs/react-testing-library/intro)。不幸的是,这个库只适用于测试React网络应用。幸运的是,这个库存在一个与React Native对应的库,那就是[React Native Testing Library](https://callstack.github.io/react-native-testing-library/)。这就是我们在测试React Native应用的组件时要使用的库。好消息是,这些库共享一个非常相似的API,所以没有太多的新概念需要学习。除了React Native测试库,我们还需要一组React Native特定的Jest匹配器,如toHaveTextContenttoHaveProp。这些匹配器由[jest-native](https://github.com/testing-library/jest-native)库提供。在进入细节之前,让我们先安装这些包。 + +```shell +npm install --save-dev react-test-renderer@17.0.1 @testing-library/react-native @testing-library/jest-native +``` + + + **NB:** 如果你面临同行的依赖问题,请确保react-test-renderer的版本与上述npm install命令中的项目的React版本相匹配。你可以通过运行npm list react --depth=0检查React版本。 + + + 如果安装失败是由于对等延迟问题,请使用--legacy-peer-deps标志与npm install命令再次尝试。 + + + 为了能够使用这些匹配器,我们需要扩展Jest的expect对象。这可以通过使用一个全局设置文件来完成。在你项目的根目录下创建一个文件setupTests.js,也就是package.json文件所在的同一目录。在该文件中添加以下一行。 + +```javascript +import '@testing-library/jest-native/extend-expect'; +``` + + + 接下来,在package.json文件中把这个文件配置为Jest's配置的设置文件(注意,路径中的是故意的,不需要替换)。 + +```javascript +{ + // ... + "jest": { + "preset": "jest-expo", + "transform": { + "^.+\\.jsx?$": "babel-jest" + }, + "transformIgnorePatterns": [ + "node_modules/(?!(jest-)?react-native|react-clone-referenced-element|@react-native-community|expo(nent)?|@expo(nent)?/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|@sentry/.*|react-router-native)" + ], + "setupFilesAfterEnv": ["/setupTests.js"] // highlight-line + } + // ... +} +``` + + + React Native测试库的主要概念是[query](https://callstack.github.io/react-native-testing-library/docs/api/queries)和[firing events](https://callstack.github.io/react-native-testing-library/docs/api#fireevent)。查询是用来从使用[render](https://callstack.github.io/react-native-testing-library/docs/api#render)函数渲染的组件中提取一组节点的。查询在测试中非常有用,例如,我们希望一些文本,如仓库的名称,能出现在渲染的组件中。下面是一个例子,如何使用[ByText](https://callstack.github.io/react-native-testing-library/docs/api/queries/#bytext)查询来检查组件的Text元素是否有正确的文本内容。 + +```javascript +import { Text, View } from 'react-native'; +import { render } from '@testing-library/react-native'; + +const Greeting = ({ name }) => { + return ( + + Hello {name}! + + ); +}; + +describe('Greeting', () => { + it('renders a greeting message based on the name prop', () => { + const { debug, getByText } = render(); + + debug(); + + expect(getByText('Hello Kalle!')).toBeDefined(); + }); +}); +``` + + + React Native Testing Library's documentation有一些关于[如何查询不同种类的元素](https://callstack.github.io/react-native-testing-library/docs/guides/how-to-query)的好提示。另一个值得阅读的指南是Kent C. Dodds的文章[Making your UI tests resilient to change](https://kentcdodds.com/blog/making-your-ui-tests-resilient-to-change)。 + + + render函数返回查询和额外的辅助工具,例如debug函数。[debug](https://callstack.github.io/react-native-testing-library/docs/api#debug)函数以用户友好的格式打印出渲染的React树。如果你不确定render函数所渲染的React树是什么样子的,可以使用它。我们通过使用getByText函数获得包含某些文本的Text节点。关于所有可用的查询,请查看React Native Testing Library's [document](https://callstack.github.io/react-native-testing-library/docs/api/queries)。toHaveTextContent匹配器用于断定节点的文本内容是正确的。可用的React Native特定匹配器的完整列表可以在jest-native库的[文档](https://github.com/testing-library/jest-native#matchers)中找到。Jest's [document](https://jestjs.io/docs/en/expect) 包含了所有通用的Jest匹配器。 + + + 第二个非常重要的React Native测试库概念是发射事件。我们可以通过使用[fireEvent](https://callstack.github.io/react-native-testing-library/docs/api#fireevent)对象的方法在所提供的节点中触发一个事件。这对于在文本字段中输入文本或按下按钮是很有用的。下面是一个如何测试提交一个简单表单的例子。 + +```javascript +import { useState } from 'react'; +import { Text, TextInput, Pressable, View } from 'react-native'; +import { render, fireEvent } from '@testing-library/react-native'; + +const Form = ({ onSubmit }) => { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + + const handleSubmit = () => { + onSubmit({ username, password }); + }; + + return ( + + + setUsername(text)} + placeholder="Username" + /> + + + setPassword(text)} + placeholder="Password" + /> + + + + Submit + + + + ); +}; + +describe('Form', () => { + it('calls function provided by onSubmit prop after pressing the submit button', () => { + const onSubmit = jest.fn(); + const { getByPlaceholderText, getByText } = render(); + + fireEvent.changeText(getByPlaceholderText('Username'), 'kalle'); + fireEvent.changeText(getByPlaceholderText('Password'), 'password'); + fireEvent.press(getByText('Submit')); + + expect(onSubmit).toHaveBeenCalledTimes(1); + + // onSubmit.mock.calls[0][0] contains the first argument of the first call + expect(onSubmit.mock.calls[0][0]).toEqual({ + username: 'kalle', + password: 'password', + }); + }); +}); +``` + + + 在这个测试中,我们要测试在使用fireEvent.changeText方法填充表单字段并使用fireEvent.press方法按下提交按钮后,onSubmit回调函数被正确调用。为了检查onSubmit函数是否被调用,以及使用哪些参数,我们可以使用一个[mock function](https://jestjs.io/docs/en/mock-function-api)。模拟函数是具有预编程行为的函数,例如一个特定的返回值。此外,我们还可以为模拟函数创建期望值,如 "期望模拟函数被调用一次"。可用期望的完整列表可以在Jest's [expect documentation](https://jestjs.io/docs/en/expect)中找到。 + + + 在进一步进入测试React Native应用的世界之前,通过在我们之前创建的\__tests\_目录中添加一个测试文件来玩玩这些例子。 + +### Handling dependencies in tests + + + 前面例子中的组件很容易测试,因为它们或多或少都是的。纯粹的组件不依赖于副作用,如网络请求或使用一些本地API,如AsyncStorage。Form组件比Greeting组件更不纯粹,因为它的状态变化可以被算作一个副作用。尽管如此,测试它并不难。 + + + 接下来,让我们看一下测试有副作用的组件的策略。让我们从我们的应用中挑选RepositoryList组件作为例子。目前,该组件有一个副作用,即用于获取审查过的存储库的GraphQL查询。目前RepositoryList组件的实现看起来是这样的。 + +```javascript +const RepositoryList = () => { + const { repositories } = useRepositories(); + + const repositoryNodes = repositories + ? repositories.edges.map((edge) => edge.node) + : []; + + return ( + + ); +}; + +export default RepositoryList; +``` + + + 唯一的副作用是使用useRepositories钩子,它发送了一个GraphQL查询。有几种方法可以测试这个组件。一种方法是按照Apollo客户端的[文档](https://www.apollographql.com/docs/react/development-testing/testing/)中的指示模拟Apollo客户端的响应。一个更简单的方法是假设useRepositories钩子按预期工作(最好是通过测试),并将组件的 "纯 "代码提取到另一个组件中,如RepositoryListContainer组件。 + +```javascript +export const RepositoryListContainer = ({ repositories }) => { + const repositoryNodes = repositories + ? repositories.edges.map((edge) => edge.node) + : []; + + return ( + + ); +}; + +const RepositoryList = () => { + const { repositories } = useRepositories(); + + return ; +}; + +export default RepositoryList; +``` + + + 现在,RepositoryList组件只包含副作用,它的实现也很简单。我们可以测试RepositoryListContainer组件,通过repositoriesprop向它提供分页的仓库数据,并检查渲染的内容是否有正确的信息。 + +
    + +
    + +### Exercises 10.17. - 10.18. + +#### Exercise 10.17: testing the reviewed repositories list + + + 实现一个测试,确保RepositoryListContainer组件正确渲染版本库的名称、描述、语言、fork数、stargazers数、平均评分和评论数。实现这个测试的一个方法是为包裹单个版本库信息的元素添加一个[testID](https://reactnative.dev/docs/view#testid)prop。 + +```javascript +const RepositoryItem = (/* ... */) => { + // ... + + return ( + + {/* ... */} + + ) +}; +``` + + + 一旦添加了testIDprop,你可以使用[getAllByTestId](https://callstack.github.io/react-native-testing-library/docs/api/queries#getallby)查询来获得这些元素。 + +```javascript +const repositoryItems = getAllByTestId('repositoryItem'); +const [firstRepositoryItem, secondRepositoryItem] = repositoryItems; + +// expect something from the the first and the second repository item +``` + + + 有了这些元素,你可以使用[toHaveTextContent](https://github.com/testing-library/jest-native#tohavetextcontent)匹配器来检查一个元素是否有某些文本内容。你可能还会发现[在元素内查询](https://testing-library.com/docs/dom-testing-library/api-within/)指南很有用。如果你不确定正在渲染的内容,使用[debug](https://callstack.github.io/react-native-testing-library/docs/api#debug)函数来查看序列化的渲染结果。 + + + 将此作为你测试的基础。 + +```javascript +describe('RepositoryList', () => { + describe('RepositoryListContainer', () => { + it('renders repository information correctly', () => { + const repositories = { + totalCount: 8, + pageInfo: { + hasNextPage: true, + endCursor: + 'WyJhc3luYy1saWJyYXJ5LnJlYWN0LWFzeW5jIiwxNTg4NjU2NzUwMDc2XQ==', + startCursor: 'WyJqYXJlZHBhbG1lci5mb3JtaWsiLDE1ODg2NjAzNTAwNzZd', + }, + edges: [ + { + node: { + id: 'jaredpalmer.formik', + fullName: 'jaredpalmer/formik', + description: 'Build forms in React, without the tears', + language: 'TypeScript', + forksCount: 1619, + stargazersCount: 21856, + ratingAverage: 88, + reviewCount: 3, + ownerAvatarUrl: + 'https://avatars2.githubusercontent.com/u/4060187?v=4', + }, + cursor: 'WyJqYXJlZHBhbG1lci5mb3JtaWsiLDE1ODg2NjAzNTAwNzZd', + }, + { + node: { + id: 'async-library.react-async', + fullName: 'async-library/react-async', + description: 'Flexible promise-based React data loader', + language: 'JavaScript', + forksCount: 69, + stargazersCount: 1760, + ratingAverage: 72, + reviewCount: 3, + ownerAvatarUrl: + 'https://avatars1.githubusercontent.com/u/54310907?v=4', + }, + cursor: + 'WyJhc3luYy1saWJyYXJ5LnJlYWN0LWFzeW5jIiwxNTg4NjU2NzUwMDc2XQ==', + }, + ], + }; + + // Add your test code here + }); + }); +}); +``` + + + 你可以把测试文件放在你喜欢的地方。然而,建议遵循前面介绍的组织测试文件的方法之一。使用repositories变量作为测试的仓库数据。应该不需要改变该变量的值。注意,版本库数据包含两个版本库,这意味着你需要检查两个版本库的信息是否存在。 + +#### Exercise 10.18: testing the sign in form + + + 执行一个测试,确保填写登录表的用户名和密码字段并按下提交按钮后,将调用onSubmit处理程序,正确的参数。处理程序的第一个参数应该是一个代表表单值的对象。你可以忽略该函数的其他参数。记住,[fireEvent](https://callstack.github.io/react-native-testing-library/docs/api#fireevent)方法可用于触发事件,[mock function](https://jestjs.io/docs/en/mock-function-api)可用于检查onSubmit处理器是否被调用。 + + + 你不需要测试任何Apollo客户端或AsyncStorage相关的代码,这些代码在useSignIn钩子中。就像之前的练习一样,将纯代码提取到它自己的组件中,并在测试中进行测试。 + + + 注意,Formik的表单提交是异步的,所以期望onSubmit函数在按下提交按钮后立即被调用是不行的。你可以通过使用async关键字使测试函数成为异步函数并使用React Native测试库的[waitFor](https://callstack.github.io/react-native-testing-library/docs/api#waitfor)辅助函数来解决这个问题。waitFor函数可以用来等待预期通过。如果预期在一定时间内没有通过,该函数将抛出一个错误。下面是一个关于如何使用它的粗略例子。 + +```javascript +import { render, fireEvent, waitFor } from '@testing-library/react-native'; +// ... + +describe('SignIn', () => { + describe('SignInContainer', () => { + it('calls onSubmit function with correct arguments when a valid form is submitted', async () => { + // render the SignInContainer component, fill the text inputs and press the submit button + + await waitFor(() => { + // expect the onSubmit function to have been called once and with a correct first argument + }); + }); + }); +}); +``` +
    + +
    + +### Extending our application + + + 现在是时候把我们到目前为止所学到的东西都用好,开始扩展我们的应用了。我们的应用仍然缺乏一些重要的功能,如审查一个仓库和注册一个用户。接下来的练习将集中在这些基本功能上。 + +
    + +
    + +### Exercises 10.19. - 10.24. + +#### Exercise 10.19: the single repository view + + + 为单个仓库实现一个视图,其中包含与审查仓库列表中相同的仓库信息,但也有一个在GitHub中打开仓库的按钮。重用RepositoryList组件中使用的RepositoryItem组件并显示GitHub仓库按钮是个好主意,例如基于一个布尔prop。 + + + 仓库的URL在GraphQL模式中的url类型的Repository字段中。你可以用repository查询从Apollo服务器获取一个单一的仓库。该查询有一个参数,就是仓库的id。下面是一个repository查询的简单例子。 + +```javascript +{ + repository(id: "jaredpalmer.formik") { + id + fullName + url + } +} +``` + + + 一如既往,在你的应用中使用你的查询之前,先在Apollo沙盒中测试你的查询。如果你不确定GraphQL模式或哪些是可用的查询,请看一下操作编辑器旁边的文档。如果你在使用id作为查询中的变量时遇到困难,花点时间研究一下Apollo客户端's [document](https://www.apollographql.com/docs/react/data/queries/) 关于查询的内容。 + + + 要学习如何在浏览器中打开一个URL,请阅读Expo's [Linking API documentation](https://docs.expo.dev/versions/latest/sdk/linking/)。在实现GitHub中打开仓库的按钮时,你会需要这个功能。提示:[Linking.openURL](https://docs.expo.dev/versions/latest/sdk/linking/#linkingopenurlurl)方法会派上用场。 + + + 视图应该有自己的路由。最好是在路由的路径中定义仓库的id作为路径参数,你可以通过使用[useParams](https://reactrouter.com/en/main/hooks/use-params)钩子来访问它。用户应该能够通过点击审查过的版本库列表中的版本库来访问该视图。你可以通过例如在RepositoryList组件中用[Pressable](https://reactnative.dev/docs/pressable)组件包装RepositoryItem,并使用navigate函数在onPress事件处理器中改变路线来实现。你可以用[useNavigate](https://reactrouter.com/en/main/hooks/use-navigate)钩子访问navigate函数。 + + + 单一仓库视图的最终版本应该是这样的。 + +![Application preview](../../images/10/13.jpg) + +#### Exercise 10.20: repository's review list + + + 现在我们有了一个单一仓库的视图,让我们在这里显示仓库的评论。仓库的评论在 GraphQL 模式中的 reviews 类型的 Repository 字段中。reviews 是一个类似于 repositories 查询中的分页列表。下面是一个获取存储库评论的例子。 + +```javascript +{ + repository(id: "jaredpalmer.formik") { + id + fullName + reviews { + edges { + node { + id + text + rating + createdAt + user { + id + username + } + } + } + } + } +} +``` + + + Review's text字段包含文本评论,rating字段是0-100之间的数字评分,createdAt是创建评论的日期。评论的user字段包含评论者的信息,它的类型是User。 + + + 我们想把评论显示为一个可滚动的列表,这使得[FlatList](https://reactnative.dev/docs/flatlist)成为一个合适的组件来完成这项工作。为了在列表的顶部显示前一个练习''版本库的信息,你可以使用FlatList组件[ListHeaderComponent](https://reactnative.dev/docs/flatlist#listheadercomponent)prop。你可以使用[ItemSeparatorComponent](https://reactnative.dev/docs/flatlist#itemseparatorcomponent)来在项目之间添加一些空间,就像在RepositoryList组件中一样。这里's一个结构的例子。 + +```javascript +const RepositoryInfo = ({ repository }) => { + // Repository's information implemented in the previous exercise +}; + +const ReviewItem = ({ review }) => { + // Single review item +}; + +const SingleRepository = () => { + // ... + + return ( + } + keyExtractor={({ id }) => id} + ListHeaderComponent={() => } + // ... + /> + ); +}; + +export default SingleRepository; +``` + + + 最终版本的版本库的评论列表应该是这样的。 + +![Application preview](../../images/10/14.jpg) + + + 评审员用户名下的日期是评审的创建日期,在Review类型的createdAt字段中。日期格式应该是用户友好的,如date.month.year。例如,你可以安装[date-fns](https://date-fns.org/)库并使用[format](https://date-fns.org/v2.28.0/docs/format)函数来格式化创建日期。 + + + 评级''的容器的圆形可以通过borderRadius样式属性来实现。你可以通过固定容器的widthheight样式属性,并将border-radius设置为width / 2,使其成为圆形。 + +#### Exercise 10.21: the review form + + + 使用Formik实现一个用于创建评论的表单。该表单应该有四个字:仓库所有者的GitHub用户名(例如 "jaredpalmer")、仓库的名称(例如 "formik")、数字评分和文本评论。使用Yup模式验证这些字段,使其包含以下验证。 + + + - 存储库所有者的用户名是一个必要的字符串 + + - 仓库的名称是一个必要的字符串 + + - 评价是0到100之间的必要数字 + + - 评论是一个可选的字符串 + + + 探索 Yup's [document](https://github.com/jquense/yup#yup) 以找到合适的验证器。在验证器中使用合理的错误信息。验证信息可以被定义为验证器方法的message参数。你可以通过使用TextInput组件的[multiline](https://reactnative.dev/docs/textinput#multiline)prop使审查字段扩展到多行。 + + + 你可以使用createReview改变创建一个评论。在Apollo沙盒中检查这个改变的参数。你可以使用[useMutation](https://www.apollographql.com/docs/react/api/react/hooks/#usemutation)钩子来发送一个改变到Apollo服务器。 + + + 在一个成功的createReview改变之后,将用户重定向到你在前面练习中实现的版本库的视图。这可以在你使用[useNavigate](https://reactrouter.com/en/main/hooks/use-navigate)钩子获得navigate函数后完成。创建的审查有一个repositoryId字段,你可以用它来构建路由's路径。 + + + 为了防止在单一版本库视图中通过repository查询获得缓存数据,在查询中使用_cache-and-network_ [fetch policy](https://www.apollographql.com/docs/react/data/queries/#setting-a-fetch-policy)。它可以像这样与useQuery钩子一起使用。 + +```javascript +useQuery(GET_REPOSITORY, { + fetchPolicy: 'cache-and-network', + // Other options +}); +``` + + + 注意,只有一个现有的公共 GitHub 仓库可以被审查,而且一个用户只能审查同一个仓库一次。你不需要处理这些错误情况,但错误的有效载荷包括这些错误的具体代码和信息。你可以通过审查你自己的一个公共仓库或任何其他公共仓库来尝试你的实现。 + + + 评审表应该可以通过应用栏访问。在应用栏中创建一个标签 "创建评论"。这个标签应该只对已经登录的用户可见。你还需要为审查表定义一个路径。 + + + 最终版本的评论表应该是这样的。 + +![Application preview](../../images/10/15.jpg) + + + 这张截图是在无效的表单提交后拍摄的,以展示表单在无效状态下的样子。 + +#### Exercise 10.22: the sign up form + + + 使用Formik实现一个注册用户的表单。这个表单应该有三个字:用户名、密码和密码确认。使用Yup模式验证该表单,使其包含以下验证。 + + + - 用户名是一个长度在1到30之间的必填字符串 + + - 密码是一个必需的字符串,长度在5到50之间 + + - 密码确认与密码相符 + + + 密码确认字段的验证可能有点棘手,但它可以通过使用[oneOf](https://github.com/jquense/yup#schemaoneofarrayofvalues-arrayany-message-string--function-schema-alias-equals)和[ref](https://github.com/jquense/yup#refpath-string-options--contextprefix-string--ref)方法来完成,就像在[本期](https://github.com/jaredpalmer/formik/issues/90#issuecomment-354873201)中建议的那样。 + + + 你可以通过使用createUser改变来创建一个新用户。通过探索Apollo沙盒中的文档来了解这个改变是如何工作的。在createUser改变成功后,通过使用useSignIn钩子将创建的用户签入,就像我们在签入表格中做的那样。在用户登录后,将用户重定向到已审查的存储库列表视图。 + + + 用户应该能够通过应用栏按下 "注册 "标签来访问注册表。这个标签应该只对未登录的用户可见。 + + + 最终版本的注册表应该如下所示: + +![Application preview](../../images/10/16.jpg) + + + 这张截图是在无效的表格提交后拍摄的,以渲染表格在无效状态下的样子。 + +#### Exercise 10.23: sorting the reviewed repositories list + + + 目前,在已审查的软件库列表中,软件库是按照软件库的首次审查日期排序的。实现一个允许用户选择原则的功能,这个原则是用来排序存储库的。可用的排序原则应该是。 + + + - 最新资料库。有最新的第一次审查的仓库在列表的顶部。这是目前的行为,应该是默认的原则。 + + - 评分最高的软件库。具有最高平均评分的版本库在列表的顶部。 + + - 评分最低的软件库。具有最低平均评分的仓库在列表的顶部。 + + + 用于获取已审核的软件库的repositories查询有一个参数叫orderBy,你可以用它来定义排序原则。该参数有两个允许的值。CREATED\_AT(按版本库第一次评论的日期排序)和RATING\_AVERAGE,(按版本库的平均评分排序)。该查询还有一个参数叫orderDirection,可以用来改变排序方向。该参数有两个允许的值。ASC(升序,最小值在前)和DESC(降序,最大值在前)。 + + + 选定的排序原则状态可以通过React's [useState](https://reactjs.org/docs/hooks-reference.html#usestate)钩子来维护。在repositories查询中使用的变量可以作为一个参数给到useRepositories钩子。 + + + 你可以使用例如[@react-native-picker/picker](https://docs.expo.io/versions/latest/sdk/picker/)库,或者[React Native Paper](https://callstack.github.io/react-native-paper/)库的[Menu](https://callstack.github.io/react-native-paper/menu.html)组件来实现排序原则'的选择。你可以使用FlatList组件的[ListHeaderComponent](https://reactnative.dev/docs/flatlist#listheadercomponent)prop来为列表提供一个包含选择组件的头。 + + + 该功能的最终版本,取决于使用的选择组件,应该是这样的。 + +![Application preview](../../images/10/17.jpg) + +#### Exercise 10.24: filtering the reviewed repositories list + + + Apollo服务器允许使用版本库的名称或所有者的用户名来过滤版本库。这可以通过repositories查询中的searchKeyword参数来完成。下面是一个如何在查询中使用该参数的例子。 + +```javascript +{ + repositories(searchKeyword: "ze") { + edges { + node { + id + fullName + } + } + } +} +``` + + + 实现一个基于关键字过滤已审查的存储库列表的功能。用户应该能够在文本输入中键入一个关键词,并且在用户键入时对列表进行过滤。你可以使用一个简单的TextInput组件或更高级的东西,如React Native Paper's [Searchbar](https://callstack.github.io/react-native-paper/searchbar.html) 组件作为文本输入。把文本输入组件放在FlatList组件的头里。 + + +为了避免在用户快速输入关键词时出现大量不必要的请求,只在短暂的延迟后挑选最新的输入。这种技术通常被称为[debouncing](https://lodash.com/docs/4.17.15#debounce)。[use-debounce](https://www.npmjs.com/package/use-debounce)库是一个方便的钩子,用于调试一个状态变量。使用它时要有一个合理的延迟时间,比如500毫秒。通过使用useState钩子来存储文本输入的值,并将去弹的值作为searchKeyword参数的值传递给查询。 + + + 你可能面临一个问题,即文本输入组件在每次击键后都会失去焦点。这是因为由ListHeaderComponentprop提供的内容不断地被取消。这可以通过将渲染FlatList组件的组件变成一个类组件,并将标题的渲染功能定义为一个类属性来解决,就像这样。 + +```javascript +export class RepositoryListContainer extends React.Component { + renderHeader = () => { + // this.props contains the component's props + const props = this.props; + + // ... + + return ( + + ); + }; + + render() { + return ( + + ); + } +} +``` + + + 最终版本的过滤功能应该是这样的。 + +![Application preview](../../images/10/18.jpg) + +
    + +
    + +### Cursor-based pagination + + + 当API从某个集合中返回一个有序的项目列表时,它通常会返回整个项目集的一个子集,以减少所需的带宽,并降低客户端应用的内存使用。所需的项目子集可以被参数化,这样客户端就可以请求例如列表中某个索引后的前20个项目。这种技术通常被称为分页。当项目可以在由游标定义的某个项目之后被请求时,我们谈论的就是基于游标的分页。 + + + 所以游标只是一个有序列表中的项目的序列化渲染。让我们看一下由repositories查询返回的分页的存储库,使用以下查询。 + +```javascript +{ + repositories(first: 2) { + totalCount + edges { + node { + id + fullName + createdAt + } + cursor + } + pageInfo { + endCursor + startCursor + hasNextPage + } + } +} +``` + + + first参数告诉API只返回前两个存储库。下面是一个查询结果的例子。 + +```javascript +{ + "data": { + "repositories": { + "totalCount": 10, + "edges": [ + { + "node": { + "id": "zeit.next.js", + "fullName": "zeit/next.js", + "createdAt": "2020-05-15T11:59:57.557Z" + }, + "cursor": "WyJ6ZWl0Lm5leHQuanMiLDE1ODk1NDM5OTc1NTdd" + }, + { + "node": { + "id": "zeit.swr", + "fullName": "zeit/swr", + "createdAt": "2020-05-15T11:58:53.867Z" + }, + "cursor": "WyJ6ZWl0LnN3ciIsMTU4OTU0MzkzMzg2N10=" + } + ], + "pageInfo": { + "endCursor": "WyJ6ZWl0LnN3ciIsMTU4OTU0MzkzMzg2N10=", + "startCursor": "WyJ6ZWl0Lm5leHQuanMiLDE1ODk1NDM5OTc1NTdd", + "hasNextPage": true + } + } + } +} +``` + + + 结果对象和参数的格式是基于[Relay's GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm),它已经成为一个相当普遍的分页规范,并且已经被广泛采用,例如在[GitHub's GraphQL API](https://docs.github.com/en/graphql)。在结果对象中,我们有一个edges数组,包含有nodecursor属性的项目。正如我们所知,node包含存储库本身。另一方面,cursor是节点的一个Base64编码表示。在这种情况下,它包含版本库的ID和版本库的创建日期作为时间戳。这是我们所需要的信息,当他们按照版本库的创建时间排序时,就可以指向该项目。pageInfo包含诸如数组中第一个和最后一个项目的游标等信息。 + + + 假设我们想获得当前集合的最后一个项目之后的下一组项目,即 "zeit/swr "存储库的。我们可以将查询的after参数设置为endCursor的值,像这样。 + +```javascript +{ + repositories(first: 2, after: "WyJ6ZWl0LnN3ciIsMTU4OTU0MzkzMzg2N10=") { + totalCount + edges { + node { + id + fullName + createdAt + } + cursor + } + pageInfo { + endCursor + startCursor + hasNextPage + } + } +} +``` + + + 现在我们有了下两个项目,我们可以继续这样做,直到hasNextPage的值为false,意味着我们已经到达了列表的末端。要深入了解基于游标的分页,请阅读Shopify的文章[Pagination with Relative Cursors](https://shopify.engineering/pagination-relative-cursors)。它提供了关于实现本身和比传统的基于索引的分页更多的细节。 + +### Infinite scrolling + + + 移动和桌面应用中的垂直滚动列表通常使用一种叫做无限滚动的技术实现。无限滚动的原理非常简单。 + + + - 获取初始项目集 + + - 当用户到达最后一个项目时,获取最后一个项目之后的下一组项目 + + + 第二步重复进行,直到用户厌倦了滚动或超过了某种滚动限制。无限滚动 "这个名字是指列表似乎是无限的--用户可以一直滚动,新的项目不断出现在列表中。 + + + 让我们来看看在实践中如何使用Apollo客户端的useQuery钩。Apollo客户端在实现基于光标的分页方面有一个很好的[文档](https://www.apollographql.com/docs/react/pagination/cursor-based/)。让我们以审查过的仓库列表为例,实现无限滚动。 + + + 首先,我们需要知道用户何时到达了列表的末尾。幸运的是,FlatList组件有一个prop[onEndReached](https://reactnative.dev/docs/virtualizedlist#onendreached),一旦用户滚动到列表的最后一项,它将调用所提供的函数。你可以使用[onEndReachedThreshold](https://reactnative.dev/docs/virtualizedlist#onendreachedthreshold)这个prop来改变onEndReach回调的早期调用。改变RepositoryList组件的FlatList组件,使其在达到列表的末端时调用一个函数。 + +```javascript +export const RepositoryListContainer = ({ + repositories, + onEndReach, + /* ... */, +}) => { + const repositoryNodes = repositories + ? repositories.edges.map((edge) => edge.node) + : []; + + return ( + + ); +}; + +const RepositoryList = () => { + // ... + + const { repositories } = useRepositories(/* ... */); + + const onEndReach = () => { + console.log('You have reached the end of the list'); + }; + + return ( + + ); +}; + +export default RepositoryList; +``` + + + 试着滚动到审查过的存储库列表的末尾,你应该在日志中看到这个消息。 + + + 接下来,我们需要在列表到达终点时获取更多的软件库。这可以通过useQuery钩子提供的[fetchMore](https://www.apollographql.com/docs/react/pagination/core-api/#the-fetchmore-function)函数来实现。为了描述Apollo客户端,如何将缓存中现有的仓库与下一组仓库合并,我们可以使用[field policy](https://www.apollographql.com/docs/react/caching/cache-field-behavior/)。一般来说,字段策略可以用[read](https://www.apollographql.com/docs/react/caching/cache-field-behavior/#the-read-function)和[merge](https://www.apollographql.com/docs/react/caching/cache-field-behavior/#the-merge-function)函数来定制读写操作中的缓存行为。 + + + 让我们为apolloClient.js文件中的repositories查询添加一个字段策略。 + +```javascript +import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client'; +import { setContext } from '@apollo/client/link/context'; +import Constants from 'expo-constants'; +import { relayStylePagination } from '@apollo/client/utilities'; // highlight-line + +const { apolloUri } = Constants.manifest.extra; + +const httpLink = createHttpLink({ + uri: apolloUri, +}); + +// highlight-start +const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + repositories: relayStylePagination(), + }, + }, + }, +}); +// highlight-end + +const createApolloClient = (authStorage) => { + const authLink = setContext(async (_, { headers }) => { + try { + const accessToken = await authStorage.getAccessToken(); + + return { + headers: { + ...headers, + authorization: accessToken ? `Bearer ${accessToken}` : '', + }, + }; + } catch (e) { + console.log(e); + + return { + headers, + }; + } + }); + + return new ApolloClient({ + link: authLink.concat(httpLink), + cache, // highlight-line + }); +}; + +export default createApolloClient; +``` + + + 如前所述,分页的结果对象和参数的格式是基于Relay的分页规范。幸运的是,Apollo客户端提供了一个预定义的字段策略,relayStylePagination,它可以在这种情况下使用。 + + + 接下来,让我们改变useRepositories钩子,使它返回一个装饰过的fetchMore函数,它用适当的参数调用实际的fetchMore函数,这样我们就可以获取下一组存储库了。 + +```javascript +const useRepositories = (variables) => { + const { data, loading, fetchMore, ...result } = useQuery(GET_REPOSITORIES, { + variables, + // ... + }); + + const handleFetchMore = () => { + const canFetchMore = !loading && data?.repositories.pageInfo.hasNextPage; + + if (!canFetchMore) { + return; + } + + fetchMore({ + variables: { + after: data.repositories.pageInfo.endCursor, + ...variables, + }, + }); + }; + + return { + repositories: data?.repositories, + fetchMore: handleFetchMore, + loading, + ...result, + }; +}; +``` + + + 确保你的pageInfocursor字段在你的repositories查询中,如分页例子中所述。你还需要包括查询的afterfirst参数。 + + + handleFetchMore函数将调用Apollo客户端的fetchMore函数,如果有更多的项目需要获取,这由hasNextPage属性决定。我们还想防止在获取过程中获取更多的项目。在这种情况下,loading将是true。在fetchMore函数中,我们为查询提供一个after变量,它接收最新的endCursor值。 + + + 最后一步是在onEndReach处理器中调用fetchMore函数。 + +```javascript +const RepositoryList = () => { + // ... + + const { repositories, fetchMore } = useRepositories({ + first: 8, + // ... + }); + + const onEndReach = () => { + fetchMore(); + }; + + return ( + + ); +}; + +export default RepositoryList; +``` + + + 在尝试无限滚动时,使用一个相对较小的first参数值,如8。这样你就不需要审查太多的存储库。你可能会面临这样一个问题:onEndReach处理程序在视图加载后被立即调用。这很可能是因为列表中的存储库太少了,以至于马上就到达了列表的末端。你可以通过增加first参数的值来解决这个问题。一旦你确信无限滚动是有效的,可以随意使用更大的first参数值。 + +
    + +
    + +### Exercises 10.25.-10.27. + +#### Exercise 10.25: infinite scrolling for the repository's reviews list + + + 为版本库的评论列表实现无限滚动。Repository类型的reviews字段有firstafter参数,类似于repositories查询。ReviewConnection类型也有pageInfo字段,就像RepositoryConnection类型。 + + + 这里有一个查询的例子。 + +```javascript +{ + repository(id: "jaredpalmer.formik") { + id + fullName + reviews(first: 2, after: "WyIxYjEwZTRkOC01N2VlLTRkMDAtODg4Ni1lNGEwNDlkN2ZmOGYuamFyZWRwYWxtZXIuZm9ybWlrIiwxNTg4NjU2NzUwMDgwXQ==") { + totalCount + edges { + node { + id + text + rating + createdAt + repositoryId + user { + id + username + } + } + cursor + } + pageInfo { + endCursor + startCursor + hasNextPage + } + } + } +} +``` + + + 缓存的字段策略可以和存储库查询类似。 + +```javascript +const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + repositories: relayStylePagination(), + }, + }, + // highlight-start + Repository: { + fields: { + reviews: relayStylePagination(), + }, + }, + // highlight-end + }, +}); +``` + + + 就像审查过的存储库列表一样,当你尝试无限滚动时,使用一个相对较小的first参数值。你可能需要创建一些新的用户,用他们来创建一些新的评论,使评论列表足够长,以便滚动。将first参数的值设置得足够高,以便onEndReach处理程序不会在视图加载后立即被调用,但也要设置得足够低,以便你可以看到一旦你到达列表的末端,就会有更多的评论被取走。一旦一切按计划进行,你就可以为first参数使用一个更大的值。 + +#### Exercise 10.26: the user's reviews view + + + 实现一个允许用户查看其评论的功能。一旦登录,用户应该能够通过按下应用栏中的 "我的评论 "标签来访问这个视图。在这个练习中,实现评论列表的无限滚动是可选的。以下是评论列表视图的大致样子。 + +![Application preview](../../images/10/20.jpg) + + + 记住,你可以通过me查询从Apollo服务器获取认证用户。这个查询返回一个User类型,它有一个reviews字段。如果你已经在你的代码中实现了一个可重复使用的me查询,你可以定制这个查询,以便有条件地获取reviews字段。这可以使用GraphQL's [include](https://graphql.org/learn/queries/#directives)指令来完成。 + + + 比方说,当前的查询大致是以如下方式实现的。 + +```javascript +const GET_CURRENT_USER = gql` + query { + me { + # user fields... + } + } +`; +``` + + + 你可以为查询提供一个includeReviews参数,并与include指令一起使用。 + +```javascript +const GET_CURRENT_USER = gql` + query getCurrentUser($includeReviews: Boolean = false) { + me { + # user fields... + reviews @include(if: $includeReviews) { + edges { + node { + # review fields... + } + cursor + } + pageInfo { + # page info fields... + } + } + } + } +`; +``` + + + includeReviews参数的默认值为false,因为我们不想造成额外的服务器开销,除非我们明确地想要获取认证用户的评论。include指令的原则很简单:如果if参数的值是true,就包括这个字段,否则就省略它。 + +#### Exercise 10.27: review actions + + + 现在,用户可以看到他们的评论,让我们为评论添加一些操作。在评论列表的每个评论下,应该有两个按钮。一个按钮是用于查看评论的存储库。按下这个按钮,用户就可以进入前面练习中实现的单一仓库评论。另一个按钮是用来删除评论的。按下这个按钮就可以删除评论。下面是这些动作的大致样子。 + +![Application preview](../../images/10/21.jpg) + + + 在按下删除按钮后,应该有一个确认提示。如果用户确认了删除,评论就被删除了。否则,删除会被丢弃。你可以使用[警报](https://reactnative.dev/docs/alert)模块来实现确认。注意,调用Alert.alert方法将不会在Expo网页预览中打开任何窗口。使用Expo移动应用或模拟器来查看警报窗口的样子。 + + +这是用户按下删除按钮后应该弹出的确认提示。 + +![Application preview](../../images/10/22.jpg) + + + 你可以使用deleteReview改变来删除一个评论。这个改变有一个参数,就是要删除的评论的ID。在执行了改变之后,更新评论列表's 查询的最简单的方法是调用 [refetch](https://www.apollographql.com/docs/react/data/queries/#refetching) 函数。 + + + 这是本节的最后一个练习。现在是时候把你的代码推送到GitHub,并把你所有完成的练习标记到[练习提交系统](https://studies.cs.helsinki.fi/stats/courses/fs-react-native-2020)。请注意,本节的练习应提交给练习提交系统中的第4章节。 + +
    + +
    + +### Additional resources + + + 随着我们越来越接近这部分的结束,让我们花点时间看看一些额外的React Native相关资源。[Awesome React Native](https://github.com/jondot/awesome-react-native)是一个非常全面的React Native资源的策划列表,如库、教程和文章。因为这个列表非常长,让我们仔细看看其中的几个亮点吧 + +#### React Native Paper + + + > Paper是React Native可定制和可生产的组件的集合,遵循谷歌的Material Design准则。 + + + [React Native Paper](https://callstack.github.io/react-native-paper/)对于React Native来说就像[Material-UI](https://material-ui.com/)对于React网络应用一样。它提供了广泛的高质量UI组件,支持[自定义主题](https://callstack.github.io/react-native-paper/theming.html)和相当简单的[设置](https://callstack.github.io/react-native-paper/getting-started.html),用于基于世博会的React Native应用。 + +#### Styled-components + + + >利用标记的模板字面(最近添加到JavaScript中)和CSS的力量,风格化组件允许你编写实际的CSS代码来风格化你的组件。它还消除了组件和样式之间的映射关系--将组件作为一个低级的样式结构来使用是再简单不过了! + + + [Styled-components](https://styled-components.com/)是一个使用[CSS-in-JS](https://en.wikipedia.org/wiki/CSS-in-JS)技术为React组件设计样式的库。在React Native中,我们已经习惯于将组件的样式定义为一个JavaScript对象,所以CSS-in-JS并不是一个未知的领域。然而,styled-components的方法与使用StyleSheet.create方法和styleprop有很大不同。 + + + 在styled-components中,组件的样式是通过使用一个叫做[标签模板字面](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#Tagged_templates)的功能或一个普通的JavaScript对象与组件一起定义。风格化组件使得基于组件的prop在运行时为组件定义新的风格属性成为可能。这带来了许多可能性,比如在浅色和深色主题之间无缝切换。它也有一个完整的[主题支持](https://styled-components.com/docs/advanced#theming)。下面是一个创建Text组件的例子,该组件具有基于props的风格变化。 + +```javascript +import styled from 'styled-components/native'; +import { css } from 'styled-components'; + +const FancyText = styled.Text` + color: grey; + font-size: 14px; + + ${({ isBlue }) => + isBlue && + css` + color: blue; + `} + + ${({ isBig }) => + isBig && + css` + font-size: 24px; + font-weight: 700; + `} +`; + +const Main = () => { + return ( + <> + Simple text + Blue text + Big text + + Big blue text + + + ); +}; +``` + + + 因为styled-components处理的是样式定义,所以可以在属性名和属性值中使用类似CSS的蛇形句法。然而,单位没有任何影响,因为属性值在内部是没有单位的。关于styled-components的更多信息,请前往[文档](https://styled-components.com/docs)。 + +#### React-spring + + + > react-spring是一个基于弹簧物理学的动画库,应该能满足你大部分的UI相关动画需求。它为你提供了足够灵活的工具,可以自信地将你的想法投射到移动界面中。 + + + [React-spring](https://www.react-spring.io/)是一个库,为React Native组件的动画化提供了一个干净的[API](https://react-spring.io/basics)。 + +#### React Navigation + + + > 为你的React Native应用提供路由和导航 + + + [React Navigation](https://reactnavigation.org/) 是一个React Native的路由库。它与我们在本章节和前面部分使用的React Router库有一些相似之处。然而,与React Router不同的是,React Navigation提供了更多的本地功能,如本地手势和动画在视图之间的转换。 + +### Closing words + + + 就这样,我们的应用已经准备好了。干得好!在我们的旅程中,我们学到了许多新的概念,如使用Expo设置我们的React Native应用,使用React Native's核心组件并为其添加样式,与服务器通信,以及测试React Native应用。最后一块拼图将是把应用部署到苹果应用商店和Google Play商店。 + + + 部署应用完全是可选的,而且这也不是很琐碎,因为你还需要分叉和部署[rate-repository-api](https://github.com/fullstack-hy2020/rate-repository-api)。对于React Native应用本身,你首先需要按照Expo's [document](https://docs.expo.io/distribution/building-standalone-apps/)创建iOS或Android构建。然后你可以把这些构建上传到苹果应用商店或谷歌应用商店。Expo也有这方面的[文档](https://docs.expo.dev/submit/introduction/)。 + +
    diff --git a/src/content/11/en/part11.md b/src/content/11/en/part11.md new file mode 100644 index 00000000000..ee6f3d4c01f --- /dev/null +++ b/src/content/11/en/part11.md @@ -0,0 +1,18 @@ +--- +mainImage: ../../../images/part-11.svg +part: 11 +lang: en +--- + +
    + +So you have a fresh feature ready to be shipped. What happens next? Do you upload files to a server manually? How do you manage the version of your product running in the wild? How do you make sure it works, and roll back to a safe version if it doesn’t? + +Doing all the above manually is a pain and doesn’t scale well for a larger team. That’s why we have Continuous Integration / Continuous Delivery systems, in short CI/CD systems. In this part, you will gain an understanding of why you should use a CI/CD system, what can one do for you, and how to get started with GitHub Actions which is available to all GitHub users by default. + +This module was crafted by the Engineering Team at Smartly.io. At Smartly.io, we automate every step of social advertising to unlock greater performance and creativity. We make every day of advertising easy, effective, and enjoyable for more than 650 brands worldwide, including eBay, Uber, and Zalando. We are one of the early adopters of GitHub Actions in wide-scale production use. Contributors: [Anna Osipova](https://www.linkedin.com/in/a-osipova/), [Anton Rautio](https://www.linkedin.com/in/anton-rautio-768190145/), [Mircea Halmagiu](https://www.linkedin.com/in/mhalmagiu/), [Tomi Hiltunen](https://www.linkedin.com/in/tomihiltunen/). + +Part updated 19th March 2024 +- Added info on using Playwright for the End to end -tests + +
    diff --git a/src/content/11/en/part11a.md b/src/content/11/en/part11a.md new file mode 100644 index 00000000000..7c97d9d3575 --- /dev/null +++ b/src/content/11/en/part11a.md @@ -0,0 +1,222 @@ +--- +mainImage: ../../../images/part-11.svg +part: 11 +letter: a +lang: en +--- + +
    + +During this part, you will build a robust deployment pipeline to a ready made [example project](https://github.com/fullstack-hy2020/full-stack-open-pokedex) starting in [exercise 11.2](/en/part11/getting_started_with_git_hub_actions#exercise-11-2). You will [fork](https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/fork-a-repo) the example project and that will create you a personal copy of the repository. In the [last two](/en/part11/expanding_further#exercises-11-20-22) exercises, you will build another deployment pipeline for some of your own previously created apps! + +There are 21 exercises in this part, and you need to complete each exercise for completing the course. Exercises are submitted via [the submissions system](https://studies.cs.helsinki.fi/stats/courses/fs-cicd) just like in the previous parts, but unlike parts 0 to 7, the submission goes to a different "course instance". + +This part will rely on many concepts covered in the previous parts of the course. It is recommended that you finish at least parts 0 to 5 before starting this part. + +Unlike the other parts of this course, you do not write many lines of code in this part, it is much more about configuration. Debugging code might be hard but debugging configurations is way harder, so in this part, you need lots of patience and discipline! + +### Getting software to production + +Writing software is all well and good but nothing exists in a vacuum. Eventually, we'll need to deploy the software to production, i.e. give it to the real users. After that we need to maintain it, release new versions, and work with other people to expand that software. + +We've already used GitHub to store our source code, but what happens when we work within a team with more developers? + +Many problems may arise when several developers are involved. The software might work just fine on my computer, but maybe some of the other developers are using a different operating system or different library versions. It is not uncommon that a code works just fine in one developer's machine but another developer can not even get it started. This is often called the "works on my machine" problem. + +There are also more involved problems. If two developers are both working on changes and they haven't decided on a way to deploy to production, whose changes get deployed? How would it be possible to prevent one developer's changes from overwriting another's? + +In this part, we'll cover ways to work together and build and deploy software in a strictly defined way so that it's clear exactly what will happen under any given circumstance. + +### Some useful terms + +In this part we'll be using some terms you may not be familiar with or you may not have a good understanding of. We'll discuss some of these terms here. Even if you are familiar with the terms, give this section a read so when we use the terms in this part, we're on the same page. + +#### Branches + +Git allows multiple copies, streams, or versions of the code to co-exist without overwriting each other. When you first create a repository, you will be looking at the main branch (usually in Git, we call this main or master, but that does vary in older projects). This is fine if there's only one developer for a project and that developer only works on one feature at a time. + +Branches are useful when this environment becomes more complex. In this context, each developer can have one or more branches. Each branch is effectively a copy of the main branch with some changes that make it diverge from it. Once the feature or change in the branch is ready it can be merged back into the main branch, effectively making that feature or change part of the main software. In this way, each developer can work on their own set of changes and not affect any other developer until the changes are ready. + +But once one developer has merged their changes into the main branch, what happens to the other developers' branches? They are now diverging from an older copy of the main branch. How will the developer on the later branch know if their changes are compatible with the current state of the main branch? That is one of the fundamental questions we will be trying to answer in this part. + +You can read more about branches e.g. from [here](https://www.atlassian.com/git/tutorials/using-branches). + +#### Pull request + +In GitHub merging a branch back to the main branch of software is quite often happening using a mechanism called [pull request](https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/about-pull-requests), where the developer who has done some changes is requesting the changes to be merged to the main branch. Once the pull request, or PR as it's often called, is made or opened, another developer checks that all is ok and merges the PR. + +If you have proposed changes to the material of this course, you have already made a pull request! + +#### Build + +The term "build" has different meanings in different languages. In some interpreted languages such as Python or Ruby, there is actually no need for a build step at all. + +In general when we talk about building we mean preparing software to run on the platform where it's intended to run. This might mean, for example, that if you've written your application in TypeScript, and you intend to run it on Node, then the build step might be transpiling the TypeScript into JavaScript. + +This step is much more complicated (and required) in compiled languages such as C and Rust where the code needs to be compiled into an executable. + +In [part 7](/en/part7/webpack) we had a look at [Webpack](https://webpack.js.org/) that is the current de facto tool for building a production version of a React or any other frontend JavaScript or TypeScript codebase. + +#### Deploy + +Deployment refers to putting the software where it needs to be for the end-user to use it. In the case of libraries, this may simply mean pushing an npm package to a package archive (such as [npmjs.com](https://www.npmjs.com/)) where other users can find it and include it in their software. + +Deploying a service (such as a web app) can vary in complexity. In [part 3](/en/part3/deploying_app_to_internet) our deployment workflow involved running some scripts manually and pushing the repository code to [Fly.io](https://fly.io/) or [Render](https://render.com/) hosting service. + +In this part, we'll develop a simple "deployment pipeline" that deploys each commit of your code automatically to Fly.io or Render if the committed code does not break anything. + +Deployments can be significantly more complex, especially if we add requirements such as "the software must be available at all times during the deployment" (zero downtime deployments) or if we have to take things like [database migrations](/en/part13/migrations_many_to_many_relationships#migrations) into account. We won't cover complex deployments like those in this part but it's important to know that they exist. + +### What is CI? + +The strict definition of CI (Continuous Integration) and the way the term is used in the industry may sometimes be different. One influential but quite early (written already in 2006) discussion of the topic is in [Martin Fowler's blog](https://www.martinfowler.com/articles/continuousIntegration.html). + +Strictly speaking, CI refers to merging developer changes to the main branch often, Wikipedia even helpfully suggests: "several times a day". This is usually true but when we refer to CI in industry, we're quite often talking about what happens after the actual merge happens. + +We'd likely want to do some of these steps: + - Lint: to keep our code clean, maintainable, and merge compatible + - Build: put all of our code together into runnable software bundle + - Test: to ensure we don't break existing features + - Package: Put it all together in an easily movable batch + - Deploy: Make it available to the world + +We'll discuss each of these steps (and when they're suitable) in more detail later. What is important to remember is that this process should be strictly defined. + +Usually, strict definitions act as a constraint on creativity/development speed. This, however, should usually not be true for CI. This strictness should be set up in such a way as to allow for easier development and working together. Using a good CI system (such as GitHub Actions that we'll cover in this part) will allow us to do this all automagically. + +### Packaging and Deployment as a part of CI + +It may be worthwhile to note that packaging and especially deployment are sometimes not considered to fall under the umbrella of CI. We'll add them in here because in the real world it makes sense to lump it all together. This is partly because they make sense in the context of the flow and pipeline (I want to get my code to users) and partially because these are in fact the most likely point of failure. + +The packaging is often an area where issues crop up in CI as this isn't something that's usually tested locally. It makes sense to test the packaging of a project during the CI workflow even if we don't do anything with the resulting package. With some workflows, we may even be testing the already built packages. This assures us that we have tested the code in the same form as what will be deployed to production. + +What about deployment then? We'll talk about consistency and repeatability at length in the coming sections but we'll mention here that we want a process that always looks the same, whether we're running tests on a development branch or the main branch. In fact, the process may literally be the same with only a check at the end to determine if we are on the main branch and need to do a deployment. In this context, it makes sense to include deployment in the CI process since we'll be maintaining it at the same time we work on CI. + +#### Is this CD thing related? + +The terms Continuous Delivery and Continuous Deployment (both of which have the acronym CD) are often used when one talks about CI that also takes care of deployments. We won't bore you with the exact definition (you can use e.g. [Wikipedia](https://en.wikipedia.org/wiki/Continuous_delivery) or [another Martin Fowler blog post](https://martinfowler.com/bliki/ContinuousDelivery.html)) but in general, we refer to CD as the practice where the main branch is kept deployable at all times. In general, this is also frequently coupled with automated deployments triggered from merges into the main branch. + +What about the murky area between CI and CD? If we, for example, have tests that must be run before any new code can be merged to the main branch, is this CI because we're making frequent merges to the main branch, or is it CD because we're making sure that the main branch is always deployable? + +So, some concepts frequently cross the line between CI and CD and, as we discussed above, deployment sometimes makes sense to consider CD as part of CI. This is why you'll often see references to CI/CD to describe the entire process. We'll use the terms "CI" and "CI/CD" interchangeably in this part. + +### Why is it important? + +Above we talked about the "works on my machine" problem and the deployment of multiple changes, but what about other issues. What if Alice committed directly to the main branch? What if Bob used a branch but didn't bother to run tests before merging? What if Charlie tries to build the software for production but does so with the wrong parameters? + +With the use of continuous integration and systematic ways of working, we can avoid these. + - We can disallow commits directly to the main branch + - We can have our CI process run on all Pull Requests (PRs) against the main branch and allow merges only when our desired conditions are met e.g. tests pass + - We can build our packages for production in the known environment of the CI system + +There are other advantages to extending this setup: + - If we use CI/CD with deployment every time there is a merge to the main branch, then we know that it will always work in production + - If we only allow merges when the branch is up to date with the main branch, then we can be sure that different developers don't overwrite each other's changes + +Note that in this part we are assuming that the main branch contains the code that is running in production. There are numerous different [workflows](https://www.atlassian.com/git/tutorials/comparing-workflows) one can use with Git, e.g. in some cases, it may be a specific release branch that contains the code that is running in production. + +### Important principles + +It's important to remember that CI/CD is not the goal. The goal is better, faster software development with fewer preventable bugs and better team cooperation. + +To that end, CI should always be configured to the task at hand and the project itself. The end goal should be kept in mind at all times. You can think of CI as the answer to these questions: + - How to make sure that tests run on all code that will be deployed? + - How to make sure that the main branch is deployable at all times? + - How to ensure that builds will be consistent and will always work on the platform it'd be deploying to? + - How to make sure that the changes don't overwrite each other? + - How to make deployments happen at the click of a button or automatically when one merges to the main branch? + +There even exists scientific evidence on the numerous benefits the usage of CI/CD has. According to a large study reported in the book [Accelerate: The Science of Lean Software and DevOps: Building and Scaling High Performing Technology Organizations](https://itrevolution.com/product/accelerate/), the use of CI/CD correlate heavily with organizational success (e.g. improves profitability and product quality, increases market share, shortens the time to market). CI/CD even makes developers happier by reducing their burnout rate. The results summarized in the book are also reported in scientific articles such as [this](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=2681909). +#### Documented behavior + +There's an old joke that a bug is just an "undocumented feature". We'd like to avoid that. We'd like to avoid any situations where we don't know the exact outcome. For example, if we depend on a label on a PR to define whether something is a "major", "minor" or "patch" release (we'll cover the meanings of those terms later), then it's important that we know what happens if a developer forgets to put a label on their PR. What if they put a label on after the build/test process has started? What happens if the developer changes the label mid-way through, which one is the one that actually releases? + +It's possible to cover all cases you can think of and still have gaps where the developer will do something "creative" that you didn't think of, so it's important to have the process fail safely in this case. + +For example, if we have the case mentioned above where the label changes midway through the build. If we didn't think of this beforehand, it might be best to fail the build and alert the user if something we weren't expecting happened. The alternative, where we deploy the wrong type of version anyway, could result in bigger problems, so failing and notifying the developer is the safest way out of this situation. + +#### Know the same thing happens every time + +We might have the best tests imaginable for our software, tests that catch every possible issue. That's great, but they're useless if we don't run them on the code before it's deployed. + +We need to guarantee that the tests will run and we need to be sure that they run against the code that will actually be deployed. For example, it's no use if the tests are only run against Alice's branch if they would fail after merging to the main branch. We're deploying from the main branch so we need to make sure that the tests are run against a copy of the main branch with Alice's changes merged in. + +This brings us to a critical concept. We need to make sure that the same thing happens every time. Or rather that the required tasks are all performed and in the right order. + +#### Code always kept deployable + +Having code that's always deployable makes life easier. This is especially true when the main branch contains the code running in the production environment. For example, if a bug is found and it needs to be fixed, you can pull a copy of the main branch (knowing it is the code running in production), fix the bug, and make a pull request back to the main branch. This is relatively straight forward. + +If, on the other hand, the main branch and production are very different and the main branch is not deployable, then you would have to find out what code is running in production, pull a copy of that, fix the bug, figure out a way to push it back, then work out how to deploy that specific commit. That's not great and would have to be a completely different workflow from a normal deployment. + +#### Knowing what code is deployed (sha sum/version) + +It's often important to know what is actually running in production. Ideally, as we discussed above, we'd have the main branch running in production. This is not always possible. Sometimes we intend to have the main branch in production but a build fails, sometimes we batch together several changes and want to have them all deployed at once. + +What we need in these cases (and is a good idea in general) is to know exactly what code is running in production. Sometimes this can be done with a version number, sometimes it's useful to have the commit SHA sum (uniquely identifying hash of that particular commit in git) attached to the code. We will discuss versioning further [a bit later in this part](/en/part11/keeping_green#versioning). + +It is even more useful if we combine the version information with a history of all releases. If, for example, we found out that a particular commit has introduced a bug, we can find out exactly when that was released and how many users were affected. This is especially useful when that bug has written bad data to the database. We'd now be able to track where that bad data went based on the time. + +### Types of CI setup + +To meet some of the requirements listed above, we want to dedicate a separate server for running the tasks in continuous integration. Having a separate server for the purpose minimizes the risk that something else interferes with the CI/CD process and causes it to be unpredictable. + +There are two options: host our own server or use a cloud service. + +#### Jenkins (and other self-hosted setups) + +Among the self-hosted options, [Jenkins](https://www.jenkins.io/) is the most popular. It's extremely flexible and +there are plugins for almost anything (except that one thing you want to do). This is a great option for many applications, using a self-hosted setup means that the entire environment is under your control, the number of resources can be controlled, secrets (we'll elaborate a little more on security in later sections of this part) are never exposed to anyone else and you can do anything you want on the hardware. + +Unfortunately, there is also a downside. Jenkins is quite complicated to set up. It's very flexible but that means that there's often quite a bit of boilerplate/template code involved to get builds working. With Jenkins specifically, it also means that CI/CD must be set up with Jenkins' own domain-specific language. There are also the risks of hardware failures which can be an issue if the setup sees heavy use. + +With self-hosted options, the billing is usually based on the hardware. You pay for the server. What you do on the server doesn't change the billing. + +#### GitHub Actions and other cloud-based solutions + +In a cloud-hosted setup, the setup of the environment is not something you need to worry about. It's there, all you need to do is tell it what to do. Doing that usually involves putting a file in your repository and then telling the CI system to read the file (or to check your repository for that particular file). + +The actual CI config for the cloud-based options is often a little simpler, at least if you stay within what is considered "normal" usage. If you want to do something a little bit more special, then cloud-based options may become more limited, or you may find it difficult to do that one specific task for which the cloud platform just isn't built for. + +In this part, we'll look at a fairly normal use case. The more complicated setups might, for example, make use of specific hardware resources, e.g. a GPU. + +Aside from the configuration issue mentioned above, there are often resource limitations on cloud-based platforms. In a self-hosted setup, if a build is slow, you can just get a bigger server and throw more resources at it. In cloud-based options, this may not be possible. For example, in [GitHub Actions](https://github.com/features/actions), the nodes your builds will run on have 2 vCPUs and 8GB of RAM. + +Cloud-based options are also usually billed by build time which is something to consider. + +#### Why pick one over the other + +In general, if you have a small to medium software project that doesn't have any special requirements (e.g. a need for a graphics card to run tests), a cloud-based solution is probably best. The configuration is simple and you don't need to go to the hassle or expense of setting up your own system. For smaller projects especially, this should be cheaper. + +For larger projects where more resources are needed or in larger companies where there are multiple teams and projects to take advantage of it, a self-hosted CI setup is probably the way to go. + +#### Why use GitHub Actions for this course + +For this course, we'll use [GitHub Actions](https://github.com/features/actions). It is an obvious choice since we're using GitHub anyway. We can get a robust CI solution working immediately without any hassle of setting up a server or configuring a third-party cloud-based service. + +Besides being easy to take into use, GitHub Actions is a good choice in other respects. It might be the best cloud-based solution at the moment. It has gained lots of popularity since its initial release in November 2019. + +
    + +
    + +### Exercise 11.1 + +Before getting our hands dirty with setting up the CI/CD pipeline let us reflect a bit on what we have read. + +#### 11.1 Warming up + +Think about a hypothetical situation where we have an application being worked on by a team of about 6 people. The application is in active development and will be released soon. + +Let us assume that the application is coded with some other language than JavaScript/TypeScript, e.g. in Python, Java, or Ruby. You can freely pick the language. This might even be a language you do not know much yourself. + +Write a short text, say 200-300 words, where you answer or discuss some of the points below. You can check the length with https://wordcounter.net/. Save your answer to the file named exercise1.md in the root of the repository that you shall create in [exercise 11.2](/en/part11/getting_started_with_git_hub_actions#exercise-11-2). + +The points to discuss: +- Some common steps in a CI setup include linting, testing, and building. What are the specific tools for taking care of these steps in the ecosystem of the language you picked? You can search for the answers by Google. +- What alternatives are there to set up the CI besides Jenkins and GitHub Actions? Again, you can ask Google! +- Would this setup be better in a self-hosted or a cloud-based environment? Why? What information would you need to make that decision? + +Remember that there are no 'right' answers to the above! + +
    diff --git a/src/content/11/en/part11b.md b/src/content/11/en/part11b.md new file mode 100644 index 00000000000..8b3b0d4c9a8 --- /dev/null +++ b/src/content/11/en/part11b.md @@ -0,0 +1,425 @@ +--- +mainImage: ../../../images/part-11.svg +part: 11 +letter: b +lang: en +--- + +
    + +Before we start playing with GitHub Actions, let's have a look at what they are and how do they work. + +GitHub Actions work on a basis of [workflows](https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/introduction-to-github-actions#workflows). A workflow is a series of [jobs](https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/introduction-to-github-actions#jobs) that are run when a certain triggering [event](https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/introduction-to-github-actions#events) happens. The jobs that are run then themselves contain instructions for what GitHub Actions should do. + +A typical execution of a workflow looks like this: + +- Triggering event happens (for example, there is a push to the main branch). +- The workflow with that trigger is executed. +- Cleanup + +### Basic needs + +In general, to have CI operate on a repository, we need a few things: + +- A repository (obviously) +- Some definition of what the CI needs to do: + This can be in the form of a specific file inside the repository or it can be defined in the CI system +- The CI needs to be aware that the repository (and the configuration file within it) exist +- The CI needs to be able to access the repository +- The CI needs permissions to perform the actions it is supposed to be able to do: + For example, if the CI needs to be able to deploy to a production environment, it needs credentials for that environment. + +That's the traditional model at least, we'll see in a minute how GitHub Actions short-circuit some of these steps or rather make it such that you don't have to worry about them! + +GitHub Actions have a great advantage over self-hosted solutions: the repository is hosted with the CI provider. In other words, GitHub provides both the repository and the CI platform. This means that if we've enabled actions for a repository, GitHub is already aware of the fact that we have workflows defined and what those definitions look like. + +
    + +
    + +### Exercise 11.2. + +In most exercises of this part, we are building a CI/CD pipeline for a small project found in [this example project repository](https://github.com/fullstack-hy2020/full-stack-open-pokedex). + +#### 11.2 The example project + +The first thing you'll want to do is to fork the example repository under your name. What it essentially does is it creates a copy of the repository under your GitHub user profile for your use. + +To fork the repository, you can click on the Fork button in the top-right area of the repository view next to the Star button: + +![](../../images/11/1.png) + +Once you've clicked on the Fork button, GitHub will start the creation of a new repository called {github_username}/full-stack-open-pokedex. + +Once the process has been finished, you should be redirected to your brand-new repository: + +![](../../images/11/2.png) + +Clone the project now to your machine. As always, when starting with a new code, the most obvious place to look first is the file package.json + +**NOTE** since the project is already a bit old, you need Node 16 to work with it! + +Try now the following: +- install dependencies (by running npm install) +- start the code in development mode +- run tests +- lint the code + +You might notice that the project contains some broken tests and linting errors. **Just leave them as they are for now.** We will get around those later in the exercises. + +**NOTE** the tests of the project have been made with [Jest](https://jestjs.io/). The course material in [part 5](/en/part5/testing_react_apps) uses [Vitest](https://vitest.dev/guide/). From the usage point of view, the libraries have barely any difference. + +As you might remember from [part 3](/en/part3/deploying_app_to_internet#frontend-production-build), the React code should not be run in development mode once it is deployed in production. Try now the following +- create a production build of the project +- run the production version locally + +Also for these two tasks, there are ready-made npm scripts in the project! + +Study the structure of the project for a while. As you notice both the frontend and the backend code are now [in the same repository](/en/part7/class_components_miscellaneous#frontend-and-backend-in-the-same-repository). In earlier parts of the course we had a separate repository for both, but having those in the same repository makes things much simpler when setting up a CI environment. + +In contrast to most projects in this course, the frontend code does not use Vite but it has a relatively simple [Webpack](/en/part7/webpack) configuration that takes care of creating the development environment and creating the production bundle. + +
    + +
    + +### Getting started with workflows + +The core component of creating CI/CD pipelines with GitHub Actions is something called a [Workflow](https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/introduction-to-github-actions#workflows). Workflows are process flows that you can set up in your repository to run automated tasks such as building, testing, linting, releasing, and deploying to name a few! The hierarchy of a workflow looks as follows: + +Workflow + +- Job + - Step + - Step +- Job + - Step + +Each workflow must specify at least one [Job](https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/introduction-to-github-actions#jobs), which contains a set of [Steps](https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/introduction-to-github-actions#steps) to perform individual tasks. The jobs will be run in parallel and the steps in each job will be executed sequentially. + +Steps can vary from running a custom command to using pre-defined actions, thus the name GitHub Actions. You can create [customized actions](https://docs.github.com/en/free-pro-team@latest/actions/creating-actions) or use any actions published by the community, which are plenty, but let's get back to that later! + +For GitHub to recognize your workflows, they must be specified in .github/workflows folder in your repository. Each Workflow is its own separate file which needs to be configured using the YAML data-serialization language. + +YAML is a recursive acronym for "YAML Ain't Markup Language". As the name might hint its goal is to be human-readable and it is commonly used for configuration files. You will notice below that it is indeed very easy to understand! + +Notice that indentations are important in YAML. You can learn more about the syntax [here](https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html). + +A basic workflow contains three elements in a YAML document. These three elements are: + +- name: Yep, you guessed it, the name of the workflow +- (on) triggers: The events that trigger the workflow to be executed +- jobs: The separate jobs that the workflow will execute (a basic workflow might contain only one job). + +A simple workflow definition looks like this: + +```yml +name: Hello World! + +on: + push: + branches: + - main + +jobs: + hello_world_job: + runs-on: ubuntu-latest + steps: + - name: Say hello + run: | + echo "Hello World!" +``` + +There is one job named hello\_world\_job, it will be run in a virtual environment with Ubuntu 20.04. The job has just one step named "Say hello", which will run the echo "Hello World!" command in the shell. + +So you may ask, when does GitHub trigger a workflow to be started? There are plenty of [options](https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows) to choose from, but generally speaking, you can configure a workflow to start once: + +- An event on GitHub occurs such as when someone pushes a commit to a repository or when an issue or pull request is created +- A scheduled event, that is specified using the [cron]( https://en.wikipedia.org/wiki/Cron)-syntax, happens +- An external event occurs, for example, a command is performed in an external application such as [Slack](https://slack.com/) or [Discord](https://discord.com/) messaging app + +To learn more about which events can be used to trigger workflows, please refer to GitHub Action's [documentation](https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows). + + +
    + +
    + +### Exercises 11.3-11.4. + +To tie this all together, let us now get GitHub Actions up and running in the example project! + +#### 11.3 Hello world! + +Create a new Workflow that outputs "Hello World!" to the user. For the setup, you should create the directory .github/workflows and a file hello.yml to your repository. + +To see what your GitHub Action workflow has done, you can navigate to the **Actions** tab in GitHub where you should see the workflows in your repository and the steps they implement. The output of your Hello World workflow should look something like this with a properly configured workflow. + +![A properly configured Hello World workflow](../../images/11/3.png) + +You should see the "Hello World!" message as an output. If that's the case then you have successfully gone through all the necessary steps. You have your first GitHub Actions workflow active! + +Note that GitHub Actions also informs you on the exact environment (operating system, and its [setup](https://github.com/actions/virtual-environments/blob/ubuntu18/20201129.1/images/linux/Ubuntu1804-README.md)) where your workflow is run. This is important since if something surprising happens, it makes debugging so much easier if you can reproduce all the steps in your machine! + +#### 11.4 Date and directory contents + +Extend the workflow with steps that print the date and current directory content in the long format. + +Both of these are easy steps, and just running commands [date](https://man7.org/linux/man-pages/man1/date.1.html) and [ls](https://man7.org/linux/man-pages/man1/ls.1.html) will do the trick. + +Your workflow should now look like this + +![Date and dir content in the workflow](../../images/11/4.png) + +As the output of the command ls -l shows, by default, the virtual environment that runs our workflow does not have any code! + +
    + +
    + +### Setting up lint, test and build steps + +After completing the first exercises, you should have a simple but pretty useless workflow set up. Let's make our workflow do something useful. + +Let's implement a GitHub Action that will lint the code. If the checks don't pass, GitHub Actions will show a red status. + +At the start, the workflow that we will save to file pipeline.yml looks like this: + +```yml +name: Deployment pipeline + +on: + push: + branches: + - main + +jobs: +``` + +Before we can run a command to lint the code, we have to perform a couple of actions to set up the environment of the job. + +#### Setting up the environment + +Setting up the environment is an important task while configuring a pipeline. We're going to use an ubuntu-latest virtual environment because this is the version of Ubuntu we're going to be running in production. + +It is important to replicate the same environment in CI as in production as closely as possible, to avoid situations where the same code works differently in CI and production, which would effectively defeat the purpose of using CI. + +Next, we list the steps in the "build" job that the CI would need to perform. As we noticed in the last exercise, by default the virtual environment does not have any code in it, so we need to checkout the code from the repository. + +This is an easy step: + +```yml +name: Deployment pipeline + +on: + push: + branches: + - main + +jobs: + simple_deployment_pipeline: # highlight-line + runs-on: ubuntu-latest # highlight-line + steps: # highlight-line + - uses: actions/checkout@v4 # highlight-line +``` + +The [uses](https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstepsuses) keyword tells the workflow to run a specific action. An action is a reusable piece of code, like a function. Actions can be defined in your repository in a separate file or you can use the ones available in public repositories. + +Here we're using a public action [actions/checkout](https://github.com/actions/checkout) and we specify a version (@v4) to avoid potential breaking changes if the action gets updated. The checkout action does what the name implies: it checkouts the project source code from Git. + +Secondly, as the application is written in JavaScript, Node.js must be set up to be able to utilize the commands that are specified in package.json. To set up Node.js, [actions/setup-node](https://github.com/actions/setup-node) action can be used. Version 20 is selected because it is the version the application is using in the production environment. + +```yml +# name and trigger not shown anymore... + +jobs: + simple_deployment_pipeline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 # highlight-line + with: # highlight-line + node-version: '20' # highlight-line +``` + +As we can see, the [with](https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstepswith) keyword is used to give a "parameter" to the action. Here the parameter specifies the version of Node.js we want to use. + + +Lastly, the dependencies of the application must be installed. Just like on your own machine we execute npm install. The steps in the job should now look something like + +```yml +jobs: + simple_deployment_pipeline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install dependencies # highlight-line + run: npm install # highlight-line +``` + +Now the environment should be completely ready for the job to run actual important tasks in! + +#### Lint + +After the environment has been set up we can run all the scripts from package.json like we would on our own machine. To lint the code all you have to do is add a step to run the npm run eslint command. + +```yml +jobs: + simple_deployment_pipeline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install dependencies + run: npm install + - name: Check style # highlight-line + run: npm run eslint # highlight-line +``` + +Note that the _name_ of a step is optional, if you define a step as follows + +```yml +- run: npm run eslint +``` + +the command that is run is used as the default name. + +
    + +
    + +### Exercises 11.5.-11.9. + +#### 11.5 Linting workflow + +Implement or copy-paste the "Lint" workflow and commit it to the repository. Use a new yml file for this workflow, you may call it e.g. pipeline.yml. + +Push your code and navigate to "Actions" tab and click on your newly created workflow on the left. You should see that the workflow run has failed: + +![Linting to workflow](../../images/11/5.png) + +#### 11.6 Fix the code + +There are some issues with the code that you will need to fix. Open up the workflow logs and investigate what is wrong. + +A couple of hints. One of the errors is best to be fixed by specifying proper env for linting, see [here](/en/part3/validation_and_es_lint#lint) how it can be done . One of the complaints concerning console.log statement could be taken care of by simply silencing the rule for that specific line. Ask google how to do it. + +Make the necessary changes to the source code so that the lint workflow passes. Once you commit new code the workflow will run again and you will see updated output where all is green again: + +![Lint error fixed](../../images/11/6.png) + +#### 11.7 Building and testing + +Let's expand on the previous workflow that currently does the linting of the code. Edit the workflow and similarly to the lint command add commands for build and test. After this step outcome should look like this + +![tests fail...](../../images/11/7.png) + +As you might have guessed, there are some problems in code... + +#### 11.8 Back to green + +Investigate which test fails and fix the issue in the code (do not change the tests). + +Once you have fixed all the issues and the Pokedex is bug-free, the workflow run will succeed and show green! + +![tests fixed](../../images/11/8.png) + +#### 11.9 Simple end-to-end tests + +The current set of tests uses [Jest](https://jestjs.io/) to ensure that the React components work as intended. This is essentially the same thing that is done in the section [Testing React apps](/en/part5/testing_react_apps) of part 5 with [Vitest](https://vitest.dev/). + +Testing components in isolation is quite useful but that still does not ensure that the system as a whole works as we wish. To have more confidence about this, let us write a couple of really simple end-to-end tests similarly we did in section [part 5](/en/part5/). You could use [Playwright](https://playwright.dev/) or [Cypress](https://www.cypress.io/) for the tests. + +No matter which you choose, you should extend Jest-definition in package.json to prevent Jest from trying to run the e2e-tests. Assuming that directory _e2e-tests_ is used for e2e-tests, the definition is: + +```json +{ + // ... + "jest": { + "testEnvironment": "jsdom", + "testPathIgnorePatterns": ["e2e-tests"] // highlight-line + } +} +``` + +**Playwright** + +Set Playwright up (you'll find [here](/en/part5/end_to_end_testing_playwright) all the info you need) to your repository. Note that in contrast to part 5, you should now install Playwright to the same project with the rest of the code! + +Use this test first: + +```js +const { test, describe, expect, beforeEach } = require('@playwright/test') + +describe('Pokedex', () => { + test('front page can be opened', async ({ page }) => { + await page.goto('') + await expect(page.getByText('ivysaur')).toBeVisible() + await expect(page.getByText('Pokémon and Pokémon character names are trademarks of Nintendo.')).toBeVisible() + }) +}) +``` + +**Note** is that although the page renders the Pokemon names with an initial capital letter, the names are actually written with lowercase letters in the source, so you should test for ivysaur instead of Ivysaur! + +Define a npm script test:e2e for running the e2e tests from the command line. + +Remember that the Playwright tests assume that the application is up and running when you run the test! Instead of starting the app manually, you should now configure a Playwright development server to start the app while tests are executed, see [here](https://playwright.dev/docs/next/api/class-testconfig#test-config-web-server) how that can be done. + +Ensure that the test passes locally. + +Once the end-to-end test works in your machine, include it in the GitHub Action workflow. That should be pretty easy by following [this](https://playwright.dev/docs/ci-intro#on-pushpull_request). + +**Cypress** + +Set Cypress up (you'll find [here](/en/part5/end_to_end_testing_cypress) all the info you need) and use this test first: + +```js +describe('Pokedex', function() { + it('front page can be opened', function() { + cy.visit('http://localhost:5000') + cy.contains('ivysaur') + cy.contains('Pokémon and Pokémon character names are trademarks of Nintendo.') + }) +}) +``` + +Define a npm script test:e2e for running the e2e tests from the command line. + +**Note** is that although the page renders the Pokemon names with an initial capital letter, the names are actually written with lowercase letters in the source, so you should test for ivysaur instead of Ivysaur! + +Ensure that the test passes locally. Remember that the Cypress tests _assume that the application is up and running_ when you run the test! If you have forgotten the details, please see [part 5](/en/part5/end_to_end_testing) how to get up and running with Cypress. + +Once the end-to-end test works in your machine, include it in the GitHub Action workflow. By far the easiest way to do that is to use the ready-made action [cypress-io/github-action](https://github.com/cypress-io/github-action). The step that suits us is the following: + +```js +- name: e2e tests + uses: cypress-io/github-action@v5 + with: + command: npm run test:e2e + start: npm run start-prod + wait-on: http://localhost:5000 +``` + +Three options are used: [command](https://github.com/cypress-io/github-action#custom-test-command) specifies how to run Cypress tests, [start](https://github.com/cypress-io/github-action#start-server) gives npm script that starts the server, and [wait-on](https://github.com/cypress-io/github-action#wait-on) says that before the tests are run, the server should have started on url . + +Note that you need to build the app in GitHub Actions before it can be started in production mode! + +**Once the pipeline works...** + +Once you are sure that the pipeline works, write another test that ensures that one can navigate from the main page to the page of a particular Pokemon, e.g. ivysaur. The test does not need to be a complex one, just check that when you navigate to a link, the page has some proper content, such as the string chlorophyll in the case of ivysaur. + +**Note** the Pokemon abilities are written with lowercase letters in the source code (the capitalization is done in CSS), so do not test for Chlorophyll but rather chlorophyll. + +The end result should be something like this + +![e2e tests](../../images/11/9.png) + +End-to-end tests are nice since they give us confidence that software works from the end user's perspective. The price we have to pay is the slower feedback time. Now executing the whole workflow takes quite much longer. + +
    diff --git a/src/content/11/en/part11c.md b/src/content/11/en/part11c.md new file mode 100644 index 00000000000..faadd98bb48 --- /dev/null +++ b/src/content/11/en/part11c.md @@ -0,0 +1,343 @@ +--- +mainImage: ../../../images/part-11.svg +part: 11 +letter: c +lang: en +--- + +
    + +Having written a nice application it's time to think about how we're going to deploy it to the use of real users. + +In [part 3](/en/part3/deploying_app_to_internet) of this course, we did this by simply running a single command from terminal to get the code up and running the servers of the cloud provider [Fly.io](https://fly.io/) or [Render](https://render.com/). + +It is pretty simple to release software in Fly.io and Render at least compared to many other types of hosting setups but it still contains risks: nothing prevents us from accidentally releasing broken code to production. + +Next, we're going to look at the principles of making a deployment safely and some of the principles of deploying software on both a small and large scale. + +### Anything that can go wrong... + +We'd like to define some rules about how our deployment process should work but before that, we have to look at some constraints of reality. + +One phrasing of Murphy's Law holds that: + "Anything that can go wrong will go wrong." + +It's important to remember this when we plan out our deployment system. Some of the things we'll need to consider could include: + - What if my computer crashes or hangs during deployment? + - I'm connected to the server and deploying over the internet, what happens if my internet connection dies? + - What happens if any specific instruction in my deployment script/system fails? + - What happens if, for whatever reason, my software doesn't work as expected on the server I'm deploying to? Can I roll back to a previous version? + - What happens if a user does an HTTP request to our software just before we do deployment (we didn't have time to send a response to the user)? + +These are just a small selection of what can go wrong during a deployment, or rather, things that we should plan for. Regardless of what happens, our deployment system should **never** leave our software in a broken state. We should also always know (or be easily able to find out) what state a deployment is in. + +Another important rule to remember when it comes to deployments (and CI in general) is: + "Silent failures are **very** bad!" + +This doesn't mean that failures need to be shown to the users of the software, it means we need to be aware if anything goes wrong. If we are aware of a problem, we can fix it. If the deployment system doesn't give any errors but fails, we may end up in a state where we believe we have fixed a critical bug but the deployment failed, leaving the bug in our production environment and us unaware of the situation. + +### What does a good deployment system do? + +Defining definitive rules or requirements for a deployment system is difficult, let's try anyway: + - Our deployment system should be able to fail gracefully at **any** step of the deployment. + - Our deployment system should **never** leave our software in a broken state. + - Our deployment system should let us know when a failure has happened. It's more important to notify about failure than about success. + - Our deployment system should allow us to roll back to a previous deployment + - Preferably this rollback should be easier to do and less prone to failure than a full deployment + - Of course, the best option would be an automatic rollback in case of deployment failures + - Our deployment system should handle the situation where a user makes an HTTP request just before/during a deployment. + - Our deployment system should make sure that the software we are deploying meets the requirements we have set for this (e.g. don't deploy if tests haven't been run). + +Let's define some things we **want** in this hypothetical deployment system too: + - We would like it to be fast + - We'd like to have no downtime during the deployment (this is distinct from the requirement we have for handling user requests just before/during the deployment). + +Next we will have two sets of exercises for automating the deployment with GitHub Actions, one for [Fly.io](https://fly.io/), another one for [Render](https://render.com/). The process of deployment is always specific to the particular cloud provider, so you can also do both the exercise sets if you want to see the differences on how these services work with respect to deployments. + +### Has the app been deployed? + +Since we are not making any real changes to the app, it might be a bit hard to see if the app deployment really works. +Let us create a dummy endpoint in the app that makes it possible to do some code changes and to ensure that the deployed version has really changed: + +```js +app.get('/version', (req, res) => { + res.send('1') // change this string to ensure a new version deployed +}) +``` + +
    + +
    + +### Exercises 11.10-11.12. (Fly.io) + +If you rather want to use other hosting options, there is an alternative set of exercises for [Render](/en/part11/deployment#exercises-11-10-11-12-render). + +#### 11.10 Deploying your application to Fly.io + +Setup your application in [Fly.io](https://fly.io/) hosting service like the one we did in [part 3](/en/part3/deploying_app_to_internet#application-to-the-internet). + +In contrast to part 3, in this part we do not deploy the code to Fly.io ourselves (with the command flyctl deploy), we let the GitHub Actions workflow do that for us. + +Before going to the automated deployment, we shall ensure in this exercise that the app can be deployed manually. + +So, create a new app in Fly.io. After that generate a Fly.io API token with the command + +```bash +fly tokens create deploy +``` + +You'll need the token soon for your deployment workflow so save it somewhere (but do not commit that to GitHub)! + +As said, before setting up the deployment pipeline in the next exercise we will now ensure that a manual deployment with the command flyctl deploy works. + +A couple of changes are needed. + +The configuration file fly.toml should be modified to include the following: + +```yml +[env] + PORT = "3000" # add this where PORT matches the internal_port below + +[processes] + app = "node app.js" # add this + +[http_service] + internal_port = 3000 + force_https = true + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 0 + processes = ["app"] +``` + +In [processes](https://fly.io/docs/reference/configuration/#the-processes-section) we define the command that starts the application. Without this change Fly.io just starts the React dev server and that causes it to shut down since the app itself does not start up. We will also set up the PORT to be passed to the app as an environment variable. + +We also need to alter the file _.dockerignore_ a bit, the next line should be removed: + +``` +dist +``` + +If the line is not removed, the product build of the frontend does not get downloaded to the Fly.io server. + +Deployment should now work _if_ the production build exists in the local machine, that is, the command _npm build_ is run. + +Before moving to the next exercise, make sure that the manual deployment with the command flyctl deploy works! + +#### 11.11 Automatic deployments + +Extend the workflow with a step to deploy your application to Fly.io by following the advice given [here](https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/). + +Note that the GitHub Action should create the production build (with _npm run build_) before the deployment step! + +You need the authorization token that you just created for the deployment. The proper way to pass it's value to GitHub Actions is to use _Repository secrets_: + +![repo secret](../../images/11/10f.png) + +Now the workflow can access the token value as follows: + +``` +${{secrets.FLY_API_TOKEN}} +``` + +If all goes well, your workflow log should look a bit like this: + +![](../../images/11/fly-good.png) + +**Remember** that it is always essential to keep an eye on what is happening in server logs when playing around with product deployments, so use flyctl logs early and use it often. No, use it all the time! + +#### 11.12 Health check + +Each deployment in Fly.io creates a [release](https://fly.io/docs/flyctl/releases/). Releases can be checked from the command line: + +```bash +$ flyctl releases +VERSION STATUS DESCRIPTION USER DATE +v18 complete Release mluukkai@iki.fi 16h56m ago +v17 complete Release mluukkai@iki.fi 17h3m ago +v16 complete Release mluukkai@iki.fi 21h22m ago +v15 complete Release mluukkai@iki.fi 21h25m ago +v14 complete Release mluukkai@iki.fi 21h34m ago +``` + +It is essential to ensure that a deployment ends up in a succeeding release, where the app is in healthy functional state. Fortunately, Fly.io has several configuration options that take care of the application health check. + +If we change the app as follows, it fails to start: + +```js +app.listen(PORT, () => { + this_causes_error + // eslint-disable-next-line no-console + console.log(`server started on port ${PORT}`) +}) +``` + +In this case, the deployment fails: + +```bash +$ flyctl releases +VERSION STATUS DESCRIPTION USER DATE +v19 failed Release mluukkai@iki.fi 3m52s ago +v18 complete Release mluukkai@iki.fi 16h56m ago +v17 complete Release mluukkai@iki.fi 17h3m ago +v16 complete Release mluukkai@iki.fi 21h22m ago +v15 complete Release mluukkai@iki.fi 21h25m ago +v14 complete Release mluukkai@iki.fi 21h34m ago +``` + +The app however stays up and running, Fly.io does not replace the functioning version (v18) with the broken one (v19). + +Let us consider the following change + +```js +// start app in a wrong port +app.listen(PORT + 1, () => { + // eslint-disable-next-line no-console + console.log(`server started on port ${PORT}`) +}) +``` + +Now the app starts but it is connected to the wrong port, so the service will not be functional. Fly.io thinks this is a successful deployment, so it deploys the app in a broken state. + +One possibility to prevent broken deployments is to use an HTTP-level check defined in section [http\_service.http_checks](https://fly.io/docs/reference/configuration/#http_service-checks). This type of check can be used to ensure that the app for real is in a functional state. + +Add a simple endpoint for doing an application health check to the backend. You may e.g. copy this code: + +```js +app.get('/health', (req, res) => { + res.send('ok') +}) +``` + +Configure then an [HTTP check](https://fly.io/docs/reference/configuration/#http_service-checks) that ensures the health of the deployments based on the HTTP request to the defined health check endpoint. + +You also need to set the [deployment strategy](https://fly.io/docs/reference/configuration/#picking-a-deployment-strategy) (in the file _fly.toml_) of the app to be canary. These strategies ensure that only an app with a healthy state gets deployed. + +Ensure that GitHub Actions notices if a deployment breaks your application: + +![](../../images/11/fly-fail.png) + +You may simulate this e.g. as follows: + +```js +app.get('/health', (req, res) => { + // eslint-disable-next-line no-constant-condition + if (true) throw('error... ') + res.send('ok') +}) +``` + +
    + +
    + +### Exercises 11.10-11.12. (Render) + +If you rather want to use other hosting options, there is an alternative set of exercises for [Fly.io](/en/part11/deployment/#exercises-11-10-11-12-fly-io). + +#### 11.10 Deploying your application to Render + +Set up your application in [Render](https://render.com/). The setup is now not quite as straightforward as in [part 3](/en/part3/deploying_app_to_internet#application-to-the-internet). You have to carefully think about what should go to these settings: + +![](../../images/11/render1.png) + +If you need to run several commands in the build or start command, you may use a simple shell script for that. + +Create eg. a file build_step.sh with the following content: + +```bash +#!/bin/bash + +echo "Build script" + +# add the commands here +``` + +Give it execution permissions (Google or see e.g. [this](https://www.guru99.com/file-permissions.html) to find out how) and ensure that you can run it from the command line: + +```bash +$ ./build_step.sh +Build script +``` + +Other option is to use a [Pre deploy command](https://docs.render.com/deploys#deploy-steps), with that you may run one additional command before the deployment starts. + +You also need to open the Advanced settings and turn the auto-deploy off since we want to control the deployment in the GitHub Actions: + +![](../../images/11/render2.png) + +Ensure now that you get the app up and running. Use the Manual deploy. + +Most likely things will fail at the start, so remember to keep the Logs open all the time. + +#### 11.11 Automatic deployments + +Next step is to automate the deployment. There are two options, a ready-made custom action or the use of the Render deploy hook. + +Deployment with custom action + +Go to GitHub Actions [marketplace](https://github.com/marketplace) and search for action for our purposes. You might search with render deploy. There are several actions to choose from. You can pick any. Quite often the best choice is the one with the most stars. It is also a good idea to look if the action is actively maintained (time of the last release) and does it have many open issues or pull requests. + +**Warning**: for some reason, the most starred option [render-action](https://github.com/Bounceapp/render-action) was very unreliable when the part was updated (16th Jan 2024), so better avoid that. If you end up with too much problems, the deploy hook might be a better option! + +Set up the action to your workflow and ensure that every commit that passes all the checks results in a new deployment. Note that you need Render API key and the app service id for the deployment. See [here](https://render.com/docs/api) how the API key is generated. You can get the service id from the URL of the Render dashboard of your app. The end of the URL (starting with _srv-_) is the id: + +```bash +https://dashboard.render.com/web/srv-randomcharachtershere +``` + +Deployment with deploy hook + +Alternative, and perhaps a more reliable option is to use [Render Deploy Hook](https://render.com/docs/deploy-hooks) which is a private URL to trigger the deployment. You can get it from your app settings: + +![fsorender1](https://user-images.githubusercontent.com/47830671/230722899-1ebb414e-ae1e-4a5e-a7b8-f376c4f1ca4d.png) + +DON'T USE the plain URL in your pipeline. Instead create GitHub secrets for your key and service id: ![fsorender2](https://user-images.githubusercontent.com/47830671/230723138-77d027be-3162-4697-987e-b654bc710187.png) +Then you can use them like this: +``` bash +- name: Trigger deployment + run: curl https://api.render.com/deploy/srv-${{ secrets.RENDER_SERVICE_ID }}?key=${{ secrets.RENDER_API_KEY }} +``` + +The deployment takes some time. See the events tab of the Render dashboard to see when the new deployment is ready: + +![](../../images/11/render3.png) + +#### 11.12 Health check + +All tests pass and the new version of the app gets automatically deployed to Render so everything seems to be in order. But does the app really work? Besides the checks done in the deployment pipeline, it is extremely beneficial to have also some "application level" health checks ensuring that the app for real is in a functional state. + +The [zero downtime deploys](https://docs.render.com/deploys#zero-downtime-deploys) in Render should ensure that your app stays functional all the time! For some reason, this property did not always work as promised when this part was updated (16th Jan 2024). The reason might be the use of a free account. + +Add a simple endpoint for doing an application health check to the backend. You may e.g. copy this code: + +```js +app.get('/health', (req, res) => { + res.send('ok') +}) +``` + +Commit the code and push it to GitHub. Ensure that you can access the health check endpoint of your app. + +Configure now a Health Check Path to your app. The configuration is done in the settings tab of the Render dashboard. + +Make a change in your code, push it to GitHub, and ensure that the deployment succeeds. + +Note that you can see the log of deployment by clicking the most recent deployment in the events tab. + +When you are set up with the health check, simulate a broken deployment by changing the code as follows: + +```js +app.get('/health', (req, res) => { + // eslint-disable-next-line no-constant-condition + if (true) throw('error... ') + res.send('ok') +}) +``` + +Push the code to GitHub and ensure that a broken version does not get deployed and the previous version of the app keeps running. + +Before moving on, fix your deployment and ensure that the application works again as intended. + +
    diff --git a/src/content/11/en/part11d.md b/src/content/11/en/part11d.md new file mode 100644 index 00000000000..0a83a7a52ae --- /dev/null +++ b/src/content/11/en/part11d.md @@ -0,0 +1,375 @@ +--- +mainImage: ../../../images/part-11.svg +part: 11 +letter: d +lang: en +--- + +
    + +Your main branch of the code should always remain green. Being green means that all the steps of your build pipeline should complete successfully: the project should build successfully, tests should run without errors, and the linter shouldn't have anything to complain about, etc. + +Why is this important? You will likely deploy your code to production specifically from your main branch. Any failures in the main branch would mean that new features cannot be deployed to production until the issue is sorted out. Sometimes you will discover a nasty bug in production that was not caught by the CI/CD pipeline. In these cases, you want to be able to roll the production environment back to a previous commit in a safe manner. + +How do you keep your main branch green then? Avoid committing any changes directly to the main branch. Instead, commit your code on a branch based on the freshest possible version of the main branch. Once you think the branch is ready to be merged into the main you create a GitHub Pull Request (also referred to as PR). + +### Working with Pull Requests + +Pull requests are a core part of the collaboration process when working on any software project with at least two contributors. When making changes to a project you checkout a new branch locally, make and commit your changes, push the branch to the remote repository (in our case to GitHub) and create a pull request for someone to review your changes before those can be merged into the main branch. + +There are several reasons why using pull requests and getting your code reviewed by at least one other person is always a good idea. +- Even a seasoned developer can often overlook some issues in their code: we all know of the tunnel vision effect. +- A reviewer can have a different perspective and offer a different point of view. +- After reading through your changes, at least one other developer will be familiar with the changes you've made. +- Using PRs allows you to automatically run all tasks in your CI pipeline before the code gets to the main branch. GitHub Actions provides a trigger for pull requests. + +You can configure your GitHub repository in such a way that pull requests cannot be merged until they are approved. + +![Compare & pull request](../../images/11/pr1a.png) + +To open a new pull request, open your branch in GitHub and click on the green "Compare & pull request" button at the top. You will be presented with a form where you can fill in the pull request description. + +![Open a new pull request](../../images/11/pr2.png) + +GitHub's pull request interface presents a description and the discussion interface. At the bottom, it displays all the CI checks (in our case each of our Github Actions) that are configured to run for each PR and the statuses of these checks. A green board is what you aim for! You can click on Details of each check to view details and run logs. + +All the workflows we looked at so far were triggered by commits to the main branch. To make the workflow run for each pull request we would have to update the trigger part of the workflow. We use the "pull_request" trigger for branch "main" (our main branch) and limit the trigger to events "opened" and "synchronize". Basically, this means, that the workflow will run when a PR into the main branch is opened or updated. + +So let us change events that [trigger](https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows) of the workflow as follows: + +```yml +on: + push: + branches: + - main + pull_request: # highlight-line + branches: [main] # highlight-line + types: [opened, synchronize] # highlight-line +``` + +We shall soon make it impossible to push the code directly to the main branch, but in the meantime, let us still run the workflow also for all the possible direct pushes to the main branch. + +
    + +
    + +### Exercises 11.13-11.14. + +Our workflow is doing a nice job of ensuring good code quality, but since it is run on commits to the main branch, it's catching the problems too late! + +#### 11.13 Pull request + +Update the trigger of the existing workflow as suggested above to run on new pull requests to your main branch. + +Create a new branch, commit your changes, and open a pull request to your main branch. + +If you have not worked with branches before, check [e.g. this tutorial](https://www.atlassian.com/git/tutorials/using-branches) to get started. + +Note that when you open the pull request, make sure that you select here your own repository as the destination base repository. By default, the selection is the original repository by and you **do not want** to do that: + +![](../../images/11/pr3.png) + +In the "Conversation" tab of the pull request you should see your latest commit(s) and the yellow status for checks in progress: + +![](../../images/11/pr4.png) + +Once the checks have been run, the status should turn to green. Make sure all the checks pass. Do not merge your branch yet, there's still one more thing we need to improve on our pipeline. + +#### 11.14 Run deployment step only for the main branch + +All looks good, but there is actually a pretty serious problem with the current workflow. All the steps, including the deployment, are run also for pull requests. This is surely something we do not want! + +Fortunately, there is an easy solution for the problem! We can add an [if](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsif) condition to the deployment step, which ensures that the step is executed only when the code is being merged or pushed to the main branch. + +The workflow [context](https://docs.github.com/en/free-pro-team@latest/actions/reference/context-and-expression-syntax-for-github-actions#contexts) gives various kinds of information about the code the workflow is run. + +The relevant information is found in [GitHub context](https://docs.github.com/en/actions/learn-github-actions/contexts#github-context), the field event_name tells us what is the "name" of the event that triggered the workflow. When a pull request is merged, the name of the event is somehow paradoxically push, the same event that happens when pushing the code to the repository. Thus, we get the desired behavior by adding the following condition to the step that deploys the code: + +```js +if: ${{ github.event_name == 'push' }} +``` + +Push some more code to your branch, and ensure that the deployment step is not executed anymore. Then merge the branch to the main branch and make sure that the deployment happens. + +
    + +
    + +### Versioning + +The most important purpose of versioning is to uniquely identify the software we're running and the code associated with it. + +The ordering of versions is also an important piece of information. For example, if the current release has broken critical functionality and we need to identify the previous version of the software so that we can roll back the release back to a stable state. + +#### Semantic Versioning and Hash Versioning + +How an application is versioned is sometimes called a versioning strategy. We'll look at and compare two such strategies. + +The first one is [semantic versioning](https://semver.org/), where a version is in the form {major}.{minor}.{patch}. For example, if the version is 1.2.3, it has 1 as the major version, 2 is the minor version, and 3 is the patch version. + +In general, changes that fix the functionality without changing how the application works from the outside are patch changes, changes that make small changes to functionality (as viewed from the outside) are minor changes and changes that completely change the application (or major functionality changes) are major changes. The definitions of each of these terms can vary from project to project. + +For example, npm-libraries are following the semantic versioning. At the time of writing this text (16th March 2023) the most recent version of React is [18.2.0](https://reactjs.org/versions/), so the major version is 18 and the minor version is 2. + +Hash versioning (also sometimes known as SHA versioning) is quite different. The version "number" in hash versioning is a hash (that looks like a random string) derived from the contents of the repository and the changes introduced in the commit that created the version. In Git, this is already done for you as the commit hash that is unique for any change set. + +Hash versioning is almost always used in conjunction with automation. It's a pain (and error-prone) to copy 32 character long version numbers around to make sure that everything is correctly deployed. + +#### But what does the version point to? + +Determining what code belongs to a given version is important and the way this is achieved is again quite different between semantic and hash versioning. In hash versioning (at least in Git) it's as simple as looking up the commit based on the hash. This will let us know exactly what code is deployed with a specific version. + +It's a little more complicated when using semantic versioning and there are several ways to approach the problem. These boil down to three possible approaches: something in the code itself, something in the repo or repo metadata, something completely outside the repo. + +While we won't cover the last option on the list (since that's a rabbit hole all on its own), it's worth mentioning that this can be as simple as a spreadsheet that lists the Semantic Version and the commit it points to. + +For the two repository based approaches, the approach with something in the code usually boils down to a version number in a file and the repo/metadata approach usually relies on [tags](https://www.atlassian.com/git/tutorials/inspecting-a-repository/git-tag) or (in the case of GitHub) releases. In the case of tags or releases, this is relatively simple, the tag or release points to a commit, the code in that commit is the code in the release. + +#### Version order + +In semantic versioning, even if we have version bumps of different types (major, minor, or patch) it's still quite easy to put the releases in order: 1.3.7 comes before 2.0.0 which itself comes before 2.1.5 which comes before 2.2.0. A list of releases (conveniently provided by a package manager or GitHub) is still needed to know what the last version is but it's easier to look at that list and discuss it: It's easier to say "We need to roll back to 3.2.4" than to try communicate a hash in person. + +That's not to say that hashes are inconvenient: if you know which commit caused the particular problem, it's easy enough to look back through a Git history and get the hash of the previous commit. But if you have two hashes, say d052aa41edfb4a7671c974c5901f4abe1c2db071 and 12c6f6738a18154cb1cef7cf0607a681f72eaff3, you really can not say which came earlier in history, you need something more, such as the Git log that reveals the ordering. + +#### Comparing the Two + +We've already touched on some of the advantages and disadvantages of the two versioning methods discussed above but it's perhaps useful to address where they'd each likely be used. + +Semantic Versioning works well when deploying services where the version number could be of significance or might actually be looked at. As an example, think of the JavaScript libraries that you're using. If you're using version 3.4.6 of a particular library, and there's an update available to 3.4.8, if the library uses semantic versioning, you could (hopefully) safely assume that you're ok to upgrade without breaking anything. If the version jumps to 4.0.1 then maybe it's not such a safe upgrade. + +Hash versioning is very useful where most commits are being built into artifacts (e.g. runnable binaries or Docker images) that are themselves uploaded or stored. As an example, if your testing requires building your package into an artifact, uploading it to a server, and running tests against it, it would be convenient to have hash versioning as it would prevent accidents. + +As an example think that you're working on version 3.2.2 and you have a failing test, you fix the failure and push the commit but as you're working in your branch, you're not going to update the version number. Without hash versioning, the artifact name may not change. If there's an error in uploading the artifact, maybe the tests run again with the older artifact (since it's still there and has the same name) and you get the wrong test results. If the artifact is versioned with the hash, then the version number *must* change on every commit and this means that if the upload fails, there will be an error since the artifact you told the tests to run against does not exist. + +Having an error happen when something goes wrong is almost always preferable to having a problem silently ignored in CI. + +#### Best of Both Worlds + +From the comparison above, it would seem that the semantic versioning makes sense for releasing software while hash-based versioning (or artifact naming) makes more sense during development. This doesn't necessarily cause a conflict. + +Think of it this way: versioning boils down to a technique that points to a specific commit and says "We'll give this point a name, it's name will be 3.5.5". Nothing is preventing us from also referring to the same commit by its hash. + +There is a catch. We discussed at the beginning of this part that we always have to know exactly what is happening with our code, for example, we need to be sure that we have tested the code we want to deploy. Having two parallel versioning (or naming) conventions can make this a little more difficult. + +For example, when we have a project that uses hash-based artifact builds for testing, it's always possible to track the result of every build, lint, and test to a specific commit and developers know the state their code is in. This is all automated and transparent to the developers. They never need to be aware of the fact that the CI system is using the commit hash underneath to name build and test artifacts. When the developers merge their code to the main branch, again the CI takes over. This time, it will build and test all the code and give it a semantic version number all in one go. It attaches the version number to the relevant commit with a Git tag. + +In the case above, the software we release is tested because the CI system makes sure that tests are run on the code it is about to tag. It would not be incorrect to say that the project uses semantic versioning and simply ignore that the CI system tests individual developer branches/PRs with a hash-based naming system. We do this because the version we care about (the one that is released) is given a semantic version. + +
    + +
    + +### Exercises 11.15-11.16. + +Let's extend our workflow so that it will automatically increase (bump) the version when a pull request is merged into the main branch and [tag](https://www.atlassian.com/git/tutorials/inspecting-a-repository/git-tag) the release with the version number. We will use an open source action developed by a third party: [anothrNick/github-tag-action](https://github.com/anothrNick/github-tag-action). + +#### 11.15 Adding versioning + +We will extend our workflow with one more step: + +```js +- name: Bump version and push tag + uses: anothrNick/github-tag-action@1.64.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +Note: you should use the most recent version of the action, see [here](https://github.com/anothrNick/github-tag-action) if a more recent version is available. + +We're passing an environmental variable secrets.GITHUB\_TOKEN to the action. As it is third party action, it needs the token for authentication in your repository. You can read more [here](https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token) about authentication in GitHub Actions. + +You may end up having this error message + +![Submissions](../../images/11/tag-error.png) + +The most likely cause for this is that your token has no write access to your repo. Go to your repository settings, select actions/general, and ensure that your token has read and write permissions: + +![Submissions](../../images/11/tag-permissions.png) + +The [anothrNick/github-tag-action](https://github.com/anothrNick/github-tag-action) action accepts some environment variables that modify the way the action tags your releases. You can look at these in the [README](https://github.com/anothrNick/github-tag-action) and see what suits your needs. + +As you can see from the documentation by default your releases will receive a *minor* bump, meaning that the middle number will be incremented. + +Modify the configuration above so that each new version is by default a _patch_ bump in the version number, so that by default, the last number is increased. + +Remember that we want only to bump the version when the change happens to the main branch! So add a similar if condition to prevent version bumps on pull request as was done in [Exercise 11.14](/en/part11/keeping_green#exercises-11-13-11-14) to prevent deployment on pull request related events. + +Complete now the workflow. Do not just add it as another step, but configure it as a separate job that [depends](https://docs.github.com/en/actions/using-workflows/advanced-workflow-features#creating-dependent-jobs) on the job that takes care of linting, testing and deployment. So change your workflow definition as follows: + +```yml +name: Deployment pipeline + +on: + push: + branches: + - main + pull_request: + branches: [main] + types: [opened, synchronize] + +jobs: + simple_deployment_pipeline: + runs-on: ubuntu-latest + steps: + // steps here + tag_release: + needs: [simple_deployment_pipeline] + runs-on: ubuntu-latest + steps: + // steps here +``` + +As mentioned [earlier](/en/part11/getting_started_with_git_hub_actions#getting-started-with-workflows), jobs of a workflow are executed in parallel. However since we want the linting, testing and deployment to be done first, we set a dependency that the tag\_release waits for since we do not want to tag the release unless it passes tests and is deployed. + +If you're uncertain of the configuration, you can set DRY_RUN to true, which will make the action output the next version number without creating or tagging the release! + +Once the workflow runs successfully, the repository mentions that there are some tags: + +![Releases](../../images/11/17-new.png) + +By clicking view all tags, you can see all the tags listed: + +![Releases](../../images/11/18-new.png) + +If needed, you can navigate to the view of a single tag that shows eg. what is the GitHub commit corresponding to the tag. + +#### 11.16 Skipping a commit for tagging and deployment + +In general, the more often you deploy the main branch to production, the better. However, there might sometimes be a valid reason to skip/prevent a particular commit or a merged pull request from being tagged and released to production. + +Modify your setup so that if a commit message in a pull request contains _#skip_, the merge will not be deployed to production and it is not tagged with a version number. + +**Hints:** + +The easiest way to implement this is to alter the [if](https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstepsif) conditions of the relevant steps. Similarly to [exercise 11-14](/en/part11/keeping_green#exercises-11-13-11-14) you can get the relevant information from the [GitHub context](https://docs.github.com/en/free-pro-team@latest/actions/reference/context-and-expression-syntax-for-github-actions#github-context) of the workflow. + +You might take this as a starting point: + +```js +name: Testing stuff + +on: + push: + branches: + - main + +jobs: + a_test_job: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: github context + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + run: echo "$GITHUB_CONTEXT" + - name: commits + env: + COMMITS: ${{ toJson(github.event.commits) }} + run: echo "$COMMITS" + - name: commit messages + env: + COMMIT_MESSAGES: ${{ toJson(github.event.commits.*.message) }} + run: echo "$COMMIT_MESSAGES" +``` + +See what gets printed in the workflow log! + +Note that you can access the commits and commit messages only when pushing or merging to the main branch, so for pull requests the github.event.commits is empty. It is anyway not needed, since we want to skip the step altogether for pull requests. + +You most likely need functions [contains](https://docs.github.com/en/actions/learn-github-actions/expressions#contains) and [join](https://docs.github.com/en/actions/learn-github-actions/expressions#join) for your if condition. + +Developing workflows is not easy, and quite often the only option is trial and error. It might actually be advisable to have a separate repository for getting the configuration right, and when it is done, to copy the right configurations to the actual repository. + +It would also make sense to re-use longer conditions by moving them to commonly accessible variables and referring these variables on the step level: + +```yaml +name: some workflow name + +env: + # the below will be 'true' + CONDITION: ${{ contains('kissa', 'ss') && contains('koira', 'ra') && contains('pretty long array of criteria to repeat in multiple places', 'crit') }} + +jobs: + job1: + # rest of the job + outputs: + # here we produce a record of the outcome of a key step in the job. + # the below will be 'true' + job2_can_run: ${{ steps.final.outcome == 'success' }} + steps: + - if: ${{ env.CONDITION == 'true' }} + run: echo 'this step is executed' + + - if: ${{ env.CONDITION == 'false' }} + run: echo 'this step will not be executed' + + - if: ${{ env.CONDITION == 'true' }} + # this is important, the id `final` is referenced in job1's `outputs`. + id: final + run: echo + + job2: + needs: + - job1 + # this job will be dependent on the above job1's final step, which in turn depends on the CONDITION defined at the beginning of the file. + # note that the `env`-variable cannot be directly accessed on the job level, so we need to use something else, + # such as the outputs from another job. + if: ${{ needs.job1.outputs.job2_can_run == 'true' }} + steps: + # rest of the job +``` + +It would also be possible to install a tool such as [act](https://github.com/nektos/act) that makes it possible to run your workflows locally. Unless you end up using more involved use cases like creating your [own custom actions](https://docs.github.com/en/free-pro-team@latest/actions/creating-actions), going through the burden of setting up a tool such as act is most likely not worth the trouble. + +
    + +
    + +### A note about using third-party actions + +When using a third-party action such that github-tag-action it might be a good idea to specify the used version with hash instead of using a version number. The reason for this is that the version number, that is implemented with a Git tag can in principle be moved. So today's version 1.61.0 might be a different code that is at next week the version 1.61.0! + +However, the code in a commit with a particular hash does not change in any circumstances, so if we want to be 100% sure about the code we use, it is safest to use the hash. + +Version [1.61.0](https://github.com/anothrNick/github-tag-action/releases/tag/1.61.0) of the action corresponds to a commit with hash 8c8163ef62cf9c4677c8e800f36270af27930f42, so we might want to change our configuration as follows: + +```js + - name: Bump version and push tag + uses: anothrNick/github-tag-action@8c8163ef62cf9c4677c8e800f36270af27930f42 // highlight-line + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +When we use actions provided by GitHub we trust them not to mess with version tags and to thoroughly test their code. + +In the case of third-party actions, the code might end up being buggy or even malicious. Even when the author of the open-source code does not have the intention of doing something bad, they might end up leaving their credentials on a post-it note in a cafe, and then who knows what might happen. + +By pointing to the hash of a specific commit we can be sure that the code we use when running the workflow will not change because changing the underlying commit and its contents would also change the hash. + +### Keep the main branch protected + +GitHub allows you to set up protected branches. It is important to protect your most important branch that should never be broken: main. In repository settings, you can choose between several levels of protection. We will not go over all of the protection options, you can learn more about them in GitHub documentation. Requiring pull request approval when merging into the main branch is one of the options we mentioned earlier. + +From CI point of view, the most important protection is requiring status checks to pass before a PR can be merged into the main branch. This means that if you have set up GitHub Actions to run e.g. linting and testing tasks, then until all the lint errors are fixed and all the tests pass the PR cannot be merged. Because you are the administrator of your repository, you will see an option to override the restriction. However, non-administrators will not have this option. + +![Unmergeable PR](../../images/11/part11d_03.png) + +To set up protection for your main branch, navigate to repository "Settings" from the top menu inside the repository. In the left-side menu select "Branches". Click "Add rule" button next to "Branch protection rules". Type a branch name pattern ("main" will do nicely) and select the protection you would want to set up. At least "Require status checks to pass before merging" is necessary for you to fully utilize the power of GitHub Actions. Under it, you should also check "Require branches to be up to date before merging" and select all of the status checks that should pass before a PR can be merged. + +![Branch protection rule](../../images/11/part11d_04.png) + +
    + +
    + +### Exercise 11.17 + +#### 11.17 Adding protection to your main branch + +Add protection to your main branch. + +You should protect it to: +- Require all pull request to be approved before merging +- Require all status checks to pass before merging + +
    diff --git a/src/content/11/en/part11e.md b/src/content/11/en/part11e.md new file mode 100644 index 00000000000..5e97ec5b082 --- /dev/null +++ b/src/content/11/en/part11e.md @@ -0,0 +1,140 @@ +--- +mainImage: ../../../images/part-11.svg +part: 11 +letter: e +lang: en +--- + +
    + +This part focuses on building a simple, effective, and robust CI system that helps developers to work together, maintain code quality, and deploy safely. What more could one possibly want? In the real world, there are more fingers in the pie than just developers and users. Even if that weren't true, even for developers, there's a lot more value to be gained from CI systems than just the things above. + +### Visibility and Understanding + +In all but the smallest companies, decisions on what to develop are not made exclusively by developers. The term 'stakeholder' is often used to refer to people, both inside and outside the development team, who may have some interest in keeping an eye on the progress of the development. To this end, there are often integrations between Git and whatever project management/bug tracking software the team is using. + +A common use of this is to have some reference to the tracking system in Git pull requests or commits. This way, for example, when you're working on issue number 123, you might name your pull request BUG-123: Fix user copy issue and the bug tracking system would notice the first part of the PR name and automatically move the issue to Done when the PR is merged. + +### Notifications + +When the CI process finishes quickly, it can be convenient to just watch it execute and wait for the result. As projects become more complex, so too does the process of building and testing the code. This can quickly lead to a situation where it takes long enough to generate the build result that a developer may want to begin working on another task. This in turn leads to a forgotten build. + +This is especially problematic if we're talking about merging PRs that may affect another developer's work, either causing problems or delays for them. This can also lead to a situation where you think you've deployed something but haven't actually finished a deployment, this can lead to miscommunication with teammates and customers (e.g. "Go ahead and try that again, the bug should be fixed"). + +There are several solutions to this problem ranging from simple notifications to more complicated processes that simply merge passing code if certain conditions are met. We're going to discuss notifications as a simple solution since it's the one that interferes with the team workflow the least. + +By default, GitHub Actions sends an email on a build failure. This can be changed to send notifications regardless of build status and can also be configured to alert you on the GitHub web interface. Great. But what if we want more. What if for whatever reason this doesn't work for our use case. + +There are integrations for example to various messaging applications such as [Slack](https://slack.com/intl/en-fi/) or [Discord](https://discord.com/), to send notifications. These integrations still decide what to send and when to send it based on logic from GitHub. + +
    + +
    + +### Exercise 11.18 + +We have set up a channel fullstack\_webhook to the course Discord group at [https://study.cs.helsinki.fi/discord/join/fullstack](https://study.cs.helsinki.fi/discord/join/fullstack) for testing a messaging integration. + +Register now to Discord if you have not already done that. You will also need a Discord webhook in this exercise. You find the webhook in the pinned message of the channel fullstack\_webhook. Please do not commit the webhook to GitHub! + +#### 11.18 Build success/failure notification action + +You can find quite a few third-party actions from [GitHub Action Marketplace](https://github.com/marketplace?type=actions) by using the search phrase [discord](https://github.com/marketplace?type=actions&query=discord). Pick one for this exercise. My choice was [discord-webhook-notify](https://github.com/marketplace/actions/discord-webhook-notify) since it has quite a few stars and decent documentation. + +Setup the action so that it gives two types of notifications: +- A success indication if a new version gets deployed +- An error indication if a build fails + +In the case of an error, the notification should be a bit more verbose to help developers find quickly which is the commit that caused it. + +See [here](https://docs.github.com/en/actions/learn-github-actions/expressions#status-check-functions) how to check the job status! + +Your notifications may look like the following: + +![Releases](../../images/11/gha-notify.png) + +
    + +
    + +### Metrics + +In the previous section, we mentioned that as projects get more complicated, so too, do their builds, and the duration of the builds increases. That's obviously not ideal: The longer the feedback loop, the slower the development. + +While there are things that can be done about this increase in build times, it's useful to have a better view of the overall picture. It's useful to know how long a build took a few months ago versus how long it takes now. Was the progression linear or did it suddenly jump? Knowing what caused the increase in build time can be very useful in helping to solve it. If the build time increased linearly from 5 minutes to 10 minutes over the last year, maybe we can expect it to take another few months to get to 15 minutes and we have an idea of how much value there is in spending time speeding up the CI process. + +Metrics can either be self-reported (also called 'push' metrics, where each build reports how long it took) or the data can be fetched from the API afterward (sometimes called 'pull' metrics). The risk with self-reporting is that the self-reporting itself takes time and may have a significant impact on "total time taken for all builds". + +This data can be sent to a time-series database or to an archive of another type. There are plenty of cloud services where you can easily aggregate the metrics, one good option is [Datadog](https://www.datadoghq.com/). + +### Periodic tasks + +There are often periodic tasks that need to be done in a software development team. Some of these can be automated with commonly available tools and some you will need to automate yourself. + +The former category includes things like checking packages for security vulnerabilities. Several tools can already do this for you. Some of these tools would even be free for certain types (e.g. open source) projects. GitHub provides one such tool, [Dependabot](https://dependabot.com/). + +Words of advice to consider: If your budget allows it, it's almost always better to use a tool that already does the job than to roll your own solution. If security isn't the industry you're aiming for, for example, use Dependabot to check for security vulnerabilities instead of making your own tool. + +What about the tasks that don't have a tool? You can automate these yourself with GitHub Actions too. GitHub Actions provides a scheduled trigger that can be used to execute a task at a particular time. + +
    + +
    + +### Exercises 11.19-11.21 + +#### 11.19 Periodic health check + +We are pretty confident now that our pipeline prevents bad code from being deployed. However, there are many sources of errors. If our application would e.g. depend on a database that would for some reason become unavailable, our application would most likely crash. That's why it would be a good idea to set up a periodic health check that would regularly do an HTTP GET request to our server. We quite often refer to this kind of request as a ping. + +It is possible to [schedule](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule) GitHub actions to happen regularly. + +Use now the action [url-health-check](https://github.com/marketplace/actions/url-health-check) or any other alternative and schedule a periodic health check ping to your deployed software. Try to simulate a situation where your application breaks down and ensure that the check detects the problem. Write this periodic workflow to an own file. + +**Note** that unfortunately it takes quite long until GitHub Actions starts the scheduled workflow for the first time. For me, it took nearly one hour. So it might be a good idea to get the check working firstly by triggering the workflow with Git push. When you are sure that the check is properly working, then switch to a scheduled trigger. + +**Note also** that once you get this working, it is best to drop the ping frequency (to max once in 24 hours) or disable the rule altogether since otherwise your health check may consume all your monthly free hours. + +#### 11.20 Your own pipeline + +Build a similar CI/CD-pipeline for some of your own applications. Some of the good candidates are the phonebook app that was built in parts 2 and 3 of the course, or the blogapp built in parts 4 and 5, or the Redux anecdotes built in part 6. You may also use some app of your own for this exercise. + +You most likely need to do some restructuring to get all the pieces together. A logical first step is to store both the frontend and backend code in the same repository. This is not a requirement but it is recommended since it makes things much more simple. + +One possible repository structure would be to have the backend at the root of the repository and the frontend as a subdirectory. You can also "copy paste" the structure of the example app of this part or try out the [example app](https://github.com/fullstack-hy2020/create-app) mentioned in [part 7](/en/part7/class_components_miscellaneous#frontend-and-backend-in-the-same-repository). + +It is perhaps best to create a new repository for this exercise and simply copy and paste the old code there. In real life, you most likely would do this all in the old repository but now "a fresh start" makes things easier. + +This is a long and perhaps quite a tough exercise, but this kind of situation where you have a "legacy code" and you need to build proper deployment pipeline is quite common in real life! + +Obviously, this exercise is not done in the same repository as the previous exercises. Since you can return only one repository to the submission system, put a link of the other repository to the one you fill into the submission form. + +#### 11.21 Protect your main branch and ask for pull request + +Protect the main branch of the repository where you did the previous exercise. This time prevent also the administrators from merging the code without a review. + +Do a pull request and ask GitHub user [mluukkai](https://github.com/mluukkai) to review your code. Once the review is done, merge your code to the main branch. Note that the reviewer needs to be a collaborator in the repository. Ping us in Discord to get the review, and to include the collaboration invite link to the message. + +**Please note** what was written above, include the link to _the collaboration invite_ in the ping, not the link to the pull request. + +Then you are done! + +
    + +
    + +### Submitting exercises and getting the credits + +Exercises of this part are submitted via [the submissions system](https://studies.cs.helsinki.fi/stats/courses/fs-cicd) just like in the previous parts, but unlike parts 0 to 7, the submission goes to different "course instance". Remember that you have to finish all the exercises to pass this part! + +Your solutions are in two repositories (pokedex and your own project), and since you can return only one repository to the submission system, put a link of the other repository to the one you fill into the submission form! + +Once you have completed the exercises and want to get the credits, let us know through the exercise submission system that you have completed the course: + +![Submissions](../../images/11/21.png) + +**Note** that you need a registration to the corresponding course part for getting the credits registered, see [here](/en/part0/general_info#parts-and-completion) for more information. + +You can download the certificate for completing this part by clicking one of the flag icons. The flag icon corresponds to the certificate's language. + +
    diff --git a/src/content/11/es/part11.md b/src/content/11/es/part11.md new file mode 100644 index 00000000000..000fab03477 --- /dev/null +++ b/src/content/11/es/part11.md @@ -0,0 +1,18 @@ +--- +mainImage: ../../../images/part-11.svg +part: 11 +lang: es +--- + +
    + +So you have a fresh feature ready to be shipped. What happens next? Do you upload files to a server manually? How do you manage the version of your product running in the wild? How do you make sure it works, and roll back to a safe version if it doesn’t? + +Doing all the above manually is a pain and doesn’t scale well for a larger team. That’s why we have Continuous Integration / Continuous Delivery systems, in short CI/CD systems. In this part, you will gain an understanding of why you should use a CI/CD system, what can one do for you, and how to get started with GitHub Actions which is available to all GitHub users by default. + +This module was crafted by the Engineering Team at Smartly.io. At Smartly.io, we automate every step of social advertising to unlock greater performance and creativity. We make every day of advertising easy, effective, and enjoyable for more than 650 brands worldwide, including eBay, Uber, and Zalando. We are one of the early adopters of GitHub Actions in wide-scale production use. Contributors: [Anna Osipova](https://www.linkedin.com/in/a-osipova/), [Anton Rautio](https://www.linkedin.com/in/anton-rautio-768190145/), [Mircea Halmagiu](https://www.linkedin.com/in/mhalmagiu/), [Tomi Hiltunen](https://www.linkedin.com/in/tomihiltunen/). + +Part updated 19th March 2024 +- Added info on using Playwright for the End to end -tests + +
    diff --git a/src/content/11/es/part11a.md b/src/content/11/es/part11a.md new file mode 100644 index 00000000000..c96ef10ceb0 --- /dev/null +++ b/src/content/11/es/part11a.md @@ -0,0 +1,222 @@ +--- +mainImage: ../../../images/part-11.svg +part: 11 +letter: a +lang: es +--- + +
    + +During this part, you will build a robust deployment pipeline to a ready made [example project](https://github.com/fullstack-hy2020/full-stack-open-pokedex) starting in [exercise 11.2](/en/part11/getting_started_with_git_hub_actions#exercise-11-2). You will [fork](https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/fork-a-repo) the example project and that will create you a personal copy of the repository. In the [last two](/en/part11/expanding_further#exercises-11-20-22) exercises, you will build another deployment pipeline for some of your own previously created apps! + +There are 21 exercises in this part, and you need to complete each exercise for completing the course. Exercises are submitted via [the submissions system](https://studies.cs.helsinki.fi/stats/courses/fs-cicd) just like in the previous parts, but unlike parts 0 to 7, the submission goes to a different "course instance". + +This part will rely on many concepts covered in the previous parts of the course. It is recommended that you finish at least parts 0 to 5 before starting this part. + +Unlike the other parts of this course, you do not write many lines of code in this part, it is much more about configuration. Debugging code might be hard but debugging configurations is way harder, so in this part, you need lots of patience and discipline! + +### Getting software to production + +Writing software is all well and good but nothing exists in a vacuum. Eventually, we'll need to deploy the software to production, i.e. give it to the real users. After that we need to maintain it, release new versions, and work with other people to expand that software. + +We've already used GitHub to store our source code, but what happens when we work within a team with more developers? + +Many problems may arise when several developers are involved. The software might work just fine on my computer, but maybe some of the other developers are using a different operating system or different library versions. It is not uncommon that a code works just fine in one developer's machine but another developer can not even get it started. This is often called the "works on my machine" problem. + +There are also more involved problems. If two developers are both working on changes and they haven't decided on a way to deploy to production, whose changes get deployed? How would it be possible to prevent one developer's changes from overwriting another's? + +In this part, we'll cover ways to work together and build and deploy software in a strictly defined way so that it's clear exactly what will happen under any given circumstance. + +### Some useful terms + +In this part we'll be using some terms you may not be familiar with or you may not have a good understanding of. We'll discuss some of these terms here. Even if you are familiar with the terms, give this section a read so when we use the terms in this part, we're on the same page. + +#### Branches + +Git allows multiple copies, streams, or versions of the code to co-exist without overwriting each other. When you first create a repository, you will be looking at the main branch (usually in Git, we call this main or master, but that does vary in older projects). This is fine if there's only one developer for a project and that developer only works on one feature at a time. + +Branches are useful when this environment becomes more complex. In this context, each developer can have one or more branches. Each branch is effectively a copy of the main branch with some changes that make it diverge from it. Once the feature or change in the branch is ready it can be merged back into the main branch, effectively making that feature or change part of the main software. In this way, each developer can work on their own set of changes and not affect any other developer until the changes are ready. + +But once one developer has merged their changes into the main branch, what happens to the other developers' branches? They are now diverging from an older copy of the main branch. How will the developer on the later branch know if their changes are compatible with the current state of the main branch? That is one of the fundamental questions we will be trying to answer in this part. + +You can read more about branches e.g. from [here](https://www.atlassian.com/git/tutorials/using-branches). + +#### Pull request + +In GitHub merging a branch back to the main branch of software is quite often happening using a mechanism called [pull request](https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/about-pull-requests), where the developer who has done some changes is requesting the changes to be merged to the main branch. Once the pull request, or PR as it's often called, is made or opened, another developer checks that all is ok and merges the PR. + +If you have proposed changes to the material of this course, you have already made a pull request! + +#### Build + +The term "build" has different meanings in different languages. In some interpreted languages such as Python or Ruby, there is actually no need for a build step at all. + +In general when we talk about building we mean preparing software to run on the platform where it's intended to run. This might mean, for example, that if you've written your application in TypeScript, and you intend to run it on Node, then the build step might be transpiling the TypeScript into JavaScript. + +This step is much more complicated (and required) in compiled languages such as C and Rust where the code needs to be compiled into an executable. + +In [part 7](/en/part7/webpack) we had a look at [Webpack](https://webpack.js.org/) that is the current de facto tool for building a production version of a React or any other frontend JavaScript or TypeScript codebase. + +#### Deploy + +Deployment refers to putting the software where it needs to be for the end-user to use it. In the case of libraries, this may simply mean pushing an npm package to a package archive (such as [npmjs.com](https://www.npmjs.com/)) where other users can find it and include it in their software. + +Deploying a service (such as a web app) can vary in complexity. In [part 3](/en/part3/deploying_app_to_internet) our deployment workflow involved running some scripts manually and pushing the repository code to [Fly.io](https://fly.io/) or [Render](https://render.com/) hosting service. + +In this part, we'll develop a simple "deployment pipeline" that deploys each commit of your code automatically to Fly.io or Render if the committed code does not break anything. + +Deployments can be significantly more complex, especially if we add requirements such as "the software must be available at all times during the deployment" (zero downtime deployments) or if we have to take things like [database migrations](/en/part13/migrations_many_to_many_relationships#migrations) into account. We won't cover complex deployments like those in this part but it's important to know that they exist. + +### What is CI? + +The strict definition of CI (Continuous Integration) and the way the term is used in the industry may sometimes be different. One influential but quite early (written already in 2006) discussion of the topic is in [Martin Fowler's blog](https://www.martinfowler.com/articles/continuousIntegration.html). + +Strictly speaking, CI refers to merging developer changes to the main branch often, Wikipedia even helpfully suggests: "several times a day". This is usually true but when we refer to CI in industry, we're quite often talking about what happens after the actual merge happens. + +We'd likely want to do some of these steps: + - Lint: to keep our code clean and maintainable + - Build: put all of our code together into runnable software bundle + - Test: to ensure we don't break existing features + - Package: Put it all together in an easily movable batch + - Deploy: Make it available to the world + +We'll discuss each of these steps (and when they're suitable) in more detail later. What is important to remember is that this process should be strictly defined. + +Usually, strict definitions act as a constraint on creativity/development speed. This, however, should usually not be true for CI. This strictness should be set up in such a way as to allow for easier development and working together. Using a good CI system (such as GitHub Actions that we'll cover in this part) will allow us to do this all automagically. + +### Packaging and Deployment as a part of CI + +It may be worthwhile to note that packaging and especially deployment are sometimes not considered to fall under the umbrella of CI. We'll add them in here because in the real world it makes sense to lump it all together. This is partly because they make sense in the context of the flow and pipeline (I want to get my code to users) and partially because these are in fact the most likely point of failure. + +The packaging is often an area where issues crop up in CI as this isn't something that's usually tested locally. It makes sense to test the packaging of a project during the CI workflow even if we don't do anything with the resulting package. With some workflows, we may even be testing the already built packages. This assures us that we have tested the code in the same form as what will be deployed to production. + +What about deployment then? We'll talk about consistency and repeatability at length in the coming sections but we'll mention here that we want a process that always looks the same, whether we're running tests on a development branch or the main branch. In fact, the process may literally be the same with only a check at the end to determine if we are on the main branch and need to do a deployment. In this context, it makes sense to include deployment in the CI process since we'll be maintaining it at the same time we work on CI. + +#### Is this CD thing related? + +The terms Continuous Delivery and Continuous Deployment (both of which have the acronym CD) are often used when one talks about CI that also takes care of deployments. We won't bore you with the exact definition (you can use e.g. [Wikipedia](https://en.wikipedia.org/wiki/Continuous_delivery) or [another Martin Fowler blog post](https://martinfowler.com/bliki/ContinuousDelivery.html)) but in general, we refer to CD as the practice where the main branch is kept deployable at all times. In general, this is also frequently coupled with automated deployments triggered from merges into the main branch. + +What about the murky area between CI and CD? If we, for example, have tests that must be run before any new code can be merged to the main branch, is this CI because we're making frequent merges to the main branch, or is it CD because we're making sure that the main branch is always deployable? + +So, some concepts frequently cross the line between CI and CD and, as we discussed above, deployment sometimes makes sense to consider CD as part of CI. This is why you'll often see references to CI/CD to describe the entire process. We'll use the terms "CI" and "CI/CD" interchangeably in this part. + +### Why is it important? + +Above we talked about the "works on my machine" problem and the deployment of multiple changes, but what about other issues. What if Alice committed directly to the main branch? What if Bob used a branch but didn't bother to run tests before merging? What if Charlie tries to build the software for production but does so with the wrong parameters? + +With the use of continuous integration and systematic ways of working, we can avoid these. + - We can disallow commits directly to the main branch + - We can have our CI process run on all Pull Requests (PRs) against the main branch and allow merges only when our desired conditions are met e.g. tests pass + - We can build our packages for production in the known environment of the CI system + +There are other advantages to extending this setup: + - If we use CI/CD with deployment every time there is a merge to the main branch, then we know that it will always work in production + - If we only allow merges when the branch is up to date with the main branch, then we can be sure that different developers don't overwrite each other's changes + +Note that in this part we are assuming that the main branch contains the code that is running in production. There are numerous different [workflows](https://www.atlassian.com/git/tutorials/comparing-workflows) one can use with Git, e.g. in some cases, it may be a specific release branch that contains the code that is running in production. + +### Important principles + +It's important to remember that CI/CD is not the goal. The goal is better, faster software development with fewer preventable bugs and better team cooperation. + +To that end, CI should always be configured to the task at hand and the project itself. The end goal should be kept in mind at all times. You can think of CI as the answer to these questions: + - How to make sure that tests run on all code that will be deployed? + - How to make sure that the main branch is deployable at all times? + - How to ensure that builds will be consistent and will always work on the platform it'd be deploying to? + - How to make sure that the changes don't overwrite each other? + - How to make deployments happen at the click of a button or automatically when one merges to the main branch? + +There even exists scientific evidence on the numerous benefits the usage of CI/CD has. According to a large study reported in the book [Accelerate: The Science of Lean Software and DevOps: Building and Scaling High Performing Technology Organizations](https://itrevolution.com/product/accelerate/), the use of CI/CD correlate heavily with organizational success (e.g. improves profitability and product quality, increases market share, shortens the time to market). CI/CD even makes developers happier by reducing their burnout rate. The results summarized in the book are also reported in scientific articles such as [this](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=2681909). +#### Documented behavior + +There's an old joke that a bug is just an "undocumented feature". We'd like to avoid that. We'd like to avoid any situations where we don't know the exact outcome. For example, if we depend on a label on a PR to define whether something is a "major", "minor" or "patch" release (we'll cover the meanings of those terms later), then it's important that we know what happens if a developer forgets to put a label on their PR. What if they put a label on after the build/test process has started? What happens if the developer changes the label mid-way through, which one is the one that actually releases? + +It's possible to cover all cases you can think of and still have gaps where the developer will do something "creative" that you didn't think of, so it's important to have the process fail safely in this case. + +For example, if we have the case mentioned above where the label changes midway through the build. If we didn't think of this beforehand, it might be best to fail the build and alert the user if something we weren't expecting happened. The alternative, where we deploy the wrong type of version anyway, could result in bigger problems, so failing and notifying the developer is the safest way out of this situation. + +#### Know the same thing happens every time + +We might have the best tests imaginable for our software, tests that catch every possible issue. That's great, but they're useless if we don't run them on the code before it's deployed. + +We need to guarantee that the tests will run and we need to be sure that they run against the code that will actually be deployed. For example, it's no use if the tests are only run against Alice's branch if they would fail after merging to the main branch. We're deploying from the main branch so we need to make sure that the tests are run against a copy of the main branch with Alice's changes merged in. + +This brings us to a critical concept. We need to make sure that the same thing happens every time. Or rather that the required tasks are all performed and in the right order. + +#### Code always kept deployable + +Having code that's always deployable makes life easier. This is especially true when the main branch contains the code running in the production environment. For example, if a bug is found and it needs to be fixed, you can pull a copy of the main branch (knowing it is the code running in production), fix the bug, and make a pull request back to the main branch. This is relatively straight forward. + +If, on the other hand, the main branch and production are very different and the main branch is not deployable, then you would have to find out what code is running in production, pull a copy of that, fix the bug, figure out a way to push it back, then work out how to deploy that specific commit. That's not great and would have to be a completely different workflow from a normal deployment. + +#### Knowing what code is deployed (sha sum/version) + +It's often important to know what is actually running in production. Ideally, as we discussed above, we'd have the main branch running in production. This is not always possible. Sometimes we intend to have the main branch in production but a build fails, sometimes we batch together several changes and want to have them all deployed at once. + +What we need in these cases (and is a good idea in general) is to know exactly what code is running in production. Sometimes this can be done with a version number, sometimes it's useful to have the commit SHA sum (uniquely identifying hash of that particular commit in git) attached to the code. We will discuss versioning further [a bit later in this part](/en/part11/keeping_green#versioning). + +It is even more useful if we combine the version information with a history of all releases. If, for example, we found out that a particular commit has introduced a bug, we can find out exactly when that was released and how many users were affected. This is especially useful when that bug has written bad data to the database. We'd now be able to track where that bad data went based on the time. + +### Types of CI setup + +To meet some of the requirements listed above, we want to dedicate a separate server for running the tasks in continuous integration. Having a separate server for the purpose minimizes the risk that something else interferes with the CI/CD process and causes it to be unpredictable. + +There are two options: host our own server or use a cloud service. + +#### Jenkins (and other self-hosted setups) + +Among the self-hosted options, [Jenkins](https://www.jenkins.io/) is the most popular. It's extremely flexible and +there are plugins for almost anything (except that one thing you want to do). This is a great option for many applications, using a self-hosted setup means that the entire environment is under your control, the number of resources can be controlled, secrets (we'll elaborate a little more on security in later sections of this part) are never exposed to anyone else and you can do anything you want on the hardware. + +Unfortunately, there is also a downside. Jenkins is quite complicated to set up. It's very flexible but that means that there's often quite a bit of boilerplate/template code involved to get builds working. With Jenkins specifically, it also means that CI/CD must be set up with Jenkins' own domain-specific language. There are also the risks of hardware failures which can be an issue if the setup sees heavy use. + +With self-hosted options, the billing is usually based on the hardware. You pay for the server. What you do on the server doesn't change the billing. + +#### GitHub Actions and other cloud-based solutions + +In a cloud-hosted setup, the setup of the environment is not something you need to worry about. It's there, all you need to do is tell it what to do. Doing that usually involves putting a file in your repository and then telling the CI system to read the file (or to check your repository for that particular file). + +The actual CI config for the cloud-based options is often a little simpler, at least if you stay within what is considered "normal" usage. If you want to do something a little bit more special, then cloud-based options may become more limited, or you may find it difficult to do that one specific task for which the cloud platform just isn't built for. + +In this part, we'll look at a fairly normal use case. The more complicated setups might, for example, make use of specific hardware resources, e.g. a GPU. + +Aside from the configuration issue mentioned above, there are often resource limitations on cloud-based platforms. In a self-hosted setup, if a build is slow, you can just get a bigger server and throw more resources at it. In cloud-based options, this may not be possible. For example, in [GitHub Actions](https://github.com/features/actions), the nodes your builds will run on have 2 vCPUs and 8GB of RAM. + +Cloud-based options are also usually billed by build time which is something to consider. + +#### Why pick one over the other + +In general, if you have a small to medium software project that doesn't have any special requirements (e.g. a need for a graphics card to run tests), a cloud-based solution is probably best. The configuration is simple and you don't need to go to the hassle or expense of setting up your own system. For smaller projects especially, this should be cheaper. + +For larger projects where more resources are needed or in larger companies where there are multiple teams and projects to take advantage of it, a self-hosted CI setup is probably the way to go. + +#### Why use GitHub Actions for this course + +For this course, we'll use [GitHub Actions](https://github.com/features/actions). It is an obvious choice since we're using GitHub anyway. We can get a robust CI solution working immediately without any hassle of setting up a server or configuring a third-party cloud-based service. + +Besides being easy to take into use, GitHub Actions is a good choice in other respects. It might be the best cloud-based solution at the moment. It has gained lots of popularity since its initial release in November 2019. + +
    + +
    + +### Exercise 11.1 + +Before getting our hands dirty with setting up the CI/CD pipeline let us reflect a bit on what we have read. + +#### 11.1 Warming up + +Think about a hypothetical situation where we have an application being worked on by a team of about 6 people. The application is in active development and will be released soon. + +Let us assume that the application is coded with some other language than JavaScript/TypeScript, e.g. in Python, Java, or Ruby. You can freely pick the language. This might even be a language you do not know much yourself. + +Write a short text, say 200-300 words, where you answer or discuss some of the points below. You can check the length with https://wordcounter.net/. Save your answer to the file named exercise1.md in the root of the repository that you shall create in [exercise 11.2](/en/part11/getting_started_with_git_hub_actions#exercise-11-2). + +The points to discuss: +- Some common steps in a CI setup include linting, testing, and building. What are the specific tools for taking care of these steps in the ecosystem of the language you picked? You can search for the answers by Google. +- What alternatives are there to set up the CI besides Jenkins and GitHub Actions? Again, you can ask Google! +- Would this setup be better in a self-hosted or a cloud-based environment? Why? What information would you need to make that decision? + +Remember that there are no 'right' answers to the above! + +
    diff --git a/src/content/11/es/part11b.md b/src/content/11/es/part11b.md new file mode 100644 index 00000000000..c57f1c24634 --- /dev/null +++ b/src/content/11/es/part11b.md @@ -0,0 +1,425 @@ +--- +mainImage: ../../../images/part-11.svg +part: 11 +letter: b +lang: es +--- + +
    + +Before we start playing with GitHub Actions, let's have a look at what they are and how do they work. + +GitHub Actions work on a basis of [workflows](https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/introduction-to-github-actions#workflows). A workflow is a series of [jobs](https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/introduction-to-github-actions#jobs) that are run when a certain triggering [event](https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/introduction-to-github-actions#events) happens. The jobs that are run then themselves contain instructions for what GitHub Actions should do. + +A typical execution of a workflow looks like this: + +- Triggering event happens (for example, there is a push to the main branch). +- The workflow with that trigger is executed. +- Cleanup + +### Basic needs + +In general, to have CI operate on a repository, we need a few things: + +- A repository (obviously) +- Some definition of what the CI needs to do: + This can be in the form of a specific file inside the repository or it can be defined in the CI system +- The CI needs to be aware that the repository (and the configuration file within it) exist +- The CI needs to be able to access the repository +- The CI needs permissions to perform the actions it is supposed to be able to do: + For example, if the CI needs to be able to deploy to a production environment, it needs credentials for that environment. + +That's the traditional model at least, we'll see in a minute how GitHub Actions short-circuit some of these steps or rather make it such that you don't have to worry about them! + +GitHub Actions have a great advantage over self-hosted solutions: the repository is hosted with the CI provider. In other words, GitHub provides both the repository and the CI platform. This means that if we've enabled actions for a repository, GitHub is already aware of the fact that we have workflows defined and what those definitions look like. + +
    + +
    + +### Exercise 11.2. + +In most exercises of this part, we are building a CI/CD pipeline for a small project found in [this example project repository](https://github.com/fullstack-hy2020/full-stack-open-pokedex). + +#### 11.2 The example project + +The first thing you'll want to do is to fork the example repository under your name. What it essentially does is it creates a copy of the repository under your GitHub user profile for your use. + +To fork the repository, you can click on the Fork button in the top-right area of the repository view next to the Star button: + +![](../../images/11/1.png) + +Once you've clicked on the Fork button, GitHub will start the creation of a new repository called {github_username}/full-stack-open-pokedex. + +Once the process has been finished, you should be redirected to your brand-new repository: + +![](../../images/11/2.png) + +Clone the project now to your machine. As always, when starting with a new code, the most obvious place to look first is the file package.json + +**NOTE** since the project is already a bit old, you need Node 16 to work with it! + +Try now the following: +- install dependencies (by running npm install) +- start the code in development mode +- run tests +- lint the code + +You might notice that the project contains some broken tests and linting errors. **Just leave them as they are for now.** We will get around those later in the exercises. + +**NOTE** the tests of the project have been made with [Jest](https://jestjs.io/). The course material in [part 5](/en/part5/testing_react_apps) uses [Vitest](https://vitest.dev/guide/). From the usage point of view, the libraries have barely any difference. + +As you might remember from [part 3](/en/part3/deploying_app_to_internet#frontend-production-build), the React code should not be run in development mode once it is deployed in production. Try now the following +- create a production build of the project +- run the production version locally + +Also for these two tasks, there are ready-made npm scripts in the project! + +Study the structure of the project for a while. As you notice both the frontend and the backend code are now [in the same repository](/en/part7/class_components_miscellaneous#frontend-and-backend-in-the-same-repository). In earlier parts of the course we had a separate repository for both, but having those in the same repository makes things much simpler when setting up a CI environment. + +In contrast to most projects in this course, the frontend code does not use Vite but it has a relatively simple [Webpack](/en/part7/webpack) configuration that takes care of creating the development environment and creating the production bundle. + +
    + +
    + +### Getting started with workflows + +The core component of creating CI/CD pipelines with GitHub Actions is something called a [Workflow](https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/introduction-to-github-actions#workflows). Workflows are process flows that you can set up in your repository to run automated tasks such as building, testing, linting, releasing, and deploying to name a few! The hierarchy of a workflow looks as follows: + +Workflow + +- Job + - Step + - Step +- Job + - Step + +Each workflow must specify at least one [Job](https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/introduction-to-github-actions#jobs), which contains a set of [Steps](https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/introduction-to-github-actions#steps) to perform individual tasks. The jobs will be run in parallel and the steps in each job will be executed sequentially. + +Steps can vary from running a custom command to using pre-defined actions, thus the name GitHub Actions. You can create [customized actions](https://docs.github.com/en/free-pro-team@latest/actions/creating-actions) or use any actions published by the community, which are plenty, but let's get back to that later! + +For GitHub to recognize your workflows, they must be specified in .github/workflows folder in your repository. Each Workflow is its own separate file which needs to be configured using the YAML data-serialization language. + +YAML is a recursive acronym for "YAML Ain't Markup Language". As the name might hint its goal is to be human-readable and it is commonly used for configuration files. You will notice below that it is indeed very easy to understand! + +Notice that indentations are important in YAML. You can learn more about the syntax [here](https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html). + +A basic workflow contains three elements in a YAML document. These three elements are: + +- name: Yep, you guessed it, the name of the workflow +- (on) triggers: The events that trigger the workflow to be executed +- jobs: The separate jobs that the workflow will execute (a basic workflow might contain only one job). + +A simple workflow definition looks like this: + +```yml +name: Hello World! + +on: + push: + branches: + - main + +jobs: + hello_world_job: + runs-on: ubuntu-latest + steps: + - name: Say hello + run: | + echo "Hello World!" +``` + +There is one job named hello\_world\_job, it will be run in a virtual environment with Ubuntu 20.04. The job has just one step named "Say hello", which will run the echo "Hello World!" command in the shell. + +So you may ask, when does GitHub trigger a workflow to be started? There are plenty of [options](https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows) to choose from, but generally speaking, you can configure a workflow to start once: + +- An event on GitHub occurs such as when someone pushes a commit to a repository or when an issue or pull request is created +- A scheduled event, that is specified using the [cron]( https://en.wikipedia.org/wiki/Cron)-syntax, happens +- An external event occurs, for example, a command is performed in an external application such as [Slack](https://slack.com/) or [Discord](https://discord.com/) messaging app + +To learn more about which events can be used to trigger workflows, please refer to GitHub Action's [documentation](https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows). + + +
    + +
    + +### Exercises 11.3-11.4. + +To tie this all together, let us now get GitHub Actions up and running in the example project! + +#### 11.3 Hello world! + +Create a new Workflow that outputs "Hello World!" to the user. For the setup, you should create the directory .github/workflows and a file hello.yml to your repository. + +To see what your GitHub Action workflow has done, you can navigate to the **Actions** tab in GitHub where you should see the workflows in your repository and the steps they implement. The output of your Hello World workflow should look something like this with a properly configured workflow. + +![A properly configured Hello World workflow](../../images/11/3.png) + +You should see the "Hello World!" message as an output. If that's the case then you have successfully gone through all the necessary steps. You have your first GitHub Actions workflow active! + +Note that GitHub Actions also informs you on the exact environment (operating system, and its [setup](https://github.com/actions/virtual-environments/blob/ubuntu18/20201129.1/images/linux/Ubuntu1804-README.md)) where your workflow is run. This is important since if something surprising happens, it makes debugging so much easier if you can reproduce all the steps in your machine! + +#### 11.4 Date and directory contents + +Extend the workflow with steps that print the date and current directory content in the long format. + +Both of these are easy steps, and just running commands [date](https://man7.org/linux/man-pages/man1/date.1.html) and [ls](https://man7.org/linux/man-pages/man1/ls.1.html) will do the trick. + +Your workflow should now look like this + +![Date and dir content in the workflow](../../images/11/4.png) + +As the output of the command ls -l shows, by default, the virtual environment that runs our workflow does not have any code! + +
    + +
    + +### Setting up lint, test and build steps + +After completing the first exercises, you should have a simple but pretty useless workflow set up. Let's make our workflow do something useful. + +Let's implement a GitHub Action that will lint the code. If the checks don't pass, GitHub Actions will show a red status. + +At the start, the workflow that we will save to file pipeline.yml looks like this: + +```yml +name: Deployment pipeline + +on: + push: + branches: + - main + +jobs: +``` + +Before we can run a command to lint the code, we have to perform a couple of actions to set up the environment of the job. + +#### Setting up the environment + +Setting up the environment is an important task while configuring a pipeline. We're going to use an ubuntu-latest virtual environment because this is the version of Ubuntu we're going to be running in production. + +It is important to replicate the same environment in CI as in production as closely as possible, to avoid situations where the same code works differently in CI and production, which would effectively defeat the purpose of using CI. + +Next, we list the steps in the "build" job that the CI would need to perform. As we noticed in the last exercise, by default the virtual environment does not have any code in it, so we need to checkout the code from the repository. + +This is an easy step: + +```yml +name: Deployment pipeline + +on: + push: + branches: + - main + +jobs: + simple_deployment_pipeline: # highlight-line + runs-on: ubuntu-latest # highlight-line + steps: # highlight-line + - uses: actions/checkout@v4 # highlight-line +``` + +The [uses](https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstepsuses) keyword tells the workflow to run a specific action. An action is a reusable piece of code, like a function. Actions can be defined in your repository in a separate file or you can use the ones available in public repositories. + +Here we're using a public action [actions/checkout](https://github.com/actions/checkout) and we specify a version (@v4) to avoid potential breaking changes if the action gets updated. The checkout action does what the name implies: it checkouts the project source code from Git. + +Secondly, as the application is written in JavaScript, Node.js must be set up to be able to utilize the commands that are specified in package.json. To set up Node.js, [actions/setup-node](https://github.com/actions/setup-node) action can be used. Version 20 is selected because it is the version the application is using in the production environment. + +```yml +# name and trigger not shown anymore... + +jobs: + simple_deployment_pipeline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 # highlight-line + with: # highlight-line + node-version: '20' # highlight-line +``` + +As we can see, the [with](https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstepswith) keyword is used to give a "parameter" to the action. Here the parameter specifies the version of Node.js we want to use. + + +Lastly, the dependencies of the application must be installed. Just like on your own machine we execute npm install. The steps in the job should now look something like + +```yml +jobs: + simple_deployment_pipeline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install dependencies # highlight-line + run: npm install # highlight-line +``` + +Now the environment should be completely ready for the job to run actual important tasks in! + +#### Lint + +After the environment has been set up we can run all the scripts from package.json like we would on our own machine. To lint the code all you have to do is add a step to run the npm run eslint command. + +```yml +jobs: + simple_deployment_pipeline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install dependencies + run: npm install + - name: Check style # highlight-line + run: npm run eslint # highlight-line +``` + +Note that the _name_ of a step is optional, if you define a step as follows + +```yml +- run: npm run eslint +``` + +the command that is run is used as the default name. + +
    + +
    + +### Exercises 11.5.-11.9. + +#### 11.5 Linting workflow + +Implement or copy-paste the "Lint" workflow and commit it to the repository. Use a new yml file for this workflow, you may call it e.g. pipeline.yml. + +Push your code and navigate to "Actions" tab and click on your newly created workflow on the left. You should see that the workflow run has failed: + +![Linting to workflow](../../images/11/5.png) + +#### 11.6 Fix the code + +There are some issues with the code that you will need to fix. Open up the workflow logs and investigate what is wrong. + +A couple of hints. One of the errors is best to be fixed by specifying proper env for linting, see [here](/en/part3/validation_and_es_lint#lint) how it can be done . One of the complaints concerning console.log statement could be taken care of by simply silencing the rule for that specific line. Ask google how to do it. + +Make the necessary changes to the source code so that the lint workflow passes. Once you commit new code the workflow will run again and you will see updated output where all is green again: + +![Lint error fixed](../../images/11/6.png) + +#### 11.7 Building and testing + +Let's expand on the previous workflow that currently does the linting of the code. Edit the workflow and similarly to the lint command add commands for build and test. After this step outcome should look like this + +![tests fail...](../../images/11/7.png) + +As you might have guessed, there are some problems in code... + +#### 11.8 Back to green + +Investigate which test fails and fix the issue in the code (do not change the tests). + +Once you have fixed all the issues and the Pokedex is bug-free, the workflow run will succeed and show green! + +![tests fixed](../../images/11/8.png) + +#### 11.9 Simple end-to-end tests + +The current set of tests uses [Jest](https://jestjs.io/) to ensure that the React components work as intended. This is essentially the same thing that is done in the section [Testing React apps](/en/part5/testing_react_apps) of part 5 with [Vitest](https://vitest.dev/). + +Testing components in isolation is quite useful but that still does not ensure that the system as a whole works as we wish. To have more confidence about this, let us write a couple of really simple end-to-end tests similarly we did in section [part 5](/en/part5/). You could use [Playwright](https://playwright.dev/) or [Cypress](https://www.cypress.io/) for the tests. + +No matter which you choose, you should extend Jest-definition in package.json to prevent Jest from trying to run the e2e-tests. Assuming that directory _e2e-tests_ is used for e2e-tests, the definition is: + +```json +{ + // ... + "jest": { + "testEnvironment": "jsdom", + "testPathIgnorePatterns": ["e2e-tests"] // highlight-line + } +} +``` + +**Playwright** + +Set Playwright up (you'll find [here](/en/part5/end_to_end_testing_playwright) all the info you need) to your repository. Note that in contrast to part 5, you should now install Playwright to the same project with the rest of the code! + +Use this test first: + +```js +const { test, describe, expect, beforeEach } = require('@playwright/test') + +describe('Pokedex', () => { + test('front page can be opened', async ({ page }) => { + await page.goto('') + await expect(page.getByText('ivysaur')).toBeVisible() + await expect(page.getByText('Pokémon and Pokémon character names are trademarks of Nintendo.')).toBeVisible() + }) +}) +``` + +**Note** is that although the page renders the Pokemon names with an initial capital letter, the names are actually written with lowercase letters in the source, so you should test for ivysaur instead of Ivysaur! + +Define a npm script test:e2e for running the e2e tests from the command line. + +Remember that the Playwright tests assume that the application is up and running when you run the test! Instead of starting the app manually, you should now configure a Playwright development server to start the app while tests are executed, see [here](https://playwright.dev/docs/next/api/class-testconfig#test-config-web-server) how that can be done. + +Ensure that the test passes locally. + +Once the end-to-end test works in your machine, include it in the GitHub Action workflow. That should be pretty easy by following [this](https://playwright.dev/docs/ci-intro#on-pushpull_request). + +**Cypress** + +Set Cypress up (you'll find [here](/en/part5/end_to_end_testing_cypress) all the info you need) and use this test first: + +```js +describe('Pokedex', function() { + it('front page can be opened', function() { + cy.visit('http://localhost:5000') + cy.contains('ivysaur') + cy.contains('Pokémon and Pokémon character names are trademarks of Nintendo.') + }) +}) +``` + +Define a npm script test:e2e for running the e2e tests from the command line. + +**Note** is that although the page renders the Pokemon names with an initial capital letter, the names are actually written with lowercase letters in the source, so you should test for ivysaur instead of Ivysaur! + +Ensure that the test passes locally. Remember that the Cypress tests _assume that the application is up and running_ when you run the test! If you have forgotten the details, please see [part 5](/en/part5/end_to_end_testing) how to get up and running with Cypress. + +Once the end-to-end test works in your machine, include it in the GitHub Action workflow. By far the easiest way to do that is to use the ready-made action [cypress-io/github-action](https://github.com/cypress-io/github-action). The step that suits us is the following: + +```js +- name: e2e tests + uses: cypress-io/github-action@v5 + with: + command: npm run test:e2e + start: npm run start-prod + wait-on: http://localhost:5000 +``` + +Three options are used: [command](https://github.com/cypress-io/github-action#custom-test-command) specifies how to run Cypress tests, [start](https://github.com/cypress-io/github-action#start-server) gives npm script that starts the server, and [wait-on](https://github.com/cypress-io/github-action#wait-on) says that before the tests are run, the server should have started on url . + +Note that you need to build the app in GitHub Actions before it can be started in production mode! + +**Once the pipeline works...** + +Once you are sure that the pipeline works, write another test that ensures that one can navigate from the main page to the page of a particular Pokemon, e.g. ivysaur. The test does not need to be a complex one, just check that when you navigate to a link, the page has some proper content, such as the string chlorophyll in the case of ivysaur. + +**Note** the Pokemon abilities are written with lowercase letters in the source code (the capitalization is done in CSS), so do not test for Chlorophyll but rather chlorophyll. + +The end result should be something like this + +![e2e tests](../../images/11/9.png) + +End-to-end tests are nice since they give us confidence that software works from the end user's perspective. The price we have to pay is the slower feedback time. Now executing the whole workflow takes quite much longer. + +
    diff --git a/src/content/11/es/part11c.md b/src/content/11/es/part11c.md new file mode 100644 index 00000000000..fcc3e701a85 --- /dev/null +++ b/src/content/11/es/part11c.md @@ -0,0 +1,343 @@ +--- +mainImage: ../../../images/part-11.svg +part: 11 +letter: c +lang: es +--- + +
    + +Having written a nice application it's time to think about how we're going to deploy it to the use of real users. + +In [part 3](/en/part3/deploying_app_to_internet) of this course, we did this by simply running a single command from terminal to get the code up and running the servers of the cloud provider [Fly.io](https://fly.io/) or [Render](https://render.com/). + +It is pretty simple to release software in Fly.io and Render at least compared to many other types of hosting setups but it still contains risks: nothing prevents us from accidentally releasing broken code to production. + +Next, we're going to look at the principles of making a deployment safely and some of the principles of deploying software on both a small and large scale. + +### Anything that can go wrong... + +We'd like to define some rules about how our deployment process should work but before that, we have to look at some constraints of reality. + +One phrasing of Murphy's Law holds that: + "Anything that can go wrong will go wrong." + +It's important to remember this when we plan out our deployment system. Some of the things we'll need to consider could include: + - What if my computer crashes or hangs during deployment? + - I'm connected to the server and deploying over the internet, what happens if my internet connection dies? + - What happens if any specific instruction in my deployment script/system fails? + - What happens if, for whatever reason, my software doesn't work as expected on the server I'm deploying to? Can I roll back to a previous version? + - What happens if a user does an HTTP request to our software just before we do deployment (we didn't have time to send a response to the user)? + +These are just a small selection of what can go wrong during a deployment, or rather, things that we should plan for. Regardless of what happens, our deployment system should **never** leave our software in a broken state. We should also always know (or be easily able to find out) what state a deployment is in. + +Another important rule to remember when it comes to deployments (and CI in general) is: + "Silent failures are **very** bad!" + +This doesn't mean that failures need to be shown to the users of the software, it means we need to be aware if anything goes wrong. If we are aware of a problem, we can fix it. If the deployment system doesn't give any errors but fails, we may end up in a state where we believe we have fixed a critical bug but the deployment failed, leaving the bug in our production environment and us unaware of the situation. + +### What does a good deployment system do? + +Defining definitive rules or requirements for a deployment system is difficult, let's try anyway: + - Our deployment system should be able to fail gracefully at **any** step of the deployment. + - Our deployment system should **never** leave our software in a broken state. + - Our deployment system should let us know when a failure has happened. It's more important to notify about failure than about success. + - Our deployment system should allow us to roll back to a previous deployment + - Preferably this rollback should be easier to do and less prone to failure than a full deployment + - Of course, the best option would be an automatic rollback in case of deployment failures + - Our deployment system should handle the situation where a user makes an HTTP request just before/during a deployment. + - Our deployment system should make sure that the software we are deploying meets the requirements we have set for this (e.g. don't deploy if tests haven't been run). + +Let's define some things we **want** in this hypothetical deployment system too: + - We would like it to be fast + - We'd like to have no downtime during the deployment (this is distinct from the requirement we have for handling user requests just before/during the deployment). + +Next we will have three sets of exercises for automating the deployment with GitHub Actions, one for [Fly.io](https://fly.io/), another one for [Render](https://render.com/). The process of deployment is always specific to the particular cloud provider, so you can also do both the exercise sets if you want to see the differences on how these services work with respect to deployments. + +### Has the app been deployed? + +Since we are not making any real changes to the app, it might be a bit hard to see if the app deployment really works. +Let us create a dummy endpoint in the app that makes it possible to do some code changes and to ensure that the deployed version has really changed: + +```js +app.get('/version', (req, res) => { + res.send('1') // change this string to ensure a new version deployed +}) +``` + +
    + +
    + +### Exercises 11.10-11.12. (Fly.io) + +If you rather want to use other hosting options, there is an alternative set of exercises for [Render](/en/part11/deployment#exercises-11-10-11-12-render). + +#### 11.10 Deploying your application to Fly.io + +Setup your application in [Fly.io](https://fly.io/) hosting service like the one we did in [part 3](/en/part3/deploying_app_to_internet#application-to-the-internet). + +In contrast to part 3, in this part we do not deploy the code to Fly.io ourselves (with the command flyctl deploy), we let the GitHub Actions workflow do that for us. + +Before going to the automated deployment, we shall ensure in this exercise that the app can be deployed manually. + +So, create a new app in Fly.io. After that generate a Fly.io API token with the command + +```bash +flyctl auth token +``` + +You'll need the token soon for your deployment workflow so save it somewhere (but do not commit that to GitHub)! + +As said, before setting up the deployment pipeline in the next exercise we will now ensure that a manual deployment with the command flyctl deploy works. + +A couple of changes are needed. + +The configuration file fly.toml should be modified to include the following: + +```yml +[env] + PORT = "3000" # add this where PORT matches the internal_port below + +[processes] + app = "node app.js" # add this + +[http_service] + internal_port = 3000 + force_https = true + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 0 + processes = ["app"] +``` + +In [processes](https://fly.io/docs/reference/configuration/#the-processes-section) we define the command that starts the application. Without this change Fly.io just starts the React dev server and that causes it to shut down since the app itself does not start up. We will also set up the PORT to be passed to the app as an environment variable. + +We also need to alter the file _.dockerignore_ a bit, the next line should be removed: + +``` +dist +``` + +If the line is not removed, the product build of the frontend does not get downloaded to the Fly.io server. + +Deployment should now work _if_ the production build exists in the local machine, that is, the command _npm build_ is run. + +Before moving to the next exercise, make sure that the manual deployment with the command flyctl deploy works! + +#### 11.11 Automatic deployments + +Extend the workflow with a step to deploy your application to Fly.io by following the advice given [here](https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/). + +Note that the GitHub Action should create the production build (with _npm run build_) before the deployment step! + +You need the authorization token that you just created for the deployment. The proper way to pass it's value to GitHub Actions is to use _Repository secrets_: + +![repo secret](../../images/11/10f.png) + +Now the workflow can access the token value as follows: + +``` +${{secrets.FLY_API_TOKEN}} +``` + +If all goes well, your workflow log should look a bit like this: + +![](../../images/11/fly-good.png) + +**Remember** that it is always essential to keep an eye on what is happening in server logs when playing around with product deployments, so use flyctl logs early and use it often. No, use it all the time! + +#### 11.12 Health check + +Each deployment in Fly.io creates a [release](https://fly.io/docs/flyctl/releases/). Releases can be checked from the command line: + +```bash +$ flyctl releases +VERSION STATUS DESCRIPTION USER DATE +v18 complete Release mluukkai@iki.fi 16h56m ago +v17 complete Release mluukkai@iki.fi 17h3m ago +v16 complete Release mluukkai@iki.fi 21h22m ago +v15 complete Release mluukkai@iki.fi 21h25m ago +v14 complete Release mluukkai@iki.fi 21h34m ago +``` + +It is essential to ensure that a deployment ends up in a succeeding release, where the app is in healthy functional state. Fortunately, Fly.io has several configuration options that take care of the application health check. + +If we change the app as follows, it fails to start: + +```js +app.listen(PORT, () => { + this_causes_error + // eslint-disable-next-line no-console + console.log(`server started on port ${PORT}`) +}) +``` + +In this case, the deployment fails: + +```bash +$ flyctl releases +VERSION STATUS DESCRIPTION USER DATE +v19 failed Release mluukkai@iki.fi 3m52s ago +v18 complete Release mluukkai@iki.fi 16h56m ago +v17 complete Release mluukkai@iki.fi 17h3m ago +v16 complete Release mluukkai@iki.fi 21h22m ago +v15 complete Release mluukkai@iki.fi 21h25m ago +v14 complete Release mluukkai@iki.fi 21h34m ago +``` + +The app however stays up and running, Fly.io does not replace the functioning version (v18) with the broken one (v19). + +Let us consider the following change + +```js +// start app in a wrong port +app.listen(PORT + 1, () => { + // eslint-disable-next-line no-console + console.log(`server started on port ${PORT}`) +}) +``` + +Now the app starts but it is connected to the wrong port, so the service will not be functional. Fly.io thinks this is a successful deployment, so it deploys the app in a broken state. + +One possibility to prevent broken deployments is to use an HTTP-level check defined in section [http\_service.http_checks](https://fly.io/docs/reference/configuration/#http_service-checks). This type of check can be used to ensure that the app for real is in a functional state. + +Add a simple endpoint for doing an application health check to the backend. You may e.g. copy this code: + +```js +app.get('/health', (req, res) => { + res.send('ok') +}) +``` + +Configure then an [HTTP check](https://fly.io/docs/reference/configuration/#http_service-checks) that ensures the health of the deployments based on the HTTP request to the defined health check endpoint. + +You also need to set the [deployment strategy](https://fly.io/docs/reference/configuration/#picking-a-deployment-strategy) (in the file _fly.toml_) of the app to be canary. These strategies ensure that only an app with a healthy state gets deployed. + +Ensure that GitHub Actions notices if a deployment breaks your application: + +![](../../images/11/fly-fail.png) + +You may simulate this e.g. as follows: + +```js +app.get('/health', (req, res) => { + // eslint-disable-next-line no-constant-condition + if (true) throw('error... ') + res.send('ok') +}) +``` + +
    + +
    + +### Exercises 11.10-11.12. (Render) + +If you rather want to use other hosting options, there is an alternative set of exercises for [Fly.io](/en/part11/deployment/#exercises-11-10-11-12-fly-io). + +#### 11.10 Deploying your application to Render + +Set up your application in [Render](https://render.com/). The setup is now not quite as straightforward as in [part 3](/en/part3/deploying_app_to_internet#application-to-the-internet). You have to carefully think about what should go to these settings: + +![](../../images/11/render1.png) + +If you need to run several commands in the build or start command, you may use a simple shell script for that. + +Create eg. a file build_step.sh with the following content: + +```bash +#!/bin/bash + +echo "Build script" + +# add the commands here +``` + +Give it execution permissions (Google or see e.g. [this](https://www.guru99.com/file-permissions.html) to find out how) and ensure that you can run it from the command line: + +```bash +$ ./build_step.sh +Build script +``` + +Other option is to use a [Pre deploy command](https://docs.render.com/deploys#deploy-steps), with that you may run one additional command before the deployment starts. + +You also need to open the Advanced settings and turn the auto-deploy off since we want to control the deployment in the GitHub Actions: + +![](../../images/11/render2.png) + +Ensure now that you get the app up and running. Use the Manual deploy. + +Most likely things will fail at the start, so remember to keep the Logs open all the time. + +#### 11.11 Automatic deployments + +Next step is to automate the deployment. There are two options, a ready-made custom action or the use of the Render deploy hook. + +Deployment with custom action + +Go to GitHub Actions [marketplace](https://github.com/marketplace) and search for action for our purposes. You might search with render deploy. There are several actions to choose from. You can pick any. Quite often the best choice is the one with the most stars. It is also a good idea to look if the action is actively maintained (time of the last release) and does it have many open issues or pull requests. + +**Warning**: for some reason, the most starred option [render-action](https://github.com/Bounceapp/render-action) was very unreliable when the part was updated (16th Jan 2024), so better avoid that. If you end up with too much problems, the deploy hook might be a better option! + +Set up the action to your workflow and ensure that every commit that passes all the checks results in a new deployment. Note that you need Render API key and the app service id for the deployment. See [here](https://render.com/docs/api) how the API key is generated. You can get the service id from the URL of the Render dashboard of your app. The end of the URL (starting with _srv-_) is the id: + +```bash +https://dashboard.render.com/web/srv-randomcharachtershere +``` + +Deployment with deploy hook + +Alternative, and perhaps a more reliable option is to use [Render Deploy Hook](https://render.com/docs/deploy-hooks) which is a private URL to trigger the deployment. You can get it from your app settings: + +![fsorender1](https://user-images.githubusercontent.com/47830671/230722899-1ebb414e-ae1e-4a5e-a7b8-f376c4f1ca4d.png) + +DON'T USE the plain URL in your pipeline. Instead create GitHub secrets for your key and service id: ![fsorender2](https://user-images.githubusercontent.com/47830671/230723138-77d027be-3162-4697-987e-b654bc710187.png) +Then you can use them like this: +``` bash +- name: Trigger deployment + run: curl https://api.render.com/deploy/srv-${{ secrets.RENDER_SERVICE_ID }}?key=${{ secrets.RENDER_API_KEY }} +``` + +The deployment takes some time. See the events tab of the Render dashboard to see when the new deployment is ready: + +![](../../images/11/render3.png) + +#### 11.12 Health check + +All tests pass and the new version of the app gets automatically deployed to Render so everything seems to be in order. But does the app really work? Besides the checks done in the deployment pipeline, it is extremely beneficial to have also some "application level" health checks ensuring that the app for real is in a functional state. + +The [zero downtime deploys](https://docs.render.com/deploys#zero-downtime-deploys) in Render should ensure that your app stays functional all the time! For some reason, this property did not always work as promised when this part was updated (16th Jan 2024). The reason might be the use of a free account. + +Add a simple endpoint for doing an application health check to the backend. You may e.g. copy this code: + +```js +app.get('/health', (req, res) => { + res.send('ok') +}) +``` + +Commit the code and push it to GitHub. Ensure that you can access the health check endpoint of your app. + +Configure now a Health Check Path to your app. The configuration is done in the settings tab of the Render dashboard. + +Make a change in your code, push it to GitHub, and ensure that the deployment succeeds. + +Note that you can see the log of deployment by clicking the most recent deployment in the events tab. + +When you are set up with the health check, simulate a broken deployment by changing the code as follows: + +```js +app.get('/health', (req, res) => { + // eslint-disable-next-line no-constant-condition + if (true) throw('error... ') + res.send('ok') +}) +``` + +Push the code to GitHub and ensure that a broken version does not get deployed and the previous version of the app keeps running. + +Before moving on, fix your deployment and ensure that the application works again as intended. + +
    diff --git a/src/content/11/es/part11d.md b/src/content/11/es/part11d.md new file mode 100644 index 00000000000..81b40ba0b9f --- /dev/null +++ b/src/content/11/es/part11d.md @@ -0,0 +1,336 @@ +--- +mainImage: ../../../images/part-11.svg +part: 11 +letter: d +lang: es +--- + +
    + +Your main branch of the code should always remain green. Being green means that all the steps of your build pipeline should complete successfully: the project should build successfully, tests should run without errors, and the linter shouldn't have anything to complain about, etc. + +Why is this important? You will likely deploy your code to production specifically from your main branch. Any failures in the main branch would mean that new features cannot be deployed to production until the issue is sorted out. Sometimes you will discover a nasty bug in production that was not caught by the CI/CD pipeline. In these cases, you want to be able to roll the production environment back to a previous commit in a safe manner. + +How do you keep your main branch green then? Avoid committing any changes directly to the main branch. Instead, commit your code on a branch based on the freshest possible version of the main branch. Once you think the branch is ready to be merged into the main you create a GitHub Pull Request (also referred to as PR). + +### Working with Pull Requests + +Pull requests are a core part of the collaboration process when working on any software project with at least two contributors. When making changes to a project you checkout a new branch locally, make and commit your changes, push the branch to the remote repository (in our case to GitHub) and create a pull request for someone to review your changes before those can be merged into the main branch. + +There are several reasons why using pull requests and getting your code reviewed by at least one other person is always a good idea. +- Even a seasoned developer can often overlook some issues in their code: we all know of the tunnel vision effect. +- A reviewer can have a different perspective and offer a different point of view. +- After reading through your changes, at least one other developer will be familiar with the changes you've made. +- Using PRs allows you to automatically run all tasks in your CI pipeline before the code gets to the main branch. GitHub Actions provides a trigger for pull requests. + +You can configure your GitHub repository in such a way that pull requests cannot be merged until they are approved. + +![Compare & pull request](../../images/11/pr1a.png) + +To open a new pull request, open your branch in GitHub and click on the green "Compare & pull request" button at the top. You will be presented with a form where you can fill in the pull request description. + +![Open a new pull request](../../images/11/pr2.png) + +GitHub's pull request interface presents a description and the discussion interface. At the bottom, it displays all the CI checks (in our case each of our Github Actions) that are configured to run for each PR and the statuses of these checks. A green board is what you aim for! You can click on Details of each check to view details and run logs. + +All the workflows we looked at so far were triggered by commits to the main branch. To make the workflow run for each pull request we would have to update the trigger part of the workflow. We use the "pull_request" trigger for branch "main" (our main branch) and limit the trigger to events "opened" and "synchronize". Basically, this means, that the workflow will run when a PR into the main branch is opened or updated. + +So let us change events that [trigger](https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows) of the workflow as follows: + +```yml +on: + push: + branches: + - main + pull_request: # highlight-line + branches: [main] # highlight-line + types: [opened, synchronize] # highlight-line +``` + +We shall soon make it impossible to push the code directly to the main branch, but in the meantime, let us still run the workflow also for all the possible direct pushes to the main branch. + +
    + +
    + +### Exercises 11.13-11.14. + +Our workflow is doing a nice job of ensuring good code quality, but since it is run on commits to the main branch, it's catching the problems too late! + +#### 11.13 Pull request + +Update the trigger of the existing workflow as suggested above to run on new pull requests to your main branch. + +Create a new branch, commit your changes, and open a pull request to your main branch. + +If you have not worked with branches before, check [e.g. this tutorial](https://www.atlassian.com/git/tutorials/using-branches) to get started. + +Note that when you open the pull request, make sure that you select here your own repository as the destination base repository. By default, the selection is the original repository by and you **do not want** to do that: + +![](../../images/11/pr3.png) + +In the "Conversation" tab of the pull request you should see your latest commit(s) and the yellow status for checks in progress: + +![](../../images/11/pr4.png) + +Once the checks have been run, the status should turn to green. Make sure all the checks pass. Do not merge your branch yet, there's still one more thing we need to improve on our pipeline. + +#### 11.14 Run deployment step only for the main branch + +All looks good, but there is actually a pretty serious problem with the current workflow. All the steps, including the deployment, are run also for pull requests. This is surely something we do not want! + +Fortunately, there is an easy solution for the problem! We can add an [if](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsif) condition to the deployment step, which ensures that the step is executed only when the code is being merged or pushed to the main branch. + +The workflow [context](https://docs.github.com/en/free-pro-team@latest/actions/reference/context-and-expression-syntax-for-github-actions#contexts) gives various kinds of information about the code the workflow is run. + +The relevant information is found in [GitHub context](https://docs.github.com/en/actions/learn-github-actions/contexts#github-context), the field event_name tells us what is the "name" of the event that triggered the workflow. When a pull request is merged, the name of the event is somehow paradoxically push, the same event that happens when pushing the code to the repository. Thus, we get the desired behavior by adding the following condition to the step that deploys the code: + +```js +if: ${{ github.event_name == 'push' }} +``` + +Push some more code to your branch, and ensure that the deployment step is not executed anymore. Then merge the branch to the main branch and make sure that the deployment happens. + +
    + +
    + +### Versioning + +The most important purpose of versioning is to uniquely identify the software we're running and the code associated with it. + +The ordering of versions is also an important piece of information. For example, if the current release has broken critical functionality and we need to identify the previous version of the software so that we can roll back the release back to a stable state. + +#### Semantic Versioning and Hash Versioning + +How an application is versioned is sometimes called a versioning strategy. We'll look at and compare two such strategies. + +The first one is [semantic versioning](https://semver.org/), where a version is in the form {major}.{minor}.{patch}. For example, if the version is 1.2.3, it has 1 as the major version, 2 is the minor version, and 3 is the patch version. + +In general, changes that fix the functionality without changing how the application works from the outside are patch changes, changes that make small changes to functionality (as viewed from the outside) are minor changes and changes that completely change the application (or major functionality changes) are major changes. The definitions of each of these terms can vary from project to project. + +For example, npm-libraries are following the semantic versioning. At the time of writing this text (16th March 2023) the most recent version of React is [18.2.0](https://reactjs.org/versions/), so the major version is 18 and the minor version is 2. + +Hash versioning (also sometimes known as SHA versioning) is quite different. The version "number" in hash versioning is a hash (that looks like a random string) derived from the contents of the repository and the changes introduced in the commit that created the version. In Git, this is already done for you as the commit hash that is unique for any change set. + +Hash versioning is almost always used in conjunction with automation. It's a pain (and error-prone) to copy 32 character long version numbers around to make sure that everything is correctly deployed. + +#### But what does the version point to? + +Determining what code belongs to a given version is important and the way this is achieved is again quite different between semantic and hash versioning. In hash versioning (at least in Git) it's as simple as looking up the commit based on the hash. This will let us know exactly what code is deployed with a specific version. + +It's a little more complicated when using semantic versioning and there are several ways to approach the problem. These boil down to three possible approaches: something in the code itself, something in the repo or repo metadata, something completely outside the repo. + +While we won't cover the last option on the list (since that's a rabbit hole all on its own), it's worth mentioning that this can be as simple as a spreadsheet that lists the Semantic Version and the commit it points to. + +For the two repository based approaches, the approach with something in the code usually boils down to a version number in a file and the repo/metadata approach usually relies on [tags](https://www.atlassian.com/git/tutorials/inspecting-a-repository/git-tag) or (in the case of GitHub) releases. In the case of tags or releases, this is relatively simple, the tag or release points to a commit, the code in that commit is the code in the release. + +#### Version order + +In semantic versioning, even if we have version bumps of different types (major, minor, or patch) it's still quite easy to put the releases in order: 1.3.7 comes before 2.0.0 which itself comes before 2.1.5 which comes before 2.2.0. A list of releases (conveniently provided by a package manager or GitHub) is still needed to know what the last version is but it's easier to look at that list and discuss it: It's easier to say "We need to roll back to 3.2.4" than to try communicate a hash in person. + +That's not to say that hashes are inconvenient: if you know which commit caused the particular problem, it's easy enough to look back through a Git history and get the hash of the previous commit. But if you have two hashes, say d052aa41edfb4a7671c974c5901f4abe1c2db071 and 12c6f6738a18154cb1cef7cf0607a681f72eaff3, you really can not say which came earlier in history, you need something more, such as the Git log that reveals the ordering. + +#### Comparing the Two + +We've already touched on some of the advantages and disadvantages of the two versioning methods discussed above but it's perhaps useful to address where they'd each likely be used. + +Semantic Versioning works well when deploying services where the version number could be of significance or might actually be looked at. As an example, think of the JavaScript libraries that you're using. If you're using version 3.4.6 of a particular library, and there's an update available to 3.4.8, if the library uses semantic versioning, you could (hopefully) safely assume that you're ok to upgrade without breaking anything. If the version jumps to 4.0.1 then maybe it's not such a safe upgrade. + +Hash versioning is very useful where most commits are being built into artifacts (e.g. runnable binaries or Docker images) that are themselves uploaded or stored. As an example, if your testing requires building your package into an artifact, uploading it to a server, and running tests against it, it would be convenient to have hash versioning as it would prevent accidents. + +As an example think that you're working on version 3.2.2 and you have a failing test, you fix the failure and push the commit but as you're working in your branch, you're not going to update the version number. Without hash versioning, the artifact name may not change. If there's an error in uploading the artifact, maybe the tests run again with the older artifact (since it's still there and has the same name) and you get the wrong test results. If the artifact is versioned with the hash, then the version number *must* change on every commit and this means that if the upload fails, there will be an error since the artifact you told the tests to run against does not exist. + +Having an error happen when something goes wrong is almost always preferable to having a problem silently ignored in CI. + +#### Best of Both Worlds + +From the comparison above, it would seem that the semantic versioning makes sense for releasing software while hash-based versioning (or artifact naming) makes more sense during development. This doesn't necessarily cause a conflict. + +Think of it this way: versioning boils down to a technique that points to a specific commit and says "We'll give this point a name, it's name will be 3.5.5". Nothing is preventing us from also referring to the same commit by its hash. + +There is a catch. We discussed at the beginning of this part that we always have to know exactly what is happening with our code, for example, we need to be sure that we have tested the code we want to deploy. Having two parallel versioning (or naming) conventions can make this a little more difficult. + +For example, when we have a project that uses hash-based artifact builds for testing, it's always possible to track the result of every build, lint, and test to a specific commit and developers know the state their code is in. This is all automated and transparent to the developers. They never need to be aware of the fact that the CI system is using the commit hash underneath to name build and test artifacts. When the developers merge their code to the main branch, again the CI takes over. This time, it will build and test all the code and give it a semantic version number all in one go. It attaches the version number to the relevant commit with a Git tag. + +In the case above, the software we release is tested because the CI system makes sure that tests are run on the code it is about to tag. It would not be incorrect to say that the project uses semantic versioning and simply ignore that the CI system tests individual developer branches/PRs with a hash-based naming system. We do this because the version we care about (the one that is released) is given a semantic version. + +
    + +
    + +### Exercises 11.15-11.16. + +Let's extend our workflow so that it will automatically increase (bump) the version when a pull request is merged into the main branch and [tag](https://www.atlassian.com/git/tutorials/inspecting-a-repository/git-tag) the release with the version number. We will use an open source action developed by a third party: [anothrNick/github-tag-action](https://github.com/anothrNick/github-tag-action). + +#### 11.15 Adding versioning + +We will extend our workflow with one more step: + +```js +- name: Bump version and push tag + uses: anothrNick/github-tag-action@1.64.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +Note: you should use the most recent version of the action, see [here](https://github.com/anothrNick/github-tag-action) if a more recent version is available. + +We're passing an environmental variable secrets.GITHUB\_TOKEN to the action. As it is third party action, it needs the token for authentication in your repository. You can read more [here](https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token) about authentication in GitHub Actions. + +You may end up having this error message + +![Submissions](../../images/11/tag-error.png) + +The most likely cause for this is that your token has no write access to your repo. Go to your repository settings, select actions/general, and ensure that your token has read and write permissions: + +![Submissions](../../images/11/tag-permissions.png) + +The [anothrNick/github-tag-action](https://github.com/anothrNick/github-tag-action) action accepts some environment variables that modify the way the action tags your releases. You can look at these in the [README](https://github.com/anothrNick/github-tag-action) and see what suits your needs. + +As you can see from the documentation by default your releases will receive a *minor* bump, meaning that the middle number will be incremented. + +Modify the configuration above so that each new version is by default a _patch_ bump in the version number, so that by default, the last number is increased. + +Remember that we want only to bump the version when the change happens to the main branch! So add a similar if condition to prevent version bumps on pull request as was done in [Exercise 11.14](/en/part11/keeping_green#exercises-11-13-11-14) to prevent deployment on pull request related events. + +Complete now the workflow. Do not just add it as another step, but configure it as a separate job that [depends](https://docs.github.com/en/actions/using-workflows/advanced-workflow-features#creating-dependent-jobs) on the job that takes care of linting, testing and deployment. So change your workflow definition as follows: + +```yml +name: Deployment pipeline + +on: + push: + branches: + - main + pull_request: + branches: [main] + types: [opened, synchronize] + +jobs: + simple_deployment_pipeline: + runs-on: ubuntu-latest + steps: + // steps here + tag_release: + needs: [simple_deployment_pipeline] + runs-on: ubuntu-latest + steps: + // steps here +``` + +As was mentioned [earlier](/en/part11/getting_started_with_git_hub_actions#getting-started-with-workflows) jobs of a workflow are executed in parallel but since we want the linting, testing and deployment to be done first, we set a dependency that the tag\_release waits the another job to execute first since we do not want to tag the release unless it passes tests and is deployed. + +If you're uncertain of the configuration, you can set DRY_RUN to true, which will make the action output the next version number without creating or tagging the release! + +Once the workflow runs successfully, the repository mentions that there are some tags: + +![Releases](../../images/11/17-new.png) + +By clicking view all tags, you can see all the tags listed: + +![Releases](../../images/11/18-new.png) + +If needed, you can navigate to the view of a single tag that shows eg. what is the GitHub commit corresponding to the tag. + +#### 11.16 Skipping a commit for tagging and deployment + +In general, the more often you deploy the main branch to production, the better. However, there might be some valid reasons sometimes to skip a particular commit or a merged pull request to become tagged and released to production. + +Modify your setup so that if a commit message in a pull request contains _#skip_, the merge will not be deployed to production and it is not tagged with a version number. + +**Hints:** + +The easiest way to implement this is to alter the [if](https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstepsif) conditions of the relevant steps. Similarly to [exercise 11-14](/en/part11/keeping_green#exercises-11-13-11-14) you can get the relevant information from the [GitHub context](https://docs.github.com/en/free-pro-team@latest/actions/reference/context-and-expression-syntax-for-github-actions#github-context) of the workflow. + +You might take this as a starting point: + +```js +name: Testing stuff + +on: + push: + branches: + - main + +jobs: + a_test_job: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: github context + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + run: echo "$GITHUB_CONTEXT" + - name: commits + env: + COMMITS: ${{ toJson(github.event.commits) }} + run: echo "$COMMITS" + - name: commit messages + env: + COMMIT_MESSAGES: ${{ toJson(github.event.commits.*.message) }} + run: echo "$COMMIT_MESSAGES" +``` + +See what gets printed in the workflow log! + +Note that you can access the commits and commit messages only when pushing or merging to the main branch, so for pull requests the github.event.commits is empty. It is anyway not needed, since we want to skip the step altogether for pull requests. + +You most likely need functions [contains](https://docs.github.com/en/actions/learn-github-actions/expressions#contains) and [join](https://docs.github.com/en/actions/learn-github-actions/expressions#join) for your if condition. + +Developing workflows is not easy, and quite often the only option is trial and error. It might actually be advisable to have a separate repository for getting the configuration right, and when it is done, to copy the right configurations to the actual repository. + +It would also be possible to install a tool such as [act](https://github.com/nektos/act) that makes it possible to run your workflows locally. Unless you end up using more involved use cases like creating your [own custom actions](https://docs.github.com/en/free-pro-team@latest/actions/creating-actions), going through the burden of setting up a tool such as act is most likely not worth the trouble. + +
    + +
    + +### A note about using third-party actions + +When using a third-party action such that github-tag-action it might be a good idea to specify the used version with hash instead of using a version number. The reason for this is that the version number, that is implemented with a Git tag can in principle be moved. So today's version 1.61.0 might be a different code that is at next week the version 1.61.0! + +However, the code in a commit with a particular hash does not change in any circumstances, so if we want to be 100% sure about the code we use, it is safest to use the hash. + +Version [1.61.0](https://github.com/anothrNick/github-tag-action/releases/tag/1.61.0) of the action corresponds to a commit with hash 8c8163ef62cf9c4677c8e800f36270af27930f42, so we might want to change our configuration as follows: + +```js + - name: Bump version and push tag + uses: anothrNick/github-tag-action@8c8163ef62cf9c4677c8e800f36270af27930f42 // highlight-line + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +When we use actions provided by GitHub we trust them not to mess with version tags and to thoroughly test their code. + +In the case of third-party actions, the code might end up being buggy or even malicious. Even when the author of the open-source code does not have the intention of doing something bad, they might end up leaving their credentials on a post-it note in a cafe, and then who knows what might happen. + +By pointing to the hash of a specific commit we can be sure that the code we use when running the workflow will not change because changing the underlying commit and its contents would also change the hash. + +### Keep the main branch protected + +GitHub allows you to set up protected branches. It is important to protect your most important branch that should never be broken: main. In repository settings, you can choose between several levels of protection. We will not go over all of the protection options, you can learn more about them in GitHub documentation. Requiring pull request approval when merging into the main branch is one of the options we mentioned earlier. + +From CI point of view, the most important protection is requiring status checks to pass before a PR can be merged into the main branch. This means that if you have set up GitHub Actions to run e.g. linting and testing tasks, then until all the lint errors are fixed and all the tests pass the PR cannot be merged. Because you are the administrator of your repository, you will see an option to override the restriction. However, non-administrators will not have this option. + +![Unmergeable PR](../../images/11/part11d_03.png) + +To set up protection for your main branch, navigate to repository "Settings" from the top menu inside the repository. In the left-side menu select "Branches". Click "Add rule" button next to "Branch protection rules". Type a branch name pattern ("main" will do nicely) and select the protection you would want to set up. At least "Require status checks to pass before merging" is necessary for you to fully utilize the power of GitHub Actions. Under it, you should also check "Require branches to be up to date before merging" and select all of the status checks that should pass before a PR can be merged. + +![Branch protection rule](../../images/11/part11d_04.png) + +
    + +
    + +### Exercise 11.17 + +#### 11.17 Adding protection to your main branch + +Add protection to your main branch. + +You should protect it to: +- Require all pull request to be approved before merging +- Require all status checks to pass before merging + +
    diff --git a/src/content/11/es/part11e.md b/src/content/11/es/part11e.md new file mode 100644 index 00000000000..c98adec662e --- /dev/null +++ b/src/content/11/es/part11e.md @@ -0,0 +1,140 @@ +--- +mainImage: ../../../images/part-11.svg +part: 11 +letter: e +lang: es +--- + +
    + +This part focuses on building a simple, effective, and robust CI system that helps developers to work together, maintain code quality, and deploy safely. What more could one possibly want? In the real world, there are more fingers in the pie than just developers and users. Even if that weren't true, even for developers, there's a lot more value to be gained from CI systems than just the things above. + +### Visibility and Understanding + +In all but the smallest companies, decisions on what to develop are not made exclusively by developers. The term 'stakeholder' is often used to refer to people, both inside and outside the development team, who may have some interest in keeping an eye on the progress of the development. To this end, there are often integrations between Git and whatever project management/bug tracking software the team is using. + +A common use of this is to have some reference to the tracking system in Git pull requests or commits. This way, for example, when you're working on issue number 123, you might name your pull request BUG-123: Fix user copy issue and the bug tracking system would notice the first part of the PR name and automatically move the issue to Done when the PR is merged. + +### Notifications + +When the CI process finishes quickly, it can be convenient to just watch it execute and wait for the result. As projects become more complex, so too does the process of building and testing the code. This can quickly lead to a situation where it takes long enough to generate the build result that a developer may want to begin working on another task. This in turn leads to a forgotten build. + +This is especially problematic if we're talking about merging PRs that may affect another developer's work, either causing problems or delays for them. This can also lead to a situation where you think you've deployed something but haven't actually finished a deployment, this can lead to miscommunication with teammates and customers (e.g. "Go ahead and try that again, the bug should be fixed"). + +There are several solutions to this problem ranging from simple notifications to more complicated processes that simply merge passing code if certain conditions are met. We're going to discuss notifications as a simple solution since it's the one that interferes with the team workflow the least. + +By default, GitHub Actions sends an email on a build failure. This can be changed to send notifications regardless of build status and can also be configured to alert you on the GitHub web interface. Great. But what if we want more. What if for whatever reason this doesn't work for our use case. + +There are integrations for example to various messaging applications such as [Slack](https://slack.com/intl/en-fi/) or [Discord](https://discord.com/), to send notifications. These integrations still decide what to send and when to send it based on logic from GitHub. + +
    + +
    + +### Exercise 11.18 + +We have set up a channel fullstack\_webhook to the course Discord group at [https://study.cs.helsinki.fi/discord/join/fullstack](https://study.cs.helsinki.fi/discord/join/fullstack) for testing a messaging integration. + +Register now to Discord if you have not already done that. You will also need a Discord webhook in this exercise. You find the webhook in the pinned message of the channel fullstack\_webhook. Please do not commit the webhook to GitHub! + +#### 11.18 Build success/failure notification action + +You can find quite a few third-party actions from [GitHub Action Marketplace](https://github.com/marketplace?type=actions) by using the search phrase [discord](https://github.com/marketplace?type=actions&query=discord). Pick one for this exercise. My choice was [discord-webhook-notify](https://github.com/marketplace/actions/discord-webhook-notify) since it has quite many stars and decent documentation. + +Setup the action so that it gives two types of notifications: +- A success indication if a new version gets deployed +- An error indication if a build fails + +In the case of an error, the notification should be a bit more verbose to help developers find quickly which is the commit that caused it. + +See [here](https://docs.github.com/en/actions/learn-github-actions/expressions#status-check-functions) how to check the job status! + +Your notifications may look like the following: + +![Releases](../../images/11/gha-notify.png) + +
    + +
    + +### Metrics + +In the previous section, we mentioned that as projects get more complicated, so too, do their builds, and the duration of the builds increases. That's obviously not ideal: The longer the feedback loop, the slower the development. + +While there are things that can be done about this increase in build times, it's useful to have a better view of the overall picture. It's useful to know how long a build took a few months ago versus how long it takes now. Was the progression linear or did it suddenly jump? Knowing what caused the increase in build time can be very useful in helping to solve it. If the build time increased linearly from 5 minutes to 10 minutes over the last year, maybe we can expect it to take another few months to get to 15 minutes and we have an idea of how much value there is in spending time speeding up the CI process. + +Metrics can either be self-reported (also called 'push' metrics, where each build reports how long it took) or the data can be fetched from the API afterward (sometimes called 'pull' metrics). The risk with self-reporting is that the self-reporting itself takes time and may have a significant impact on "total time taken for all builds". + +This data can be sent to a time-series database or to an archive of another type. There are plenty of cloud services where you can easily aggregate the metrics, one good option is [Datadog](https://www.datadoghq.com/). + +### Periodic tasks + +There are often periodic tasks that need to be done in a software development team. Some of these can be automated with commonly available tools and some you will need to automate yourself. + +The former category includes things like checking packages for security vulnerabilities. Several tools can already do this for you. Some of these tools would even be free for certain types (e.g. open source) projects. GitHub provides one such tool, [Dependabot](https://dependabot.com/). + +Words of advice to consider: If your budget allows it, it's almost always better to use a tool that already does the job than to roll your own solution. If security isn't the industry you're aiming for, for example, use Dependabot to check for security vulnerabilities instead of making your own tool. + +What about the tasks that don't have a tool? You can automate these yourself with GitHub Actions too. GitHub Actions provides a scheduled trigger that can be used to execute a task at a particular time. + +
    + +
    + +### Exercises 11.19-11.21 + +#### 11.19 Periodic health check + +We are pretty confident now that our pipeline prevents bad code from being deployed. However, there are many sources of errors. If our application would e.g. depend on a database that would for some reason become unavailable, our application would most likely crash. That's why it would be a good idea to set up a periodic health check that would regularly do an HTTP GET request to our server. We quite often refer to this kind of request as a ping. + +It is possible to [schedule](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule) GitHub actions to happen regularly. + +Use now the action [url-health-check](https://github.com/marketplace/actions/url-health-check) or any other alternative and schedule a periodic health check ping to your deployed software. Try to simulate a situation where your application breaks down and ensure that the check detects the problem. Write this periodic workflow to an own file. + +**Note** that unfortunately it takes quite long until GitHub Actions starts the scheduled workflow for the first time. For me, it took nearly one hour. So it might be a good idea to get the check working firstly by triggering the workflow with Git push. When you are sure that the check is properly working, then switch to a scheduled trigger. + +**Note also** that once you get this working, it is best to drop the ping frequency (to max once in 24 hours) or disable the rule altogether since otherwise your health check may consume all your monthly free hours. + +#### 11.20 Your own pipeline + +Build a similar CI/CD-pipeline for some of your own applications. Some of the good candidates are the phonebook app that was built in parts 2 and 3 of the course, or the blogapp built in parts 4 and 5, or the Redux anecdotes built in part 6. You may also use some app of your own for this exercise. + +You most likely need to do some restructuring to get all the pieces together. A logical first step is to store both the frontend and backend code in the same repository. This is not a requirement but it is recommended since it makes things much more simple. + +One possible repository structure would be to have the backend at the root of the repository and the frontend as a subdirectory. You can also "copy paste" the structure of the example app of this part or try out the [example app](https://github.com/fullstack-hy2020/create-app) mentioned in [part 7](/en/part7/class_components_miscellaneous#frontend-and-backend-in-the-same-repository). + +It is perhaps best to create a new repository for this exercise and simply copy and paste the old code there. In real life, you most likely would do this all in the old repository but now "a fresh start" makes things easier. + +This is a long and perhaps quite a tough exercise, but this kind of situation where you have a "legacy code" and you need to build proper deployment pipeline is quite common in real life! + +Obviously, this exercise is not done in the same repository as the previous exercises. Since you can return only one repository to the submission system, put a link of the other repository to the one you fill into the submission form. + +#### 11.21 Protect your main branch and ask for pull request + +Protect the main branch of the repository where you did the previous exercise. This time prevent also the administrators from merging the code without a review. + +Do a pull request and ask GitHub user [mluukkai](https://github.com/mluukkai) to review your code. Once the review is done, merge your code to the main branch. Note that the reviewer needs to be a collaborator in the repository. Ping us in Discord to get the review, and to include the collaboration invite link to the message. + +**Please note** what was written above, include the link to _the collaboration invite_ in the ping, not the link to the pull request. + +Then you are done! + +
    + +
    + +### Submitting exercises and getting the credits + +Exercises of this part are submitted via [the submissions system](https://studies.cs.helsinki.fi/stats/courses/fs-cicd) just like in the previous parts, but unlike parts 0 to 7, the submission goes to different "course instance". Remember that you have to finish all the exercises to pass this part! + +Your solutions are in two repositories (pokedex and your own project), and since you can return only one repository to the submission system, put a link of the other repository to the one you fill into the submission form! + +Once you have completed the exercises and want to get the credits, let us know through the exercise submission system that you have completed the course: + +![Submissions](../../images/11/21.png) + +**Note** that you need a registration to the corresponding course part for getting the credits registered, see [here](/en/part0/general_info#parts-and-completion) for more information. + +You can download the certificate for completing this part by clicking one of the flag icons. The flag icon corresponds to the certificate's language. + +
    diff --git a/src/content/11/fi/osa11.md b/src/content/11/fi/osa11.md new file mode 100644 index 00000000000..cdbc259814b --- /dev/null +++ b/src/content/11/fi/osa11.md @@ -0,0 +1,11 @@ +--- +mainImage: ../../../images/part-11.svg +part: 11 +lang: fi +--- + +
    + +Kurssin yhdestoista, CI/CD:tä käsittelevä osa löytyy [englanninkielisestä kurssimateriaalista](/en/part11). + +
    diff --git a/src/content/11/zh/part11.md b/src/content/11/zh/part11.md new file mode 100644 index 00000000000..40ff00b27f2 --- /dev/null +++ b/src/content/11/zh/part11.md @@ -0,0 +1,18 @@ +--- +mainImage: ../../../images/part-11.svg +part: 11 +lang: zh +--- + +
    + + + 这样你就有了一个新的功能,准备发货了。接下来会发生什么?你会手动上传文件到服务器吗?你如何管理你的产品在野外运行的版本?你如何确保它能工作,并在它不能工作时回滚到一个安全版本? + + +手动完成上述所有工作是一件很痛苦的事情,而且对于一个较大的团队来说,不能很好地扩展。这就是为什么我们有持续集成/持续交付系统,简称CI/CD系统。在这一部分,你将了解为什么要使用CI/CD系统,它能为你做什么,以及如何开始使用GitHub Actions,所有GitHub用户默认都可以使用。 + + + 本模块由Smartly.io的工程团队制作。在Smartly.io,我们将社交广告的每一步自动化,以释放更大的性能和创造力。我们让全球650多个品牌的每一天的广告都变得简单、有效和愉快,包括eBay、Uber和Zalando。我们是GitHub Actions在大规模生产中的早期采用者之一。贡献者。[Anna Osipova](https://www.linkedin.com/in/a-osipova/), [Anton Rautio](https://www.linkedin.com/in/anton-rautio-768190145/), [Mircea Halmagiu](https://www.linkedin.com/in/mhalmagiu/), [Tomi Hiltunen] (https://www.linkedin.com/in/tomihiltunen/). + +
    diff --git a/src/content/11/zh/part11a.md b/src/content/11/zh/part11a.md new file mode 100644 index 00000000000..c3e54f6ef55 --- /dev/null +++ b/src/content/11/zh/part11a.md @@ -0,0 +1,314 @@ +--- +mainImage: ../../../images/part-11.svg +part: 11 +letter: a +lang: zh +--- + +
    + + + 在这一部分中,你将从[练习11.2](/en/part11/getting_started_with_git_hub_actions#exercise-11-2)开始,建立一个强大的部署管道到一个现成的[实例项目](https://github.com/fullstack-hy2020/full-stack-open-pokedex)。你将[fork](https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/fork-a-repo)这个例子项目,这将为你创建一个仓库的个人副本。在[最后两个](/en/part11/expanding_further#exercises-11-20-22)练习中,你将为一些你自己的先前创建的应用构建另一个部署管道 + + + 这一部分有21个练习,你需要完成每个练习才能完成课程。练习是通过[提交系统](https://studies.cs.helsinki.fi/stats/courses/fs-cicd)提交的,就像前几部分一样,但与第0至7部分不同的是,提交到一个不同的 "课程实例"。 + + + 这一部分将依赖于课程前几部分所涉及的许多概念。建议你在开始这一部分之前,至少要完成第0至5部分。 + + + 与本课程的其他部分不同,你在这部分不会写很多行代码,它更多的是关于配置。调试代码可能很难,但调试配置就更难了,所以在这一部分,你需要大量的耐心和纪律性! + +### Getting software to production + + + 编写软件是件好事,但没有什么是存在于真空中的。最终,我们需要将软件部署到生产中,也就是说,将它交给真正的用户。在那之后,我们需要维护它,发布新的版本,并与其他人合作来扩展该软件。 + + + 我们已经使用GitHub来存储我们的源代码,但当我们在一个有更多开发者的团队中工作时,会发生什么? + + + 当几个开发人员参与时,可能会出现许多问题。该软件可能在我的电脑上工作得很好,但也许其他一些开发者使用的是不同的操作系统或不同的库版本。一个代码在一个开发者的机器上工作得很好,但另一个开发者甚至无法启动它,这种情况并不罕见。这通常被称为 "在我的机器上工作 "的问题。 + + + 也有一些更复杂的问题。如果两个开发者都在做修改,而他们还没有决定如何部署到生产中,那么谁的修改会被部署?怎样才能防止一个开发者的修改覆盖另一个开发者的修改? + + + 在这一部分中,我们将讨论如何共同工作,并以严格定义的方式构建和部署软件,以便清楚地知道在任何特定情况下会发生什么。 + +### Some useful terms + + + 在这部分中,我们将使用一些你可能不熟悉的术语,或者你可能没有很好的理解。我们将在这里讨论其中一些术语。即使你熟悉这些术语,也要读一读这部分,这样当我们在这部分使用这些术语时,我们就会在同一页上。 + +#### Branches + + + Git允许代码的多个副本、流或版本共存而不互相覆盖。当你第一次创建一个仓库时,你会看到主分支(通常在git中,我们称之为mastermain,但这在老项目中确实有所不同)。如果一个项目只有一个开发者,而且这个开发者每次只做一个功能,那么这就很好。 + + +当这种环境变得更加复杂时,分支就很有用。在这种情况下,每个开发人员可以有一个或多个分支。每个分支实际上是主分支的一个副本,其中的一些变化使其与主分支相背离。一旦分支中的功能或变化准备就绪,就可以合并回到主分支中,有效地使该功能或变化成为主软件的一部分。通过这种方式,每个开发者都可以在自己的修改集上工作,在修改准备好之前不影响其他开发者。 + + + 但是,一旦一个开发者将他们的修改合并到主分支,其他开发者的分支会发生什么?他们现在正从主分支的一个较早的副本中分化出来。后面那个分支的开发者如何知道他们的修改是否与主分支的当前状态兼容?这就是我们在这一部分要回答的基本问题之一。 + + + 你可以从[这里](https://www.atlassian.com/git/tutorials/using-branches)阅读更多关于分支的信息。 + +#### Pull request + + + 在GitHub中,将一个分支合并到软件的主干分支,通常是通过一个叫做[pull request](https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/about-pull-requests)的机制实现的,在这个机制中,做了一些修改的开发者要求将这些修改合并到主干分支。一旦提出了拉动请求,也就是通常所说的PR,或者打开了,另一个开发者就会检查是否一切正常,然后合并PR。 + + + 如果你对本课程的材料提出了修改意见,你就已经提出了一个拉动请求! + +#### Build + + + 术语 "构建 "在不同语言中有不同的含义。在一些解释型语言中,如Python或Ruby,实际上根本就不需要构建步骤。 + + + 一般来说,当我们谈论构建时,我们指的是准备软件在它要运行的平台上运行。这可能意味着,例如,如果你用TypeScript编写了你的应用,而你打算在Node上运行它,那么构建步骤可能是将TypeScript转译成JavaScript。 + + + 在C和Rust这样的编译语言中,这个步骤要复杂得多(而且需要),因为代码需要被编译成可执行文件。 + + + 在[第7章节](/en/part7/webpack)中,我们看了一下[webpack](https://webpack.js.org/),它是目前构建React或任何其他前端JavaScript或TypeScript代码库的生产版本的事实工具。 + +#### Deploy + + + 部署指的是把软件放在最终用户需要使用的地方。在库的情况下,这可能只是意味着将一个npm包推送到一个包存档(如npmjs.com),其他用户可以找到它并将其包含在他们的软件中。 + + + 部署一个服务(如网络应用)的复杂程度可能不同。在[第三章节](/en/part3/deploying_app_to_internet)中,我们的部署工作流程涉及手动运行一些脚本,并将版本库代码推送到[Heroku](https://www.heroku.com/)托管服务。 + + + 在这一部分中,我们将开发一个简单的 "部署管道",将你每次提交的代码自动部署到Heroku,如果提交的代码没有破坏任何东西。 + + + 部署可以明显地更复杂,特别是如果我们增加了诸如 "软件在部署期间必须一直可用"(零停机部署)的要求,或者如果我们必须考虑到诸如[数据库迁移](/en/part13/migrations_many_to_many_relationships#migrations)的事情。在这一部分中,我们不会涉及像那些复杂的部署,但知道它们的存在是很重要的。 + +### What is CI? + + + CI(持续集成)的严格定义和该术语在业界的使用方式有很大不同。一个有影响力但相当早的(2006年)关于这个话题的讨论是在[Martin Fowler's blog](https://www.martinfowler.com/articles/continuousIntegration.html)。 + + + 严格来说,CI指的是经常将开发人员的修改合并到主分支中,维基百科甚至帮助性地建议。维基百科甚至建议:"每天数次"。这通常是正确的,但是当我们在行业中提到CI时,我们通常在谈论实际合并发生后的情况。 + + + 我们可能想做其中的一些步骤。 + + - 提示:保持我们的代码清洁和可维护。 + + - 构建:将我们所有的代码整合成软件 + + - 测试:以确保我们不会破坏现有的功能 + + -打包。把它全部放在一个容易移动的批次中 + + - 上传/部署。将它提供给全世界 + + + 我们将在后面更详细地讨论这些步骤中的每一个(以及它们何时适合)。要记住的是,这个过程应该被严格定义。 + + + 通常,严格的定义是对创造力/开发速度的一种限制。然而,对于CI来说,这通常不应该是真的。这种严格性应该以允许更容易开发和合作的方式来设置。使用一个好的CI系统(比如我们将在这部分介绍的GitHub Actions)将使我们能够自动地完成这些工作。 + +### Packaging and Deployment as a part of CI + + + 可能值得注意的是,打包和特别是部署有时不被认为是属于CI的范畴。我们将它们添加到这里,因为在现实世界中,将它们放在一起是有意义的。部分原因是它们在流程和管道的背景下是有意义的(我想把我的代码交给用户),部分原因是这些实际上是最有可能发生故障的点。 + + + 包装往往是CI中出现问题的一个领域,因为这不是通常在本地测试的东西。在CI工作流程中测试项目的包装是有意义的,即使我们不对产生的包做任何事情。在某些工作流程中,我们甚至可以测试已经建立的包。这就保证了我们已经测试了与将被部署到生产中的代码相同的形式。 + + + 那部署呢?我们将在接下来的章节中详细讨论一致性和可重复性,但我们在这里要提到的是,无论我们是在开发分支还是在主分支上运行测试,我们都希望有一个看起来相同的过程。事实上,这个过程可能实际上是一样的,只是在最后进行检查,以确定我们是否在主分支上,并需要进行部署。在这种情况下,将部署包括在 CI 过程中是有意义的,因为我们在进行 CI 工作的同时,也在维护它。 + +#### Is this CD thing related? + + + 术语连续交付连续部署(两者的首字母缩写都是CD)经常被用于谈论也负责部署的CI。我们不会用确切的定义来烦扰你(你可以使用例如[Wikipedia](https://en.wikipedia.org/wiki/Continuous_delivery)或[另一篇Martin Fowler的博文](https://martinfowler.com/bliki/ContinuousDelivery.html)),但一般来说,我们把CD称为主分支始终保持可部署的做法。一般来说,这也经常与由合并到主分支所引发的自动部署结合起来。 + + + CI和CD之间的模糊区域如何处理?例如,如果我们在任何新代码被合并到主干分支之前必须运行测试,那么这是CI,因为我们经常合并到主干分支,还是CD,因为我们要确保主干分支总是可以部署的? + + + 所以,有些概念经常跨越CI和CD之间的界限,正如我们上面所讨论的,部署有时是有意义的,可以将CD视为CI的一部分。这就是为什么你会经常看到用CI/CD来描述整个过程。在这一部分,我们将交替使用 "CI "和 "CI/CD "这两个术语。 + +### Why is it important? + + + 上面我们谈到了 "在我的机器上工作 "的问题和多个变化的部署,但其他问题呢?如果 Alice 直接提交到主分支怎么办?如果Bob使用了一个分支,但在合并前没有费心去运行测试,那该怎么办?如果Charlie试图为生产构建软件,但用了错误的参数,怎么办? + + + 通过使用持续集成和系统化的工作方式,我们可以避免这些。 + + - 我们可以不允许直接提交到主分支上 + + - 我们可以让CI流程在所有针对主分支的拉动请求(PR)上运行,只有在满足我们所需的条件时才允许合并,如测试通过。 + + - 我们可以在CI系统的已知环境中为生产构建我们的包。 + + + 扩展这个设置还有其他好处。 + + - 如果我们在每次合并到主分支时都使用CD与部署,那么我们就知道它在生产中总是有效的。 + + - 如果我们只允许在该分支与主分支保持一致的时候进行合并,那么我们就可以确保不同的开发者不会互相覆盖对方的修改。 + + + 注意,在这部分中,我们假设主分支(名为mastermain)包含了正在生产中运行的代码。人们可以使用git的许多不同的[工作流程](https://www.atlassian.com/git/tutorials/comparing-workflows),例如,在某些情况下,可能是一个特定的release分支包含了正在生产中运行的代码。 + +### Important principles + + + 重要的是要记住,CI/CD不是目标。目标是更好、更快的软件开发,减少可预防的错误和更好的团队合作。 + + + 为此,CI应该始终根据手头的任务和项目本身进行配置。最终目标应始终铭记在心。你可以把CI看成是这些问题的答案。 + + - 如何确保在所有将要部署的代码上运行测试? + + - 如何确保主分支在任何时候都是可部署的? + + - 如何确保构建是一致的,并且总是在它要部署的平台上工作? + + - 如何确保这些变化不会相互覆盖? + + - 如何在点击按钮时进行部署,或者在一个分支合并到主分支时自动部署? + + + 甚至有科学证据表明CI/CD的使用有很多好处。根据[《加速》](https://itrevolution.com/product/accelerate/)一书中报告的一项大型研究。,CI/CD的使用与组织的成功有很大关系(例如,提高利润率和产品质量,增加市场份额,缩短上市时间)。CI/CD甚至通过减少开发人员的倦怠率而使他们更快乐。书中总结的结果在科学文章中也有报道,如[这个](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=2681909)。 +#### Documented behavior + + + 有一个老笑话说,错误只是一个 "未记录的功能"。我们想避免这种情况。我们希望避免任何我们不知道确切结果的情况。例如,如果我们依靠PR上的标签来定义某个东西是 "主要"、"次要 "还是 "补丁 "版本(我们将在后面介绍这些术语的含义),那么我们必须知道,如果开发者忘记在他们的PR上贴标签会发生什么。如果他们在构建/测试过程开始后才贴上标签呢?如果开发者在中途改变了标签会怎样,哪一个才是真正的发布? + + + 你有可能覆盖所有你能想到的情况,但仍然有差距,即开发者会做一些你没有想到的 "创造性 "的事情,所以在这种情况下,让程序安全失败是很重要的。 + + + 例如,如果我们有上面提到的情况,标签在构建过程中发生变化。如果我们事先没有想到这一点,那么如果发生了我们没有预料到的事情,最好是让构建失败并提醒用户。另一种情况是,我们还是部署了错误的版本,这可能会导致更大的问题,所以失败并通知开发者是解决这种情况的最安全的方法。 + +#### Know the same thing happens every time + + + 我们可能对我们的软件进行了可以想象的最好的测试,这些测试可以抓住每一个可能的问题。这很好,但如果我们不在代码部署前运行它们,它们就没有用。 + + + 我们需要保证这些测试能够运行,并且我们需要确保它们能够针对实际部署的代码运行。例如,如果测试只针对Alice的分支运行,而在合并到主分支后会失败,那就没有用。我们要从主干分支部署,所以我们需要确保测试是针对主干分支的副本进行的,并将 Alice 的修改合并进来。 + + + 这给我们带来了一个关键的概念。我们需要确保每次都发生同样的事情。或者说,所需的任务都是按照正确的顺序进行的。 + +#### Code always kept deployable + + + 拥有始终可部署的代码会使生活更轻松。当主分支包含在生产环境中运行的代码时,这一点尤其正确。例如,如果发现了一个需要修复的错误,你可以从主分支拉出一份副本(知道它是在生产环境中运行的代码),修复这个错误,并向主分支提出拉取请求。这是很直接的做法。 + + + 另一方面,如果主干分支和生产分支差别很大,而且主干分支不能部署,那么你就必须找出在生产中运行的代码,拉出一份副本,修复错误,想办法推回,然后再想办法部署那个特定的提交。这不是很好,而且必须是一个与正常部署完全不同的工作流程。 + +#### Knowing what code is deployed (sha sum/version) + + + 知道在生产中实际运行的是什么往往很重要。理想情况下,正如我们上面所讨论的,我们会在生产中运行主分支。这并不总是可行的。有时我们打算让主分支在生产中运行,但构建失败了,有时我们把几个变化批在一起,想让它们一次全部部署。 + + + 在这些情况下,我们所需要的(一般来说也是个好主意)是确切地知道哪些代码在生产中运行。有时这可以用版本号来完成,有时将提交的SHA和(git中特定提交的唯一标识哈希值)附在代码上也很有用。我们将进一步讨论版本问题[在本章节稍后](/en/part11/keeping_green#versioning)。 + + + 如果我们把版本信息和所有版本的历史结合起来,就更有用了。例如,如果我们发现某个特定的提交引入了一个bug,我们就可以准确地找出这个bug是什么时候发布的,有多少用户受到影响。当这个bug在数据库中写入坏数据时,这就特别有用。我们现在就可以根据时间来追踪这些坏数据的去向。 + +### Types of CI setup + + + 为了满足上面列出的一些要求,我们想用一个单独的服务器来运行持续集成的任务。有一个单独的服务器用于此目的,可以最大限度地减少其他东西干扰CI/CD过程并导致其不可预测的风险。 + + +有两种选择:托管我们自己的服务器或使用云服务。 + +#### Jenkins (and other self-hosted setups) + + +在自我托管的选项中,[Jenkins](https://www.jenkins.io/)是最受欢迎的。它非常灵活,而且 + +有几乎任何东西的插件(除了你想做的那件事情)。这对许多应用来说是一个很好的选择,使用自我托管的设置意味着整个环境在你的控制之下,资源的数量可以被控制,秘密(我们将在这一部分的后面部分详细说明安全问题)永远不会暴露给其他人,你可以在硬件上做任何你想做的事情。 + + + 不幸的是,也有一个坏处。Jenkins的设置相当复杂。它非常灵活,但这意味着通常需要相当多的模板/模版代码来实现构建工作。具体到Jenkins,这也意味着CI/CD必须用Jenkins自己的特定领域语言进行设置。还有硬件故障的风险,如果设置被大量使用,这可能是一个问题。 + + + 对于自我托管的选项,计费通常是基于硬件的。你为服务器付费。你在服务器上做什么并不改变计费。 + +#### GitHub Actions and other cloud-based solutions + + + 在云托管的设置中,环境的设置不是你需要担心的事情。它就在那里,你所要做的就是告诉它该做什么。这样做通常包括在你的版本库中放置一个文件,然后告诉CI系统读取该文件(或者检查你的版本库中的特定文件)。 + + + 基于云的选项的实际CI配置通常要简单一些,至少如果你保持在被认为是 "正常 "的使用范围内。如果你想做一些更特别的事情,那么基于云的选项可能会变得更加有限,或者你会发现很难完成云平台不适合的特定任务。 + + + 在这一部分,我们将看看一个相当正常的使用案例。更复杂的设置可能,例如,利用特定的硬件资源,例如,GPU。 + + + 除了上面提到的配置问题外,基于云的平台上通常有资源限制。在自我托管的设置中,如果构建速度慢,你可以得到一个更大的服务器,并在它身上投入更多资源。在基于云的选项中,这可能是不可能的。例如,在[GitHub Actions](https://github.com/features/actions)中,你的构建将运行在2个vCPUs和8GB内存的节点上。 + + + 基于云的选项通常也是按构建时间计费的,这是需要考虑的问题。 + +#### Why pick one over the other + + + 一般来说,如果你有一个中小型的软件项目,没有任何特殊的要求(例如需要一个图形卡来运行测试),基于云的解决方案可能是最好的。配置很简单,你不需要为建立你自己的系统而费心费力。特别是对于较小的项目,这应该是比较便宜的。 + + + 对于需要更多资源的大型项目,或者在有多个团队和项目需要利用它的大公司,自我托管的CI设置可能是最好的方式。 + +#### Why use GitHub Actions for this course + + + 对于本课程,我们将使用[GitHub Actions](https://github.com/features/actions)。这是一个明显的选择,因为我们无论如何都要使用GitHub。我们可以立即得到一个强大的CI解决方案,而不需要设置服务器或配置第三方的云服务。 + + + 除了易于使用,GitHub Actions在其他方面也是一个不错的选择。它可能是目前最好的基于云的解决方案。自2019年11月首次发布以来,它已经获得了很多人气。 + +
    + +
    + +### Exercise 11.1 + + +在我们动手设置CI/CD管道之前,让我们对我们所读的内容进行一下思考。 + +#### 11.1 Warming up + + + 想一想一个假设的情况,我们有一个由大约6个人组成的团队正在开发的应用。该应用正在积极开发中,并将很快发布。 + + + 让我们假设这个应用是用JavaScript/TypeScript以外的其他语言编码的,例如用Python、Java或Ruby。你可以自由选择语言。这甚至可能是一种你自己都不太了解的语言。 + + + 写一篇短文,比如200-300字,在其中回答或讨论下面的一些观点。你可以用https://wordcounter.net/ 来检查长度。把你的答案保存在你将在[练习11.2](/en/part11/getting_started_with_git_hub_actions#exercise-11-2)中创建的版本库的根目录下,名为exercise1.md。 + + + 要讨论的要点。 + + - CI设置中的一些常见步骤包括lintingtestingbuilding。在你选择的语言的生态系统中,照顾这些步骤的具体工具是什么?你可以通过谷歌搜索答案。 + + - 除了Jenkins和GitHub Actions之外,还有什么其他方法来设置CI?同样,你可以问google! + + - 这样的设置在自我托管或基于云的环境中会更好吗?为什么?你需要什么信息来做这个决定? + + + 请记住,上述问题没有"正确"的答案! + +
    diff --git a/src/content/11/zh/part11b.md b/src/content/11/zh/part11b.md new file mode 100644 index 00000000000..3cfd2c94d05 --- /dev/null +++ b/src/content/11/zh/part11b.md @@ -0,0 +1,475 @@ +--- +mainImage: ../../../images/part-11.svg +part: 11 +letter: b +lang: zh +--- + +
    + + + 在我们开始玩GitHub动作之前,让我们先看看它们是什么,它们是如何工作的。 + + + GitHub 动作的工作基础是 [工作流](https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/introduction-to-github-actions#workflows)。工作流程是一系列的[工作](https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/introduction-to-github-actions#jobs),当某个触发的[事件](https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/introduction-to-github-actions#events)发生时就会运行。运行的作业本身就包含了GitHub Actions应该做什么的指令。 + + + 一个典型的工作流程的执行是这样的。 + + + - 触发事件发生(例如,有一个推送到主分支)。 + + - 带有该触发器的工作流被执行。 + + - 清理 + +### Basic needs + + + 一般来说,要让 CI 在一个版本库上运行,我们需要一些东西。 + + + - 一个版本库(显然)。 + + - CI需要做什么的一些定义。 + + 这可以是版本库中特定文件的形式,也可以是CI系统中的定义。 + + - CI需要知道版本库(和其中的文件)的存在。 + + - CI需要能够访问该版本库 + + - CI需要权限来执行它应该能做的动作。 + + 例如,如果 CI 需要能够部署到生产环境,它需要该环境的证书。 + + + 这至少是传统的模式,我们将在一分钟内看到GitHub Actions如何绕过其中的一些步骤,或者说让你不必担心这些问题 + + + 与自我托管的解决方案相比,GitHub Actions 有一个很大的优势:仓库由 CI 供应商托管。换句话说,Github同时提供仓库和CI平台。这意味着,如果我们为一个仓库启用了行动,GitHub已经知道我们定义了工作流程,以及这些定义是什么样子的。 + +
    + +
    + +### Exercise 11.2. + + + 在这部分的大部分练习中,我们正在为[这个例子的项目库](https://github.com/fullstack-hy2020/full-stack-open-pokedex)中的一个小项目建立一个CI/CD管道。 + +#### 11.2 The example project + + + 你要做的第一件事是用你的名字分叉这个例子库。它的作用是在你的 GitHub 用户配置文件下创建一个仓库的副本供你使用。 + + + 要分叉该版本库,你可以点击版本库视图右上角明星按钮旁边的分叉按钮。 + +![](../../images/11/1.png) + + + 一旦你点击了分叉按钮,GitHub就会开始创建一个新的仓库,名为{github_username}/full-stack-open-pokedex。 + + + 一旦这个过程完成,你应该被重定向到你的全新仓库。 + +![](../../images/11/2.png) + + + 现在克隆项目到你的机器上。像往常一样,当开始使用一个新的代码时,最明显的地方就是package.json文件。 + + + 现在尝试以下操作。 + + - 安装依赖项(通过运行npm install) + + - 在开发模式下启动代码 + + - 运行测试 + + - 对代码进行润色 + + + 你可能会注意到该项目包含一些破碎的测试和刷新错误。**我们将在后面的练习中解决这些问题。 + + + 你可能还记得[第三章节](/en/part3/deploying_app_to_internet#frontend-production-build),React代码不应该在开发模式下运行,一旦它被部署到生产中。现在试试下面的方法 + + - 创建一个项目的生产build。 + + - 在本地运行该生产版本 + + + 同样对于这两项任务,项目中也有现成的npm脚本! + + + 研究一下这个项目的结构。正如你所注意到的,前端和后端代码现在都[在同一个仓库](/en/part7/class_components_miscellaneous#frontend-and-backend-in-the-same-repository)。在课程的早期部分,我们为二者建立了单独的仓库,但在建立CI环境时,将它们放在同一个仓库里会使事情变得更简单。 + + + 与本课程中的大多数项目相反,前端代码没有使用create-react-app,但它有一个相对简单的[webpack](/en/part7/webpack)配置,负责创建开发环境和创建生产包。 + +
    + +
    + +### Getting started with workflows + + + 使用GitHub Actions创建CI/CD管道的核心组件是一个叫做[工作流](https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/introduction-to-github-actions#workflows)的东西。工作流是你可以在你的版本库中设置的流程,以运行自动化任务,如构建、测试、刷新、发布和部署,仅举几个例子工作流的层次结构看起来如下。 + + + 工作流 + + + - 工作 + + - 步骤 + + - 步骤 + + - 工作 + + - 步骤 + + + 每个工作流必须指定至少一个[作业](https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/introduction-to-github-actions#jobs),其中包含一组[步骤](https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/introduction-to-github-actions#steps)来执行单个任务。作业将被并行运行,每个作业中的步骤将被按顺序执行。 + + +步骤可以从运行自定义命令到使用预先定义的行动,因此被称为GitHub行动。你可以创建[自定义动作](https://docs.github.com/en/free-pro-team@latest/actions/creating-actions)或使用社区发布的任何动作,这些动作很多,但让我们稍后再来讨论这个问题! + + + 为了让GitHub识别你的工作流,它们必须被指定在你的仓库的.github/workflows文件夹中。每个工作流都是它自己的独立文件,需要使用YAML数据序列化语言进行配置。 + + +YAML是 "YAML Ain't Markup Language "的递归首字母缩写。正如它的名字所暗示的那样,它的目标是人类可读的,它通常被用于配置文件。你会注意到下面的内容,它确实非常容易理解! + + + 注意,缩进在YAML中是很重要的。你可以了解更多关于语法的信息[这里](https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html)。 + + + 在YAML文档中,一个基本的工作流程包含三个元素。这三个元素是 + + + - 名称:是的,你猜对了,工作流的名称 + + - (关于)触发器。触发该工作流执行的事件 + + - 工作。工作流将执行的独立作业(一个基本的工作流可能只包含一个作业)。 + + + 一个简单的工作流定义如下所示: + +```yml +name: Hello World! + +on: + push: + branches: + - master + +jobs: + hello_world_job: + runs-on: ubuntu-latest + steps: + - name: Say hello + run: | + echo "Hello World!" +``` + + + 在这个例子中,触发器是推送到主分支,在我们的项目中被称为master。(你的主分支可以叫mainmaster)。 有一个名为hello_world/job的工作,它将在Ubuntu 20.04的虚拟环境中运行。这个作业只有一个步骤,名为 "Say hello",它将在shell中运行echo "Hello World!"命令。 + + + 所以你可能会问,GitHub什么时候会触发工作流的启动?有很多[选项](https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows)可以选择,但一般来说,你可以将工作流配置为启动一次。 + + + - GitHub上的一个事件发生,比如有人向仓库推送了一个提交,或者创建了一个问题或拉动请求 + + - 一个使用[cron](https://en.wikipedia.org/wiki/Cron)语法指定的计划事件发生了 + + - 发生一个外部事件,例如,在一个外部应用中执行一个命令,如[Slack](https://slack.com/)或[Discord](https://discord.com/)消息应用 + + + 要了解更多关于哪些事件可以用来触发工作流程,请参考GitHub Action的[文档](https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows)。 + + +
    + +
    + +### Exercises 11.3-11.4. + + + 为了将这一切联系起来,现在让我们在示例项目中启动并运行Github Action! + +#### 11.3 Hello world! + + + 创建一个新的工作流,向用户输出 "Hello World!"。对于设置,你应该创建目录.github/workflows和一个文件hello.yml到你的仓库。 + + + 要看你的GitHub行动工作流程做了什么,你可以导航到GitHub中的**行动**标签,在那里你应该看到你的仓库中的工作流程和它们实现的步骤。如果工作流程配置得当,"你好,世界 "工作流程的输出应该是这样的。 + +![A properly configured Hello World workflow](../../images/11/3.png) + + + 你应该看到 "Hello World!"信息作为输出。如果是这样的话,那么你已经成功地完成了所有必要的步骤。你的第一个GitHub Actions工作流已经激活了! + + + 注意,GitHub Actions 还会告诉你工作流运行的具体环境(操作系统和它的 [setup](https://github.com/actions/virtual-environments/blob/ubuntu18/20201129.1/images/linux/Ubuntu1804-README.md))是什么。这很重要,因为如果发生了令人惊讶的事情,如果你能在你的机器上重现所有的步骤,那么调试就会容易得多 + +#### 11.4 Date and directory contents + + + 用长格式打印日期和当前目录内容的步骤来扩展工作流程。 + + +这两个步骤都很简单,只要运行命令[date](https://man7.org/linux/man-pages/man1/date.1.html)和[ls](https://man7.org/linux/man-pages/man1/ls.1.html)就可以了。 + + + 你的工作流程现在应该是这样的 + +![Date and dir content in workflow](../../images/11/4.png) + + + 正如命令ls -l的输出显示,默认情况下,运行我们工作流程的虚拟环境没有任何代码! + +
    + +
    + +### Setting up lint, test and build steps + + + 在完成第一个练习后,你应该有一个简单但相当无用的工作流设置。让我们来让我们的工作流做一些有用的事情。 + + + 让我们实现一个Github动作,对代码进行检查。如果检查没有通过,Github行动将显示红色状态。 + + + 开始时,我们将保存在文件pipeline.yml中的工作流程是这样的。 + +```js +name: Deployment pipeline + +on: + push: + branches: + - master + +jobs: +``` + + + 在我们运行命令对代码进行润色之前,我们必须执行几个动作来设置工作的环境。 + +#### Setting up the environment + + + 设置环境是配置管道时的一项重要任务。我们将使用一个ubuntu-latest虚拟环境,因为这是我们将在生产中运行的Ubuntu版本。 + + + 在CI中尽可能地复制与生产中相同的环境是很重要的,以避免相同的代码在CI和生产中工作不同的情况,这将有效地挫败使用CI的目的。 + + + 接下来,我们列出CI需要执行的 "构建 "工作中的步骤。正如我们在上一个练习中注意到的,默认情况下,虚拟环境中没有任何代码,所以我们需要从仓库中签出代码。 + + +这是一个简单的步骤。 + +```js +name: Deployment pipeline + +on: + push: + branches: + - master + +jobs: + simple_deployment_pipeline: // highlight-line + runs-on: ubuntu-latest // highlight-line + steps: // highlight-line + - uses: actions/checkout@v4 // highlight-line +``` + + + [uses](https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstepsuses)关键词告诉工作流要运行一个特定的动作。行动是一段可重复使用的代码,就像一个函数。行动可以在你的版本库中定义为一个单独的文件,也可以使用公共版本库中的行动。 + + + 这里我们使用一个公共动作[actions/checkout](https://github.com/actions/checkout),我们指定了一个版本(@v4),以避免在动作被更新时可能出现的破坏性变化。checkout动作就像它的名字所暗示的那样:它从git检查项目的源代码。 + + + 其次,由于应用是用JavaScript编写的,Node.js必须被设置为能够利用package.json中指定的命令。要设置Node.js,可以使用[actions/setup-node](https://github.com/actions/setup-node) 动作。版本16被选中,因为它是应用在生产环境中使用的版本。 + +```js +# name and trigger not shown anymore... + +jobs: + simple_deployment_pipeline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 // highlight-line + with: // highlight-line + node-version: '20' // highlight-line +``` + + + 我们可以看到,[with](https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstepswith)关键字被用来给动作提供一个 "参数"。这里的参数指定了我们要使用的Node.js的版本。 + + + + 最后,必须安装应用的依赖项。就像在你自己的机器上,我们执行npm install。工作中的步骤现在看起来应该是这样的 + +```js +jobs: + simple_deployment_pipeline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v2 + with: + node-version: '20' + - name: npm install // highlight-line + run: npm install // highlight-line +``` + + + 现在环境应该完全准备好了,以便作业在其中运行实际的重要任务! + +#### Lint + + + 环境建立后,我们可以像在自己的机器上一样,运行package.json中所有的脚本。要对代码进行检查,你所要做的就是添加一个步骤来运行npm run eslint命令。 + +```js +jobs: + simple_deployment_pipeline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v2 + with: + node-version: '20' + - name: npm install + run: npm install + - name: lint // highlight-line + run: npm run eslint // highlight-line +``` + +
    + +
    + +### Exercises 11.5.-11.9. + +#### 11.5 Linting workflow + + + 执行或复制-粘贴"Lint "工作流程,并将其提交到版本库中。为这个工作流程使用一个新的yml文件,你可以称它为例如pipeline.yml。 + + + 推送你的代码,并导航到 "行动 "选项卡,在左边点击你新创建的工作流。你应该看到工作流运行失败了。 + +![Linting to workflow](../../images/11/5.png) + +#### 11.6 Fix the code + + + 代码中存在一些问题,你需要加以解决。打开工作流程的日志,调查一下哪里出了问题。 + + + 有几个提示。其中一个错误最好通过为linting指定适当的env来解决,见[这里](/en/part3/validation_and_es_lint#lint)如何做。关于console.log语句的投诉之一,可以通过简单地沉默该特定行的规则来解决。问问google怎么做吧。 + + + 对源代码进行必要的修改,使lint工作流通过。一旦你提交了新的代码,工作流就会再次运行,你会看到更新的输出,所有的东西都是绿色的。 + +![Lint error fixed](../../images/11/6.png) + +#### 11.7 Building and testing + + + 让我们在之前的工作流程上进行扩展,该工作流程目前正在对代码进行刷新。编辑工作流程,并在lint命令的基础上增加build和test的命令。这一步之后,结果应该是这样的 + +![tests fail...](../../images/11/7.png) + + + 正如你可能已经猜到的,代码中存在一些问题... + +#### 11.8 Back to green + + + 调查哪个测试失败了,在代码中修复这个问题(不要改变测试)。 + + + 一旦你修复了所有的问题,并且Pokedex没有错误,工作流程的运行就会成功,并且显示为绿色! + +![tests fixed](../../images/11/8.png) + +#### 11.9 Simple end to end -tests + + + 目前的测试集使用[Jest](https://jestjs.io/)来确保React组件按预期工作。这与第5章节的[Testing React apps](/en/part5/testing_react_apps)所做的事情完全一样。 + + + 孤立地测试组件是相当有用的,但这仍然不能确保系统作为一个整体按照我们的意愿工作。为了对此更有信心,让我们用[Cypress](https://www.cypress.io/)库写几个真正简单的端到端测试,就像我们在第五章节的[端到端测试](/en/part5/end_to_end_testing)中做的那样。 + + + 所以,设置Cypress(你会发现[这里](/en/part5/end_to_end_testing/)你需要的所有信息)并首先使用这个测试。 + +```js +describe('Pokedex', function() { + it('front page can be opened', function() { + cy.visit('http://localhost:5000') + cy.contains('ivysaur') + cy.contains('Pokémon and Pokémon character names are trademarks of Nintendo.') + }) +}) +``` + + + 定义一个npm脚本 test:e2e 用于从命令行运行e2e测试。 + + + **注意**不要在Cypress测试文件名中包含spec这个词,那样也会导致Jest运行它,而且可能会引起问题。 + + + **另一件需要注意的事情是**尽管页面上渲染的小精灵名字是以大写字母开始的,但实际上在源文件中这些名字是以小写字母书写的,所以是ivysaur而不是Ivysaur! + + + 确保测试在本地通过。请记住,Cypress测试_假设当你运行测试时,应用已经启动并运行了_!如果你忘记了细节(我也遇到过这种情况!),请看[第5章节](/en/part5/end_to_end_testing)如何使用Cypress启动和运行。 + + + 一旦端到端测试在你的机器上运行,将其纳入GitHub行动工作流程。到目前为止,最简单的方法是使用现成的行动[cypress-io/github-action](https://github.com/cypress-io/github-action)。适合我们的步骤如下。 + +```js +- name: e2e tests + uses: cypress-io/github-action@v2 + with: + command: npm run test:e2e + start: npm run start-prod + wait-on: http://localhost:5000 +``` + + + 使用了三个选项。[command](https://github.com/cypress-io/github-action#custom-test-command)指定如何运行Cypress测试。[start](https://github.com/cypress-io/github-action#start-server)给出了启动服务器的npm脚本,[wait-on](https://github.com/cypress-io/github-action#wait-on)表示在运行测试之前,服务器应该已经在url 中启动。 + + + 一旦你确定管道工作,写另一个测试,确保人们可以从主页面导航到一个特定的小精灵的页面,例如ivysaur。这个测试不需要很复杂,只需检查当你浏览一个链接时,该页面有一些正确的内容,例如在ivysaur的情况下,字符串chlorophyll。 + + + **注意**口袋妖怪的能力也是用小写字母写的,大写字母是在CSS中完成的,所以不要搜索例如Chlorophyll,而是chlorophyll。 + + + **注意2**,你不应该尝试bulbasaur,由于某些原因,该特定小精灵的页面不能正常工作...... + + + 最后的结果应该是这样的 + +![e2e tests](../../images/11/9.png) + + + 端到端测试是很好的,因为它们从最终用户的角度给了我们软件工作的信心。我们必须付出的代价是较慢的反馈时间。现在执行整个工作流程需要相当长的时间。 + +
    diff --git a/src/content/11/zh/part11c.md b/src/content/11/zh/part11c.md new file mode 100644 index 00000000000..e96d1ee036d --- /dev/null +++ b/src/content/11/zh/part11c.md @@ -0,0 +1,215 @@ +--- +mainImage: ../../../images/part-11.svg +part: 11 +letter: c +lang: zh +--- + +
    + + + 在写好了一个漂亮的应用之后,是时候考虑我们如何将其部署到真正的用户中去了。 + + + 在本课程的[第三章节](/en/part3/deploying_app_to_internet)中,我们通过简单的推送git仓库到云提供商[Heroku](https://www.heroku.com/home)的服务器来实现。至少与许多其他类型的托管设置相比,在Heroku中发布软件是相当简单的,但它仍然包含风险:没有什么能阻止我们意外地将破损的代码推到生产中。 + + + 接下来,我们要看一下安全部署的原则,以及在小规模和大规模部署软件的一些原则。 + +### Anything that can go wrong... + + + 我们想定义一些关于我们的部署过程应该如何工作的规则,但在这之前,我们必须看看现实的一些限制。 + + +"墨菲定律 "的一个措辞认为。 + + "任何可能出错的事情都会出错。" + + + 当我们计划我们的部署系统时,记住这一点很重要。我们需要考虑的一些事情可能包括。 + + -如果我的电脑在部署过程中崩溃或挂起怎么办? + + - 我连接到服务器并通过互联网进行部署,如果我的互联网连接中断了会怎样? + + - 如果我的部署脚本/系统中的任何特定指令失败会怎样? + + - 如果由于某种原因,我的软件在我部署的服务器上不能按预期工作,会发生什么?我可以回滚到以前的版本吗? + + - 如果一个用户在我们进行部署之前对我们的软件做了一个HTTP请求(我们没有时间向用户发送响应)会发生什么? + + + 这些只是部署过程中可能出错的一小部分,或者说,我们应该计划的事情。无论发生什么,我们的部署系统都**绝不应该**让我们的软件处于破碎状态。我们也应该总是知道(或者很容易找到)一个部署处于什么状态。 + + + 当涉及到部署(和一般的CI)时,要记住的另一个重要规则是。 + + "无声的失败是**非常**糟糕的!" + + + 这并不意味着故障需要显示给软件的用户,它意味着如果有什么问题,我们需要意识到。如果我们意识到一个问题,我们就可以修复它,如果部署系统没有给出任何错误,但却失败了,我们可能最终会陷入这样一种状态:我们认为我们已经修复了一个关键的错误,但部署却失败了,把这个错误留在了我们的生产环境中,而我们却没有意识到这种情况。 + +### What does a good deployment system do? + + + 为部署系统定义明确的规则或要求是困难的,无论如何让我们尝试一下。 + + - 我们的部署系统应该能够在部署的**任何**步骤中优雅地失败。 + + - 我们的部署系统应该**永远不会**让我们的软件处于崩溃状态。 + + - 我们的部署系统应该让我们知道何时发生了故障。通知失败比通知成功更重要。 + + - 我们的部署系统应该允许我们回滚到以前的部署。 + + - 与全面部署相比,这种回滚最好更容易做到,而且不容易失败。 + + - 当然,最好的选择是在部署失败的情况下自动回滚 + + - 我们的部署系统应该处理用户在部署之前/期间发出HTTP请求的情况。 + + - 我们的部署系统应该确保我们正在部署的软件符合我们为此设定的要求(例如,如果没有运行测试就不要部署)。 + + + 让我们在这个假设的部署系统中也定义一些我们**想要的东西。 + + - 我们希望它是快速的 + + - 我们希望在部署期间没有停机时间(这与我们在部署之前/期间处理用户请求的要求不同)。 + +
    + +
    + +### Exercises 11.10-11.12. + + + 在进行下面的练习之前,你应该在Heroku环境中设置你的应用,就像我们在[第三章节](/en/part3/deploying_app_to_internet#application-to-the-internet)中所做的那样。 + + + 与第三章节相比,现在我们不自己推送代码到Heroku,我们让Github Actions工作流为我们做这件事! + + + 确保你现在已经安装了[Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli#download-and-install),并使用CLI以heroku login登录到Heroku。 + + + 使用CLI在Heroku中创建一个新的应用:heroku create --region eu {your-app-name},选择一个靠近你自己位置的[region](https://devcenter.heroku.com/articles/regions)!(你也可以不填应用,Heroku会为你创建一个应用名称)。 + + + 使用命令heroku authorizations:create为你的Heroku配置文件生成一个API令牌,并将证书保存在本地文件中,但**不要将这些证书推送到GitHub**! + + + 在你的部署工作流程中,你很快就会需要这个令牌。请看关于Heroku令牌的更多信息[这里](https://devcenter.heroku.com/articles/platform-api-quickstart)。 + +#### 11.10 Deploying your application to Heroku + + +用一个步骤来扩展工作流程,将你的应用部署到Heroku。 + + + 下面假设你使用社区开发的现成的Heroku部署动作[AkhileshNS/heroku-deploy](https://github.com/AkhileshNS/heroku-deploy)。 + + + 你需要你刚创建的用于部署的授权令牌。把它的值传递给GitHub Actions的正确方法是使用仓库的秘密。 + +![repo secret](../../images/11/10x.png) + + + 现在工作流可以访问令牌值,如下所示。 + +``` +${{secrets.HEROKU_API_KEY}} +``` + + + 如果一切顺利,你的工作流日志应该有点像这样。 + +![](../../images/11/11.png) + + + 然后你可以用浏览器试试这个应用,但很可能你会遇到一个问题。如果我们仔细阅读[第三章节的"应用到互联网"一节](/en/part3/deploying_app_to_internet#application-to-the-internet),我们注意到Heroku假定版本库有一个叫做Procfile的文件,告诉Heroku如何启动应用。 + + + 所以,添加一个合适的Procfile,确保应用正常启动。 + + + **记住**,在玩产品部署时,随时关注服务器日志中发生的事情是非常必要的,所以要尽早使用heroku logs,并经常使用它。不,是一直使用它! + +#### 11.11 Health check + + + 在继续之前,让我们用一个更多的步骤来扩展工作流程,一个确保应用在部署后正常运行的检查。 + + + 实际上不需要一个单独的工作流步骤,因为行动 + + [deploy-to-heroku](https://github.com/marketplace/actions/deploy-to-heroku)包含一个选项来处理这个问题。 + + + 添加一个简单的端点,用于在后端进行应用健康检查。例如,你可以复制这段代码。 + +```js +app.get('/health', (req, res) => { + res.send('ok') +}) +``` + + + 在应用中设置一个假的端点也是一个好主意,这样就可以进行一些代码修改,并确保部署的版本真的发生了变化。 + +```js +app.get('/version', (req, res) => { + res.send('1') // change this string to ensure a new version deployed +}) +``` + + + 现在从[文档](https://github.com/marketplace/actions/deploy-to-heroku)中查看如何在部署步骤中包含健康检查。使用创建的健康检查URL的端点。你很可能还需要checkstring选项来使检查工作。 + + + 如果一个部署破坏了你的应用,确保Actions注意到。你可以通过写一个错误的启动命令到Procfile来模拟这个情况。 + +![](../../images/11/12x.png) + + + 在进入下一个练习之前,修复你的部署,并确保应用再次按计划运行。 + +#### 11.12. Rollback + + + 如果部署导致应用损坏,最好的办法是回滚到以前的版本。幸运的是,Heroku让这变得非常容易。每次部署到Heroku都会产生一个[release](https://blog.heroku.com/releases-and-rollbacks#releases)。你可以用heroku releases命令查看你的应用的发布。 + +```js +$ heroku releases +=== calm-wildwood-40210 Releases - Current: v8 +v8 Deploy de15fc2b mluukkai@iki.fi 2022/03/02 19:14:22 +0200 (~ 8m ago) +v7 Deploy 8748a04e mluukkai@iki.fi 2022/03/02 19:06:28 +0200 (~ 16m ago) +v6 Deploy a617a93d mluukkai@iki.fi 2022/03/02 19:00:02 +0200 (~ 23m ago) +v5 Deploy 70f9b219 mluukkai@iki.fi 2022/03/02 18:48:47 +0200 (~ 34m ago) +v4 Deploy 0b2db00d mluukkai@iki.fi 2022/03/02 17:53:24 +0200 (~ 1h ago) +v3 Deploy f1cd250b mluukkai@iki.fi 2022/03/02 17:44:32 +0200 (~ 1h ago) +v2 Enable Logplex mluukkai@iki.fi 2022/03/02 17:00:26 +0200 (~ 2h ago) +v1 Initial release mluukkai@iki.fi 2022/03/02 17:00:25 +0200 (~ 2h ago) +``` + + +只需在命令行中使用一个命令,就可以快速地进行[回滚](https://blog.heroku.com/releases-and-rollbacks#rollbacks)到一个版本。 + + + 更棒的是,[deploy-to-heroku](https://github.com/marketplace/actions/deploy-to-heroku)这个动作可以为我们解决回滚的问题! + + + 所以请再次阅读[文档](https://github.com/marketplace/actions/deploy-to-heroku)并修改工作流程,以完全防止部署失败。你可以再次通过破坏Procfile来模拟一个破坏的部署。 + +![](../../images/11/13x.png) + + + 确保应用在中断部署的情况下仍能保持运行。 + + + 注意,尽管有自动回滚操作,但构建还是失败了,当这种情况在现实生活中发生时,找到导致问题的原因并迅速修复它是至关重要的。像往常一样,开始找出问题原因的最好地方是研究Heroku的日志。 + +![](../../images/11/14.png) + +
    diff --git a/src/content/11/zh/part11d.md b/src/content/11/zh/part11d.md new file mode 100644 index 00000000000..33a63229102 --- /dev/null +++ b/src/content/11/zh/part11d.md @@ -0,0 +1,412 @@ +--- +mainImage: ../../../images/part-11.svg +part: 11 +letter: d +lang: zh +--- + +
    + + + 你的代码的主分支应该始终保持绿色。绿色意味着你的构建管道的所有步骤都应该成功完成:项目应该成功构建,测试应该无误运行,并且linter不应该有任何产生警告,等等。 + + + 为什么这很重要?你可能会专门从主分支将代码部署到生产中。主分支中的任何故障都意味着在问题解决之前,新功能无法部署到生产中。有时,你会在生产中发现一个讨厌的错误,而CI/CD管线却没有发现。在这种情况下,你希望能够以安全的方式将生产环境回滚到之前的提交。 + + + 那你如何保持主分支的绿色?避免将任何修改直接提交到主分支。相反,在一个基于主分支的最新版本的分支上提交代码。一旦你认为该分支可以合并到主分支,你就创建一个 GitHub 拉动请求(也被称为 PR)。 + +### Working with Pull Requests + + + 在任何至少有两个贡献者的软件项目上工作时,拉取请求是协作过程的核心部分。当对项目进行修改时,你要在本地检出一个新的分支,做出并提交你的修改,将该分支推送到远程仓库(在我们的例子中是推送到GitHub),并创建一个拉动请求,让别人在你的修改被合并到主分支之前进行审查。 + + + 为什么使用拉动请求并让至少一个人审查你的代码总是一个好主意,有几个原因。 + + - 即使是经验丰富的开发者也会经常忽略他们代码中的一些问题:我们都知道隧道视野效应。 + + - 评审员可以有不同的视角,提供不同的观点。 + + - 在读完你的修改后,至少有一个其他开发者会熟悉你所做的修改。 + + - 使用PR可以让你在代码进入主分支之前自动运行CI管道中的所有任务。GitHub Actions为拉取请求提供了一个触发器。 + + + 你可以配置你的GitHub仓库,使拉动请求在被批准之前不能被合并。 + +![Compare & pull request](../../images/11/part11d_00.png) + + + 要打开一个新的拉动请求,在GitHub中打开你的分支,点击顶部的绿色 "比较和拉动请求 "按钮。你会看到一个表格,你可以在其中填写拉动请求的描述。 + +![Open a new pull request](../../images/11/part11d_01.png) + + + GitHub's pull request界面渲染的是描述和讨论界面。在底部,它显示了为每个PR配置的所有CI检查(在我们的例子中是每个Github行动),以及这些检查的状态。绿板是你的目标你可以点击每个检查的细节来查看细节和运行日志。 + + + 到目前为止,我们所看到的所有工作流程都是由对主分支的提交触发的。要使工作流为每个拉动请求运行,我们必须更新工作流的触发部分。我们对 "主 "分支(我们的主分支)使用 "pull_request "触发器,并将该触发器限制为 "打开 "和 "同步 "事件。基本上,这意味着,当主分支的PR被打开或更新时,工作流就会运行。 + + + 因此,让我们改变工作流程的[触发](https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows)事件如下。 + +```js +on: + push: + branches: + - master + pull_request: // highlight-line + branches: [master] // highlight-line + types: [opened, synchronize] // highlight-line +``` + + + 我们很快就会使代码无法直接推送到主分支,但与此同时,让我们仍然为所有可能直接推送到主分支的工作流程运行。 + +
    + +
    + +### Exercises 11.13-11.14. + + + 我们的工作流在确保良好的代码质量方面做得很好,但由于它是在提交到主分支的过程中运行的,所以它发现问题的时间太晚了 + +#### 11.13 Pull request + + + 按照上面的建议,更新现有工作流的触发器,使其在主干分支的新拉取请求上运行。 + + + 创建一个新的分支,提交你的修改,并向主分支发出拉取请求。 + + + 如果你以前没有使用过分支,可以查看[例如这个教程](https://www.atlassian.com/git/tutorials/using-branches)来开始。 + + + 注意,当你打开拉取请求时,确保在这里选择你的自己的仓库作为目标基础仓库。默认情况下,选择的是智能的原始仓库,你**不希望**这样做。 + +![](../../images/11/15a.png) + + + 在拉动请求的 "对话 "标签中,你应该看到你的最新提交和正在进行的检查的黄色状态。 + +![](../../images/11/16.png) + + + 一旦检查完成,状态应该转为绿色。确保所有的检查都通过了。先不要合并你的分支,我们还有一件事需要改进,那就是我们的管道。 + +#### 11.14 Run deployment step only for the main branch + + + 一切看起来都很好,但实际上目前的工作流程有一个相当严重的问题。所有的步骤,包括部署,都是为拉取请求而进行的。这肯定是我们不希望看到的。 + + + 幸运的是,这个问题有一个简单的解决方案!我们可以在部署步骤中添加一个[if](https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#jobsjob_idif)条件,确保该步骤只在代码被合并或推送到主分支时执行。 + + + 工作流[context](https://docs.github.com/en/free-pro-team@latest/actions/reference/context and-expression-syntax-for-github-actions#contexts)给出了工作流运行的代码的各种信息。 + + +相关信息在[GitHub context](https://docs.github.com/en/actions/learn-github-actions/contexts#github-context)中找到,字段event_name告诉我们触发工作流的事件的 "名字 "是什么。当拉动请求被合并时,事件的名称在某种程度上是矛盾的push,与推送代码到仓库时的事件相同。因此,我们通过在部署代码的步骤中添加以下条件来获得所需的行为。 + +```js +if: ${{ github.event_name == 'push' }} +``` + + + 再推送一些代码到你的分支,并确保部署步骤不再执行。然后将该分支合并到主分支,确保部署发生。 + +
    + +
    + +### Versioning + + + 版本管理最重要的目的是唯一地识别我们正在运行的软件和与之相关的代码。 + + +版本的排序也是一个重要的信息。例如,如果当前的版本破坏了关键功能,我们需要识别软件的前一版本,这样我们就可以将该版本回滚到稳定状态。 + +#### Semantic Versioning and Hash Versioning + + + 一个应用如何进行版本管理,有时被称为版本策略。我们将看一看并比较两个这样的策略。 + + + 第一种是[语义版本管理](https://semver.org/),版本的形式是{major}.{minor}.{patch}。例如,如果版本是1.2.3,它有1作为主要版本,2是次要版本,3是补丁版本。 + + + 一般来说,修复功能而不从外部改变应用的工作方式的变化是patch变化,对功能进行小的改变(从外部看)的变化是minor变化,完全改变应用的变化(或主要功能变化)是major变化。这些术语的定义可能因项目而异。 + + + 例如,npm-libraries遵循的是语义上的版本划分。在写这篇文字的时候(2022年3月3日),React的最新版本是[17.0.2](https://reactjs.org/versions/),所以主要版本是17,已经提升了两个补丁级别,次要版本仍然是0。 + +Hash versioning (also sometimes known as SHA versioning) is quite different. The version "number" in hash versioning is a hash (that looks like a random string) derived from the contents of the repository and the changes introduced in this commit. In git, this is already done for you as the commit hash that is unique for any change set. + + +哈希版本控制几乎总是与自动化结合使用。拷贝32个字符的长版本号,以确保所有东西都被正确部署,是件很痛苦的事(而且容易出错)。 + +#### But what does the version point to? + + + 确定一个给定的版本中有哪些代码是很重要的,而实现这一目标的方式在语义版本控制和哈希版本控制之间又有很大的不同。在哈希版本管理中(至少在git中),这就像根据哈希查找提交一样简单。这将让我们确切地知道哪些代码是用哪个版本部署的。 + + + 在使用语义版本管理时,情况就比较复杂了,有几种方法可以解决这个问题。这些方法归结为三种可能的方法:代码本身的东西、 repo或 repo元数据中的东西、完全在 repo之外的东西。 + + + 虽然我们不会讨论清单上的最后一个选项(因为这本身就是一个兔子洞),但是值得一提的是,这可以是一个简单的电子表格,列出语义版本及其指向的提交。 + + + 对于这两种基于存储库的方法来说,在代码中包含某些内容的方法通常可以归结为文件中的版本号,而存储库/元数据方法通常依赖于[tags](https://www.atlassian.com/git/tutorials/inspecting-a-repository/git-tag)或者(在GitHub的情况下)发布。在标签或版本的情况下,这相对简单,标签或版本指向一个提交,该提交中的代码就是该版本中的代码。 + +#### Version order + + + 在语义版本管理中,即使我们有不同类型的版本跳跃(major、minor或patch),要把这些版本按顺序排列还是相当容易的。1.3.7在2.0.0之前,而2.0.0本身在2.1.5之前,而2.1.5在2.2.0之前。要知道最后一个版本是什么,仍然需要一个版本列表(由软件包管理器或GitHub提供,很方便),但看这个列表和讨论它更容易。说 "我们需要回滚到3.2.4 "比当面沟通一个哈希值更容易。 + + + 这并不是说哈希值是不方便的:如果你知道哪个提交导致了特定的问题,很容易通过git历史回看并获得前一个提交的哈希值。但如果你有两个哈希值,比如d052aa41edfb4a7671c974c5901f4abe1c2db07112c6f6738a18154cb1cef7cf0607a681f72eaff3,你真的不能说哪个在历史上变得更早,你需要更多东西,比如揭示排序的git日志。 + +#### Comparing the Two + + + 我们已经谈到了上面讨论的两种版本管理方法的一些优势和劣势,但是解决它们各自可能被使用的地方也许是有用的。 + + + Semantic Versioning(语义版本控制)在部署版本号可能具有重要意义或者实际上可能被查看的服务时,效果很好。举个例子,想想你正在使用的JavaScript库。如果你正在使用某个特定库的3.4.6版本,而现在有了3.4.8版本的更新,那么如果该库使用了语义版本管理,你就可以(希望)安全地假定,你可以在不破坏任何东西的情况下进行升级。如果版本跳转到4.0.1,那么也许就不是那么安全的升级了。 + + + 哈希版本管理在大多数提交被构建到工件(例如可运行的二进制文件或Docker镜像)中的情况下非常有用,这些工件本身被上传或存储。举个例子,如果你的测试需要将你的包构建成一个工件,上传到服务器上,然后针对它进行测试,那么采用哈希版本控制就很方便,因为它可以防止意外。 + + + 举个例子,你在3.2.2版本上工作,你有一个失败的测试,你修复了这个失败并推送了提交,但由于你在你的分支中工作,你不会更新版本号。如果没有哈希版本管理,工件的名称可能不会改变。如果在上传工件时有错误,可能测试会用旧的工件再次运行(因为它还在那里,并且有相同的名字),你会得到错误的测试结果。如果工件是用哈希值定义的,那么版本号*必须*在每次提交时改变,这意味着如果上传失败,会有一个错误,因为你告诉测试要运行的工件不存在。 + + + 在出错时发生错误几乎总是比在CI中默默地忽略一个问题要好。 + +#### Best of Both Worlds + + + 从上面的比较来看,语义版本管理对发布软件有意义,而基于哈希的版本管理(或工件命名)在开发期间更有意义。这不一定会造成冲突。 + + + 这样想吧:版本管理归结为一种技术,它指向一个特定的提交,并说 "我们将给这个点一个名字,它的名字将是3.5.5"。没有什么可以阻止我们用哈希值来指代同一个提交。 + + + 有一个问题。我们在这一部分的开头讨论过,我们总是要知道我们的代码到底发生了什么,例如,我们需要确定我们已经测试了我们想要部署的代码。有两个平行的版本(或命名)惯例会使这个问题变得有点困难。 + + + 例如,当我们有一个项目使用基于哈希的工件构建进行测试时,总是可以跟踪每一个构建、lint和测试的结果到一个特定的提交,开发人员知道他们的代码处于什么状态。这些都是自动化的,对开发者来说是透明的。他们不需要知道CI系统正在使用下面的提交哈希值来命名构建和测试工件的事实。当开发人员将他们的代码合并到主分支时,CI再次接管。这一次,它将构建和测试所有的代码,并一次性给它一个语义上的版本号。它用一个git标签把版本号附在相关的提交上。 + + + 在上述案例中,我们发布的软件是经过测试的,因为CI系统确保对它要标记的代码进行测试。如果说该项目使用了语义上的版本管理,而简单地忽略了CI系统用基于哈希的命名系统来测试各个开发者分支/PR,这并不是不正确的。我们这样做是因为我们所关心的版本(被发布的版本)被赋予了一个语义版本。 + +
    + +
    + +### Exercises 11.15-11.16. + + + 让我们扩展我们的工作流程,以便当一个拉动请求被合并到主分支时,它会自动增加(bump)版本,并[tag](https://www.atlassian.com/git/tutorials/inspecting-a-repository/git-tag)用版本号发布。我们将使用一个由第三方开发的开源动作。[anothrNick/github-tag-action](https://github.com/anothrNick/github-tag-action)。 + +#### 11.15 Adding versioning + + + 我们将用多一个步骤来扩展我们的工作流程。 + +```js +- name: Bump version and push tag + uses: anothrNick/github-tag-action@1.36.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + + + 我们将环境变量secrets.GITHUB\_TOKEN传递给该行动。由于它是第三方行动,它需要在你的存储库中进行认证的令牌。你可以[在这里](https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token)阅读更多关于GitHub Actions中的认证。 + + + [anothrNick/github-tag-action](https://github.com/anothrNick/github-tag-action) 动作可以接受多个环境变量。这些变量可以修改行动对你的版本进行标记的方式。你可以在[README](https://github.com/anothrNick/github-tag-action)中查看这些变量,看看什么适合你的需要。 + + + 正如你从文档中看到的,默认情况下,你的版本会收到一个*小的凸起,意味着中间的数字会被增加。 + + + 修改上面的配置,使每个新的版本默认是一个_补丁_版本号的提升,所以默认情况下,最后的数字会被增加。 + + + 记住,我们只想在主分支发生变化时提升版本。因此,添加一个类似的if条件,以防止在pull request上的版本颠簸,就像在[练习11.14](/en/part11/keeping_green#exercises-11-13-11-14)中做的那样,防止在pull request相关的事件上进行部署。 + + + 现在完成工作流程。不要只是把它作为另一个步骤加入,而是把它配置成一个单独的工作,[依赖于](https://docs.github.com/en/actions/using-workflows/advanced-workflow-features#creating-dependent-jobs)负责打针、测试和部署的工作。因此,请修改你的工作流程定义如下。 + +```yml +name: Deployment pipeline + +on: + push: + branches: + - master + pull_request: + branches: [master] + types: [opened, synchronize] + +jobs: + simple_deployment_pipeline: + runs-on: ubuntu-latest + steps: + // steps here + tag_release: + needs: [simple_deployment_pipeline] + runs-on: ubuntu-latest + steps: + // steps here +``` + + + 正如[前面](/en/part11/getting_started_with_git_hub_actions#getting-started-with-workflows)提到的那样,工作流中的作业是并行执行的,但由于我们希望先完成打样、测试和部署,所以我们设置了一个依赖关系,即tag/_release等待另一个作业先执行,因为我们不希望在发布版通过测试并被部署之前对其进行打样。 + + + 如果你对配置不确定,你可以将DRY_RUN设置为true,这将使该动作输出下一个版本号,而不需要创建或标记发布版! + + +一旦工作流程运行成功,版本库就会提到有一些标签。 + +![Releases](../../images/11/17.png) + + + 点击它,你可以看到列出的所有标签(也就是git标记发布的机制)。 + +![Releases](../../images/11/18.png) + +#### 11.16 Skipping a commit for tagging and deployment + + + 一般来说,你越是频繁地将主分支部署到生产中,就越好。然而,有时可能会有一些合理的理由,跳过某个特定的提交或合并的拉动请求,使之成为标记并发布到生产中。 + + + 修改你的设置,如果拉动请求中的提交信息包含_#skip_,那么合并后的请求将不会被部署到生产中,也不会被标记上版本号。 + + + **提示:** + + + 实现这一点的最简单方法是改变相关步骤的[if](https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstepsif) 条件。与[练习11-14](/en/part11/keeping_green#exercises-11-13-11-14)类似,你可以从工作流的[GitHub上下文](https://docs.github.com/en/free-pro-team@latest/actions/reference/context-and-expression-syntax-for-github-actions#github-context)获取相关信息。 + + + 你可以把它作为一个起点。 + +```js +name: Testing stuff + +on: + push: + branches: + - main + +jobs: + a_test_job: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: github context + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + run: echo "$GITHUB_CONTEXT" + - name: commits + env: + COMMITS: ${{ toJson(github.event.commits) }} + run: echo "$COMMITS" + - name: commit messages + env: + COMMIT_MESSAGES: ${{ toJson(github.event.commits.*.message) }} + run: echo "$COMMIT_MESSAGES" +``` + + + 看看工作流日志中会打印出什么! + + + 注意,你只能在推送或合并到主分支时访问提交和提交信息,所以对于拉取请求,github.event.commits是空的。反正也不需要,因为我们想对拉取请求完全跳过这一步。 + + + 你很可能需要函数 [contains](https://docs.github.com/en/actions/learn-github-actions/expressions#contains) 和 [join](https://docs.github.com/en/actions/learn-github-actions/expressions#join) 作为你的if条件。 + + + 开发工作流程并不容易,很多时候,唯一的选择是试验和错误。实际上,最好是有一个单独的仓库来获得正确的配置,当它完成后,将正确的配置复制到实际的仓库中。 + + + 也可以安装一个工具,如[act](https://github.com/nektos/act),使其有可能在本地运行你的工作流程。如果你最终涉及到更多的用例,例如通过创建你的[自己的自定义动作](https://docs.github.com/en/free-pro-team@latest/actions/creating-actions),通过设置一个像act这样的工具的负担,很可能是值得的。 + +
    + +
    + +### A note about using third party actions + + + 当使用第三方动作,如github-tag-action时,用哈希值指定所使用的版本而不是使用版本号可能是一个好主意。这样做的原因是,用git标签实现的版本号原则上可以移动。所以今天的1.33.0版本可能是一个不同的代码,而下周的版本是1.33.0! + + + 然而,在任何情况下,带有特定哈希值的提交代码都不会改变,所以如果我们想100%确定我们使用的代码,使用哈希值是最安全的。 + + + 动作的版本[1.33.0](https://github.com/anothrNick/github-tag-action/releases)对应于带有哈希值的提交 eca2b69f9e2c24be7decccd0f15fdb1ea5906598,所以我们可能要改变我们的配置如下。 + +```js + - name: Bump version and push tag + uses: anothrNick/github-tag-action@eca2b69f9e2c24be7decccd0f15fdb1ea5906598 // highlight-line + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + + + 当我们使用GitHub提供的操作时,我们相信他们不会乱用版本标签,并且会彻底测试他们的代码。 + + + 在第三方动作的情况下,代码最终可能是有缺陷的,甚至是恶意的。即使开源代码的作者没有做坏事的意图,他们最终也可能把他们的证书留在咖啡馆的便条上,然后谁知道会发生什么。 + + + 通过指向特定提交的哈希值,我们可以确保运行工作流时使用的代码不会改变,因为改变底层提交及其内容也会改变哈希值。 + +### Keep the main branch protected + + + GitHub允许你设置受保护的分支。保护你最重要的分支是很重要的,它不应该被破坏。master/main。在版本库设置中,你可以选择几个级别的保护。我们不会详述所有的保护选项,你可以在 GitHub 文档中了解更多信息。合并到主分支时要求拉取请求批准是我们前面提到的选项之一。 + + + 从 CI 的角度来看,最重要的保护措施是要求在 PR 合并到主干分支之前必须通过状态检查。这意味着,如果你设置了GitHub动作来运行例如linting和测试任务,那么在所有lint错误被修复和所有测试通过之前,PR不能被合并。因为你是仓库的管理员,你会看到一个选项来覆盖这个限制。然而,非管理员就没有这个选项了。 + +![Unmergeable PR](../../images/11/part11d_03.png) + + + 要为你的主分支设置保护,在版本库的顶部菜单中导航到版本库的 "设置"。在左边的菜单中选择 "Branches"。点击 "分支保护规则 "旁边的 "添加规则 "按钮。输入一个分支名称模式("master "或 "main "就可以了),并选择你想设置的保护。至少 "要求在合并前通过状态检查 "是必要的,这样才能充分利用 GitHub Actions 的力量。在它下面,你还应该勾选 "要求分支在合并前是最新的",并选择所有在合并 PR 前应该通过的状态检查。 + +![Branch protection rule](../../images/11/part11d_04.png) + +
    + +
    + +### Exercise 11.17 + +#### 11.17 Adding protection to your main branch + + + 为你的master(或main)分支添加保护。 + + + 你应该保护它。 + + - 要求所有拉动请求在合并前被批准 + + - 要求所有状态检查在合并前通过 + + + 还不要检查包括管理员。如果你这样做了,你就需要其他人来审查你的拉动请求,以获得代码的发布 + +
    diff --git a/src/content/11/zh/part11e.md b/src/content/11/zh/part11e.md new file mode 100644 index 00000000000..3f009d8e32d --- /dev/null +++ b/src/content/11/zh/part11e.md @@ -0,0 +1,185 @@ +--- +mainImage: ../../../images/part-11.svg +part: 11 +letter: e +lang: zh +--- + +
    + + + 这部分的重点是建立一个简单、有效和强大的CI系统,帮助开发人员一起工作,保持代码质量,并安全部署。还能有什么要求呢?在现实世界中,除了开发人员和用户之外,还有更多的手指参与其中。即使这不是真的,即使对开发人员来说,从CI系统中获得的价值也远不止上述内容。 + +### Visibility and Understanding + + + 除了最小的公司之外,所有的公司都不是完全由开发人员来决定开发什么。术语"利益相关者"经常被用来指开发团队内部和外部的人,他们可能对关注开发的进展有一些兴趣。为此,git和团队正在使用的任何项目管理/bug跟踪软件之间经常会有集成。 + + + 这方面的一个常见用途是在git拉动请求或提交中对跟踪系统有一些参考。这样一来,例如,当你在处理123号问题时,你可以将你的拉动请求命名为BUG-123。修复用户拷贝问题,错误跟踪系统会注意到PR名称的第一章节,并在PR被合并时自动将问题移至Done。 + +### Notifications + + + 当CI过程快速完成时,只看它执行并等待结果是很方便的。随着项目变得更加复杂,构建和测试代码的过程也变得更加复杂。这可能很快就会导致这样一种情况:生成构建结果需要足够长的时间,以至于开发人员可能想开始处理另一项任务。这反过来又导致了一个被遗忘的构建。 + + + 如果我们谈论的是合并可能影响另一个开发者的工作的PR,这就特别有问题,可能会给他们带来问题或延误。这也可能导致这样一种情况:你认为你已经部署了一些东西,但实际上还没有完成部署,这可能导致与队友和客户的错误沟通(例如,"继续,再试一次,这个错误应该被修复")。 + + + 对于这个问题有几种解决方案,从简单的通知到更复杂的流程,如果满足某些条件,就简单地合并传递代码。我们将把通知作为一个简单的解决方案来讨论,因为它是对团队工作流程干扰最小的一个。 + + + 默认情况下,GitHub Actions会在构建失败时发送一封邮件。这可以改变为无论构建状态如何都会发送通知,也可以配置为在GitHub网页界面上提醒你。很好。但如果我们想要更多呢?如果由于某种原因,这对我们的用例不适用呢。 + + + 有一些集成,例如与各种消息应用的集成,如[Slack](https://slack.com/intl/en-fi/)或[Discord](https://discord.com/),来发送通知。这些集成仍然根据GitHub的逻辑来决定发送什么和何时发送。 + +
    + +
    + +### Exercise 11.18 + + + 我们在[https://study.cs.helsinki.fi/discord/join/fullstack](https://study.cs.helsinki.fi/discord/join/fullstack)的课程 Discord 小组中设立了一个频道 fullstack\_webhook,用于测试消息集成。 + + + 如果你还没有注册,现在就注册到 Discord。在这个练习中,你还需要一个Discord webhook。你可以在频道fullstack/_webhook的钉子信息中找到webhook。请不要把webhook提交到GitHub! + +#### 11.18 Build success/failure notification action + + + 你可以通过使用[GitHub Action Marketplace](https://github.com/marketplace?type=actions)中的搜索词[discord](https://github.com/marketplace?type=actions&query=discord)找到不少第三方动作。为这次练习选一个。我的选择是[discord-webhook-notify)](https://github.com/marketplace/actions/discord-webhook-notify),因为它有相当多的星星和一个体面的文档。 + + + 设置这个动作,让它给出两种类型的通知。 + + - 如果一个新的版本被部署,一个成功指示 + + - 构建失败时的错误提示 + + + 在出错的情况下,通知应该更详细一点,以帮助开发者迅速找到导致错误的提交。 + + + 参见[这里](https://docs.github.com/en/actions/learn-github-actions/expressions#status-check-functions)如何检查工作状态! + + + 你的通知可能如下所示:下面这样。 + +![Releases](../../images/11/20x.png) + +
    + +
    + +### Metrics + + + 在上一节中,我们提到随着项目变得越来越复杂,它们的构建也越来越复杂,构建的时间也越来越长。这显然是不理想的。反馈回路越长,开发速度就越慢。 + + + 虽然有一些事情可以解决构建时间增加的问题,但对整体情况有一个更好的认识是很有用的。了解几个月前的构建时间和现在的构建时间,是非常有用的。进展是线性的还是突然跳跃的?知道是什么导致了构建时间的增加,对于帮助解决这个问题非常有用。如果构建时间在去年从5分钟线性增加到10分钟,也许我们可以预计再过几个月就会达到15分钟,我们就可以知道花时间加快CI过程有多大价值。 + + + 指标可以是自我报告的(也称为"推"指标,即每次构建都报告它花了多长时间),也可以是事后从API中获取数据(有时称为"拉"指标)。自我报告的风险在于,自我报告本身需要时间,可能会对 "所有构建的总时间 "产生重大影响。 + + + 这些数据可以被发送到一个时间序列数据库或另一种类型的档案。有很多云服务,你可以很容易地汇总这些指标,一个好的选择是[Datadog](https://www.datadoghq.com/)。 + +### Periodic tasks + + +在软件开发团队中经常有一些定期任务需要完成。其中有些可以用常用的工具自动完成,有些你需要自己来自动完成。 + + +前一类包括检查软件包的安全漏洞等事情。一些工具已经可以为你做到这一点。其中一些工具甚至对某些类型的项目(如开放源代码)是免费的。GitHub提供了一个这样的工具,[Dependabot](https://dependabot.com/)。 + + + 需要考虑的建议的话。如果你的预算允许,使用一个已经完成工作的工具几乎总是比推出你自己的解决方案要好。例如,如果安全不是你的目标行业,那么使用Dependabot来检查安全漏洞,而不是制作你自己的工具。 + + + 那那些没有工具的任务呢?你也可以自己用GitHub Actions来实现这些任务的自动化。GitHub Actions 提供了一个预定的触发器,可以用来在特定时间执行任务。 + +
    + +
    + +### Exercises 11.19-11.21 + +#### 11.19 Periodic health check + + + 我们现在很有信心,我们的管道可以防止不良代码被部署。然而,错误的来源有很多。如果我们的应用依赖于一个数据库,而这个数据库由于某种原因变得不可用,我们的应用就很可能崩溃。这就是为什么建立一个定期的健康检查是个好主意,它将定期对我们的服务器进行HTTP GET请求。我们经常把这种请求称为ping。 + + +可以[安排](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule)GitHub动作定期发生。 + + +现在使用[url-health-check](https://github.com/marketplace/actions/url-health-check)或任何其他行动,安排定期健康检查ping到你部署的软件。试着模拟你的应用发生故障的情况,并确保检查能发现问题。把这个定期工作流程写到一个自己的文件中。 + + + **注意**,不幸的是,直到GitHub Actions第一次启动预定的工作流,需要相当长的时间。对我来说,这花了将近一个小时。所以最好先用git push触发工作流,让检查工作正常进行。当你确定检查工作正常时,再切换到预定触发器。 + + + **也注意**,一旦你得到这个工作,最好降低ping频率(最多24小时一次)或完全禁用该规则,因为否则你的健康检查可能会消耗[你所有的](https://devcenter.heroku.com/articles/free-dyno-hours)每月空闲时间。 + +#### 11.20 Your own pipeline + + +为你自己的一些应用建立一个类似的CI/CD管道。一些好的候选程序是在课程的第2和第3章节建立的电话簿应用,或在第4和第5章节建立的博客应用,或在第6章节建立的redux名言警句。你也可以在这个练习中使用你自己的一些应用。 + + +你很可能需要做一些结构调整,以使所有的部分都在一起。合理的第一步是将前端和后端代码存储在同一个仓库里。这不是一个要求,但建议这样做,因为它使事情更简单。 + + + 一种可能的版本库结构是将后端放在版本库的根目录下,前端作为一个子目录。你也可以 "复制粘贴 "本章节的示例程序的结构,或者试试[第7章节](/en/part7/class_components_miscellaneous#frontend-and-backend-in-the-same-repository)中提到的[示例程序](https://github.com/fullstack-hy2020/create-app)。 + + + 也许最好为这个练习创建一个新的仓库,并简单地复制和粘贴旧代码。在现实生活中,你很可能会在旧版本库中完成这些工作,但现在 "重新开始 "使事情变得更容易。 + + +这是一个漫长的,也许是相当艰难的练习,但这种情况下,你有一个 "遗留的代码",你需要建立适当的部署管道,在现实生活中是很常见的! + + + 很明显,这个练习并不像前面的练习那样在同一个版本库中进行。因为你只能向提交系统返回一个版本库,所以把另一个版本库的链接放到你填写的提交表格中。 + +#### 11.21 Protect your main branch and ask for pull request + + + 保护你之前练习的版本库的主分支。这次也要防止管理员在没有审核的情况下合并代码。 + + + 做一个拉动请求,请GitHub用户[mluukkai](https://github.com/mluukkai)和/或 中的任何一个来审查你的代码。一旦审查完毕,就把你的代码合并到主分支。注意,审查者需要是版本库中的合作者。在 Discord 中与我们联系以获得审查,最好是发送私人信息,并在信息中加入合作邀请链接。 + + + 然后你就完成了! + +
    + +
    + +### Submitting exercises and getting the credits + + + 这一部分的练习是通过[提交系统](https://studies.cs.helsinki.fi/stats/courses/fs-cicd)提交的,就像前面几部分一样,但与0到7部分不同的是,提交到不同的 "课程实例"。请记住,你必须完成所有的练习才能通过这一部分! + + + 你的解决方案在两个仓库中(pokedex和你自己的项目),由于你只能向提交系统返回一个仓库,请将另一个仓库的链接放在你填写的提交表格中 + + + 一旦你完成了练习并想获得学分,通过练习提交系统让我们知道你已经完成了课程。 + +![Submissions](../../images/11/21.png) + + + 注意,"在Moodle中完成的考试 "说明是指[全栈开放课程的考试](/en/part0/general_info#sign-up-for-the-exam),在你从这部分获得学分之前必须完成。 + + + **注意**你需要注册相应的课程部分以获得注册的学分,更多信息见[这里](/en/part0/general_info#parts-and-completion)。 + + + 你可以通过点击其中一个标志图标来下载完成这部分的证书。旗帜图标与证书的语言相对应。 + +
    diff --git a/src/content/12/en/part12.md b/src/content/12/en/part12.md new file mode 100644 index 00000000000..b987e8970cb --- /dev/null +++ b/src/content/12/en/part12.md @@ -0,0 +1,20 @@ +--- +mainImage: ../../../images/part-12.svg +part: 12 +lang: en +--- + +
    + +In this part, we will learn how to package code into standard units of software called containers. These containers can help us develop software faster and easier than before. Along the way, we will also explore a completely new viewpoint for web development, outside of the now-familiar Node.js backend and React frontend. + +We will utilize containers to create immutable execution environments for our Node.js and React projects. Containers also make it easy to include multiple services with our projects. With their flexibility, we will explore and experiment with many different and popular tools by utilizing containers. + +This section has been created by [Jami Kousa](https://github.com/jakousa) in collaboration with the Helsinki-based Services Foundation team at Unity. The Services Foundation team works on providing platforms for other teams at Unity to succeed in their mission of building great services for their customers. The team is passionate about improving Unity’s developer experience and works on tools like the Unity Dashboard, the Unity Editor, and [Unity.com](https://unity.com/). + +Part updated 21th Mar 2024 +- Create react app replaced with Vite + +**Note:** If you started this part before the update, you can see [here](https://github.com/fullstack-hy2020/fullstack-hy2020.github.io/tree/4015af9dddb61cb01f013456d8728e8f553be347/src/content/12) the old material. There are some changes in the frontend configurations. + +
    diff --git a/src/content/12/en/part12a.md b/src/content/12/en/part12a.md new file mode 100644 index 00000000000..19c1d56cf9b --- /dev/null +++ b/src/content/12/en/part12a.md @@ -0,0 +1,423 @@ +--- +mainImage: ../../../images/part-12.svg +part: 12 +letter: a +lang: en +--- + +
    + +The part was updated 21st Mar 2024: Create react app was replaced with Vite in the todo-frontend. + +If you started this part before the update, you can see [here](https://github.com/fullstack-hy2020/fullstack-hy2020.github.io/tree/4015af9dddb61cb01f013456d8728e8f553be347/src/content/12) the old material. There are some changes in the frontend configurations. +
    + +
    + +Software development includes the whole lifecycle from envisioning the software to programming and to releasing it to the end-users, and even maintaining it. This part will introduce containers, a modern tool utilized in the latter parts of the software lifecycle. + +Containers encapsulate your application into a single package. This package will include the application and all of its dependencies. As a result, each container can run isolated from the other containers. + +Containers prevent the application inside from accessing files and resources of the device. Developers can give the contained applications permission to access files and specify available resources. More accurately, containers are OS-level virtualization. The easiest-to-compare technology is a virtual machine (VM). VMs are used to run multiple operating systems on a single physical machine. They have to run the whole operating system, whereas a container runs the software using the host operating system. The resulting difference between VMs and containers is that there is hardly any overhead when running containers; they only need to run a single process. + +As containers are relatively lightweight, at least compared to virtual machines, they can be quick to scale. And as they isolate the software running inside, it enables the software to run identically almost anywhere. As such, they are the go-to option in any cloud environment or application with more than a handful of users. + +Cloud services like AWS, Google Cloud, and Microsoft Azure all support containers in multiple different forms. These include AWS Fargate and Google Cloud Run, both of which run containers as serverless - where the application container does not even need to be running if it is not used. You can also install container runtime on most machines and run containers there yourself - including your own machine. + +So containers are used in cloud environment and even during development. What are the benefits of using containers? Here are two common scenarios: + +Scenario 1: You are developing a new application that needs to run on the same machine as a legacy application. Both require installing different versions of Node. + +You can probably use nvm, virtual machines, or dark magic to get them running at the same time. However, containers are an excellent solution as you can run both applications in their respective containers. They are isolated from each other and do not interfere. + +Scenario 2: Your application runs on your machine. You need to move the application to a server. + +It is not uncommon that the application just does not run on the server despite it works just fine on your machine. It may be due to some missing dependency or other differences in the environments. Here containers are an excellent solution since you can run the application in the same execution environment both on your machine and on the server. It is not perfect: different hardware can be an issue, but you can limit the differences between environments. + +Sometimes you may hear about the "Works in my container" issue. The phrase describes a situation in which the application works fine in a container running on your machine but breaks when the container is started on a server. The phrase is a play on the infamous "Works on my machine" issue, which containers are often promised to solve. The situation also is most likely a usage error. + +### About this part + +In this part, the focus of our attention will not be on the JavaScript code. Instead, we are interested in the configuration of the environment in which the software is executed. As a result, the exercises may not contain any coding, the applications are available to you through GitHub and your tasks will include configuring them. The exercises are to be submitted to a single GitHub repository which will include all of the source code and the configurations that you do during this part. + +You will need basic knowledge of Node, Express, and React. Only the core parts, 1 through 5, are required to be completed before this part. + +
    + +
    + +### Exercise 12.1 +### Warning + +Since we are stepping right outside of our comfort zone as JavaScript developers, this part may require you to take a detour and familiarize yourself with shell / command line / command prompt / terminal before getting started. + +If you have only ever used a graphical user interface and never touched e.g. Linux or terminal on Mac, or if you get stuck in the first exercises we recommend doing the Part 1 of "Computing tools for CS studies" first: . Skip the section for "SSH connection" and Exercise 11. Otherwise, it includes everything you are going to need to get started here! + +#### Exercise 12.1: Using a computer (without graphical user interface) + +Step 1: Read the text below the "Warning" header. + +Step 2: Download this [repository](https://github.com/fullstack-hy2020/part12-containers-applications) and make it your submission repository for this part. + +Step 3: Run curl http://helsinki.fi and save the output into a file. Save that file into your repository as file script-answers/exercise12_1.txt. The directory script-answers was created in the previous step. + +
    +
    + +### Submitting exercises and earning credits + +Submit the exercises via the [submissions system](https://studies.cs.helsinki.fi/stats/) just like in the previous parts. Exercises in this part are submitted to its [own course instance](https://studies.cs.helsinki.fi/stats/courses/fs-containers). + +Completing this part on containers will get you 1 credit. Note that you need to do all the exercises for earning the credit or the certificate. + +Once you have completed the exercises and want to get the credits, let us know through the exercise submission system that you have completed the course: + +![Submitting exercises for credits](../../images/10/23.png) + +You can download the certificate for completing this part by clicking one of the flag icons. The flag icon corresponds to the language of the certificate. + +### Tools of the trade + +The basic tools you are going to need vary between operating systems: + +* [WSL 2 terminal](https://docs.microsoft.com/en-us/windows/wsl/install-win10) on Windows +* Terminal on Mac +* Command Line on a Linux + +### Installing everything required for this part + +We will begin by installing the required software. The installation step will be one of the possible obstacles. As we are dealing with OS-level virtualization, the tools will require superuser access on the computer. They will have access to your operating systems kernel. + +The material is built around [Docker](https://www.docker.com/), a set of products that we will use for containerization and the management of containers. Unfortunately, if you can not install Docker you probably can not complete this part. + +As the install instructions depend on your operating system, you will have to find the correct install instructions from the link below. Note that they may have multiple different options for your operating system. + +- [Get Docker](https://docs.docker.com/get-docker/) + +Now that that headache is hopefully over, let's make sure that our versions match. Yours may have a bit higher numbers than here: + +```bash +$ docker -v +Docker version 25.0.3, build 4debf41 +``` + +### Containers and images + +There are two core concepts in this part: container and image. They are easy to confuse with one another. + +A container is a runtime instance of an image. + +Both of the following statements are true: + +- Images include all of the code, dependencies and instructions on how to run the application +- Containers package software into standardized units + +It is no wonder they are easily mixed up. + +To help with the confusion, almost everyone uses the word container to describe both. But you can never actually build a container or download one since containers only exist during runtime. Images, on the other hand, are **immutable** files. As a result of the immutability, you can not edit an image after you have created one. However, you can use existing images to create a new image by adding new layers on top of the existing ones. + +Cooking metaphor: + +* Image is pre-cooked, frozen treat. +* Container is the delicious treat. + +[Docker](https://www.docker.com/) is the most popular containerization technology and pioneered the standards most containerization technologies use today. In practice, Docker is a set of products that help us to manage images and containers. This set of products will enable us to leverage all of the benefits of containers. For example, the Docker engine will take care of turning the immutable files called images into containers. + +For managing the Docker containers, there is also a tool called [Docker Compose](https://docs.docker.com/compose/) that allows one to **orchestrate** (control) multiple containers at the same time. In this part we shall use Docker Compose to set up a complex local development environment. In the final version of the development environment that we will set up, even installing Node in our machine will not be required anymore. + +There are several concepts we need to go over. But we will skip those for now and learn about Docker first! + +Let us start with the command docker container run that is used to run images within a container. The command structure is the following: _container run IMAGE-NAME_ that we will tell Docker to create a container from an image. A particularly nice feature of the command is that it can run a container even if the image to run is not downloaded on our device yet. + +Let us run the command + +```bash +$ docker container run hello-world +``` + +There will be a lot of output, but let's split it into multiple sections, which we can decipher together. The lines are numbered by me so that it is easier to follow the explanation. Your output will not have the numbers. + +```bash +1. Unable to find image 'hello-world:latest' locally +2. latest: Pulling from library/hello-world +3. b8dfde127a29: Pull complete +4. Digest: sha256:5122f6204b6a3596e048758cabba3c46b1c937a46b5be6225b835d091b90e46c +5. Status: Downloaded newer image for hello-world:latest +``` + +Because the image hello-world was not found on our machine, the command first downloaded it from a free registry called [Docker Hub](https://hub.docker.com/). You can see the Docker Hub page of the image with your browser here: [https://hub.docker.com/_/hello-world](https://hub.docker.com/_/hello-world) + +The first part of the message states that we did not have the image "hello-world:latest" yet. This reveals a bit of detail about images themselves; image names consist of multiple parts, kind of like an URL. An image name is in the following format: + +- _registry/organisation/image:tag_ + +In this case the 3 missing fields defaulted to: +- _index.docker.io/library/hello-world:latest_ + +The second row shows the organisation name, "library" where it will get the image. In the Docker Hub URL, the "library" is shortened to _. + +The 3rd and 5th rows only show the status. But the 4th row may be interesting: each image has a unique digest based on the layers from which the image is built. In practice, each step or command that was used in building the image creates a unique layer. The digest is used by Docker to identify that an image is the same. This is done when you try to pull the same image again. + +So the result of using the command was a pull and then output information about the **image**. After that, the status told us that a new version of hello-world:latest was indeed downloaded. You can try pulling the image with _docker image pull hello-world_ and see what happens. + +The following output was from the container itself. It also explains what happened when we ran _docker container run hello-world_. + +```bash +Hello from Docker! +This message shows that your installation appears to be working correctly. + +To generate this message, Docker took the following steps: + 1. The Docker client contacted the Docker daemon. + 2. The Docker daemon pulled the "hello-world" image from the Docker Hub. + (amd64) + 3. The Docker daemon created a new container from that image which runs the + executable that produces the output you are currently reading. + 4. The Docker daemon streamed that output to the Docker client, which sent it + to your terminal. + +To try something more ambitious, you can run an Ubuntu container with: + $ docker container run -it ubuntu bash + +Share images, automate workflows, and more with a free Docker ID: + https://hub.docker.com/ + +For more examples and ideas, visit: + https://docs.docker.com/get-started/ +``` + +The output contains a few new things for us to learn. Docker daemon is a background service that makes sure the containers are running, and we use the Docker client to interact with the daemon. We now have interacted with the first image and created a container from the image. During the execution of that container, we received the output. + +
    + +
    + +### Exercise 12.2 + +Some of these exercises do not require you to write any code or configurations to a file. +In these exercises you should use [script](https://man7.org/linux/man-pages/man1/script.1.html) command to record the commands you have used; try it yourself with _script_ to start recording, _echo "hello"_ to generate some output, and _exit_ to stop recording. It saves your actions into a file named "typescript" (that has nothing to do with the TypeScript programming language, the name is just a coincidence). + +If _script_ does not work, you can just copy-paste all commands you used into a text file. + +#### Exercise 12.2: Running your second container + +> Use _script_ to record what you do, save the file as script-answers/exercise12_2.txt + +The hello-world output gave us an ambitious task to do. Do the following: + +- Step 1. Run an Ubuntu container with the command given by hello-world + +The step 1 will connect you straight into the container with bash. You will have access to all of the files and tools inside of the container. The following steps are run within the container: + +- Step 2. Create directory /usr/src/app + +- Step 3. Create a file /usr/src/app/index.js + +- Step 4. Run exit to quit from the container + +Google should be able to help you with creating directories and files. + +
    + +
    + +### Ubuntu image + +The command you just used to run the Ubuntu container, _docker container run -it ubuntu bash_, contains a few additions to the previously run hello-world. Let's see the --help to get a better understanding. I'll cut some of the output so we can focus on the relevant parts. + +```bash +$ docker container run --help + +Usage: docker container run [OPTIONS] IMAGE [COMMAND] [ARG...] +Run a command in a new container + +Options: + ... + -i, --interactive Keep STDIN open even if not attached + -t, --tty Allocate a pseudo-TTY + ... +``` + +The two options, or flags, _-it_ make sure we can interact with the container. After the options, we defined that the image to run is _ubuntu_. Then we have the command _bash_ to be executed inside the container when we start it. + +You can try other commands that the Ubuntu image might be able to execute. As an example try _docker container run --rm ubuntu ls_. The _ls_ command will list all of the files in the directory and _--rm_ flag will remove the container after execution. Normally containers are not deleted automatically. + +Let's continue with our first Ubuntu container with the **index.js** file inside of it. The container has stopped running since we exited it. We can list all of the containers with _container ls -a_, the _-a_ (or --all) will list containers that have already been exited. + +```bash +$ docker container ls -a +CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES +b8548b9faec3 ubuntu "bash" 3 minutes ago Exited (0) 6 seconds ago hopeful_clarke +``` + +> Editor's note: the command _docker container ls_ has also a shorter form _docker ps_, I prefer the shorter one. + +We have two options when addressing a container. The identifier in the first column can be used to interact with the container almost always. Plus, most commands accept the container name as a more human-friendly method of working with them. The name of the container was automatically generated to be **"hopeful_clarke"** in my case. + +The container has already exited, yet we can start it again with the start command that will accept the id or name of the container as a parameter: _start CONTAINER-ID-OR-CONTAINER-NAME_. + +```bash +$ docker start hopeful_clarke +hopeful_clarke +``` + +The start command will start the same container we had previously. Unfortunately, we forgot to start it with the flag _--interactive_ (that can also be written _-i_) so we can not interact with it. + +The container is actually up and running as the command _container ls -a_ shows, but we just can not communicate with it: + +```bash +$ docker container ls -a +CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES +b8548b9faec3 ubuntu "bash" 7 minutes ago Up (0) 15 seconds ago hopeful_clarke +``` + +Note that we can also execute the command without the flag _-a_ to see just those containers that are running: + +```bash +$ docker container ls +CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES +8f5abc55242a ubuntu "bash" 8 minutes ago Up 1 minutes hopeful_clarke +``` + +Let's kill it with the _kill CONTAINER-ID-OR-CONTAINER-NAME_ command and try again. + +```bash +$ docker kill hopeful_clarke +hopeful_clarke +``` + +_docker kill_ sends a [signal SIGKILL](https://man7.org/linux/man-pages/man7/signal.7.html) to the process forcing it to exit, and that causes the container to stop. We can check it's status with _container ls -a_: + +```bash +$ docker container ls -a +CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES +b8548b9faec3 ubuntu "bash" 26 minutes ago Exited 2 seconds ago hopeful_clarke +``` + +Now let us start the container again, but this time in interactive mode: + +```bash +$ docker start -i hopeful_clarke +root@b8548b9faec3:/# +``` + +Let's edit the file index.js and add in some JavaScript code to execute. We are just missing the tools to edit the file. [Nano](https://www.nano-editor.org/) will be a good text editor for now. The install instructions were found from the first result of Google. We will omit using sudo since we are already root. + +```bash +root@b8548b9faec3:/# apt-get update +root@b8548b9faec3:/# apt-get -y install nano +root@b8548b9faec3:/# nano /usr/src/app/index.js +``` + +Now we have Nano installed and can start editing files! + +
    + +
    + +### Exercise 12.3 - 12.4 + +#### Exercise 12.3: Ubuntu 101 + +> Use _script_ to record what you do, save the file as script-answers/exercise12_3.txt + +Edit the _/usr/src/app/index.js_ file inside the container with the now installed Nano and add the following line + +```js +console.log('Hello World') +``` + +If you are not familiar with Nano you can ask for help in the chat or Google. + +#### Exercise 12.4: Ubuntu 102 + +> Use _script_ to record what you do, save the file as script-answers/exercise12_4.txt + +Install Node while inside the container and run the index file with _node /usr/src/app/index.js_ in the container. + +The instructions for installing Node are sometimes hard to find, so here is something you can copy-paste: + +```bash +curl -sL https://deb.nodesource.com/setup_20.x | bash +apt install -y nodejs +``` + +You will need to install the _curl_ into the container. It is installed in the same way as you did with _nano_. + +After the installation, ensure that you can run your code inside the container with the command: + +``` +root@b8548b9faec3:/# node /usr/src/app/index.js +Hello World +``` + +
    + +
    + +### Other Docker commands + +Now that we have Node installed in the container, we can execute JavaScript in the container! Let's create a new image from the container. The command + +```bash +commit CONTAINER-ID-OR-CONTAINER-NAME NEW-IMAGE-NAME +``` + +will create a new image that includes the changes we have made. You can use _container diff_ to check for the changes between the original image and container before doing so. + +```bash +$ docker commit hopeful_clarke hello-node-world +``` + +You can list your images with _image ls_: + +```bash +$ docker image ls +REPOSITORY TAG IMAGE ID CREATED SIZE +hello-node-world latest eef776183732 9 minutes ago 252MB +ubuntu latest 1318b700e415 2 weeks ago 72.8MB +hello-world latest d1165f221234 5 months ago 13.3kB +``` + +You can now run the new image as follows: + +```bash +docker run -it hello-node-world bash +root@4d1b322e1aff:/# node /usr/src/app/index.js +``` + +There are multiple ways to do the same. Let's try a better solution. We will clean the slate with _container rm_ to remove the old container. + +```bash +$ docker container ls -a +CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES +b8548b9faec3 ubuntu "bash" 31 minutes ago Exited (0) 9 seconds ago hopeful_clarke + +$ docker container rm hopeful_clarke +hopeful_clarke +``` + +Create a file index.js to your current directory and write _console.log('Hello, World')_ inside it. No need for containers yet. + +Next, let's skip installing Node altogether. There are plenty of useful Docker images in Docker Hub ready for our use. Let's use the image [https://hub.docker.com/_/node](https://hub.docker.com/_/node), which has Node already installed. We only need to pick a version. + +By the way, the _container run_ accepts _--name_ flag that we can use to give a name for the container. + +```bash +$ docker container run -it --name hello-node node:20 bash +``` + +Let us create a directory for the code inside the container: + +``` +root@77d1023af893:/# mkdir /usr/src/app +``` + +While we are inside the container on this terminal, open another terminal and use the _container cp_ command to copy file from your own machine to the container. + +```bash +$ docker container cp ./index.js hello-node:/usr/src/app/index.js +``` + +And now we can run _node /usr/src/app/index.js_ in the container. We can commit this as another new image, but there is an even better solution. The next section will be all about building your images like a pro. + +
    diff --git a/src/content/12/en/part12b.md b/src/content/12/en/part12b.md new file mode 100644 index 00000000000..5cef77033cb --- /dev/null +++ b/src/content/12/en/part12b.md @@ -0,0 +1,914 @@ +--- +mainImage: ../../../images/part-12.svg +part: 12 +letter: b +lang: en +--- + +
    + +The part was updated 21th Mar 2024: Create react app was replaced with Vite in the todo-frontend. + +If you started the part before the update, you can see [here](https://github.com/fullstack-hy2020/fullstack-hy2020.github.io/tree/4015af9dddb61cb01f013456d8728e8f553be347/src/content/12) the old material. There are some changes in the frontend configurations. +
    + +
    + +In the previous section, we used two different base images: ubuntu and node, and did some manual work to get a simple "Hello, World!" running. The tools and commands we learned during that process will be helpful. In this section, we will learn how to build images and configure environments for our applications. We will start with a regular Express/Node.js backend and build on top of that with other services, including a MongoDB database. + +### Dockerfile + +Instead of modifying a container by copying files inside, we can create a new image that contains the "Hello, World!" application. The tool for this is the Dockerfile. Dockerfile is a simple text file that contains all of the instructions for creating an image. Let's create an example Dockerfile from the "Hello, World!" application. + +If you did not already, create a directory on your machine and create a file called Dockerfile inside that directory. Let's also put an index.js containing _console.log('Hello, World!')_ next to the Dockerfile. Your directory structure should look like this: + +``` +├── index.js +└── Dockerfile +``` + +inside that Dockerfile we will tell the image three things: + +- Use the [node:20](https://hub.docker.com/_/node) as the base for our image +- Include the index.js file inside the image, so we don't need to manually copy it into the container +- When we run a container from the image, use Node to execute the index.js file. + +The wishes above will translate into a basic Dockerfile. The best location to place this file is usually at the root of the project. + +The resulting Dockerfile looks like this: + +```Dockerfile +FROM node:20 + +WORKDIR /usr/src/app + +COPY ./index.js ./index.js + +CMD node index.js +``` + +FROM instruction will tell Docker that the base for the image should be node:20. COPY instruction will copy the file index.js from the host machine to the file with the same name in the image. CMD instruction tells what happens when _docker run_ is used. CMD is the default command that can then be overwritten with the argument given after the image name. See _docker run --help_ if you forgot. + +The WORKDIR instruction was slipped in to ensure we don't interfere with the contents of the image. It will guarantee all of the following commands will have /usr/src/app set as the working directory. If the directory doesn't exist in the base image, it will be automatically created. + +If we do not specify a WORKDIR, we risk overwriting important files by accident. If you check the root (_/_) of the node:20 image with _docker run node:20 ls_, you can notice all of the directories and files that are already included in the image. + +Now we can use the command _docker build_ to build an image based on the Dockerfile. Let's spice up the command with one additional flag: _-t_, this will help us name the image: + +```bash +$ docker build -t fs-hello-world . +[+] Building 3.9s (8/8) FINISHED +... +``` + +So the result is "Docker please build with tag (you may think of the tag as the name of the resulting image.) fs-hello-world the Dockerfile in this directory". You can point to any Dockerfile, but in our case, a simple dot will mean the Dockerfile is in this directory. That is why the command ends with a period. After the build is finished, you can run it with _docker run fs-hello-world_: + +```bash +$ docker run fs-hello-world +Hello, World +``` + +As images are just files, they can be moved around, downloaded and deleted. You can list the images you have locally with _docker image ls_, delete them with _docker image rm_. See what other command you have available with _docker image --help_. + +One more thing: before it was mentioned that the default command, defined by the CMD in the Dockerfile, can be overwritten if needed. We could e.g. open a bash session to the container and observe it's content: + +```bash +$ docker run -it fs-hello-world bash +root@2932e32dbc09:/usr/src/app# ls +index.js +root@2932e32dbc09:/usr/src/app# +``` + +### More meaningful image + +Moving an Express server to a container should be as simple as moving the "Hello, World!" application inside a container. The only difference is that there are more files. Thankfully _COPY_ instruction can handle all that. Let's delete the index.js and create a new Express server. Lets use [express-generator](https://expressjs.com/en/starter/generator.html) to create a basic Express application skeleton. + +```bash +$ npx express-generator + ... + + install dependencies: + $ npm install + + run the app: + $ DEBUG=playground:* npm start +``` + +First, let's run the application to get an idea of what we just created. Note that the command to run the application may be different from you, my directory was called playground. + +```bash +$ npm install +$ DEBUG=playground:* npm start + playground:server Listening on port 3000 +0ms +``` + +Great, so now we can navigate to [http://localhost:3000](http://localhost:3000) and the app is running there. + +Containerizing that should be relatively easy based on the previous example. + +- Use node as base +- Set working directory so we don't interfere with the contents of the base image +- Copy ALL of the files in this directory to the image +- Start with DEBUG=playground:* npm start + +Let's place the following Dockerfile at the root of the project: + +```Dockerfile +FROM node:20 + +WORKDIR /usr/src/app + +COPY . . + +CMD DEBUG=playground:* npm start +``` + +Let's build the image from the Dockerfile and then run it: + +```bash +docker build -t express-server . +docker run -p 3123:3000 express-server +``` + +The _-p_ flag in the run command will inform Docker that a port from the host machine should be opened and directed to a port in the container. The format is _-p host-port:application-port_. + +The application is now running! Let's test it by sending a GET request to [http://localhost:3123/](http://localhost:3123/). + +> If yours doesn't work, skip to the next section. There is an explanation why it may not work even if you followed the steps correctly. + +Shutting the app down is a headache at the moment. Use another terminal and _docker kill_ command to kill the application. The _docker kill_ will send a kill signal (SIGKILL) to the application to force it to shut down. It needs the name or the id of the container as an argument. + +By the way, when using the id as the argument, the beginning of the ID is enough for Docker to know which container we mean. + +```bash +$ docker container ls + CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES + 48096ca3ffec express-server "docker-entrypoint.s…" 9 seconds ago Up 6 seconds 0.0.0.0:3123->3000/tcp, :::3123->3000/tcp infallible_booth + +$ docker kill 48 + 48 +``` + +In the future, let's use the same port on both sides of _-p_. Just so we don't have to remember which one we happened to choose. + +#### Fixing potential issues we created by copy-pasting + +There are a few steps we need to change to create a more comprehensive Dockerfile. It may even be that the above example doesn't work in all cases because we skipped an important step. + +When we ran npm install on our machine, in some cases the **Node package manager** may install operating system specific dependencies during the install step. We may accidentally move non-functional parts to the image with the COPY instruction. This can easily happen if we copy the node_modules directory into the image. + +This is a critical thing to keep in mind when we build our images. It's best to do most things, such as to run _npm install_ during the build process inside the container rather than doing those prior to building. The easy rule of thumb is to only copy files that you would push to GitHub. Build artifacts or dependencies should not be copied since those can be installed during the build process. + +We can use .dockerignore to solve the problem. The file .dockerignore is very similar to .gitignore, you can use that to prevent unwanted files from being copied to your image. The file should be placed next to the Dockerfile. Here is a possible content of a .dockerignore + +``` +.dockerignore +.gitignore +node_modules +Dockerfile +``` + +However, in our case, the .dockerignore isn't the only thing required. We will need to install the dependencies during the build step. The _Dockerfile_ changes to: + +```Dockerfile +FROM node:20 + +WORKDIR /usr/src/app + +COPY . . + +RUN npm install # highlight-line + +CMD DEBUG=playground:* npm start +``` + +The npm install can be risky. Instead of using npm install, npm offers a much better tool for installing dependencies, the _ci_ command. + +Differences between ci and install: + +- install may update the package-lock.json +- install may install a different version of a dependency if you have ^ or ~ in the version of the dependency. + +- ci will delete the node_modules folder before installing anything +- ci will follow the package-lock.json and does not alter any files + +So in short: _ci_ creates reliable builds, while _install_ is the one to use when you want to install new dependencies. + +As we are not installing anything new during the build step, and we don't want the versions to suddenly change, we will use _ci_: + +```Dockerfile +FROM node:20 + +WORKDIR /usr/src/app + +COPY . . + +RUN npm ci # highlight-line + +CMD DEBUG=playground:* npm start +``` + +Even better, we can use _npm ci --omit=dev_ to not waste time installing development dependencies. + +> As you noticed in the comparison list; npm ci will delete the node_modules folder so creating the .dockerignore did not matter. However, .dockerignore is an amazing tool when you want to optimize your build process. We will talk briefly about these optimizations later. + +Now the Dockerfile should work again, try it with _docker build -t express-server . && docker run -p 3123:3000 express-server_ + +> Note that we are here chaining two bash commands with &&. We could get (nearly) the same effect by running both commands separately. When chaining commands with && if one command fails, the next ones in the chain will not be executed. + +We set an environment variable _DEBUG=playground:*_ during CMD for the npm start. However, with Dockerfiles we could also use the instruction ENV to set environment variables. Let's do that: + +```Dockerfile +FROM node:20 + +WORKDIR /usr/src/app + +COPY . . + +RUN npm ci + +#highlight-start +ENV DEBUG=playground:* +#highlight-end + +#highlight-start +CMD npm start +#highlight-end +``` + +> If you're wondering what the DEBUG environment variable does, read [here](http://expressjs.com/en/guide/debugging.html#debugging-express). + +#### Dockerfile best practices + +There are 2 rules of thumb you should follow when creating images: + +- Try to create as **secure** of an image as possible +- Try to create as **small** of an image as possible + +Smaller images are more secure by having less attack surface area, and also move faster in deployment pipelines. + +Snyk has a great list of the 10 best practices for Node/Express containerization. Read those [here](https://snyk.io/blog/10-best-practices-to-containerize-nodejs-web-applications-with-docker/). + +One big carelessness we have left is running the application as root instead of using a user with lower privileges. Let's do a final fix to the Dockerfile: + +```Dockerfile +FROM node:20 + +WORKDIR /usr/src/app + +#highlight-start +COPY --chown=node:node . . +#highlight-end + +RUN npm ci + +ENV DEBUG=playground:* + +#highlight-start +USER node +#highlight-end + +CMD npm start +``` + +
    + +
    + +### Exercise 12.5. + +#### Exercise 12.5: Containerizing a Node application + +The repository that you cloned or copied in the [first exercise](/en/part12/introduction_to_containers#exercise-12-1) contains a todo-app. See the todo-app/todo-backend and read through the README. We will not touch the todo-frontend yet. + +- Step 1. Containerize the todo-backend by creating a todo-app/todo-backend/Dockerfile and building an image. + +- Step 2. Run the todo-backend image with the correct ports open. Make sure the visit counter increases when used through a browser in http://localhost:3000/ (or some other port if you configure so) + +Tip: Run the application outside of a container to examine it before starting to containerize. + +
    + +
    + +### Using Docker compose + +In the previous section, we created an Express server, knowing that it will run in port 3123, and used the commands _docker build -t express-server . && docker run -p 3123:3000 express-server_ to run it. This already looks like something you would need to put into a script to remember. Fortunately, Docker offers us a better solution. + +[Docker compose](https://docs.docker.com/compose/) is another fantastic tool, which can help us to manage containers. Let's start using compose as we learn more about containers as it will help us save some time with the configuration. + +Now we can turn the previous spell into a yaml file. The best part about yaml files is that you can save these to a Git repository! + +Create the file **docker-compose.yml** and place it at the root of the project, next to the Dockerfile. This time we will use the same port for host and container. The file content is: + +```yaml +services: + app: # The name of the service, can be anything + image: express-server # Declares which image to use + build: . # Declares where to build if image is not found + ports: # Declares the ports to publish + - 3000:3000 +``` + +The meaning of each line is explained as a comment. If you want to see the full specification see the [documentation](https://docs.docker.com/compose/compose-file/). + +Now we can use _docker compose up_ to build and run the application. If we want to rebuild the images we can use _docker compose up --build_. + +You can also run the application in the background with _docker compose up -d_ (_-d_ for detached) and close it with _docker compose down_. + +> Note that some older Docker versions (especially in Windows) do not support the command _docker compose_. One way to circumvent this problem is to [install](https://docs.docker.com/compose/install/) the stand alone command _docker-compose_ that works mostly similarly to _docker compose_. However, the preferable fix is to update the Docker to a more recent version. + +Creating files like _docker-compose.yml_ that declare what you want instead of script files that you need to run in a specific order / a specific number of times is often a great practice. + +
    + +
    + +### Exercise 12.6. + +#### Exercise 12.6: Docker compose + +Create a todo-app/todo-backend/docker-compose.yml file that works with the Node application from the previous exercise. + +The visit counter is the only feature that is required to be working. + +
    + +
    + +### Utilizing containers in development + +When you are developing software, containerization can be used in various ways to improve your quality of life. One of the most useful cases is by bypassing the need to install and configure tools twice. + +It may not be the best option to move your entire development environment into a container, but if that's what you want it's certainly possible. We will revisit this idea at the end of this part. But until then, run the Node application itself outside of containers. + +The application we met in the previous exercises uses MongoDB. Let's explore [Docker Hub](https://hub.docker.com/) to find a MongoDB image. Docker Hub is the default place where Docker pulls the images from, you can use other registries as well, but since we are already knee-deep in Docker it's a good choice. With a quick search, we can find [https://hub.docker.com/_/mongo](https://hub.docker.com/_/mongo) + +Create a new yaml called todo-app/todo-backend/docker-compose.dev.yml that looks like following: + +```yml +services: + mongo: + image: mongo + ports: + - 3456:27017 + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + MONGO_INITDB_DATABASE: the_database +``` + +The meaning of the two first environment variables defined above is explained on the Docker Hub page: + +> These variables, used in conjunction, create a new user and set that user's password. This user is created in the admin authentication database and given the role of root, which is a "superuser" role. + +The last environment variable *MONGO\_INITDB\_DATABASE* will tell MongoDB to create a database with that name. + +You can use _-f_ flag to specify a file to run the Docker Compose command with e.g. + +```bash +docker compose -f docker-compose.dev.yml up +``` + +Now that we may have multiple compose files, it's useful. + +Next, start the MongoDB with _docker compose -f docker-compose.dev.yml up -d_. With _-d_ it will run it in the background. You can view the output logs with _docker compose -f docker-compose.dev.yml logs -f_. There the _-f_ will ensure we follow the logs. + +As said previously, currently we do not want to run the Node application inside a container. Developing while the application itself is inside a container is a challenge. We will explore that option later in this part. + +Run the good old _npm install_ first on your machine to set up the Node application. Then start the application with the relevant environment variable. You can modify the code to set them as the defaults or use the .env file. There is no hurt in putting these keys to GitHub since they are only used in your local development environment. I'll just throw them in with the _npm run dev_ to help you copy-paste. + +```bash +MONGO_URL=mongodb://localhost:3456/the_database npm run dev +``` + +This won't be enough; we need to create a user to be authorized inside of the container. The url http://localhost:3000/todos leads to an authentication error: + +```bash +[nodemon] 2.0.12 +[nodemon] to restart at any time, enter `rs` +[nodemon] watching path(s): *.* +[nodemon] watching extensions: js,mjs,json +[nodemon] starting `node ./bin/www` +/Users/mluukkai/dev/fs-ci-lokakuu/repo/todo-app/todo-backend/node_modules/mongodb/lib/cmap/connection.js:272 + callback(new MongoError(document)); + ^ +MongoError: command find requires authentication + at MessageStream.messageHandler (/Users/mluukkai/dev/fs-ci-lokakuu/repo/todo-app/todo-backend/node_modules/mongodb/lib/cmap/connection.js:272:20) +``` + +### Bind mount and initializing the database + +In the [MongoDB Docker Hub](https://hub.docker.com/_/mongo) page under "Initializing a fresh instance" is the info on how to execute JavaScript to initialize the database and a user for it. + +The exercise project has a file todo-app/todo-backend/mongo/mongo-init.js with contents: + +```js +db.createUser({ + user: 'the_username', + pwd: 'the_password', + roles: [ + { + role: 'dbOwner', + db: 'the_database', + }, + ], +}); + +db.createCollection('todos'); + +db.todos.insert({ text: 'Write code', done: true }); +db.todos.insert({ text: 'Learn about containers', done: false }); +``` + +This file will initialize the database with a user and a few todos. Next, we need to get it inside the container at startup. + +We could create a new image FROM mongo and COPY the file inside, or we can use a [bind mount](https://docs.docker.com/storage/bind-mounts/) to mount the file mongo-init.js to the container. Let's do the latter. + +Bind mount is the act of binding a file (or directory) on the host machine to a file (or directory) in the container. A bind mount is done by adding a _-v_ flag with _container run_. The syntax is _-v FILE-IN-HOST:FILE-IN-CONTAINER_. Since we already learned about Docker Compose let's skip that. The bind mount is declared under key volumes in _docker-compose.dev.yml_. Otherwise the format is the same, first host and then container: + +```yml + mongo: + image: mongo + ports: + - 3456:27017 + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + MONGO_INITDB_DATABASE: the_database + # highlight-start + volumes: + - ./mongo/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js + # highlight-end +``` + +The result of the bind mount is that the file mongo-init.js in the mongo folder of the host machine is the same as the mongo-init.js file in the container's /docker-entrypoint-initdb.d directory. Changes to either file will be available in the other. We don't need to make any changes during runtime. But this will be the key to software development in containers. + +Run _docker compose -f docker-compose.dev.yml down --volumes_ to ensure that nothing is left and start from a clean slate with _docker compose -f docker-compose.dev.yml up_ to initialize the database. + +If you see an error like this: + +```bash +mongo_database | failed to load: /docker-entrypoint-initdb.d/mongo-init.js +mongo_database | exiting with code -3 +``` + +you may have a read permission problem. They are not uncommon when dealing with volumes. In the above case, you can use _chmod a+r mongo-init.js_, which will give everyone read access to that file. Be careful when using _chmod_ since granting more privileges can be a security issue. Use the _chmod_ only on the mongo-init.js on your computer. + +Now starting the Express application with the correct environment variable should work: + +```bash +MONGO_URL=mongodb://the_username:the_password@localhost:3456/the_database npm run dev +``` + +Let's check that the http://localhost:3000/todos returns the two todos we inserted in the initialization. We can and should use Postman to test the basic functionality of the app, such as adding or deleting a todo. + +### Still problems? + +For some reason, the initialization of Mongo has caused problems for many. + +If the app does not work and you still end up with the following error: + +```bash +/Users/mluukkai/dev/fs-ci-lokakuu/repo/todo-app/todo-backend/node_modules/mongodb/lib/cmap/connection.js:272 + callback(new MongoError(document)); + ^ +MongoError: command find requires authentication + at MessageStream.messageHandler (/Users/mluukkai/dev/fs-ci-lokakuu/repo/todo-app/todo-backend/node_modules/mongodb/lib/cmap/connection.js:272:20) +``` + +run these commands: + +```bash +docker compose -f docker-compose.dev.yml down --volumes +docker image rm mongo +``` + +After these, try to start Mongo again. + +If the problem persists, let us drop the idea of a volume altogether and copy the initialization script to a custom image. Create the following Dockerfile to the directory todo-app/todo-backend/mongo: + +```Dockerfile +FROM mongo + +COPY ./mongo-init.js /docker-entrypoint-initdb.d/ +``` + +Build it to an image with the command: + +```bash +docker build -t initialized-mongo . +``` + +Now change the docker-compose.dev.yml file to use the new image: + +```yml + mongo: + image: initialized-mongo # highlight-line + ports: + - 3456:27017 + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + MONGO_INITDB_DATABASE: the_database +``` + +Now the app should finally work. + +### Persisting data with volumes + +By default, database containers are not going to preserve our data. When you close the database container you may or may not be able to get the data back. + +> Mongo is actually a rare case in which the container indeed does preserve the data. This happens, since the developers who made the Docker image for Mongo have defined a volume to be used. [This line](https://github.com/docker-library/mongo/blob/cb8a419053858e510fc68ed2d69415b3e50011cb/4.4/Dockerfile#L113) in the Dockerfile will instruct Docker to preserve the data in a volume. + +There are two distinct methods to store the data: +- Declaring a location in your filesystem (called [bind mount](https://docs.docker.com/storage/bind-mounts/)) +- Letting Docker decide where to store the data ([volume](https://docs.docker.com/storage/volumes/)) + +The first choice is preferable in most cases whenever one really needs to avoid the data being deleted. + +Let's see both in action with Docker compose. Let us start with bind mount: + +```yml +services: + mongo: + image: mongo + ports: + - 3456:27017 + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + MONGO_INITDB_DATABASE: the_database + volumes: + - ./mongo/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js + - ./mongo_data:/data/db # highlight-line +``` + +The above will create a directory called *mongo\_data* to your local filesystem and map it into the container as _/data/db_. This means the data in _/data/db_ is stored outside of the container but still accessible by the container! Just remember to add the directory to .gitignore. + +A similar outcome can be achieved with a named volume: + +```yml +services: + mongo: + image: mongo + ports: + - 3456:27017 + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + MONGO_INITDB_DATABASE: the_database + volumes: + - ./mongo/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js + - mongo_data:/data/db # highlight-line + +volumes: # highlight-line + mongo_data: # highlight-line +``` + +Now the volume is created and managed by Docker. After starting the application (_docker compose -f docker-compose.dev.yml up_) you can list the volumes with _docker volume ls_, inspect one of them with _docker volume inspect_ and even delete them with _docker volume rm_: + +```bash +$ docker volume ls +DRIVER VOLUME NAME +local todo-backend_mongo_data +$ docker volume inspect todo-backend_mongo_data +[ + { + "CreatedAt": "2024-19-03T12:52:11Z", + "Driver": "local", + "Labels": { + "com.docker.compose.project": "todo-backend", + "com.docker.compose.version": "1.29.2", + "com.docker.compose.volume": "mongo_data" + }, + "Mountpoint": "/var/lib/docker/volumes/todo-backend_mongo_data/_data", + "Name": "todo-backend_mongo_data", + "Options": null, + "Scope": "local" + } +] +``` + +The named volume is still stored in your local filesystem but figuring out where may not be as trivial as with the previous option. + +
    + +
    + +### Exercise 12.7. + +#### Exercise 12.7: Little bit of MongoDB coding + +Note that this exercise assumes that you have done all the configurations made in the material after exercise 12.5. You should still run the todo-app backend outside a container; just the MongoDB is containerized for now. + +The todo application has no proper implementation of routes for getting one todo (GET /todos/:id) and updating one todo (PUT /todos/:id). Fix the code. + +
    + +
    + +### Debugging issues in containers + +> When coding, you most likely end up in a situation where everything is broken. + +> \- Matti Luukkainen + +When developing with containers, we need to learn new tools for debugging, since we can not just "console.log" everything. When code has a bug, you may often be in a state where at least something works, so you can work forward from that. Configuration most often is in either of two states: 1. working or 2. broken. We will go over a few tools that can help when your application is in the latter state. + +When developing software, you can safely progress step by step, all the time verifying that what you have coded behaves as expected. Often, this is not the case when doing configurations. The configuration you may be writing can be broken until the moment it is finished. So when you write a long docker-compose.yml or Dockerfile and it does not work, you need to take a moment and think about the various ways you could confirm something is working. + +Question Everything is still applicable here. As said in [part 3](/en/part3/saving_data_to_mongo_db): The key is to be systematic. Since the problem can exist anywhere, you must question everything, and eliminate all possible sources of error one by one. + +For myself, the most valuable method of debugging is stopping and thinking about what I'm trying to accomplish instead of just bashing my head at the problem. Often there is a simple, alternate, solution or quick google search that will get me moving forward. + +#### exec + +The Docker command [exec](https://docs.docker.com/engine/reference/commandline/exec/) is a heavy hitter. It can be used to jump right into a container when it's running. + +Let's start a web server in the background and do a little bit of debugging to get it running and displaying the message "Hello, exec!" in our browser. Let's choose [Nginx](https://www.nginx.com/) which is, among other things, a server capable of serving static HTML files. It has a default index.html that we can replace. + +```bash +$ docker container run -d nginx +``` + +Ok, now the questions are: + +- Where should we go with our browser? +- Is it even running? + +We know how to answer the latter: by listing the running containers. + +```bash +$ docker container ls +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +3f831a57b7cc nginx ... 3 sec ago Up 2 sec 80/tcp keen_darwin +``` + +Yes! We got the first question answered as well. It seems to listen on port 80, as seen on the output above. + +Let's shut it down and restart with the _-p_ flag to have our browser access it. + +```bash +$ docker container stop keen_darwin +$ docker container rm keen_darwin + +$ docker container run -d -p 8080:80 nginx +``` + +> **Editor's note_** when doing development, it is **essential** to constantly follow the container logs. I'm usually not running containers in a detached mode (that is with -d) since it requires a bit of an extra effort to open the logs. +> +> When I'm 100% sure that everything works... no, when I'm 200% sure, then I might relax a bit and start the containers in detached mode. Until everything again falls apart and it is time to open the logs again. + +Let's look at the app by going to http://localhost:8080. It seems that it is showing the wrong message! Let's hop right into the container and fix this. Keep your browser open, we won't need to shut down the container for this fix. We will execute bash inside the container, the flags _-it_ will ensure that we can interact with the container: + +```bash +$ docker container ls +CONTAINER ID IMAGE COMMAND PORTS NAMES +7edcb36aff08 nginx ... 0.0.0.0:8080->80/tcp wonderful_ramanujan + +$ docker exec -it wonderful_ramanujan bash +root@7edcb36aff08:/# +``` + +Now that we are in, we need to find the faulty file and replace it. Quick Google tells us that file itself is _/usr/share/nginx/html/index.html_. + +Let's move to the directory and delete the file + +```bash +root@7edcb36aff08:/# cd /usr/share/nginx/html/ +root@7edcb36aff08:/# rm index.html +``` + +Now, if we go to http://localhost:8080/ we know that we deleted the correct file. The page shows 404. Let's replace it with one containing the correct contents: + +```bash +root@7edcb36aff08:/# echo "Hello, exec!" > index.html +``` + +Refresh the page, and our message is displayed! Now we know how exec can be used to interact with the containers. Remember that all of the changes are lost when the container is deleted. To preserve the changes, you must use _commit_ just as we did in [previous section](/en/part12/introduction_to_containers#other-docker-commands). + +
    + +
    + +### Exercise 12.8. + +#### Exercise 12.8: Mongo command-line interface + +> Use _script_ to record what you do, save the file as script-answers/exercise12_8.txt + +While the MongoDB from the previous exercise is running, access the database with the Mongo command-line interface (CLI). You can do that using docker exec. Then add a new todo using the CLI. + +The command to open CLI when inside the container is _mongosh_ + +The Mongo CLI will require the username and password flags to authenticate correctly. Flags _-u root -p example_ should work, the values are from the _docker-compose.dev.yml_. + +* Step 1: Run MongoDB +* Step 2: Use docker exec to get inside the container +* Step 3: Open Mongo CLI + +When you have connected to the Mongo CLI you can ask it to show the DBs inside: + +```bash +> show dbs +admin 0.000GB +config 0.000GB +local 0.000GB +the_database 0.000GB +``` + +To access the correct database: + +```bash +> use the_database +``` + +And finally to find out the collections: + +```bash +> show collections +todos +``` + +We can now access the data in those collections: + +```bash +> db.todos.find({}) +[ + { + _id: ObjectId("633c270ba211aa5f7931f078"), + text: 'Write code', + done: false + }, + { + _id: ObjectId("633c270ba211aa5f7931f079"), + text: 'Learn about containers', + done: false + } +] +``` + +Insert one new todo with the text: "Increase the number of tools in my tool belt" with the status done as false. Consult the [documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.insertOne/) to see how the addition is done. + +Ensure that you see the new todo both in the Express app and when querying from Mongo CLI. + +
    + +
    + +### Redis + +[Redis](https://redis.io/) is a [key-value](https://redis.com/nosql/key-value-databases/) database. In contrast to eg. MongoDB, the data stored in key-value storage has a bit less structure, there are eg. no collections or tables, it just contains junks of data that can be fetched based on the key that was attached to the data (the value). + +By default, Redis works in-memory, which means that it does not store data persistently. + +An excellent use case for Redis is to use it as a cache. Caches are often used to store data that is otherwise slow to fetch and save until it's no longer valid. After the cache becomes invalid, you would then fetch the data again and store it in the cache. + +Redis has nothing to do with containers. But since we are already able to add any 3rd party service to your applications, why not learn about a new one? + +
    + +
    + +### Exercises 12.9. - 12.11. + +#### Exercise 12.9: Set up Redis for the project + +The Express server has already been configured to use Redis, and it is only missing the *REDIS_URL* environment variable. The application will use that environment variable to connect to the Redis. Read through the [Docker Hub page for Redis](https://hub.docker.com/_/redis), add Redis to the todo-app/todo-backend/docker-compose.dev.yml by defining another service after mongo: + +```yml +services: + mongo: + ... + redis: + ??? +``` + +Since the Docker Hub page doesn't have all the info, we can use Google to aid us. The default port for Redis is found by doing so: + +![google search result for "default port for redis" is 6379](../../images/12/redis_port_by_google.png) + +We won't have any idea if the configuration works unless we try it. The application will not start using Redis by itself, that shall happen in the next exercise. + +Once Redis is configured and started, restart the backend and give it the REDIS\_URL, which has the form redis://host:port + +```bash +REDIS_URL=insert-redis-url-here MONGO_URL=mongodb://the_username:the_password@localhost:3456/the_database npm run dev +``` + +You can now test the configuration by adding the line + +```js +const redis = require('../redis') +``` + +to the Express server e.g. in the file routes/index.js. If nothing happens, the configuration is done right. If not, the server crashes: + +```bash +events.js:291 + throw er; // Unhandled 'error' event + ^ + +Error: Redis connection to localhost:637 failed - connect ECONNREFUSED 127.0.0.1:6379 + at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1144:16) +Emitted 'error' event on RedisClient instance at: + at RedisClient.on_error (/Users/mluukkai/opetus/docker-fs/container-app/express-app/node_modules/redis/index.js:342:14) + at Socket. (/Users/mluukkai/opetus/docker-fs/container-app/express-app/node_modules/redis/index.js:223:14) + at Socket.emit (events.js:314:20) + at emitErrorNT (internal/streams/destroy.js:100:8) + at emitErrorCloseNT (internal/streams/destroy.js:68:3) + at processTicksAndRejections (internal/process/task_queues.js:80:21) { + errno: -61, + code: 'ECONNREFUSED', + syscall: 'connect', + address: '127.0.0.1', + port: 6379 +} +[nodemon] app crashed - waiting for file changes before starting... +``` + +#### Exercise 12.10: + +The project already has [https://www.npmjs.com/package/redis](https://www.npmjs.com/package/redis) installed and two functions "promisified" - getAsync and setAsync. + +- setAsync function takes in key and value, using the key to store the value. + +- getAsync function takes in a key and returns the value in a promise. + +Implement a todo counter that saves the number of created todos to Redis: + +- Step 1: Whenever a request is sent to add a todo, increment the counter by one. +- Step 2: Create a GET /statistics endpoint where you can ask for the usage metadata. The format should be the following JSON: + +```json +{ + "added_todos": 0 +} +``` + +#### Exercise 12.11: + +> Use _script_ to record what you do, save the file as script-answers/exercise12_11.txt + +If the application does not behave as expected, direct access to the database may be beneficial in pinpointing problems. Let us try out how [redis-cli](https://redis.io/topics/rediscli) can be used to access the database. + +- Go to the Redis container with _docker exec_ and open the redis-cli. +- Find the key you used with _[KEYS *](https://redis.io/commands/keys)_ +- Check the value of the key with the command [GET](https://redis.io/commands/get) +- Set the value of the counter to 9001, find the right command from [here](https://redis.io/commands/) +- Make sure that the new value works by refreshing the page http://localhost:3000/statistics +- Create a new todo with Postman and ensure from redis-cli that the counter has increased accordingly +- Delete the key from the cli and ensure that the counter works when new todos are added + +
    + +
    + +### Persisting data with Redis + +In the previous section, it was mentioned that by default Redis does not persist the data. However, the persistence is easy to toggle on. We only need to start the Redis with a different command, as instructed by the [Docker hub page](https://hub.docker.com/_/redis): + +```yml +services: + redis: + # Everything else + command: ['redis-server', '--appendonly', 'yes'] # Overwrite the CMD + volumes: # Declare the volume + - ./redis_data:/data +``` + +The data will now be persisted to the directory redis_data of the host machine. +Remember to add the directory to .gitignore! + +#### Other functionality of Redis + +In addition to the GET, SET and DEL operations on keys and values, Redis can do also quite a lot more. It can for example automatically expire keys, which is a very useful feature when Redis is used as a cache. + +Redis can also be used to implement the so-called [publish-subscribe](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) (or PubSub) pattern which is an asynchronous communication mechanism for distributed software. In this scenario, Redis works as a message broker between two or more services. Some of the services are publishing messages by sending those to Redis, which on arrival of a message, informs the parties that have subscribed to those messages. + +
    + +
    + +### Exercise 12.12. + +#### Exercise 12.12: Persisting data in Redis + +Check that the data is not persisted by default, after running: + +```bash +docker compose -f docker-compose.dev.yml down +docker compose -f docker-compose.dev.yml up +``` + +the counter value is reset to 0. + +Then create a volume for Redis data (by modifying todo-app/todo-backend/docker-compose.dev.yml) and make sure that the data survives after running: + +```bash +docker compose -f docker-compose.dev.yml down +docker compose -f docker-compose.dev.yml up +``` + +
    diff --git a/src/content/12/en/part12c.md b/src/content/12/en/part12c.md new file mode 100644 index 00000000000..5a78e955032 --- /dev/null +++ b/src/content/12/en/part12c.md @@ -0,0 +1,789 @@ +--- +mainImage: ../../../images/part-12.svg +part: 12 +letter: c +lang: en +--- + +
    + +The part was updated 21th Mar 2024: Create react app was replaced with Vite in the todo-frontend. + +If you started the part before the update, you can see [here](https://github.com/fullstack-hy2020/fullstack-hy2020.github.io/tree/4015af9dddb61cb01f013456d8728e8f553be347/src/content/12) the old material. There are some changes in the frontend configurations. +
    + +
    + +We have now a basic understanding of Docker and can use it to easily set up eg. a database for our app. Let us now move our focus to the frontend. + +### React in container + +Let's create and containerize a React application next. We start with the usual steps: + +```bash +$ npm create vite@latest hello-front -- --template react +$ cd hello-front +$ npm install +``` + +The next step is to turn the JavaScript code and CSS, into production-ready static files. Vite already has _build_ as an npm script so let's use that: + +```bash +$ npm run build + ... + + Creating an optimized production build... + ... + The build folder is ready to be deployed. + ... +``` + +Great! The final step is figuring out a way to use a server to serve the static files. As you may know, we could use [express.static](https://expressjs.com/en/starter/static-files.html) with the Express server to serve the static files. I'll leave that as an exercise for you to do at home. Instead, we are going to go ahead and start writing our Dockerfile: + +```Dockerfile +FROM node:20 + +WORKDIR /usr/src/app + +COPY . . + +RUN npm ci + +RUN npm run build +``` + +That looks about right. Let's build it and see if we are on the right track. Our goal is to have the build succeed without errors. Then we will use bash to check inside of the container to see if the files are there. + +```bash +$ docker build . -t hello-front + => [4/5] RUN npm ci + => [5/5] RUN npm run + ... + => => naming to docker.io/library/hello-front + +$ docker run -it hello-front bash + +root@98fa9483ee85:/usr/src/app# ls + Dockerfile README.md dist index.html node_modules package-lock.json package.json public src vite.config.js + +root@98fa9483ee85:/usr/src/app# ls dist + assets index.html vite.svg +``` + +A valid option for serving static files now that we already have Node in the container is [serve](https://www.npmjs.com/package/serve). Let's try installing serve and serving the static files while we are inside the container. + +```bash +root@98fa9483ee85:/usr/src/app# npm install -g serve + + added 89 packages in 2s + +root@98fa9483ee85:/usr/src/app# serve dist + + ┌────────────────────────────────────────┐ + │ │ + │ Serving! │ + │ │ + │ - Local: http://localhost:3000 │ + │ - Network: http://172.17.0.2:3000 │ + │ │ + └────────────────────────────────────────┘ + +``` + +Great! Let's ctrl+c to exit out and then add those to our Dockerfile. + +The installation of serve turns into a RUN in the Dockerfile. This way the dependency is installed during the build process. The command to serve the dist directory will become the command to start the container: + +```Dockerfile +FROM node:20 + +WORKDIR /usr/src/app + +COPY . . + +RUN npm ci + +RUN npm run build + +#highlight-start +RUN npm install -g serve +#highlight-end + +#highlight-start +CMD ["serve", "dist"] +#highlight-end +``` + +Our CMD now includes square brackets and as a result, we now use the exec form of CMD. There are actually **three** different forms for CMD, out of which the exec form is preferred. Read the [documentation](https://docs.docker.com/reference/dockerfile/#cmd) for more info. + +When we now build the image with _docker build . -t hello-front_ and run it with _docker run -p 5001:3000 hello-front_, the app will be available in http://localhost:5001. + +### Using multiple stages + +While serve is a valid option, we can do better. A good goal is to create Docker images so that they do not contain anything irrelevant. With a minimal number of dependencies, images are less likely to break or become vulnerable over time. + +[Multi-stage builds](https://docs.docker.com/build/building/multi-stage/) are designed to split the build process into many separate stages, where it is possible to limit what parts of the image files are moved between the stages. That opens possibilities for limiting the size of the image since not all the by-products of the build are necessary for the resulting image. Smaller images are faster to upload and download and they help reduce the number of vulnerabilities that your software may have. + +With multi-stage builds, a tried and true solution like [Nginx](https://en.wikipedia.org/wiki/Nginx) can be used to serve static files without a lot of headaches. The Docker Hub [page for Nginx](https://hub.docker.com/_/nginx) tells us the required info to open the ports and "Hosting some simple static content". + +Let's use the previous Dockerfile but change the FROM to include the name of the stage: + +```Dockerfile +# The first FROM is now a stage called build-stage +# highlight-start +FROM node:20 AS build-stage +# highlight-end + +WORKDIR /usr/src/app + +COPY . . + +RUN npm ci + +RUN npm run build + +# This is a new stage, everything before this is gone, except for the files that we want to COPY +# highlight-start +FROM nginx:1.25-alpine +# highlight-end + +# COPY the directory dist from the build-stage to /usr/share/nginx/html +# The target location here was found from the Docker hub page +# highlight-start +COPY --from=build-stage /usr/src/app/dist /usr/share/nginx/html +# highlight-end +``` + +We have also declared another stage, where only the relevant files of the first stage (the dist directory, that contains the static content) are copied. + +After we build it again, the image is ready to serve the static content. The default port will be 80 for Nginx, so something like _-p 8000:80_ will work, so the parameters of the RUN command need to be changed a bit. + +Multi-stage builds also include some internal optimizations that may affect your builds. As an example, multi-stage builds skip stages that are not used. If we wish to use a stage to replace a part of a build pipeline, like testing or notifications, we must pass **some** data to the following stages. In some cases this is justified: copy the code from the testing stage to the build stage. This ensures that you are building the tested code. + +
    + +
    + +### Exercises 12.13 - 12.14. + +#### Exercise 12.13: Todo application frontend + +Finally, we get to the todo-frontend. View the todo-app/todo-frontend and read through the README. + +Start by running the frontend outside the container and ensure that it works with the backend. + +Containerize the application by creating todo-app/todo-frontend/Dockerfile and use the [ENV](https://docs.docker.com/engine/reference/builder/#env) instruction to pass *VITE\_BACKEND\_URL* to the application and run it with the backend. The backend should still be running outside a container. + +**Note** that you need to set *VITE\_BACKEND\_URL* before building the frontend, otherwise, it does not get defined in the code! + +#### Exercise 12.14: Testing during the build process + +One interesting possibility that utilizing multi-stage builds gives us, is to use a separate build stage for [testing](https://docs.docker.com/language/nodejs/run-tests/). If the testing stage fails, the whole build process will also fail. Note that it may not be the best idea to move all testing to be done during the building of an image, but there may be some containerization-related tests where it might be worth considering. + +Extract a component Todo that represents a single todo. Write a test for the new component and add running the tests into the build process. + +You can add a new build stage for the test if you wish to do so. If you do so, remember to read the last paragraph before exercise 12.13 again! + +
    + +
    + +### Development in containers + +Let's move the whole todo application development to a container. There are a few reasons why you would want to do that: + +- To keep the environment similar between development and production to avoid bugs that appear only in the production environment +- To avoid differences between developers and their personal environments that lead to difficulties in application development +- To help new team members hop in by having them install container runtime - and requiring nothing else. + +These all are great reasons. The tradeoff is that we may encounter some unconventional behavior when we aren't running the applications like we are used to. We will need to do at least two things to move the application to a container: + +- Start the application in development mode +- Access the files with VS Code + +Let's start with the frontend. Since the Dockerfile will be significantly different from the production Dockerfile let's create a new one called dev.Dockerfile. + +**Note** we shall use the name dev.Dockerfile for development configurations and Dockerfile otherwise. + +Starting Vite in development mode should be easy. Let's start with the following: + +```Dockerfile +FROM node:20 + +WORKDIR /usr/src/app + +COPY . . + +# Change npm ci to npm install since we are going to be in development mode +RUN npm install + +# npm run dev is the command to start the application in development mode +CMD ["npm", "run", "dev", "--", "--host"] +``` + +> Note the extra parameters _-- --host_ in the _CMD_. Those are needed to expose the development server to be visible outside the Docker network. By default the development server is exposed only to localhost, and despite we access the frontend still using the localhost address, it is in reality attached to the Docker network. + +During build the flag _-f_ will be used to tell which file to use, it would otherwise default to Dockerfile, so the following command will build the image: + +```bash +docker build -f ./dev.Dockerfile -t hello-front-dev . +``` + +Vite will be served in port 5173, so you can test that it works by running a container with that port published. + +The second task, accessing the files with VSCode, is not yet taken care of. There are at least two ways of doing this: + +- [The Visual Studio Code Remote - Containers extension](https://code.visualstudio.com/docs/remote/containers) +- Volumes, the same thing we used to preserve data with the database + +Let's go over the latter since that will work with other editors as well. Let's do a trial run with the flag _-v_, and if that works, then we will move the configuration to a docker-compose file. To use the _-v_, we will need to tell it the current directory. The command _pwd_ should output the path to the current directory for us. Let's try this with _echo $(pwd)_ in the command line. We can use that as the left side for _-v_ to map the current directory to the inside of the container or we can use the full directory path. + +```bash +$ docker run -p 5173:5173 -v "$(pwd):/usr/src/app/" hello-front-dev +> todo-vite@0.0.0 dev +> vite --host + + VITE v5.1.6 ready in 130 ms +``` + +Now we can edit the file src/App.jsx, and the changes should be hot-loaded to the browser! + +If you have a Mac with M1/M2 processor, the above command fails. In the error message, we notice the following: + +``` +Error: Cannot find module @rollup/rollup-linux-arm64-gnu +``` + +The problem is the library [rollup](https://www.npmjs.com/package/rollup) that has its own version for all operating systems and processor architectures. Due to the volume mapping, the container is now using the *node_modules* from the host machine directory where the _@rollup/rollup-darwin-arm64_ (the version suitable Mac M1/M2) is installed, so the right version of the library for the container _@rollup/rollup-linux-arm64-gnu_ is not found. + +There are several ways to fix the problem. Let's use the perhaps simplest one. Start the container with bash as the command, and run the _npm install_ inside the container: + +```bash +$ docker run -it -v "$(pwd):/usr/src/app/" front-dev bash +root@b83e9040b91d:/usr/src/app# npm install +``` + +Now both versions of the library rollup are installed and the container works! + +Next, let's move the config to the file docker-compose.dev.yml. This file should be at the root of the project as well: + +```yml +services: + app: + image: hello-front-dev + build: + context: . # The context will pick this directory as the "build context" + dockerfile: dev.Dockerfile # This will simply tell which dockerfile to read + volumes: + - ./:/usr/src/app # The path can be relative, so ./ is enough to say "the same location as the docker-compose.yml" + ports: + - 5173:5173 + container_name: hello-front-dev # This will name the container hello-front-dev +``` + +With this configuration, _docker compose -f docker-compose.dev.yml up_ can run the application in development mode. You don't even need Node installed to develop it! + +**Note** we shall use the name docker-compose.dev.yml for development environment compose files, and the default name docker-compose.yml otherwise. + +Installing new dependencies is a headache for a development setup like this. One of the better options is to install the new dependency **inside** the container. So instead of doing e.g. _npm install axios_, you have to do it in the running container e.g. _docker exec hello-front-dev npm install axios_, or add it to the package.json and run _docker build_ again. + +
    +
    + +### Exercise 12.15 + +#### Exercise 12.15: Set up a frontend development environment + +Create todo-frontend/docker-compose.dev.yml and use volumes to enable the development of the todo-frontend while it is running inside a container. + +
    + +
    + +### Communication between containers in a Docker network + +The Docker Compose tool sets up a network between the containers and includes a DNS to easily connect two containers. Let's add a new service to the Docker Compose and we shall see how the network and DNS work. + +[Busybox](https://www.busybox.net/) is a small executable with multiple tools that you may need. It is called "The Swiss Army Knife of Embedded Linux", and we definitely can use it to our advantage. + +Busybox can help us to debug our configurations. So if you get lost in the later exercises of this section, you should use Busybox to find out what works and what doesn't. Let's use it to explore what was just said. That the containers are inside a network and you can easily connect between them. Busybox can be added to the mix by changing docker-compose.dev.yml to: + +```yml +services: + app: + image: hello-front-dev + build: + context: . + dockerfile: dev.Dockerfile + volumes: + - ./:/usr/src/app + ports: + - 5173:5173 + container_name: hello-front-dev + debug-helper: # highlight-line + image: busybox # highlight-line +``` + +The Busybox container won't have any process running inside so we can not _exec_ in there. Because of that, the output of _docker compose up_ will also look like this: + +```bash +$ docker compose -f docker-compose.dev.yml up 0.0s +Attaching to front-dev, debug-helper-1 +debug-helper-1 exited with code 0 +front-dev | +front-dev | > todo-vite@0.0.0 dev +front-dev | > vite --host +front-dev | +front-dev | +front-dev | VITE v5.2.2 ready in 153 ms +``` + +This is expected as it's just a toolbox. Let's use it to send a request to hello-front-dev and see how the DNS works. While the hello-front-dev is running, we can do the request with [wget](https://en.wikipedia.org/wiki/Wget) since it's a tool included in Busybox to send a request from the debug-helper to hello-front-dev. + +With Docker Compose we can use _docker compose run SERVICE COMMAND_ to run a service with a specific command. Command wget requires the flag _-O_ with _-_ to output the response to the stdout: + +```bash +$ docker compose -f docker-compose.dev.yml run debug-helper wget -O - http://app:5173 + +Connecting to app:5173 (192.168.240.3:5173) +writing to stdout + + + + + + + + Vite + React + + +
    + + + +``` + +That is it! Let's replace the proxy_pass address in nginx.dev.conf with that one. + +One more thing: we added an option [depends_on](https://docs.docker.com/compose/compose-file/05-services/#depends_on) to the configuration that ensures that the _nginx_ container is not started before the frontend container _app_ is started: + +```bash +services: + app: + # ... + nginx: + image: nginx:1.20.1 + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + ports: + - 8080:80 + container_name: reverse-proxy + depends_on: // highlight-line + - app // highlight-line +``` + +If we do not enforce the starting order with depends\_on there a risk that Nginx fails on startup since it tries to resolve all DNS names that are referred in the config file: + +```bash +http { + server { + listen 80; + + location / { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + + proxy_pass http://app:5173; // highlight-line + } + } +} +``` + + Note that depends\_on does not guarantee that the service in the depended container is ready for action, it just ensures that the container has been started (and the corresponding entry is added to DNS). If a service needs to wait another service to become ready before the startup, [other solutions](https://docs.docker.com/compose/startup-order/) should be used. + +
    + +
    + +### Exercises 12.17. - 12.19. + +#### Exercise 12.17: Set up an Nginx reverse proxy server in front of todo-frontend + +We are going to put the Nginx server in front of both todo-frontend and todo-backend. Let's start by creating a new docker-compose file todo-app/docker-compose.dev.yml and todo-app/nginx.dev.conf. + +```bash +todo-app +├── todo-frontend +├── todo-backend +├── nginx.dev.conf // highlight-line +└── docker-compose.dev.yml // highlight-line +``` + +Add the services Nginx and the todo-frontend built with todo-app/todo-frontend/dev.Dockerfile into the todo-app/docker-compose.dev.yml. + +![connection diagram between browser, nginx, express and frontend](../../images/12/ex_12_16_nginx_front.png) + +In this and the following exercises you **do not** need to support the build option, that is, the command: + +```bash +docker compose -f docker-compose.dev.yml up --build +``` + +It is enough to build the frontend and backend at their own repositories. + +#### Exercise 12.18: Configure the Nginx server to be in front of todo-backend + +Add the service todo-backend to the docker-compose file todo-app/docker-compose.dev.yml in development mode. + +Add a new location to the nginx.dev.conf file, so that requests to _/api_ are proxied to the backend. Something like this should do the trick: + +```conf + server { + listen 80; + + # Requests starting with root (/) are handled + location / { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + + proxy_pass ... + } + + # Requests starting with /api/ are handled + location /api/ { + proxy_pass ... + } + } +``` + +The *proxy\_pass* directive has an interesting feature with a trailing slash. As we are using the path _/api_ for location but the backend application only answers in paths _/_ or _/todos_ we will want the _/api_ to be removed from the request. In other words, even though the browser will send a GET request to _/api/todos/1_ we want the Nginx to proxy the request to _/todos/1_. Do this by adding a trailing slash _/_ to the URL at the end of *proxy\_pass*. + +This is a [common issue](https://serverfault.com/questions/562756/how-to-remove-the-path-with-an-nginx-proxy-pass) + +![comments about forgetting to use the trailing slash](../../images/12/nginx_trailing_slash_stackoverflow.png) + +This illustrates what we are looking for and may be helpful if you are having trouble: + +![diagram of calling / and /api in action](../../images/12/nginx-back-vite.png) + +#### Exercise 12.19: Connect the services, todo-frontend with todo-backend + +> In this exercise, submit the entire development environment, including both Express and React applications, dev.Dockerfiles and docker-compose.dev.yml. + +Finally, it is time to put all the pieces together. Before starting, it is essential to understand where the React app is actually run. The above diagram might give the impression that React app is run in the container but it is totally wrong. + +It is just the React app source code that is in the container. When the browser hits the address http://localhost:8080 (assuming that you set up Nginx to be accessed in port 8080), the React source code gets downloaded from the container to the browser: + +![diagram showing that the react code is sent to the browser for its execution](../../images/12/nginx-setup-vite.png) + +Next, the browser starts executing the React app, and all the requests it makes to the backend should be done through the Nginx reverse proxy: + +![diagram showing requests made from the browser to /api of nginx and the proxy in action proxying the request to /todos](../../images/12/nginx-setup2.png) + +The frontend container is actually only accessed on the first request that gets the React app source code to the browser. + +Now set up your app to work as depicted in the above figure. Make sure that the todo-frontend works with todo-backend. It will require changes to the *VITE\_BACKEND\_URL* environmental variable in the frontend. + +Make sure that the development environment is now fully functional, that is: +- all features of the todo app work +- you can edit the source files and the changes take effect by reloading the app +- frontend should access the backend through Nginx, so the requests should be done to http://localhost:8080/api/todos: + +![network tab of the browser developer tools showing that the url request includes 8080/api/todos](../../images/12/todos-dev-right-2.png) + +Note that your app should work even if no [exposed port](https://docs.docker.com/network/#published-ports) are defined for the backend and frontend in the docker compose file: + +```yml +services: + app: + image: todo-front-dev + volumes: + - ./todo-frontend/:/usr/src/app + # no ports here! + + server: + image: todo-back-dev + volumes: + - ./todo-backend/:/usr/src/app + environment: + - ... + # no ports here! + + nginx: + image: nginx:1.20.1 + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + ports: + - 8080:80 # this is needed + container_name: reverse-proxy + depends_on: + - app +``` + +We just need to expose the Nginx port to the host machine since the access to the backend and frontend is proxied to the right container port by Nginx. Because Nginx, frontend and backend are defined in the same Docker compose configuration, Docker puts those to the same [Docker network](https://docs.docker.com/network/) and thanks to that, Nginx has direct access to frontend and backend containers ports. + +
    + +
    + +### Tools for Production + +Containers are fun tools to use in development, but the best use case for them is in the production environment. There are many more powerful tools than Docker Compose to run containers in production. + +Heavyweight container orchestration tools like [Kubernetes](https://kubernetes.io/) allow us to manage containers on a completely new level. These tools hide away the physical machines and allow us, the developers, to worry less about the infrastructure. + +If you are interested in learning more in-depth about containers come to the [DevOps with Docker](https://devopswithdocker.com) course and you can find more about Kubernetes in the advanced 5 credit [DevOps with Kubernetes](https://devopswithkubernetes.com) course. You should now have the skills to complete both of them! + +
    + +
    + +### Exercises 12.20.-12.22. + +#### Exercise 12.20: + +Create a production todo-app/docker-compose.yml file with all of the services, Nginx, todo-backend, todo-frontend, MongoDB and Redis. Use Dockerfiles instead of dev.Dockerfiles and make sure to start the applications in production mode. + +Please use the following structure for this exercise: + +```bash +todo-app +├── todo-frontend +├── todo-backend +├── nginx.dev.conf +├── docker-compose.dev.yml +├── nginx.conf // highlight-line +└── docker-compose.yml // highlight-line +``` + +#### Exercise 12.21: + +Create a similar containerized development environment of one of your own full stack apps that you have created during the course or in your free time. You should structure the app in your submission repository as follows: + +```bash +└── my-app + ├── frontend + | └── dev.Dockerfile + ├── backend + | └── dev.Dockerfile + ├── nginx.dev.conf + └── docker-compose.dev.yml +``` + +#### Exercise 12.22: + +Finish this part by creating a containerized production setup of your own full stack app. +Structure the app in your submission repository as follows: + +```bash +└── my-app + ├── frontend + | ├── dev.Dockerfile + | └── Dockerfile + ├── backend + | └── dev.Dockerfile + | └── Dockerfile + ├── nginx.dev.conf + ├── nginx.conf + ├── docker-compose.dev.yml + └── docker-compose.yml +``` + +### Submitting exercises and getting the credits + +This was the last exercise in this section. It's time to push your code to GitHub and mark all of your finished exercises to the [exercise submission system](https://studies.cs.helsinki.fi/stats/courses/fs-containers). + +Exercises of this part are submitted just like in the previous parts, but unlike parts 0 to 7, the submission goes to an own [course instance](https://studies.cs.helsinki.fi/stats/courses/fs-containers). Remember that you have to finish all the exercises to pass this part! + +Once you have completed the exercises and want to get the credits, let us know through the exercise submission system that you have completed the course: + +![Submissions](../../images/11/21.png) + +**Note** that you need a registration to the corresponding course part for getting the credits registered, see [here](/en/part0/general_info#parts-and-completion) for more information. + +You can download the certificate for completing this part by clicking one of the flag icons. The flag icon corresponds to the certificate's language. + +
    + + diff --git a/src/content/12/es/part12.md b/src/content/12/es/part12.md new file mode 100644 index 00000000000..bc5cd7295e3 --- /dev/null +++ b/src/content/12/es/part12.md @@ -0,0 +1,20 @@ +--- +mainImage: ../../../images/part-12.svg +part: 12 +lang: es +--- + +
    + +En esta parte, aprenderemos como empaquetar el código en unidades de software estandarizadas llamadas contenedores. Estos contenedores pueden ayudarnos a desarrollar software más rápido y fácil que antes. Durante el camino exploraremos un punto de vista completamente nuevo del desarrollo web, alejado de los ya familiares Node.js y React. + +Utilizaremos contenedores para crear entornos de ejecución inmutables para nuestros proyectos de Node.js y React. Los contenedores también simplifican la inclusión de múltiples servicios en nuestros proyectos. Con su flexibilidad, exploraremos y experimentaremos con muchas herramientas populares al utilizar los contenedores. + +Esta sección ha sido creada por [Jami Kousa](https://github.com/jakousa) en colaboración con el equipo Services Fundation de Unity radicado en Helsinki. El equipo Services Fundation trabaja como proveedor de plataformas para el resto de los equipos de Unity en su misión de construir excelentes servicios para sus clientes. El equipo está enfocado en mejorar la experiencia de los desarrolladores de Unity y trabaja en herramientas como el Unity Dashboard, el Unity Editor y [Unity.com](https://unity.com/). + +Parte actualizada el 21 de Marzo de 2024 +- Create react app reemplazado con Vite + +**Note:** Si comenzaste esta parte antes de la actualización, puedes ver el viejo material [aquí](https://github.com/fullstack-hy2020/fullstack-hy2020.github.io/tree/4015af9dddb61cb01f013456d8728e8f553be347/src/content/12). Hay algunos cambios en las configuraciones del frontend. + +
    diff --git a/src/content/12/es/part12a.md b/src/content/12/es/part12a.md new file mode 100644 index 00000000000..65ed1f9454a --- /dev/null +++ b/src/content/12/es/part12a.md @@ -0,0 +1,423 @@ +--- +mainImage: ../../../images/part-12.svg +part: 12 +letter: a +lang: es +--- + +
    + +Esta parte fue actualizada el 21 de Marzo de 2024: Create react app reemplazado con Vite en el frontend de todo. + +Si comenzaste esta parte antes de la actualización, puedes ver el viejo material [aquí](https://github.com/fullstack-hy2020/fullstack-hy2020.github.io/tree/4015af9dddb61cb01f013456d8728e8f553be347/src/content/12). Hay algunos cambios en las configuraciones del frontend. +
    + +
    + +El desarrollo de software incluye un amplio ciclo, desde imaginar el software hasta la programación y el lanzamiento al usuario final, e incluso su mantenimiento. Esta parte será una introducción a los contenedores, una herramienta moderna utilizada en las partes finales del ciclo de desarrollo de software. + +Los contenedores encapsulan tu aplicación en un solo paquete. Este paquete incluirá a la aplicación y a todas sus dependencias. Como resultado, cada contenedor puede correr aislado de otros contenedores. + +Los contenedores previenen que la aplicación pueda acceder a los archivos y recursos del dispositivo. Los desarrolladores pueden establecer permisos a las aplicaciones para que accedan a los archivos y también especificar recursos disponibles. Más precisamente, los contenedores son virtualizaciones a nivel de Sistema Operativo ( OS-level virtualization ). La comparación más cercana es con una máquina virtual (VM). VMs son utilizadas para ejecutar múltiples sistemas operativos en una misma máquina física. Ellas tienen que ejecutar todo el sistema operativo, mientras que los contenedores ejecutan el software utilizando el sistema operativo del host. La diferencia resultante entre las máquinas virtuales y los contenedores es que se consumen menos recursos cuando se ejecutan contenedores; solo necesitan ejecutar un solo proceso. + +Como los contenedores son relativamente ligeros, al menos comparados con las máquinas virtuales, estos pueden escalar rápidamente. Y como aíslan el software que ejecutan dentro, permiten que el software se ejecute de manera idéntica en cualquier ambiente. Por ello, son la opción preferida en cualquier entorno basado en la nube o aplicación con más de un puñado de usuarios. + +Servicios como AWS, Google Cloud y Microsoft Azure soportan contenedores en diferentes formas. Estos incluyen a AWS Fargate y Google Cloud Run, ambos permiten ejecutar los contenedores sin servidor (serverless) - donde el contenedor de la aplicación ni siquiera necesita estar ejecutándose si no es utilizado. También puedes instalar un entorno de ejecución de contenedores en la mayoría de los ordenadores y ejecutarlos tú mismo- incluyendo tu propia máquina. + +Por lo que los contenedores son utilizados en ambientes en la nube e incluso durante el desarrollo. Cuáles son los beneficios de utilizar contenedores? He aquí dos escenarios comunes: + +Escenario 1: Estás desarrollando una aplicación nueva que necesita ejecutarse en la misma máquina que una aplicación antigua (legacy). Ambas requieren instalar diferentes versiones de Node. + +Probablemente puedas utilizar nvm, máquinas virtuales o magia negra para lograr ejecutarlas al mismo tiempo. Sin embargo los contenedores son una excelente solución ya que puedes ejecutar ambas aplicaciones en sus respectivos contenedores. Ellas están aisladas una de otra y no interfieren. + +Escenario 2: Tu aplicación se ejecuta en tu ordenador. Necesitas mover la aplicación a un servidor + +No es poco común que la aplicación simplemente no se ejecute en el servidor a pesar de estar trabajando bien en tu ordenador. Esto puede ocurrir debido a algunas dependencias faltantes o otras diferencias en los entornos. Aquí los contenedores son una excelente solución ya que puedes ejecutar tu aplicación en el mismo ambiente tanto en tu ordenador como en el servidor. No es perfecto: las diferencias en el hardware pueden provocar incidentes, pero puedes limitar estas diferencias entre los ambientes. + +Alguna vez podrás escuchar sobre el problema "Works in my container" (Funciona en mi contenedor). La frase describe la situación en la que la aplicación funciona bien en un contenedor ejecutándose en tu ordenador pero se rompe cuando el contenedor es iniciado en el servidor. Esta frase es una variante del infame problema "Works on my machine" (Funciona en mi maquina), que con frecuencia los contenedores resuelven. La situación es también con mucha certeza, un error de uso. + +### Sobre esta parte ### + +En esta parte, el foco de nuestra atención no estará en el código JavaScript. En cambio, nos interesa la configuración del entorno en el que se ejecuta el software. Como resultado, es posible que los ejercicios no contengan nada de código, las aplicaciones están disponibles a través de GitHub y sus tareas incluirán configurarlas. Los ejercicios deben enviarse a un solo repositorio de GitHub que incluirá todo el código fuente y las configuraciones que realizes durante esta parte. + +Necesitarás conocimientos básicos de Node, Express y React. Solo las partes principales, 1 a 5, deben ser completadas antes de esta parte. + +
    + +
    + +### Ejercicio 12.1 +### Advertencia + +Dado que estamos saliendo de nuestra zona de confort como desarrolladores de JavaScript, esta parte puede requerir que tomes un desvío para familiarizarte con shell / línea de comandos/ intérprete de comandos / terminal antes de comenzar. + +Si solo has utilizado una interfaz gráfica de usuario y nunca has tocado, por ejemplo, Linux o el terminal en Mac, o si te quedas atascado en los primeros ejercicios, te recomendamos hacer primero la Parte 1 de "Herramientas informáticas para estudios de CS": . Omite la sección "Conexión SSH" y el Ejercicio 11. ¡Esto incluye todo lo que necesitaras para comenzar aquí! + +#### Ejercicio 12.1: Usando una computadora (sin la interfaz gráfica de usuario) + +Paso 1: Lee el texto debajo del titulo "Advertencia". + +Paso 2: Descarga este [repositorio](https://github.com/fullstack-hy2020/part12-containers-applications) y conviértelo en tu repositorio de envío para esta parte del curso. + +Paso 3: Ejecuta curl http://helsinki.fi y guarda el resultado en un archivo. Guarda el archivo en tu repositorio con el nombre script-answers/exercise12_1.txt. El directorio script-answers ha sido creado en el paso anterior. + +
    +
    + +### Enviar los ejercicios y recibir los créditos ### + +Envía los ejercicios utilizando el [sistema de envío](https://studies.cs.helsinki.fi/stats/) igual que en las partes anteriores. Los ejercicios de esta parte son enviados a su [propia instancia del curso](https://studies.cs.helsinki.fi/stats/courses/fs-containers). + +Completar esta parte supondrá la obtención de 1 crédito. Ten en cuenta que debes realizar todos los ejercicios para obtener el crédito o el certificado. + +Cuando completes los ejercicios y desees obtener los créditos, déjanoslo saber a través del sistema de envío de ejercicios que has completado el curso: + +![Enviando los ejercicios para obtener los créditos](../../images/10/23.png) + +Puedes descargar el certificado de finalización de esta parte dando click en uno de los íconos de las banderas. Cada bandera corresponde con el idioma del certificado. + +### Herramientas del oficio + +Las herramientas básicas que necesitaras varían de acuerdo a los sistemas operativos: + +* [Terminal WSL 2](https://docs.microsoft.com/en-us/windows/wsl/install-win10) en Windows +* Terminal en Mac +* Command Line en Linux + +### Instalando todo lo necesario para esta parte + +Comenzaremos instalando el software necesario. El paso de instalación será uno de los posibles obstáculos. Como estamos tratando con la virtualización a nivel del sistema operativo, las herramientas requerirán acceso de superusuario en el ordenador. Tendrán acceso al kernel de tu sistema operativo. + +Este material está basado en [Docker](https://www.docker.com/), un conjunto de productos que utilizaremos para la contenedorización y la administración de los contenedores. Desafortunadamente si no puedes instalar Docker probablemente no podrás completar esta parte. + +Como las instrucciones de instalación dependen de tu sistema operativo, deberás encontrar las instrucciones de instalación correctas en el siguiente enlace. Ten en cuenta que pueden haber múltiples opciones diferentes para tu sistema operativo. + +- [Obtén Docker](https://docs.docker.com/get-docker/) + +Ahora que esperamos que ese dolor de cabeza haya terminado, asegurémonos de que nuestras versiones coincidan. La tuya puede tener números un poco más altos que esta: + +```bash +$ docker -v +Docker version 25.0.3, build 4debf41 +``` + +### Contenedores e imágenes + +Hay dos conceptos básicos en esta parte: contenedores e imágenes. Los cuales son fáciles de confundir entre sí: + +Un contenedor es una instancia en tiempo de ejecución de una imagen. + +Las dos afirmaciones siguientes son verdaderas: + +- Las imágenes incluyen todo el código, dependencias e instrucciones sobre cómo ejecutar la aplicación +- Los contenedores empaquetan software en unidades estandarizadas + +No es de extrañar que se confundan fácilmente. + +Para ayudar con la confusión, casi todos usan la palabra contenedor para describir ambos. Pero en realidad nunca se puede construir un contenedor o descargar uno, ya que los contenedores solo existen durante el tiempo de ejecución. Las imágenes, por otro lado, son archivos **inmutables**. Como resultado de la inmutabilidad, no puedes editar una imagen después de haberla creado. Sin embargo, puedes usar imágenes existentes para crear una nueva imagen agregando nuevas capas encima de las existentes. + +Metáfora de la cocina: + +* La imagen es un manjar pre-cocinado y congelado. +* El contenedor es el delicioso manjar. + +[Docker](https://www.docker.com/) es la tecnología de contenedorización más popular y fue pionera en los estándares que la mayoría de las tecnologías de contenedorización utilizan en la actualidad. En la práctica, Docker es un conjunto de productos que nos ayudan a gestionar imágenes y contenedores. Este conjunto de productos nos permitirá aprovechar todos los beneficios de los contenedores. Por ejemplo, el motor de Docker se encargará de convertir los archivos inmutables llamados imágenes en contenedores. + +Para administrar los contenedores Docker, también existe una herramienta llamada [Docker Compose](https://docs.docker.com/compose/) que permite **orquestar** (controlar) varios contenedores al mismo tiempo. En esta parte, utilizaremos Docker Compose para configurar un entorno de desarrollo local complejo. En la versión final del entorno de desarrollo que configuraremos, incluso instalar Node en nuestra máquina ya no sera un requisito. + +Hay varios conceptos que necesitamos repasar. ¡Pero los omitiremos por ahora y aprenderemos sobre Docker primero! + +Comencemos con el comando docker container run que se usa para ejecutar imágenes dentro de un contenedor. La estructura del comando es la siguiente: _container run IMAGE-NAME_ le indicaremos a Docker que cree un contenedor a partir de una imagen. Una característica particularmente interesante del comando es que puede ejecutar un contenedor incluso si la imagen para ejecutar aún no se ha descargado en nuestro dispositivo. + +Ejecutemos el comando + +```bash +§ docker container run hello-world +``` + +Habrá muchos resultados, pero separemoslos en varias secciones, que podemos descifrar juntos. Las líneas están numeradas para que sea más fácil seguir la explicación. Tu output no tendrá los números. + +```bash +1. Unable to find image 'hello-world:latest' locally +2. latest: Pulling from library/hello-world +3. b8dfde127a29: Pull complete +4. Digest: sha256:5122f6204b6a3596e048758cabba3c46b1c937a46b5be6225b835d091b90e46c +5. Status: Downloaded newer image for hello-world:latest +``` + +Debido a que la imagen hello-world no se encontró en nuestra máquina, el comando primero la descargó de un registro gratuito llamado [Docker Hub](https://hub.docker.com/). Puedes ver la página de Docker Hub de la imagen con tu navegador aquí: [https://hub.docker.com/_/hello-world](https://hub.docker.com/_/hello-world) + +La primera parte del mensaje indica que aún no teníamos la imagen "hello-world:latest". Esto revela un poco de detalle sobre las imágenes mismas; los nombres de las imágenes constan de varias partes, como una URL. El nombre de una imagen tiene el siguiente formato: + +- _registry/organisation/image:tag_ + +En este caso, los 3 campos que faltan resuelven por defecto a: +- _index.docker.io/library/hello-world:latest_ + +La segunda fila muestra el nombre de la organización, "librería" donde obtendrá la imagen. En la URL de Docker Hub, la "librería" se acorta a _. + +Las filas 3 y 5 solo muestran el estado. Pero la cuarta fila puede ser interesante: cada imagen tiene un resumen único basado en las capas a partir de las cuales se construye la imagen. En la práctica, cada paso o comando que se usó para construir la imagen crea una capa única. Docker usa el resumen para identificar que una imagen es la misma. Esto se hace cuando intenta extraer la misma imagen nuevamente. + +Entonces, el resultado de usar el comando fue extraer y luego generar información sobre la **imagen**. Después de eso, el estado nos dijo que se descargó una nueva versión de hello-world:latest. Puedes intentar extraer la imagen con _docker image pull hello-world_ y ver qué sucede. + +El siguiente output fue del propio contenedor. También explica lo que sucedió cuando ejecutamos _docker container run hello-world_. + +```bash +Hello from Docker! +This message shows that your installation appears to be working correctly. + +To generate this message, Docker took the following steps: + 1. The Docker client contacted the Docker daemon. + 2. The Docker daemon pulled the "hello-world" image from the Docker Hub. + (amd64) + 3. The Docker daemon created a new container from that image which runs the + executable that produces the output you are currently reading. + 4. The Docker daemon streamed that output to the Docker client, which sent it + to your terminal. + +To try something more ambitious, you can run an Ubuntu container with: + $ docker container run -it ubuntu bash + +Share images, automate workflows, and more with a free Docker ID: + https://hub.docker.com/ + +For more examples and ideas, visit: + https://docs.docker.com/get-started/ +``` + +El output contiene algunas cosas nuevas para que aprendamos. Docker daemon es un servicio en segundo plano que se asegura de que los contenedores se estén ejecutando, y usamos el Docker client para interactuar con el daemon. Ahora hemos interactuado con la primera imagen y hemos creado un contenedor a partir de la imagen. Durante la ejecución de ese contenedor, recibimos el output. + +
    + +
    + +### Ejercicio 12.2 + +Algunos de estos ejercicios no requieren que escribas ningún código o configuración en un archivo. +En estos ejercicios, debes usar el comando [script](https://man7.org/linux/man-pages/man1/script.1.html) para registrar los comandos que has usado; pruébalo con _script_ para comenzar a grabar, _echo "hello"_ para generar algún output y _exit_ para detener la grabación. Guarda tus acciones en un archivo llamado "typescript" (que no tiene nada que ver con lenguaje de programación TypeScript, el nombre es solo una coincidencia). + +Si _script_ no funciona, puedes simplemente copiar y pegar todos los comandos que utilizaste en un archivo de texto. + +#### Ejercicio 12.2: Ejecutando tu segundo contenedor + +> Usa _script_ para registrar lo que haces, guarda el archivo en script-answers/exercise12_2.txt + +El resultado de hello-world nos dio una tarea ambiciosa que hacer. Haz lo siguiente: + +> Paso 1. Ejecuta un contenedor de Ubuntu con el comando proporcionado por hello-world + +El paso 1 conectará directamente al contenedor con bash. Tendrás acceso a todos los archivos y herramientas dentro del contenedor. Los siguientes pasos se ejecutan dentro del contenedor: + +- Paso 2. Crea el directorio /usr/src/app + +- Paso 3. Crea el archivo /usr/src/app/index.js + +- Paso 4. Ejecuta exit para salir del contenedor + +Google debería poder ayudarte a crear directorios y archivos. + +
    + +
    + +### Imagen de Ubuntu + +El comando que acabas de usar para ejecutar el contenedor de Ubuntu, _docker container run -it ubuntu bash_, contiene algunas adiciones al hello-world ejecutado anteriormente. Veamos --help para entenderlo mejor. Cortaré parte del output para que podamos centrarnos en las partes relevantes. + +```bash +$ docker container run --help + +Usage: docker container run [OPTIONS] IMAGE [COMMAND] [ARG...] +Run a command in a new container + +Options: + ... + -i, --interactive Keep STDIN open even if not attached + -t, --tty Allocate a pseudo-TTY + ... +``` + +Las dos opciones, o banderas, _-it_ aseguran que podamos interactuar con el contenedor. Después de las opciones, definimos que la imagen a ejecutar es _ubuntu_. Luego tenemos el comando _bash_ que se ejecutará dentro del contenedor cuando lo iniciemos. + +Puedes probar otros comandos que la imagen de Ubuntu podría ejecutar. Como ejemplo, prueba _docker container run --rm ubuntu ls_. El comando _ls_ enumerará todos los archivos en el directorio y la bandera _--rm_ eliminará el contenedor después de la ejecución. Normalmente, los contenedores no se eliminan automáticamente. + +Continuemos con nuestro primer contenedor de Ubuntu con el archivo **index.js** dentro. El contenedor ha dejado de ejecutarse desde que salimos de él. Podemos enumerar todos los contenedores con _container ls -a_,la _-a_ (o --all) enumerará los contenedores que ya se han cerrado. + +```bash +$ docker container ls -a +CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES +b8548b9faec3 ubuntu "bash" 3 minutes ago Exited (0) 6 seconds ago hopeful_clarke +``` + +> Nota del editor: el comando _docker container ls_ también tiene una forma más corta _docker ps_, yo prefiero la que es más corta. + +Tenemos dos opciones a la hora de abordar un contenedor. El identificador de la primera columna se puede utilizar para interactuar con el contenedor casi siempre. Además, la mayoría de los comandos aceptan el nombre del contenedor como un método más amigable para trabajar con ellos. El nombre del contenedor se generó automáticamente para ser **"hopeful_clarke"** en mi caso. + +El contenedor ya termino de ejecutarse, pero podemos iniciarlo de nuevo con el comando de inicio que aceptará el id o el nombre del contenedor como parámetro: _start CONTAINER-ID-OR-CONTAINER-NAME_. + +```bash +$ docker start hopeful_clarke +hopeful_clarke +``` + +El comando iniciará el mismo contenedor que teníamos anteriormente. Desafortunadamente, olvidamos iniciarlo con la bandera _--interactive_ (que también puede escribirse como _-i_) por lo que no podemos interactuar con él. + +El contenedor está realmente en funcionamiento como muestra el comando _container ls -a_, pero simplemente no podemos comunicarnos con el: + +```bash +$ docker container ls -a +CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES +b8548b9faec3 ubuntu "bash" 7 minutes ago Up (0) 15 seconds ago hopeful_clarke +``` + +Ten en cuenta que también podemos ejecutar el comando sin la bandera _-a_ para ver solo los contenedores que se están ejecutando: + +```bash +$ docker container ls +CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES +8f5abc55242a ubuntu "bash" 8 minutes ago Up 1 minutes hopeful_clarke +``` + +Matémoslo con el comando _kill CONTAINER-ID-OR-CONTAINER-NAME_ e intentemos nuevamente. + +```bash +$ docker kill hopeful_clarke +hopeful_clarke +``` + +_docker kill_ envía una [señal SIGKILL](https://man7.org/linux/man-pages/man7/signal.7.html) al proceso forzándolo a salir, y eso hace que el contenedor se detenga. Podemos verificar su estado con _container ls -a_: + +```bash +$ docker container ls -a +CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES +b8548b9faec3 ubuntu "bash" 26 minutes ago Exited 2 seconds ago hopeful_clarke +``` + +Ahora iniciemos el contenedor de nuevo, pero esta vez en modo interactivo: + +```bash +$ docker start -i hopeful_clarke +root@b8548b9faec3:/# +``` + +Editemos el archivo index.js y agreguemos código JavaScript para ejecutar. Solo nos faltan las herramientas para editar el archivo. Nano será un buen editor de texto por ahora. Las instrucciones de instalación se encontraron en el primer resultado de Google. Omitiremos usar sudo ya que ya somos root. + +```bash +root@b8548b9faec3:/# apt-get update +root@b8548b9faec3:/# apt-get -y install nano +root@b8548b9faec3:/# nano /usr/src/app/index.js +``` + +¡Ahora tenemos nano instalado y podemos comenzar a editar archivos! + +
    + +
    + +### Ejercicio 12.3 - 12.4 + +#### Ejercicio 12.3: Ubuntu 101 + +> Utiliza _script_ para registrar lo que haces, guarda el archivo en script-answers/exercise12_3.txt + +Edita el archivo _/usr/src/app/index.js_ dentro del contenedor con Nano y agrega la siguiente línea + +```js +console.log('Hello World') +``` + +Si no estás familiarizado con Nano, puedes pedir ayuda en el chat o en Google. + +#### Ejercicio 12.4: Ubuntu 102 + +> Utiliza _script_ para registrar lo que haces, guarda el archivo en script-answers/exercise12_4.txt + +Instala Node mientras estás dentro del contenedor y ejecuta el archivo index con _node /usr/src/app/index.js_ en el contenedor. + +Las instrucciones para instalar Node a veces son difíciles de encontrar, así que aquí hay algo que puedes copiar y pegar: + +```bash +curl -sL https://deb.nodesource.com/setup_20.x | bash +apt install -y nodejs +``` + +Deberás instalar _curl_ en el contenedor. Se instala de la misma manera que lo hiciste con _nano_. + +Después de la instalación, asegúrate de que puedes ejecutar tu código dentro del contenedor con el comando: + +``` +root@b8548b9faec3:/# node /usr/src/app/index.js +Hello World +``` + +
    + +
    + +### Otros comandos de Docker + +¡Ahora que tenemos Node instalado, podemos ejecutar JavaScript en el contenedor! Vamos a crear una nueva imagen desde el contenedor. El comando + +```bash +commit CONTAINER-ID-OR-CONTAINER-NAME NEW-IMAGE-NAME +``` + +creará una nueva imagen que incluye los cambios que hemos realizado. Puedes usar _container diff_ para verificar los cambios entre la imagen original y el contenedor antes de hacerlo. + +```bash +$ docker commit hopeful_clarke hello-node-world +``` + +Puedes enumerar tus imágenes con _image ls_: + +```bash +$ docker image ls +REPOSITORY TAG IMAGE ID CREATED SIZE +hello-node-world latest eef776183732 9 minutes ago 252MB +ubuntu latest 1318b700e415 2 weeks ago 72.8MB +hello-world latest d1165f221234 5 months ago 13.3kB +``` + +Ahora puedes ejecutar la nueva imagen de la siguiente manera: + +```bash +docker run -it hello-node-world bash +root@4d1b322e1aff:/# node /usr/src/app/index.js +``` + +Hay varias formas de hacer lo mismo. Probemos una mejor solución. Limpiaremos la pizarra con _container rm_ para retirar el contenedor antiguo. + +```bash +$ docker container ls -a +CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES +b8548b9faec3 ubuntu "bash" 31 minutes ago Exited (0) 9 seconds ago hopeful_clarke + +$ docker container rm hopeful_clarke +hopeful_clarke +``` + +Crea un archivo index.js en tu directorio actual y escribe _console.log('Hello, World')_ dentro de él. No hay necesidad de contenedores todavía. + +A continuación, nos saltaremos la instalación manual de Node por completo. Hay muchas imágenes útiles de Docker en Docker Hub, listas para nuestro uso. Usemos la imagen [https://hub.docker.com/_/Node](https://hub.docker.com/_/Node), que ya tiene Node instalado. Sólo tenemos que elegir una versión. + +Por cierto, el _container run_ acepta el indicador _--name_ que podemos usar para dar un nombre al contenedor. + +```bash +$ docker container run -it --name hello-node node:20 bash +``` + +Vamos a crear un directorio para el código dentro del contenedor: + +``` +root@77d1023af893:/# mkdir /usr/src/app +``` + +Mientras estamos dentro del contenedor en este terminal, abre otro terminal y usa el comando _container cp_ para copiar el archivo desde tu propia máquina al contenedor. + +```bash +$ docker container cp ./index.js hello-node:/usr/src/app/index.js +``` + +Y ahora podemos ejecutar _node /usr/src/app/index.js_ en el contenedor. Podemos guardar esto como otra imagen nueva, pero hay una solución aún mejor. La siguiente sección tratará sobre la construcción de tus imágenes como un profesional. + +
    diff --git a/src/content/12/es/part12b.md b/src/content/12/es/part12b.md new file mode 100644 index 00000000000..faa6ea1b0e8 --- /dev/null +++ b/src/content/12/es/part12b.md @@ -0,0 +1,914 @@ +--- +mainImage: ../../../images/part-12.svg +part: 12 +letter: b +lang: es +--- + +
    + +Esta parte fue actualizada el 21 de Marzo de 2024: Create react app reemplazado con Vite en el frontend de todo. + +Si comenzaste esta parte antes de la actualización, puedes ver el viejo material [aquí](https://github.com/fullstack-hy2020/fullstack-hy2020.github.io/tree/4015af9dddb61cb01f013456d8728e8f553be347/src/content/12). Hay algunos cambios en las configuraciones del frontend. +
    + +
    + +En la sección anterior, usamos dos imágenes base diferentes: ubuntu y node, e hicimos un trabajo manual para ejecutar un simple "¡Hola, mundo!". Las herramientas y los comandos que aprendimos durante ese proceso nos serán útiles más adelante. En esta sección, aprenderemos a crear imágenes y configurar entornos para nuestras aplicaciones. Comenzaremos con un backend regular de Express/Node.js y lo desarrollaremos con otros servicios, incluida una base de datos MongoDB. + +### Dockerfile + +En lugar de modificar un contenedor copiando archivos dentro, podemos crear una nueva imagen que contenga la aplicación "¡Hola, mundo!". La herramienta para esto es el Dockerfile. Dockerfile es un archivo de texto simple que contiene todas las instrucciones para crear una imagen. Vamos a crear un Dockerfile de ejemplo a partir de la aplicación "Hello, World!". + +Si aún no lo hiciste, crea un directorio en tu máquina y crea un archivo llamado Dockerfile dentro de ese directorio. También coloquemos un index.js que contenga _console.log('Hello, World!')_ al lado del Dockerfile. La estructura de su directorio debería verse así: + +``` +├── index.js +└── Dockerfile +``` + +Dentro de ese Dockerfile le diremos a la imagen tres cosas: + +- Usa [node:20](https://hub.docker.com/_/node) como base para nuestra imagen. +- Incluye al archivo index.js dentro de la imagen, así no necesitaremos copiarlo manualmente en el contenedor +- Cuando ejecutemos el contenedor desde la imagen, usa node para ejecutar el archivo index.js. + +Las instrucciones anteriores se traducirán en un Dockerfile básico. La mejor ubicación para colocar este archivo suele ser la raíz del proyecto. + +El archivo Dockerfile resultante se ve así: + +```Dockerfile +FROM node:20 + +WORKDIR /usr/src/app + +COPY ./index.js ./index.js + +CMD node index.js +``` + +La instrucción FROM le dirá a Docker que la base de la imagen debe ser node:20. La instrucción COPY copiará el archivo index.js de la máquina host al archivo con el mismo nombre en la imagen. La instrucción CMD dice lo que sucede cuando se usa _docker run_. CMD es el comando predeterminado que luego se puede sobrescribir con el argumento dado después del nombre de la imagen. Consulta _docker run --help_ si lo has olvidado. + +La instrucción WORKDIR se introdujo para garantizar que no interfiramos con el contenido de la imagen. Garantizará que todos los siguientes comandos tendrán /usr/src/app configurado como el directorio de trabajo. Si el directorio no existe en la imagen base, se creará automáticamente. + +Si no especificamos un WORKDIR, corremos el riesgo de sobrescribir archivos importantes por accidente. Si verificas la raíz (_/_) de la imagen node:20 con _docker run node:20 ls_, puedes notar todos los directorios y archivos que ya están incluidos en la imagen. + +Ahora podemos usar el comando _docker build_ para construir una imagen basada en el Dockerfile. Vamos a modificar el comando con una bandera adicional: _-t_, esto nos ayudará a nombrar la imagen: + +```bash +$ docker build -t fs-hello-world . +[+] Building 3.9s (8/8) FINISHED +... +``` + +Entonces, el resultado es "Docker, por favor construye con la etiqueta (puedes pensar que la etiqueta es el nombre de la imagen resultante.) fs-hello-world el Dockerfile en este directorio". Puedes apuntar a cualquier Dockerfile, pero en nuestro caso, un simple punto significará el Dockerfile en este directorio. Es por eso que el comando termina con un punto. Una vez finalizado el build, puedes ejecutarlo con _docker run fs-hello-world_. + +```bash +$ docker run fs-hello-world +Hello, World +``` + +Como las imágenes son solo archivos, se pueden mover, descargar y eliminar. Puedes enumerar las imágenes que tienes localmente con _docker image ls_, eliminarlas con _docker image rm_. Ve qué otro comando tienes disponible con _docker image --help_. + +Una cosa más: antes se mencionó que el comando predeterminado, definido por CMD en el Dockerfile, puede ser sobre-escrito si fuera necesario. Podríamos, por ejemplo, abrir una sesión de bash en el contenedor y observar su contenido: + +```bash +$ docker run -it fs-hello-world bash +root@2932e32dbc09:/usr/src/app# ls +index.js +root@2932e32dbc09:/usr/src/app# +``` + +### Imagen más significativa + +Mover un servidor Express a un contenedor debería ser tan simple como mover la aplicación "¡Hola, mundo!" dentro de un contenedor. La única diferencia es que hay más archivos. Afortunadamente, la instrucción _COPY_ puede manejar todo eso. Eliminemos index.js y creemos un nuevo servidor Express. Usemos [express-generator](https://expressjs.com/en/starter/generator.html) para crear el esqueleto básico de una aplicación Express. + +```bash +$ npx express-generator + ... + + install dependencies: + $ npm install + + run the app: + $ DEBUG=playground:* npm start +``` + +Primero, ejecutemos la aplicación para tener una idea de lo que acabamos de crear. Ten en cuenta que el comando para ejecutar la aplicación puede ser diferente al tuyo, mi directorio se llama playground. + +```bash +$ npm install +$ DEBUG=playground:* npm start + playground:server Listening on port 3000 +0ms +``` + +Genial, ahora podemos navegar a [http://localhost:3000](http://localhost:3000) y la aplicación se está ejecutando allí. + +Poner la aplicación en un contenedor debería ser relativamente fácil según el ejemplo anterior. + +- Usar node como base +- Establecer el directorio de trabajo para que no interfiramos con el contenido de la imagen base +- Copia TODOS los archivos en este directorio a la imagen +- Ejecútala con DEBUG=playground:* npm start + +Coloquemos el siguiente Dockerfile en la raíz del proyecto: + +```Dockerfile +FROM node:20 + +WORKDIR /usr/src/app + +COPY . . + +CMD DEBUG=playground:* npm start +``` + +Construiremos la imagen desde Dockerfile y luego la ejecutaremos: + +```bash +docker build -t express-server . +docker run -p 3123:3000 express-server +``` + +La bandera _-p_ informará a Docker de que se debe abrir un puerto de la máquina host y dirigirlo a un puerto en el contenedor. El formato es _-p host-port:application-port_. + +¡La aplicación ya se está ejecutando! Probémoslo enviando una solicitud GET a [http://localhost:3123/](http://localhost:3123/). + +> Si el tuyo no funciona, pasa a la siguiente sección. Hay una explicación de por qué puede que no funcione incluso si has seguido los pasos correctamente. + +Cerrarlo es un dolor de cabeza en este momento. Usa otro terminal y el comando _docker kill_ para cerrar la aplicación. El _docker kill_ enviará una señal de eliminación (SIGKILL) a la aplicación para obligarla a cerrarse. Necesitas el nombre o id del contenedor como argumento. + +Por cierto, cuando se usa id como argumento, el comienzo de la ID es suficiente para que Docker sepa a qué contenedor nos referimos. + +```bash +$ docker container ls + CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES + 48096ca3ffec express-server "docker-entrypoint.s…" 9 seconds ago Up 6 seconds 0.0.0.0:3123->3000/tcp, :::3123->3000/tcp infallible_booth + +$ docker kill 48 + 48 +``` + +En el futuro, usemos el mismo puerto en ambos lados de _-p_. Solo para que no tengamos que recordar cuál elegimos. + +#### Solucionar problemas potenciales que creamos al copiar y pegar + +Hay algunos cambios que debemos realizar para crear un Dockerfile más completo. Incluso puede ser que el ejemplo anterior no funcione en todos los casos porque nos saltamos un paso importante. + +Cuando ejecutamos npm install en nuestra máquina, en algunos casos el **administrador de paquetes de node (npm)** puede instalar dependencias específicas del sistema operativo durante el paso de instalación. Es posible que accidentalmente movamos partes no funcionales a la imagen con la instrucción COPY. Esto puede suceder fácilmente si copiamos el directorio node_modules en la imagen. + +Esto es algo crítico a tener en cuenta cuando construimos nuestras imágenes. Es mejor hacer la mayoría de las cosas, como ejecutar _npm install_ durante el proceso de compilación dentro del contenedor en lugar de hacerlo antes de compilar. La regla general es copiar solo los archivos que enviarías a GitHub. Los artefactos o las dependencias de compilación no se deben copiar, ya que se pueden instalar durante el proceso de compilación. + +Podemos usar .dockerignore para resolver el problema. El archivo .dockerignore es muy similar a .gitignore, puedes usarlo para evitar que se copien archivos no deseados en tu imagen. El archivo debe colocarse junto al Dockerfile. Aquí hay un posible contenido de un .dockerignore + +``` +.dockerignore +.gitignore +node_modules +Dockerfile +``` + +Sin embargo, en nuestro caso, .dockerignore no es lo único que se requiere. Tendremos que instalar las dependencias durante el paso de compilación. El _Dockerfile_ cambia a: + +```Dockerfile +FROM node:20 + +WORKDIR /usr/src/app + +COPY . . + +RUN npm install # highlight-line + +CMD DEBUG=playground:* npm start +``` + +La instalación de npm puede ser riesgosa. En lugar de usar npm install, npm ofrece una herramienta mucho mejor para instalar dependencias, el comando _ci_. + +Diferencias entre ci e install: + +- install puede actualizar el package-lock.json +- install puede instalar una versión diferente de una dependencia si tiene ^ o ~ en la versión de la dependencia. + +- ci eliminará la carpeta node_modules antes de instalar cualquier cosa +- ci seguirá el package-lock.json y no alterará ningún archivo + +En resumen: _ci_ crea compilaciones confiables, mientras que _install_ es el que se usa cuando deseas instalar nuevas dependencias. + +Como no estamos instalando nada nuevo durante el paso de compilación y no queremos que las versiones cambien repentinamente, usaremos _ci_: + +```Dockerfile +FROM node:20 + +WORKDIR /usr/src/app + +COPY . . + +RUN npm ci # highlight-line + +CMD DEBUG=playground:* npm start +``` + +Aún mejor, podemos usar _npm ci --omit=dev_ para no perder tiempo instalando dependencias de desarrollo. + +> Como habrás notado en la lista de comparación; npm ci eliminará la carpeta node_modules, por lo que no importó crear el .dockerignore. Sin embargo, .dockerignore es una herramienta increíble cuando deseas optimizar tu proceso de compilación. Hablaremos brevemente sobre estas optimizaciones más adelante. + +Ahora el Dockerfile debería funcionar de nuevo, pruébalo con _docker build -t express-server . && docker run -p 3123:3000 express-server_ + +> Ten en cuenta que estamos aquí encadenando dos comandos bash con &&. Podríamos obtener (casi) el mismo efecto ejecutando ambos comandos por separado. Al encadenar comandos con &&, si un comando falla, los siguientes de la cadena no se ejecutarán. + +Configuramos una variable de entorno _DEBUG=playground:*_ durante CMD para el inicio de npm. Sin embargo, con Dockerfiles también podríamos usar la instrucción ENV para establecer variables de entorno. Vamos a hacer eso: + +```Dockerfile +FROM node:20 + +WORKDIR /usr/src/app + +COPY . . + +RUN npm ci + +#highlight-start +ENV DEBUG=playground:* +#highlight-end + +#highlight-start +CMD npm start +#highlight-end +``` + +> Si te preguntas qué hace la variable de entorno DEBUG, lee [Debugging Express](https://expressjs.com/en/guide/debugging.html). + +#### Mejores prácticas de Dockerfile + +Hay 2 reglas generales que debes seguir al crear imágenes: + +- Intenta crear una imagen lo más **segura** posible +- Intenta crear una imagen lo más **pequeña** posible + +Las imágenes más pequeñas son más seguras al tener menos área de superficie de ataque, y también se mueven más rápido en las pipelines de despliegue. + +Snyk tiene una excelente lista de las [10 mejores prácticas para la creación de contenedores de node/express](https://snyk.io/blog/10-best-practices-to-containerize-nodejs-web-applications-with-docker/). + +Un gran descuido que debemos resolver es ejecutar la aplicación como root en lugar de usar un usuario con menos privilegios. Hagamos un ultimo arreglo al Dockerfile: + +```Dockerfile +FROM node:20 + +WORKDIR /usr/src/app + +#highlight-start +COPY --chown=node:node . . +#highlight-end + +RUN npm ci + +ENV DEBUG=playground:* + +#highlight-start +USER node +#highlight-end + +CMD npm start +``` + +
    + +
    + +### Ejercicio 12.5. + +#### Ejercicio 12.5: Contenedorización de una aplicación de Node.js + +El repositorio que clonaste o copiaste en el [primer ejercicio](/es/part12/introduccion_a_los_contenedores#ejercicio-12-1) contiene una aplicación de tareas (todo-app). Échale un vistazo a todo-app/todo-backend y lee el README. Todavía no tocaremos todo-frontend. + +- Paso 1. Pon a todo-backend en un contenedor creando un todo-app/todo-backend/Dockerfile y construyendo una imagen. + +- Paso 2. Ejecuta la imagen de todo-backend con los puertos correctos abiertos. Asegúrate de que el contador de visitas aumente cuando se usa a través de un navegador en http://localhost:3000/ (o algún otro puerto si lo configuras) + +Sugerencia: Ejecuta la aplicación fuera de un contenedor para examinarla antes de comenzar. + +
    + +
    + +### Utilizando docker-compose + +En la sección anterior, creamos un servidor Express, sabiendo que se ejecutaría en el puerto 3123, y utilizamos los comandos _docker build -t express-server . && docker run -p 3123:3000 express-server_ para ejecutarlo. Esto ya parece algo que necesitarías poner en un script para recordarlo. Afortunadamente, Docker nos ofrece una mejor solución. + +[Docker-compose](https://docs.docker.com/compose/) es otra herramienta fantástica que puede ayudarnos a administrar contenedores. Comencemos a usar docker-compose a medida que aprendamos más sobre los contenedores, ya que nos ayudará a ahorrar algo de tiempo con la configuración. + +Y ahora podemos convertir el hechizo anterior en un archivo yaml. ¡La mejor parte de los archivos yaml es que puedes guardarlos en un repositorio de Git! + +Crea el archivo **docker-compose.yml** y colócalo en la raíz del proyecto, junto al Dockerfile. Esta vez utilizaremos el mismo puerto para host y contenedor. El contenido del archivo es: + +```yaml +services: + app: # The name of the service, can be anything + image: express-server # Declares which image to use + build: . # Declares where to build if image is not found + ports: # Declares the ports to publish + - 3000:3000 +``` + +El significado de cada línea se explica como un comentario. Si deseas ver la especificación completa, consulta la [documentación](https://docs.docker.com/compose/compose-file/). + +Ahora podemos usar _docker compose up_ para compilar y ejecutar la aplicación. Si queremos reconstruir las imágenes podemos usar _docker compose up --build_. + +También puedes ejecutar la aplicación en segundo plano con _docker compose up -d_ (_-d_ para separado) y cerrarla con _docker compose down_. + +> Ten en cuenta que algunas versiones antiguas de Docker (especialmente en Windows) no soportan el comando _docker compose_. Una forma de evitar este problema es [instalando](https://docs.docker.com/compose/install/) el comando _docker-compose_ que funciona casi siempre igual que _docker compose_. Sin embargo, la solución preferible es actualizar Docker a su versión más reciente. + +Crear archivos como _docker-compose.yml_ que declaran lo que deseas en lugar de archivos de script que necesitas ejecutar en un orden específico / un número específico de veces es a menudo una buena práctica. + +
    + +
    + +### Ejercicio 12.6. + +#### Ejercicio 12.6: Docker compose + +Crea un archivo todo-app/todo-backend/docker-compose.yml que funcione con la aplicación de node del ejercicio anterior. + +El contador de visitas es la única funcionalidad que se requiere que funcione. + +
    + +
    + +### Uso de contenedores en desarrollo + +Cuando estás desarrollando software, la contenedorización se puede utilizar de varias maneras para mejorar tu calidad de vida. Uno de los casos más útiles es evitar la necesidad de instalar y configurar las herramientas dos veces. + +Puede que no sea la mejor opción mover todo tu entorno de desarrollo a un contenedor, pero si eso es lo que deseas, es posible. Retomaremos esta idea al final de esta parte. Pero hasta entonces, ejecuta la propia aplicación de Node fuera de los contenedores. + +La aplicación que conocimos en los ejercicios anteriores utiliza MongoDB. Exploremos [Docker Hub](https://hub.docker.com/) para encontrar una imagen de MongoDB. Docker Hub es el lugar predeterminado desde donde Docker extrae las imágenes, también puedes usar otros registros, pero dado que ya estamos metidos hasta las rodillas en Docker, es una buena opción. Con una búsqueda rápida, podemos encontrar [https://hub.docker.com/_/mongo](https://hub.docker.com/_/mongo) + +Crea un nuevo yaml llamado todo-app/todo-backend/docker-compose.dev.yml que se parece a lo siguiente: + +```yml +services: + mongo: + image: mongo + ports: + - 3456:27017 + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + MONGO_INITDB_DATABASE: the_database +``` + +El significado de las dos primeras variables de entorno definidas anteriormente se explica en la página de Docker Hub: + +> Estas variables, usadas en conjunto, crean un nuevo usuario y establecen la contraseña de ese usuario. Este usuario se crea en la base de datos de autenticación del administrador y se le otorga el rol de root ("superusuario"). + +La última variable de entorno *MONGO\_INITDB\_DATABASE* le indicará a MongoDB que cree una base de datos con ese nombre. + +Puedes usar la bandera _-f_ para especificar un archivo que se ejecute con el comando Docker Compose, por ejemplo: + +```bash +docker compose -f docker-compose.dev.yml up +``` + +Ahora que podríamos tener multiples archivos de compose, es útil. + +A continuación, inicia MongoDB con _docker compose -f docker-compose.dev.yml up -d_. Con _-d_ se ejecutará en segundo plano. Puedes ver los registros del output con _docker compose -f docker-compose.dev.yml logs -f_. Allí, _-f_ se asegurará de que seguimos los registros. + +Como se dijo anteriormente, actualmente no queremos ejecutar la aplicación Node dentro de un contenedor. Desarrollar mientras la aplicación está dentro de un contenedor es un desafío. Exploraremos esa opción más adelante en esta parte. + +Ejecuta el buen y viejo _npm install_ primero en tu máquina para configurar la aplicación Node. Luego inicia la aplicación con la variable de entorno relevante. Puedes modificar el código para configurarlas como predeterminadas o usar el archivo .env. No pasa nada por poner estas claves en GitHub, ya que solo se usan en tu entorno de desarrollo local. Las agregaré con _npm run dev_ para ayudarte a copiar y pegar. + +```bash +MONGO_URL=mongodb://localhost:3456/the_database npm run dev +``` + +Esto no será suficiente; necesitamos crear un usuario para ser autorizado dentro del contenedor. La url http://localhost:3000/todos genera un error de autenticación: + +```bash +[nodemon] 2.0.12 +[nodemon] to restart at any time, enter `rs` +[nodemon] watching path(s): *.* +[nodemon] watching extensions: js,mjs,json +[nodemon] starting `node ./bin/www` +/Users/mluukkai/dev/fs-ci-lokakuu/repo/todo-app/todo-backend/node_modules/mongodb/lib/cmap/connection.js:272 + callback(new MongoError(document)); + ^ +MongoError: command find requires authentication + at MessageStream.messageHandler (/Users/mluukkai/dev/fs-ci-lokakuu/repo/todo-app/todo-backend/node_modules/mongodb/lib/cmap/connection.js:272:20) +``` + +### Vincular e inicializar la base de datos + +En la página [MongoDB Docker Hub](https://hub.docker.com/_/mongo) en la sección "Inicializar una nueva instancia" se encuentra la información sobre cómo ejecutar JavaScript para inicializar la base de datos y un usuario para ella. + +El proyecto de ejercicio tiene un archivo todo-app/todo-backend/mongo/mongo-init.js con el contenido: + +```js +db.createUser({ + user: 'the_username', + pwd: 'the_password', + roles: [ + { + role: 'dbOwner', + db: 'the_database', + }, + ], +}); + +db.createCollection('todos'); + +db.todos.insert({ text: 'Write code', done: true }); +db.todos.insert({ text: 'Learn about containers', done: false }); +``` + +Este archivo inicializará la base de datos con un usuario y algunas tareas. A continuación, debemos colocarlo dentro del contenedor al inicio. + +Podríamos crear una nueva imagen DESDE mongo y COPIAR el archivo, o podemos usar un bind mount para montar el archivo mongo-init.js en el contenedor. Hagamos esto último. + +Bind mount es el acto de vincular un archivo (o directorio) en la máquina host a un archivo (o directorio) en el contenedor. Podríamos agregar una bandera _-v_ con _container run_. La sintaxis es _-v ARCHIVO-EN-HOST:ARCHIVO-EN-CONTENEDOR_. Como ya aprendimos sobre Docker Compose, omitámoslo. El bind mount se declara bajo la clave volumes en _docker-compose.dev.yml_. De lo contrario, el formato es el mismo, primero host y luego contenedor: + +```yml + mongo: + image: mongo + ports: + - 3456:27017 + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + MONGO_INITDB_DATABASE: the_database + # highlight-start + volumes: + - ./mongo/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js + # highlight-end +``` + +El resultado del vínculo es que el archivo mongo-init.js en la carpeta mongo de la máquina host es el mismo que el archivo mongo-init.js en el directorio /docker-entrypoint-initdb.d del contenedor. Los cambios en cualquiera de los archivos estarán disponibles en el otro. No necesitamos hacer ningún cambio durante el tiempo de ejecución. Pero esta será la clave para el desarrollo de software en contenedores. + +Ejecuta _docker compose -f docker-compose.dev.yml down --volumes_ para asegurarte de que no quede nada y comienza desde cero con _docker compose -f docker-compose.dev.yml up_ para inicializar la base de datos. + +Si ves un error como este: + +```bash +mongo_database | failed to load: /docker-entrypoint-initdb.d/mongo-init.js +mongo_database | exiting with code -3 +``` + +es posible que tengas un problema de permiso de lectura. No son raros cuando se trata de volúmenes. En el caso anterior, puedes usar _chmod a+r mongo-init.js_, que les dará a todos acceso de lectura a ese archivo. Ten cuidado al usar _chmod_ ya que otorgar más privilegios puede ser un problema de seguridad. Usa _chmod_ solo en mongo-init.js en tu computadora. + +Ahora, iniciar la aplicación Express con la variable de entorno correcta debería funcionar: + +```bash +MONGO_URL=mongodb://the_username:the_password@localhost:3456/the_database npm run dev +``` + +Verifiquemos que http://localhost:3000/todos devuelve las tareas. Debería devolver las dos tareas que inicializamos. Podemos y debemos usar Postman para probar la funcionalidad básica de la aplicación, como agregar o eliminar una tarea. + +### ¿Aún con problemas? + +Por algún motivo, para muchos, la inicialización de Mongo ha causado problemas. + +Si la aplicación no funciona y aún tienes el siguiente error: + +```bash +/Users/mluukkai/dev/fs-ci-lokakuu/repo/todo-app/todo-backend/node_modules/mongodb/lib/cmap/connection.js:272 + callback(new MongoError(document)); + ^ +MongoError: command find requires authentication + at MessageStream.messageHandler (/Users/mluukkai/dev/fs-ci-lokakuu/repo/todo-app/todo-backend/node_modules/mongodb/lib/cmap/connection.js:272:20) +``` + +ejecuta estos comandos: + +```bash +docker compose -f docker-compose.dev.yml down --volumes +docker image rm mongo +``` + +Luego, intenta iniciar Mongo otra vez. + +Si el problema persiste, dejemos de lado la idea de usar un volumen y copiemos el script de inicialización a una imagen personalizada. Crea el siguiente Dockerfile al directorio todo-app/todo-backend/mongo + +```Dockerfile +FROM mongo + +COPY ./mongo-init.js /docker-entrypoint-initdb.d/ +``` + +Agrégalo a una imagen con el comando: + +```bash +docker build -t initialized-mongo . +``` + +Ahora cambia al archivo docker-compose.dev.yml para que utilize la nueva imagen: + +```yml + mongo: + image: initialized-mongo # highlight-line + ports: + - 3456:27017 + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + MONGO_INITDB_DATABASE: the_database +``` + +Ahora la aplicación debería funcionar. + +### Persistiendo datos con volúmenes + +Por defecto, los contenedores no conservarán nuestros datos. Cuando cierres el contenedor de la base datos, es posible que no puedas recuperar los datos. + +> Mongo, de hecho, es un caso raro en el que el contenedor conserva los datos. Esto sucede, ya que los desarrolladores que crearon la imagen de Docker para Mongo han definido un volumen para usar. [Esta línea](https://github.com/docker-library/mongo/blob/cb8a419053858e510fc68ed2d69415b3e50011cb/4.4/Dockerfile#L113) en el Dockerfile indicará a Docker que conserve los datos en un volumen. + +Hay dos métodos distintos para almacenar los datos: +- Declarar una ubicación en tu sistema de archivos (llamado [bind mount](https://docs.docker.com/storage/bind-mounts/) (montaje de enlace)) +- Dejar que Docker decida dónde almacenar los datos ([volumen](https://docs.docker.com/storage/volumes/)) + +La primera opción es preferible en la mayoría de los casos siempre que uno realmente necesite evitar eliminar los datos. + +Veamos ambos en acción con docker-compose. Comencemos con bind mount: + +```yml +services: + mongo: + image: mongo + ports: + - 3456:27017 + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + MONGO_INITDB_DATABASE: the_database + volumes: + - ./mongo/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js + - ./mongo_data:/data/db # highlight-line +``` + +Esto creará un directorio llamado *mongo\_data* en tu sistema de archivos local y lo asignará al contenedor como _/data/db_. Esto significa que los datos en _/data/db_ se almacenan fuera del contenedor, ¡pero el contenedor aún puede acceder a ellos! Solo recuerda agregar el directorio a .gitignore. + +Se puede lograr un resultado similar con un volumen con nombre: + +```yml +services: + mongo: + image: mongo + ports: + - 3456:27017 + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + MONGO_INITDB_DATABASE: the_database + volumes: + - ./mongo/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js + - mongo_data:/data/db + +volumes: # highlight-line + mongo_data: # highlight-line +``` + +Ahora, el volumen es creado y controlado por Docker. Después de iniciar la aplicación (_docker compose -f docker-compose.dev.yml up_) puedes enumerar los volúmenes con _docker volume ls_, inspeccionar uno de ellos con _docker volume inspect_ e incluso eliminarlos con _docker volume rm_: + +```bash +$ docker volume ls +DRIVER VOLUME NAME +local todo-backend_mongo_data +$ docker volume inspect todo-backend_mongo_data +[ + { + "CreatedAt": "2024-19-03T12:52:11Z", + "Driver": "local", + "Labels": { + "com.docker.compose.project": "todo-backend", + "com.docker.compose.version": "1.29.2", + "com.docker.compose.volume": "mongo_data" + }, + "Mountpoint": "/var/lib/docker/volumes/todo-backend_mongo_data/_data", + "Name": "todo-backend_mongo_data", + "Options": null, + "Scope": "local" + } +] +``` + +El volumen con nombre aún está almacenado en tu sistema de archivos local, pero descubrir dónde puede no ser tan trivial como con la opción anterior. + +
    + +
    + +### Ejercicio 12.7. + +#### Ejercicio 12.7: Escribiendo un poco de código de MongoDB + +Ten en cuenta que este ejercicio asume que has realizado todas las configuraciones realizadas en el material después del ejercicio 12.5. Aún debes ejecutar el backend de la aplicación de tareas fuera de un contenedor, solo MongoDB está en un contenedor por ahora. + +La aplicación de tareas no tiene una implementación adecuada de las rutas para obtener una tarea pendiente (GET /todos/:id) y actualizar una tarea pendiente (PUT /todos/:id). Arregla el código. + +
    + +
    + +### Depurando problemas en contenedores + +> Cuando codificas, lo más probable es que termines en una situación en la que todo está roto. + +> \- Matti Luukkainen + +Al desarrollar con contenedores, necesitamos aprender nuevas herramientas para la depuración, ya que no podemos simplemente usar "console.log" para todo. Cuando el código tiene un error, a menudo puede estar en un estado en el que al menos algo funciona y podemos avanzar a partir de ahí. La configuración suele estar en cualquiera de los dos estados: 1. funcionando o 2. rota. Repasaremos algunas herramientas que pueden ayudar cuando tu aplicación se encuentra en este último estado. + +Al desarrollar software, puedes avanzar paso a paso con seguridad, verificando todo el tiempo que lo que has codificado se comporta como se espera. A menudo, este no es el caso cuando se realizan configuraciones. La configuración que puedes estar escribiendo puede romperse hasta en el momento en que finaliza. Entonces, cuando escribes un docker-compose.yml o Dockerfile largo y no funciona, debes tomarte un momento y pensar en las diversas formas en que podrías confirmar que algo funciona. + +Cuestionar todo sigue siendo aplicable aquí. Como se dijo en la [parte 3](/es/part3/guardando_datos_en_mongo_db): La clave es ser sistemático. Dado que el problema puede existir en cualquier lugar, debes cuestionar todo y eliminar todas las posibles fuentes de error una por una. + +Para mí, el método más valioso de depuración es detenerme y pensar en lo que estoy tratando de lograr en lugar de simplemente golpearme la cabeza con el problema. A menudo hay una solución simple, alternativa, o una búsqueda rápida en Google que me ayudará a seguir adelante. + +#### exec + +El comando de Docker [exec](https://docs.docker.com/engine/reference/commandline/exec/) es un gran bateador. Se puede usar para saltar directamente a un contenedor cuando se está ejecutando. + +Iniciemos un servidor web en segundo plano y hagamos un poco de depuración para que funcione y muestre el mensaje "¡Hola, exec!" en nuestro navegador. Elijamos [Nginx](https://www.nginx.com/) que es, entre otras cosas, un servidor capaz de servir archivos HTML estáticos. Tiene un index.html predeterminado que podemos reemplazar. + +```bash +$ docker container run -d nginx +``` + +Bien, ahora las preguntas son: + +- ¿Dónde debemos ir con nuestro navegador? +- ¿Está incluso funcionando? + +Sabemos cómo responder a lo último: enumerando los contenedores en ejecución. + +```bash +$ docker container ls +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +3f831a57b7cc nginx "/docker-entrypoint.…" About a minute ago Up About a minute 80/tcp keen_darwin +``` + +¡Sí! También hemos respondido a la primera pregunta. Parece escuchar en el puerto 80, como se ve en la salida anterior. + +Apaguémoslo y reiniciemos con el indicador _-p_ para que nuestro navegador acceda a él. + +```bash +$ docker container stop keen_darwin +$ docker container rm keen_darwin + +$ docker container run -d -p 8080:80 nginx +``` + +> **Nota del editor:** al desarrollar, es **esencial** seguir constantemente los logs del contenedor. Normalmente no ejecuto contenedores en modo detached (es decir, con -d) ya que requiere un poco de esfuerzo adicional abrir los logs. +> +> Cuando estoy 100% seguro de que todo funciona... no, cuando estoy 200% seguro, entonces puedo relajarme un poco e iniciar los contenedores en modo detached. Hasta que todo vuelva a desmoronarse y sea hora de abrir los logs nuevamente. + +Miremos a la aplicación en http://localhost:8080. ¡Parece que muestra un mensaje incorrecto! Saltemos directamente al contenedor y arreglémoslo. Mantén tu navegador abierto, no necesitaremos cerrar el contenedor para esta corrección. Ejecutaremos bash dentro del contenedor, las banderas _-it_ asegurarán que podamos interactuar con el contenedor: + +```bash +$ docker container ls +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +7edcb36aff08 nginx "/docker-entrypoint.…" About a minute ago Up About a minute 0.0.0.0:8080->80/tcp, :::8080->80/tcp wonderful_ramanujan + +$ docker exec -it wonderful_ramanujan bash +root@7edcb36aff08:/# +``` + +Ahora que estamos dentro, necesitamos encontrar el archivo defectuoso y reemplazarlo. Rapidamente Google nos dice que el archivo en sí es _/usr/share/nginx/html/index.html_. + +Pasemos al directorio y eliminemos el archivo. + +```bash +root@7edcb36aff08:/# cd /usr/share/nginx/html/ +root@7edcb36aff08:/# rm index.html +``` + +Ahora, si vamos a http://localhost:8080/ sabemos que eliminamos el archivo correcto. La página muestra 404. Vamos a reemplazarlo con uno que contenga el contenido correcto: + +```bash +root@7edcb36aff08:/# echo "Hello, exec!" > index.html +``` + +¡Actualiza la página y se mostrará nuestro mensaje! Ahora sabemos cómo se puede usar exec para interactuar con los contenedores. Recuerda que todos los cambios se pierden cuando se elimina el contenedor. Para conservar los cambios, debes usar _commit_ tal como lo hicimos en la [sección anterior](/es/part12/introduccion_a_los_contenedores#otros-comandos-de-docker). + +
    + +
    + +### Ejercicio 12.8. + +#### Ejercicio 12.8: Interfaz de linea de comandos (CLI) de Mongo + +> Usa _script_ para registrar lo que haces, guarda el archivo en script-answers/exercise12_8.txt + +Mientras se ejecuta MongoDB del ejercicio anterior, accede a la base de datos con la interfaz de línea de comandos (CLI) de mongo. Puedes hacerlo usando docker exec. A continuación, agrega una tarea nueva mediante la CLI. + +El comando para abrir CLI cuando estás dentro del contenedor es _mongosh_ + +La CLI de mongo requerirá las banderas de nombre de usuario y contraseña para autenticarse correctamente. Las banderas _-u root -p example_ deberían funcionar, los valores corresponden a los que se encuentran en el archivo _docker-compose.dev.yml_. + +* Paso 1: Ejecuta MongoDB +* Paso 2: Utiliza docker exec para ingresar al contenedor +* Paso 3: Abre Mongo CLI + +Cuando te hayas conectado a Mongo CLI, puedes pedirle que muestre las DBs dentro: + +```bash +> show dbs +admin 0.000GB +config 0.000GB +local 0.000GB +the_database 0.000GB +``` + +Para acceder a la base de datos correcta: + +```bash +> use the_database +``` + +Y finalmente para conocer las colecciones: + +```bash +> show collections +todos +``` + +Ahora podemos acceder a los datos en esas colecciones: + +```bash +> db.todos.find({}) +[ + { + _id: ObjectId("633c270ba211aa5f7931f078"), + text: 'Write code', + done: false + }, + { + _id: ObjectId("633c270ba211aa5f7931f079"), + text: 'Learn about containers', + done: false + } +] +``` + +Inserta una tarea pendiente nueva con el texto: "Increase the number of tools in my tool belt" con el estado done como false. Consulta la [documentación](https://www.mongodb.com/docs/manual/reference/method/db.collection.insertOne/) para ver cómo se realiza la adición. + +Asegúrate de ver la nueva tarea tanto en la aplicación Express como al consultar desde la CLI de Mongo. + +
    + +
    + +### Redis + +[Redis](https://redis.io/) es una base de datos [clave-valor](https://redis.com/nosql/key-value-databases/). En contraste con por ej. MongoDB, los datos almacenados en un almacén de clave-valor tienen un poco menos de estructura, por ejemplo, no contienen colecciones ni tablas, solo contienen pedazos de datos que se pueden obtener en función de la clave que se adjuntó a los datos (el valor). + +De forma predeterminada, Redis funciona en memoria, lo que significa que no almacena datos de forma persistente. + +Un caso de uso excelente para Redis es usarlo como caché. Los cachés a menudo se usan para almacenar datos que, de otro modo, serían lentos para obtener y guardar hasta que ya no sean válidos. Después de que la memoria caché se vuelva inválida, obtendrás los datos nuevamente y los almacenarás en la memoria caché. + +Redis no tiene nada que ver con los contenedores. Pero dado que ya podemos agregar cualquier servicio de terceros a tus aplicaciones, ¿por qué no conocer uno nuevo? + +
    + +
    + +### Ejercicios 12.9. - 12.11. + +#### Ejercicio 12.9: Instalando Redis en el proyecto + +El servidor Express ya se configuró para usar Redis y solo falta la variable de entorno *REDIS_URL*. La aplicación utilizará esa variable de entorno para conectarse a Redis. Lee la [página de Docker Hub para Redis](https://hub.docker.com/_/redis), agrega Redis a todo-app/todo-backend/docker-compose.dev.yml definiendo otro servicio después de mongo: + +```yml +services: + mongo: + ... + redis: + ??? +``` + +Dado que la página de Docker Hub no tiene toda la información, podemos usar Google para ayudarnos. El puerto predeterminado para Redis se encuentra fácilmente al buscarlo: + +![resultado de busqueda de google para "default port for redis" es 6379](../../images/12/redis_port_by_google.png) + +No sabremos si la configuración funciona a menos que la probemos. La aplicación no comenzará a usar Redis por sí sola, eso sucederá en el próximo ejercicio. + +Una vez que Redis esté configurado e iniciado, reinicia el backend y asígnale el REDIS\_URL, que tiene la forma redis://host:port + +```bash +REDIS_URL=insert-redis-url-here MONGO_URL=mongodb://the_username:the_password@localhost:3456/the_database npm run dev +``` + +Ahora puedes probar la configuración agregando la línea + +```js +const redis = require('../redis') +``` + +al servidor Express, por ejemplo, en el archivo routes/index.js. Si no pasa nada, la configuración se hizo correctamente. Si no, el servidor falla: + +```bash +events.js:291 + throw er; // Unhandled 'error' event + ^ + +Error: Redis connection to localhost:637 failed - connect ECONNREFUSED 127.0.0.1:6379 + at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1144:16) +Emitted 'error' event on RedisClient instance at: + at RedisClient.on_error (/Users/mluukkai/opetus/docker-fs/container-app/express-app/node_modules/redis/index.js:342:14) + at Socket. (/Users/mluukkai/opetus/docker-fs/container-app/express-app/node_modules/redis/index.js:223:14) + at Socket.emit (events.js:314:20) + at emitErrorNT (internal/streams/destroy.js:100:8) + at emitErrorCloseNT (internal/streams/destroy.js:68:3) + at processTicksAndRejections (internal/process/task_queues.js:80:21) { + errno: -61, + code: 'ECONNREFUSED', + syscall: 'connect', + address: '127.0.0.1', + port: 6379 +} +[nodemon] app crashed - waiting for file changes before starting... +``` + +#### Ejercicio 12.10: + +El proyecto ya tiene [https://www.npmjs.com/package/redis](https://www.npmjs.com/package/redis) instalado y dos funciones "promisificadas": getAsync y setAsync. + +- La función setAsync toma la clave y el valor, usando la clave para almacenar el valor. + +- La función getAsync toma la clave y devuelve el valor en una promesa. + +Implementa un contador de tareas pendientes que guarde la cantidad de tareas pendientes creadas en Redis: + +- Paso 1: Cada vez que se envíe una solicitud para agregar una tarea pendiente, incrementa el contador en uno. +- Paso 2: Crea un endpoint GET/statistics donde puedas solicitar los metadatos de uso. El formato debe ser el siguiente JSON: + +```json +{ + "added_todos": 0 +} +``` + +#### Ejercicio 12.11: + +> Utiliza _script_ para registrar lo que haces, guarda el archivo como script-answers/exercise12_11.txt + +Si la aplicación no se comporta como se esperaba, un acceso directo a la base de datos puede ser beneficioso para identificar problemas. Probemos cómo se puede usar [redis-cli](https://redis.io/topics/rediscli) para acceder a la base de datos. + +- Ve al contenedor de redis con _docker exec_ y abre redis-cli. +- Encuentra la clave que usaste con _[KEYS *](https://redis.io/commands/keys)_ +- Verifica el valor de la clave con el comando [GET](https://redis.io/commands/get) +- Establece el valor del contador en 9001, encuentra el comando correcto [aquí](https://redis.io/commands/) +- Asegúrate de que el nuevo valor funcione actualizando la página http://localhost:3000/statistics +- Crea una nueva tarea con Postman y asegúrate de que el contador haya aumentado en consecuencia desde redis-cli +- Elimina la clave de cli y asegúrate de que el contador funcione cuando se agreguen nuevas tareas + +
    + +
    + +### Persistiendo datos con Redis + +En la sección anterior, se mencionó que por defecto Redis no conserva los datos. Sin embargo, la persistencia es fácil de activar. Solo necesitamos iniciar Redis con un comando diferente, como se indica en la [página de Docker hub](https://hub.docker.com/_/redis): + +```yml +services: + redis: + # Everything else + command: ['redis-server', '--appendonly', 'yes'] # Overwrite the CMD + volumes: # Declare the volume + - ./redis_data:/data +``` + +Los datos ahora se almacenarán en el directorio redis_data de la máquina host. +¡Recuerda agregar el directorio a .gitignore! + +#### Otras funcionalidades de Redis + +Además de las operaciones GET, SET y DEL en claves y valores, Redis también puede hacer mucho más. Por ejemplo, puede hacer que las claves caduquen automáticamente, lo que es una característica muy útil cuando Redis se usa como caché. + +Redis también se puede utilizar para implementar el patrón denominado [publish-subscribe](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) (o PubSub), que es un mecanismo de comunicación asíncrona para aplicaciones distribuidas. En este escenario, Redis funciona como un agente de mensajes entre dos o más aplicaciones. Algunas de las aplicaciones están publicando mensajes enviándolos a Redis, que al recibir un mensaje, informa a las partes que se han suscrito a esos mensajes. + +
    + +
    + +### Ejercicio 12.12. + +#### Ejercicio 12.12: Persistiendo datos en Redis + +Comprueba que los datos no se conservan de forma predeterminada, después de ejecutar: + +```bash +docker compose -f docker-compose.dev.yml down +docker compose -f docker-compose.dev.yml up +``` + + el valor del contador se restablece a 0. + +Luego, crea un volumen para los datos de Redis (modificando todo-app/todo-backend/docker-compose.dev.yml) y asegúrate de que los datos sobrevivan después de ejecutar: + +```bash +docker compose -f docker-compose.dev.yml down +docker compose -f docker-compose.dev.yml up +``` + +
    diff --git a/src/content/12/es/part12c.md b/src/content/12/es/part12c.md new file mode 100644 index 00000000000..505b5a0697d --- /dev/null +++ b/src/content/12/es/part12c.md @@ -0,0 +1,789 @@ +--- +mainImage: ../../../images/part-12.svg +part: 12 +letter: c +lang: es +--- + +
    + +Esta parte fue actualizada el 21 de Marzo de 2024: Create react app reemplazado con Vite en el frontend de todo. + +Si comenzaste esta parte antes de la actualización, puedes ver el viejo material [aquí](https://github.com/fullstack-hy2020/fullstack-hy2020.github.io/tree/4015af9dddb61cb01f013456d8728e8f553be347/src/content/12). Hay algunos cambios en las configuraciones del frontend. +
    + +
    + +Ahora tenemos una comprensión básica de Docker y podemos utilizarlo para fácilmente configurar, por ejemplo, una base de datos para nuestra aplicación. Ahora, cambiemos nuestro enfoque hacia el frontend. + +### React en un contenedor + +A continuación, vamos a crear y a poner en un contenedor a una aplicación React. Comenzamos con los pasos habituales: + +```bash +$ npm create vite@latest hello-front -- --template react +$ cd hello-front +$ npm install +``` + +El siguiente paso es convertir el código JavaScript y CSS en archivos estáticos listos para producción, Vite ya tiene un _build_ como un script npm, así que usemos eso: + +```bash +$ npm run build + ... + + Creating an optimized production build... + ... + The build folder is ready to be deployed. + ... +``` + +¡Excelente! El paso final es encontrar una forma de usar un servidor para servir los archivos estáticos. Como sabrás, podríamos usar nuestro [express.static](https://expressjs.com/en/starter/static-files.html) con el servidor Express para servir los archivos estáticos. Te lo dejo como ejercicio para que lo hagas en casa. En su lugar, seguiremos adelante y comenzaremos a escribir nuestro Dockerfile: + +```Dockerfile +FROM node:20 + +WORKDIR /usr/src/app + +COPY . . + +RUN npm ci + +RUN npm run build +``` + +Eso parece correcto. Hagamos el build y veamos si estamos en el camino correcto. Nuestro objetivo es que la compilación tenga éxito sin errores. Luego usaremos bash para verificar dentro del contenedor para ver si los archivos están allí. + +```bash +$ docker build . -t hello-front + => [4/5] RUN npm ci + => [5/5] RUN npm run + ... + => => naming to docker.io/library/hello-front + +$ docker run -it hello-front bash + +root@98fa9483ee85:/usr/src/app# ls + Dockerfile README.md dist index.html node_modules package-lock.json package.json public src vite.config.js + +root@98fa9483ee85:/usr/src/app# ls dist + assets index.html vite.svg +``` + +Una opción válida para servir archivos estáticos ahora que ya tenemos Node en el contenedor es [serve](https://www.npmjs.com/package/serve). Intentemos instalar serve y servir los archivos estáticos mientras estamos dentro del contenedor. + +```bash +root@98fa9483ee85:/usr/src/app# npm install -g serve + + added 89 packages in 2s + +root@98fa9483ee85:/usr/src/app# serve dist + + ┌────────────────────────────────────────┐ + │ │ + │ Serving! │ + │ │ + │ - Local: http://localhost:3000 │ + │ - Network: http://172.17.0.2:3000 │ + │ │ + └────────────────────────────────────────┘ + +``` + +¡Excelente! Hagamos ctrl+c para salir y luego los agregaremos a nuestro Dockerfile. + +La instalación de serve se convierte en un RUN en el Dockerfile. De esta manera, la dependencia se instala durante el proceso de compilación. El comando para servir el directorio dist se convertirá en el comando para iniciar el contenedor: + +```Dockerfile +FROM node:20 + +WORKDIR /usr/src/app + +COPY . . + +RUN npm ci + +RUN npm run build + +#highlight-start +RUN npm install -g serve +#highlight-end + +#highlight-start +CMD ["serve", "dist"] +#highlight-end +``` + +Nuestro CMD ahora incluye corchetes y, como resultado, usamos la forma exec de CMD. En realidad, hay **tres** formas diferentes para CMD, de las cuales se prefiere la forma exec. Lee la [documentación](https://docs.docker.com/reference/dockerfile/#cmd) para obtener más información. + +Cuando ahora construimos la imagen con _docker build . -t hello-front_ y la ejecutamos con _docker run -p 5000:3000 hello-front_, la aplicación estará disponible en http://localhost:5000. + +### Usando múltiples etapas + +Si bien serve es una opción válida, podemos hacerlo mejor. Un buen objetivo es crear imágenes de Docker para que no contengan nada irrelevante. Con un número mínimo de dependencias, es menos probable que las imágenes se rompan o se vuelvan vulnerables con el tiempo. + +Los [builds de varias etapas](https://docs.docker.com/build/building/multi-stage/) están diseñadas para dividir el proceso de compilación en muchas etapas separadas, donde es posible limitar qué partes de los archivos de imagen se mueven entre las etapas. Eso abre posibilidades para limitar el tamaño de la imagen, ya que no todos los subproductos del build son necesarios para la imagen resultante. Las imágenes más pequeñas son más rápidas de cargar y descargar y ayudan a reducir la cantidad de vulnerabilidades que puede tener tu software. + +Con builds de varias etapas, se puede usar una solución probada y robusta como [Nginx](https://es.wikipedia.org/wiki/Nginx) para servir archivos estáticos sin muchos dolores de cabeza. La [página de Nginx](https://hub.docker.com/_/nginx) de Docker Hub nos brinda la información necesaria para abrir los puertos y "Alojamiento de contenido estático simple". + +Usemos el Dockerfile anterior pero cambiemos FROM para incluir el nombre de la etapa: + +```Dockerfile +# El primer FROM ahora es una etapa llamada build-stage +# highlight-start +FROM node:20 AS build-stage +# highlight-end + +WORKDIR /usr/src/app + +COPY . . + +RUN npm ci + +RUN npm run build + +# Esta es una nueva etapa, todo lo anterior a esta linea ha desaparecido, excepto por los archivos que queremos COPIAR +# highlight-start +FROM nginx:1.25-alpine +# highlight-end + +# COPIA el directorio dist de build-stage a /usr/share/nginx/html +# El destino fue encontrado en la pagina de Docker hub +# highlight-start +COPY --from=build-stage /usr/src/app/dist /usr/share/nginx/html +# highlight-end +``` + +Hemos declarado también otra etapa, de donde solo se mueven los archivos relevantes de la primera etapa (el directorio dist, que contiene el contenido estático). + +Después de que la construimos de nuevo, la imagen está lista para servir el contenido estático. El puerto predeterminado será 80 para Nginx, por lo que algo como _-p 8000:80_ funcionará, por lo que los parámetros del comando RUN deben cambiarse un poco. + +Los builds de varias etapas también incluyen algunas optimizaciones internas que pueden afectar tus builds. Como ejemplo, los builds de varias etapas se saltan las etapas que no se utilizan. Si deseamos usar una etapa para reemplazar una parte de un pipeline de build, como pruebas o notificaciones, debemos pasar **algunos** datos a las siguientes etapas. En algunos casos esto está justificado: copia el código de la etapa de prueba a la etapa de build. Esto garantiza que estás haciendo el build con el código probado. + +
    + +
    + +### Ejercicios 12.13 - 12.14. + +#### Ejercicio 12.13: Frontend de la aplicación de tareas + +Finalmente, llegamos al frontend de la aplicación de tareas pendientes. Ve a todo-app/todo-frontend y lee el README. + +Comienza ejecutando el frontend fuera del contenedor y asegúrate de que funciona con el backend. + +Pon a la aplicación en un contenedor creando todo-app/todo-frontend/Dockerfile y utiliza a la instrucción [ENV](https://docs.docker.com/engine/reference/builder/#env) para pasar *VITE\_BACKEND\_URL* a la aplicación y ejecútala con el backend. El backend aún debería estar ejecutándose fuera de un contenedor. + +**Ten en cuenta** que debes configurar *VITE\_BACKEND\_URL* antes de hacer el build del frontend, de lo contrario, no quedará definida en el código. + +#### Ejercicio 12.14: Pruebas durante el proceso de build + +Una posibilidad interesante que utilizar builds de varias etapas nos da, es usar una etapa de build separada para [pruebas](https://docs.docker.com/language/nodejs/run-tests/). Si la etapa de prueba falla, todo el proceso de build también fallará. Ten en cuenta que puede que no sea la mejor idea mover todas las pruebas para que se realicen durante el build de una imagen, pero puede ser buena idea que existan algunas pruebas relacionadas con la creación de contenedores. + +Extrae un componente llamado Todo que represente a una sola tarea. Escribe una prueba para el nuevo componente y agrega la ejecución de pruebas al proceso de build. + +Puedes agregar una nueva etapa de build para la prueba si lo deseas. Si lo haces, ¡recuerda leer de nuevo el último párrafo antes del ejercicio 12.13! + +
    + +
    + +### Desarrollo en contenedores + +Movamos todo el desarrollo de la aplicación de tareas pendientes a un contenedor. Hay algunas razones por las que querrías hacer eso: + +- Para mantener el entorno similar entre el desarrollo y la producción y así evitar errores que aparecen solo en el entorno de producción. +- Evitar diferencias entre los desarrolladores y sus entornos personales que generen dificultades en el desarrollo de aplicaciones. +- Para ayudar a los nuevos miembros del equipo a incorporarse, haciéndoles instalar el tiempo de ejecución del contenedor, sin necesidad de nada más. + +Todas estas son buenas razones. La contrapartida es que podemos encontrarnos con algún comportamiento no convencional cuando no estamos ejecutando las aplicaciones como estamos acostumbrados. Tendremos que hacer al menos dos cosas para mover la aplicación a un contenedor: + +- Iniciar la aplicación en modo de desarrollo +- Acceder a los archivos con VSCode + +Comencemos con el frontend. Dado que el Dockerfile será significativamente diferente al Dockerfile de producción, creemos uno nuevo llamado dev.Dockerfile. + +**Nota** usaremos el nombre dev.Dockerfile para las configuraciones de desarrollo y Dockerfile para lo demás. + +Iniciar Vite en modo de desarrollo debería ser fácil. Comencemos con lo siguiente: + +```Dockerfile +FROM node:20 + +WORKDIR /usr/src/app + +COPY . . + +# Cambia npm ci a npm install ya que vamos a estar en modo de desarrollo +RUN npm install + +# npm run dev es el comando para iniciar la aplicación en modo de desarrollo +CMD ["npm", "run", "dev", "--", "--host"] +``` + +> Nota los parámetros adicionales _-- --host_ en el _CMD_. Esos son necesarios para exponer el servidor de desarrollo y hacerlo visible fuera de la red Docker. Por defecto, el servidor de desarrollo solo se expone a localhost, y a pesar de que accedemos al frontend todavía usando la dirección de localhost, en realidad está conectado a la red Docker. + +Durante el build, se usará el indicador _-f_ para indicar qué archivo usar; de lo contrario, el predeterminado sería Dockerfile, por lo que el siguiente comando hará el build de la imagen: + +```bash +docker build -f ./dev.Dockerfile -t hello-front-dev . +``` + +Vite se servirá en el puerto 5173, por lo que puedes probar que funciona al ejecutar un contenedor con ese puerto publicado. + +La segunda tarea, acceder a los archivos con VSCode, aún no se ha realizado. Hay al menos dos formas de hacer esto: + +- [Extensión de Visual Studio Code Remote - Containers](https://code.visualstudio.com/docs/remote/containers) +- Volúmenes, lo mismo que usamos para conservar los datos con la base de datos. + +Repasemos esto último, ya que también funcionará con otros editores. Hagamos una ejecución de prueba con el indicador _-v_ y, si funciona, moveremos la configuración a un archivo docker-compose. Para usar _-v_, necesitaremos decirle el directorio actual. El comando _pwd_ debería generar la ruta al directorio actual. Intentemos esto con _echo $(pwd)_ en la línea de comandos. Podemos usarlo con _-v_ a la izquierda para asignar el directorio actual al interior del contenedor o podemos usar la ruta completa del directorio. + +```bash +$ docker run -p 5173:5173 -v "$(pwd):/usr/src/app/" hello-front-dev +> todo-vite@0.0.0 dev +> vite --host + + VITE v5.1.6 ready in 130 ms +``` + +Ahora podemos editar el archivo src/App.jsx, ¡y los cambios deberían cargarse de forma instantánea en el navegador! + +Si tienes una Mac con procesador M1/M2, el comando anterior falla. En el mensaje de error, notamos lo siguiente: + +``` +Error: Cannot find module @rollup/rollup-linux-arm64-gnu +``` + +El problema es la librería [rollup](https://www.npmjs.com/package/rollup) que tiene su propia versión para todos los sistemas operativos y arquitecturas de procesador. Debido al mapeo de volúmenes, el contenedor ahora está usando los *node_modules* del directorio de la máquina anfitriona donde está instalado _@rollup/rollup-darwin-arm64_ (la versión adecuada para Mac M1/M2), por lo que no se encuentra la versión correcta de la librería para el contenedor _@rollup/rollup-linux-arm64-gnu_. + +Hay varias formas de solucionar el problema. Usemos quizás la más simple. Inicia el contenedor con bash como el comando, y ejecuta _npm install_ dentro del contenedor: + +```bash +$ docker run -it -v "$(pwd):/usr/src/app/" front-dev bash +root@b83e9040b91d:/usr/src/app# npm install +``` + +¡Ahora ambas versiones de la librería rollup están instaladas y el contenedor funciona! + +A continuación, movamos la configuración al archivo docker-compose.dev.yml. Este archivo también debe estar en la raíz del proyecto: + +```yml +services: + app: + image: hello-front-dev + build: + context: . # El contexto tomará este directorio como el "contexto del build" + dockerfile: dev.Dockerfile # Esto simplemente le indicará qué dockerfile leer + volumes: + - ./:/usr/src/app # La ruta puede ser relativa, por lo que ./ es suficiente para decir "la misma ubicación que el docker-compose.yml" + ports: + - 5173:5173 + container_name: hello-front-dev # Esto nombrará el contenedor como hello-front-dev +``` + +Con esta configuración, _docker compose -f docker-compose.dev.yml up_ puede ejecutar la aplicación en modo de desarrollo. ¡Ni siquiera necesitas tener Node instalado para trabajar en ella! + +**Nota** usaremos el nombre docker-compose.dev.yml para los archivos de composición del entorno de desarrollo, y el nombre predeterminado docker-compose.yml en otros casos. + +Instalar nuevas dependencias es un dolor de cabeza para una configuración de desarrollo como esta. Una de las mejores opciones es instalar la nueva dependencia **dentro** del contenedor. Entonces, en lugar de hacer, p.ej. _npm install axios_, debes hacerlo en el contenedor en ejecución, p.ej. _docker exec hello-front-dev npm install axios_, o agrégalo a package.json y ejecuta _docker build_ nuevamente. + +
    +
    + +### Ejercicio 12.15 + +#### Ejercicio 12.15: Configurar un entorno de desarrollo frontend + +Crea todo-frontend/docker-compose.dev.yml y usa volúmenes para habilitar el desarrollo de todo-frontend mientras se ejecuta dentro de un contenedor. + +
    + +
    + +### Comunicación entre contenedores en una red Docker + +La herramienta Docker Compose configura una red entre los contenedores e incluye un DNS para conectar fácilmente dos contenedores. Agreguemos un nuevo servicio a Docker Compose y veremos cómo funcionan la red y el DNS. + +[Busybox](https://www.busybox.net/) es un pequeño ejecutable con varias herramientas que podrías necesitar. Se llama "La navaja suiza de Embedded Linux", y definitivamente podemos usarlo para nuestro beneficio. + +Busybox puede ayudarnos a depurar nuestras configuraciones. Entonces, si te pierdes en los últimos ejercicios de esta sección, puedes usar Busybox para averiguar qué funciona y qué no. Usémoslo para explorar lo que se acaba de decir. Que los contenedores están dentro de una red y que puedes conectarte fácilmente entre ellos. Busybox se puede agregar a la mezcla cambiando docker-compose.dev.yml a: + +```yml +services: + app: + image: hello-front-dev + build: + context: . + dockerfile: dev.Dockerfile + volumes: + - ./:/usr/src/app + ports: + - 5173:5173 + container_name: hello-front-dev + debug-helper: # highlight-line + image: busybox # highlight-line +``` + +El contenedor Busybox no tendrá ningún proceso ejecutándose dentro por lo que no podemos usar _exec_ allí. Por eso, la salida de _docker compose up_ también se verá así: + +```bash +$ docker compose -f docker-compose.dev.yml up 0.0s +Attaching to front-dev, debug-helper-1 +debug-helper-1 exited with code 0 +front-dev | +front-dev | > todo-vite@0.0.0 dev +front-dev | > vite --host +front-dev | +front-dev | +front-dev | VITE v5.2.2 ready in 153 ms +``` + +Esto es de esperar ya que es solo una caja de herramientas. Usémoslo para enviar una solicitud a hello-front-dev y ver cómo funciona el DNS. Mientras se ejecuta hello-front-dev, podemos realizar la solicitud con [wget](https://es.wikipedia.org/wiki/GNU_Wget) ya que es una herramienta incluida en Busybox para enviar una solicitud desde el asistente de depuración a hello-front-dev. + +Con Docker Compose podemos usar _docker compose run SERVICE COMMAND_ para ejecutar un servicio con un comando específico. El comando wget requiere la bandera _-O_ con _-_ para enviar la respuesta a stdout: + +```bash +$ docker compose -f docker-compose.dev.yml run debug-helper wget -O - http://app:5173 + +Connecting to app:5173 (192.168.240.3:5173) +writing to stdout + + + + + + + + Vite + React + + +
    + + + +``` + +¡Eso es! Reemplacemos la dirección proxy_pass en nginx.conf con esa. + +Una cosa más: agregamos una opción [depends_on](https://docs.docker.com/compose/compose-file/05-services/#depends_on) a la configuración que garantiza que el contenedor _nginx_ no se inicie antes que el contenedor frontend _app_: + +```bash +services: + app: + # ... + nginx: + image: nginx:1.20.1 + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + ports: + - 8080:80 + container_name: reverse-proxy + depends_on: // highlight-line + - app // highlight-line +``` + +Si no hacemos cumplir el orden de inicio con depends\_on, existe el riesgo de que Nginx falle en el inicio, ya que intenta recuperar todos los nombres de DNS a los que se hace referencia en el archivo de configuración: + +```bash +http { + server { + listen 80; + + location / { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + + proxy_pass http://app:5173; // highlight-line + } + } +} +``` + +Ten en cuenta que depends\_on no garantiza que el servicio en el contenedor dependiente esté listo para la acción, solo asegura que el contenedor se haya iniciado (y la entrada correspondiente se agregue al DNS). Si un servicio necesita esperar a que otro servicio esté listo antes del inicio, se deben usar [otras soluciones](https://docs.docker.com/compose/startup-order/). + +
    + +
    + +### Ejercicios 12.17. - 12.19. + +#### Ejercicio 12.17: Configura un servidor proxy inverso Nginx delante de todo-frontend + +A continuación vamos a poner el servidor nginx delante de todo-frontend y todo-backend. Comencemos creando un nuevo archivo docker-compose todo-app/docker-compose.dev.yml y todo-app/nginx.dev.conf. + +```bash +todo-app +├── todo-frontend +├── todo-backend +├── nginx.dev.conf // highlight-line +└── docker-compose.dev.yml // highlight-line +``` + +Agrega los servicios Nginx y todo-frontend creados con todo-app/todo-frontend/dev.Dockerfile en todo-app/docker-compose.dev.yml. + +![diagrama de conexión entre navegador, nginx, express y frontend](../../images/12/ex_12_16_nginx_front.png) + +En este y en los siguientes ejercicios **no necesitas** darle soporte a la opción build, eso es, el comando: + +```bash +docker compose -f docker-compose.dev.yml up --build +``` + +Es suficiente hacer el build del frontend y el backend en sus propios repositorios. + +#### Ejercicio 12.18: Configura el servidor Nginx para que esté delante de todo-backend + +Agrega el servicio todo-backend al archivo docker-compose todo-app/docker-compose.dev.yml en el modo de desarrollo. + +Agrégale una nueva ubicación al archivo nginx.dev.conf, para que las solicitudes a _/api_ se transmitan al backend a través del proxy. Algo como esto debería hacer el truco: + +```conf + server { + listen 80; + + # Requests comenzando con root (/) son manejados + location / { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + + proxy_pass ... + } + + # Requests comenzando con /api/ son manejados + location /api/ { + proxy_pass ... + } + } +``` + +La directiva *proxy\_pass* tiene una característica interesante con una barra inclinada final. Como estamos usando la ruta _/api_ para la ubicación, pero la aplicación backend solo responde en las rutas _/_ o _/todos_, queremos que se elimine _/api_ de la solicitud. En otras palabras, aunque el navegador envíe una solicitud GET a _/api/todos/1_, queremos que Nginx envíe la solicitud a _/todos/1_. Haz esto agregando una barra inclinada final _/_ a la URL al final de *proxy\_pass*. + +Este es un [problema común](https://serverfault.com/questions/562756/how-to-remove-the-path-with-an-nginx-proxy-pass) + +![comentarios sobre haber olvidado usar la barra inclinada](../../images/12/nginx_trailing_slash_stackoverflow.png) + +Esto ilustra lo que estamos buscando y puede ser útil si tienes problemas: + +![diagrama de llamado a / y /api en acción](../../images/12/nginx-back-vite.png) + +#### Ejercicio 12.19: Conecta los servicios, todo-frontend con todo-backend + +> En este ejercicio, envía todo el entorno de desarrollo, incluyendo ambas aplicaciones Express y React, dev.Dockerfiles y docker-compose.dev.yml. + +Finalmente, es hora de juntar todas las piezas. Antes de empezar, es esencial entender dónde se ejecuta realmente la aplicación React. El diagrama anterior podría dar la impresión de que la aplicación React se ejecuta en el contenedor, pero esto es completamente incorrecto. + +Es solo el *código fuente de la aplicación React* lo que está en el contenedor. Cuando el navegador accede a la dirección http://localhost:8080 (suponiendo que hayas configurado Nginx para ser accesible en el puerto 8080), el código fuente de React se descarga del contenedor al navegador: + +![diagrama mostrando que el código de react es enviado al navegador para su ejecución](../../images/12/nginx-setup-vite.png) + +A continuación, el navegador comienza a ejecutar la aplicación React, y todas las solicitudes que hace al backend deben hacerse a través del proxy inverso Nginx: + +![diagrama mostrando solicitudes hechas desde al navegador a /api de nginx y el proxy en acción solicitando a /todos](../../images/12/nginx-setup2.png) + +En realidad solo accedemos al contenedor frontend en la primera solicitud, la cual obtiene el código fuente de la aplicación React para el navegador. + +Ahora configura tu aplicación para que funcione como se muestra en la figura anterior. Asegúrate de que el todo-frontend funcione con todo-backend. Esto requerirá cambios en la variable de entorno *VITE\_BACKEND\_URL* en el frontend. + +Asegúrate de que el entorno de desarrollo ahora esté completamente funcional, es decir: +- todas las funcionalidades de la aplicación de tareas funcionan +- puedes editar los archivos fuente *y* los cambios se reflejan al recargar la aplicación +- el frontend debe acceder al backend a través de Nginx, por lo que las solicitudes deben hacerse a http://localhost:8080/api/todos: + +![pestaña de red de las herramientas de desarrollo del navegador mostrando que la url a la que el navegador llama incluye 8080/api/todos](../../images/12/todos-dev-right-2.png) + +Ten en cuenta que tu aplicación debe funcionar incluso si no se definen [puertos expuestos](https://docs.docker.com/network/#published-ports) para el backend y el frontend en el archivo docker compose: + +```yml +services: + app: + image: todo-front-dev + volumes: + - ./todo-frontend/:/usr/src/app + # no hay puertos aquí! + + server: + image: todo-back-dev + volumes: + - ./todo-backend/:/usr/src/app + environment: + - ... + # no hay puertos aquí! + + nginx: + image: nginx:1.20.1 + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + ports: + - 8080:80 # esto es necesario + container_name: reverse-proxy + depends_on: + - app +``` + +Solo necesitamos exponer el puerto de Nginx a la máquina host ya que el acceso al backend y frontend funciona a través del proxy de Nginx, el cual lo envía al puerto correcto del contenedor. Debido a que Nginx, frontend y backend están definidos en la misma configuración de Docker compose, Docker los pone en la misma [Docker network](https://docs.docker.com/network/) y gracias a eso, Nginx tiene acceso directo a los puertos de los contenedores frontend y backend. + +
    + +
    + +### Herramientas para la producción + +Los contenedores son herramientas divertidas para usar en el desarrollo, pero el mejor caso de uso para ellos es en el entorno de producción. Hay muchas herramientas más potentes que Docker Compose para ejecutar contenedores en producción. + +Herramientas de orquestación de contenedores pesados como [Kubernetes](https://kubernetes.io/) nos permiten administrar contenedores en un nivel completamente nuevo. Estas herramientas ocultan las máquinas físicas y nos permiten a nosotros, los desarrolladores, preocuparnos menos por la infraestructura. + +Si estás interesado en obtener más información sobre los contenedores, accede al curso [DevOps con Docker](https://devopswithdocker.com) y podrás encontrar más información sobre Kubernetes en el curso avanzado de 5 créditos [DevOps con Kubernetes](https://devopswithkubernetes.com) curso. ¡Ahora deberías tener las habilidades para completar ambos! + +
    + +
    + +### Ejercicios 12.20.-12.22. + +#### Ejercicio 12.20: + +Crea un archivo de producción todo-app/docker-compose.yml con todos los servicios, Nginx, todo-backend, todo-frontend, MongoDB y Redis. Utiliza Dockerfiles en lugar de dev.Dockerfiles y asegúrate de iniciar las aplicaciones en modo de producción. + +Utiliza la siguiente estructura para este ejercicio: + +```bash +todo-app +├── todo-frontend +├── todo-backend +├── nginx.dev.conf +├── docker-compose.dev.yml +├── nginx.conf // highlight-line +└── docker-compose.yml // highlight-line +``` + +#### Ejercicio 12.21: + +Crea un entorno de desarrollo en contenedores similar para una de tus propias aplicaciones, puedes usar las que hayas creado durante el curso o en tu tiempo libre. Debes estructurar la aplicación en tu repositorio de envío de la siguiente manera: + +```bash +└── my-app + ├── frontend + | └── dev.Dockerfile + ├── backend + | └── dev.Dockerfile + ├── nginx.dev.conf + └── docker-compose.dev.yml +``` + +#### Ejercicio 12.22: + +Termina esta parte creando una configuración de producción en contenedores de tu propia aplicación. +Estructura la aplicación en tu repositorio de envío de la siguiente manera: + +```bash +└── my-app + ├── frontend + | ├── dev.Dockerfile + | └── Dockerfile + ├── backend + | └── dev.Dockerfile + | └── Dockerfile + ├── nginx.dev.conf + ├── nginx.conf + ├── docker-compose.dev.yml + └── docker-compose.yml +``` + +### Envío de ejercicios y obtención de créditos. + +Este fue el último ejercicio de esta sección. Es hora de enviar tu código a GitHub y marcar todos sus ejercicios terminados en el [sistema de envío de ejercicios](https://studies.cs.helsinki.fi/stats/courses/fs-containers). + +Los ejercicios de esta parte se envían al igual que en las partes anteriores, pero a diferencia de las partes 0 a 7, la presentación va a una [instancia propia del curso](https://studies.cs.helsinki.fi/stats/courses/fs-containers). ¡Recuerda que tienes que terminar todos los ejercicios para aprobar esta parte! + +Una vez que hayas completado los ejercicios y quieras obtener los créditos, infórmanos a través del sistema de envío de ejercicios que has completado el curso: + +![Submissions](../../images/11/21.png) + +**Ten en cuenta** que necesitas registrarte en la parte del curso correspondiente para obtener los créditos registrados, consulta [aquí](/es/part0/informacion_general#partes-y-finalizacion) para obtener más información. + +Puedes descargar el certificado por completar esta parte haciendo clic en uno de los íconos de bandera. El ícono de la bandera corresponde al idioma del certificado. + +
    + + diff --git a/src/content/12/fi/osa12.md b/src/content/12/fi/osa12.md new file mode 100644 index 00000000000..c214aec5ec2 --- /dev/null +++ b/src/content/12/fi/osa12.md @@ -0,0 +1,11 @@ +--- +mainImage: ../../../images/part-12.svg +part: 12 +lang: fi +--- + +
    + +Kurssin kahdestoista, kontteja käsittelevä osa löytyy [englanninkielisestä kurssimateriaalista](/en/part12). + +
    diff --git a/src/content/12/zh/part12.md b/src/content/12/zh/part12.md new file mode 100644 index 00000000000..7499786dc40 --- /dev/null +++ b/src/content/12/zh/part12.md @@ -0,0 +1,18 @@ +--- +mainImage: ../../../images/part-12.svg +part: 12 +lang: zh +--- + +
    + + + 在这部分中,我们将学习如何将代码打包成标准的软件单元,称为容器。这些容器可以帮助我们比以前更快、更容易地开发软件。在这一过程中,我们还将在现在熟悉的Node.js后端和React前端之外,探索一种全新的网络开发观点。 + + + 我们将利用容器来为我们的Node.js和React项目创建不可改变的执行环境。容器还可以使我们的项目轻松地包含多种服务。有了这种灵活性,我们将通过利用容器来探索和尝试许多不同的流行工具。 + + + 本节由[Jami Kousa](https://github.com/jakousa)与Unity公司位于赫尔辛基的服务基金会团队合作创建。服务基础团队致力于为Unity的其他团队提供平台,以成功完成他们为客户构建伟大服务的使命。该团队热衷于改善Unity的开发者体验,致力于开发Unity Dashboard、Unity Editor和[Unity.com](https://unity.com/)等工具。 + +
    diff --git a/src/content/12/zh/part12a.md b/src/content/12/zh/part12a.md new file mode 100644 index 00000000000..b7ec57edb71 --- /dev/null +++ b/src/content/12/zh/part12a.md @@ -0,0 +1,507 @@ +--- +mainImage: ../../../images/part-12.svg +part: 12 +letter: a +lang: zh +--- + +
    + + + 软件开发包括整个生命周期,从设想软件到编程,再到将软件发布给最终用户,甚至是维护软件。这一部分将介绍容器,一种在软件生命周期的后半部分使用的现代工具。 + + + 容器将你的应用封装成一个单一的包。然后,这个包将包括所有与应用有关的依赖性。因此,每个容器可以与其他容器隔离运行。 + + + 容器防止里面的应用访问设备的文件和资源。开发人员可以给所包含的应用访问文件的权限,并指定可用的资源。更准确地说,容器是操作系统级的虚拟化。最容易比较的技术是虚拟机(VM)。虚拟机被用来在一台物理机器上运行多个操作系统。他们必须运行整个操作系统,而容器则是使用主机操作系统运行软件。因此,虚拟机和容器之间的区别是,运行容器时几乎没有任何开销;它们只需要运行一个进程。 + + +由于容器是相对轻量级的,至少与虚拟机相比,它们可以快速扩展。而且,由于它们隔离了内部运行的软件,它使软件几乎可以在任何地方以相同的方式运行。因此,它们是任何云环境或应用中超过少数用户的首选方案。 + + + 像AWS、谷歌云和微软Azure这样的云服务都支持多种不同形式的容器。这些服务包括AWS Fargate和Google Cloud Run,这两种服务都是以无服务器的方式运行容器--如果不使用应用容器,甚至不需要运行。你也可以在大多数机器上安装容器运行时间,自己在那里运行容器--包括你自己的机器。 + + +所以容器在云环境中,甚至在开发过程中都可以使用。使用容器的好处是什么?这里有两个常见的场景。 + +Scenario 1: You are developing a new application that needs to run on the same machine as a legacy application. Both require different versions of Node installed. + + + 你可能可以使用nvm、虚拟机或黑魔法来让它们同时运行。然而,容器是一个很好的解决方案,因为你可以在各自的容器中运行两个应用。它们是相互隔离的,不会相互干扰。 + +Scenario 2: Your application runs on your machine. You need to move the application to a server. + + +尽管应用在你的机器上运行得很好,但它就是不能在服务器上运行,这种情况并不罕见。这可能是由于某些依赖性的缺失或环境的其他差异。在这里,容器是一个很好的解决方案,因为你可以在你的机器和服务器上的同一执行环境中运行应用。这并不完美:不同的硬件可能是一个问题,但你可以限制环境之间的差异。 + + + 有时你可能会听到"在我的容器中工作"问题。这句话描述了这样一种情况:应用在你的机器上运行的容器中工作正常,但当容器在服务器上启动时就会中断。这句话是对臭名昭著的"在我的机器上工作"问题的一种戏谑,容器通常被 promise 解决这个问题。这种情况也很可能是一个使用错误。 + +### About this part ### + + + 在这一部分,我们关注的重点将不是JavaScript代码。相反,我们对执行软件的环境配置感兴趣。因此,练习可能不包含任何编码,应用可以通过GitHub提供给你,你的任务将包括配置它们。练习将被提交到一个GitHub仓库,其中将包括你在这部分所做的所有源代码和配置。 + + + 你将需要Node、Express和React的基本知识。只有核心部分,即1到5,需要在这部分之前完成。 + +
    + +
    + +### Exercise 12.1 +### Warning + + + 由于我们正走出我们作为JavaScript开发者的舒适区,这部分可能需要你绕道而行,在开始之前熟悉shell/命令行/命令提示符/终端。 + + + 如果你只使用过图形用户界面,从未接触过例如Linux或Mac上的终端,或者如果你在第一个练习中被卡住了,我们建议先做 "CS学习的计算工具 "的第一章节。。跳过 "SSH连接 "部分和练习11。否则,它包括了你在这里开始学习所需要的一切! + +#### Exercise 12.1: Using a computer (without graphical user interface) + + + 第一步:阅读警告标题下的文字。 + + + 第2步:下载这个[仓库](https://github.com/fullstack-hy2020/part12-containers-applications),并把它作为你在这部分的提交仓库。 + + + 第三步:运行curl http://helsinki.fi,并将输出保存到一个文件中。将该文件保存到你的仓库中,作为文件script-answers/exercise12_1.txt。目录script-answers已在上一步中创建。 + +
    +
    + +### Submitting exercises and earning credits ### + + + 通过[提交系统](https://studies.cs.helsinki.fi/stats/)提交练习,就像在前面的部分一样。这一部分的练习被提交到其[自己的课程实例](https://studies.cs.helsinki.fi/stats/courses/fs-containers)。 + + + 在集装箱上完成这部分内容将得到1个学分。注意,你需要做所有的练习来获得学分或证书。 + + +一旦你完成了练习并想获得学分,请通过练习提交系统让我们知道你已经完成了该课程。 + +![Submitting exercises for credits](../../images/10/23.png) + + + 你可以通过点击其中一个旗帜图标下载完成这部分的证书。旗帜图标与证书的语言相对应。 + +### Tools of the trade + + + 你所需要的基本工具在不同的操作系统中有所不同。 + + + * Windows上的[WSL 2终端](https://docs.microsoft.com/en-us/windows/wsl/install-win10) + + * Mac上的终端 + + * Linux上的命令行 + +### Installing everything required for this part ### + + + 我们将从安装所需软件开始。安装步骤将是可行的障碍之一。由于我们正在处理操作系统级的虚拟化,这些工具将需要计算机上的超级用户权限。他们将有机会进入你的操作系统内核。 + + + 该材料是围绕[Docker](https://www.docker.com/)建立的,这是一套我们将用于容器化和容器管理的产品。不幸的是,如果你不能安装Docker,你可能无法完成这一部分。 + + + 由于安装说明取决于你的操作系统,你将不得不从下面的链接中找到正确的安装说明。注意,他们可能对你的操作系统有多个不同的选项。 + + + + - [获取Docker](https://docs.docker.com/get-docker/) + + + 现在这个令人头疼的问题有望得到解决,让我们确保我们的版本相符。你的版本可能比这里的数字高一点。 + +```bash +$ docker -v +Docker version 20.10.5, build 55c4c88 +``` + +### Containers and images + + + 在开始使用容器时有两个核心概念,它们很容易相互混淆。 + + + 一个**容器**是一个**图像**的运行时实例。 + + + 以下两种说法都是真的。 + + + - 图像包括所有的代码、依赖性和关于如何运行应用的指示 + + - 容器将软件打包成标准化的单元 + + + 难怪它们很容易被混淆。 + + + 为了帮助混淆,几乎每个人都用容器这个词来描述两者。但你永远无法真正建立一个容器或下载一个,因为容器只在运行时存在。另一方面,图像是**不可变的**文件。由于它的不可更改性,在你创建了一个镜像之后,你不能再编辑它。然而,你可以使用现有的图像来创建一个新的图像,在现有的图像之上添加新的层。 + + + 烹饪比喻。 + + + * 图像是预先煮好的、冷冻的食物。 + + *容器是美味的食物。 + + + [Docker](https://www.docker.com/)是最流行的容器化技术,并开创了今天大多数容器化技术的标准。在实践中,Docker是一套帮助我们管理镜像和容器的产品。这套产品将使我们能够利用容器的所有好处。例如,docker引擎将负责把称为镜像的不可变文件变成容器。 + + +对于管理docker容器,还有一个叫做[Docker Compose](https://docs.docker.com/compose/)的工具,它允许人们在同一时间**orchestrate**(控制)多个容器。在这一部分,我们将使用Docker Compose来建立一个复杂的本地开发环境。在我们建立的最终版本的开发环境中,甚至将Node安装到我们的机器上都不再是一个要求了。 + + + 有几个概念我们需要去了解。但我们现在先跳过这些,先了解一下Docker! + + + 让我们从命令docker container run开始,该命令用于在容器中运行镜像。该命令的结构如下。_container run IMAGE-NAME_,我们将告诉Docker从一个镜像中创建一个容器。该命令的一个特别好的特点是,即使要运行的镜像还没有下载到我们的设备上,它也可以运行一个容器。 + + + 让我们运行这个命令 + +```bash +§ docker container run hello-world +``` + + + 会有很多输出,但让我们把它分成多个部分,我们可以一起解读。这些行是由我来编号的,这样可以更容易地跟随解释。你的输出将不会有这些数字。 + +```bash +1. Unable to find image 'hello-world:latest' locally +2. latest: Pulling from library/hello-world +3. b8dfde127a29: Pull complete +4. Digest: sha256:5122f6204b6a3596e048758cabba3c46b1c937a46b5be6225b835d091b90e46c +5. Status: Downloaded newer image for hello-world:latest +``` + + + 因为在我们的机器上没有找到镜像hello-world,所以该命令首先从一个名为[Docker Hub](https://hub.docker.com/)的免费注册中心下载它。你可以用你的浏览器在这里看到该镜像的Docker Hub页面。[https://hub.docker.com/_/hello-world](https://hub.docker.com/_/hello-world) + + + 消息的第一章节指出,我们还没有 "hello-world:最新 "的镜像。这揭示了关于图像本身的一些细节;图像名称由多个部分组成,有点像URL。一个图像名称的格式如下。 + + + - _注册处/组织/图像:标签_ + + + 在这种情况下,3个缺失的字段默认为。 + + - _index.docker.io/library/hello-world:update_ + + + 第二行显示了组织名称,"library",它将在那里获得图像。在Docker Hub的网址中,"library "被缩短为_。 + + + 第三和第五行只显示状态。但第4行可能很有趣:每个镜像都有一个基于构建该镜像的的唯一摘要。在实践中,在构建镜像时使用的每个步骤或命令都会创建一个独特的层。Docker使用该摘要来识别一个镜像是相同的。当你试图再次拉取相同的镜像时,就会这样做。 + + + 所以,使用该命令的结果是拉取,然后输出关于**图像的信息。之后,状态告诉我们,确实下载了一个新版本的hello-world:fresh。你可以尝试用_docker image pull hello-world_来拉取镜像,看看会发生什么。 + + + 下面的输出是来自容器本身。它也解释了当我们运行_docker container run hello-world_时发生了什么。 + +```bash +Hello from Docker! +This message shows that your installation appears to be working correctly. + +To generate this message, Docker took the following steps: + 1. The Docker client contacted the Docker daemon. + 2. The Docker daemon pulled the "hello-world" image from the Docker Hub. + (amd64) + 3. The Docker daemon created a new container from that image which runs the + executable that produces the output you are currently reading. + 4. The Docker daemon streamed that output to the Docker client, which sent it + to your terminal. + +To try something more ambitious, you can run an Ubuntu container with: + $ docker container run -it ubuntu bash + +Share images, automate workflows, and more with a free Docker ID: + https://hub.docker.com/ + +For more examples and ideas, visit: + https://docs.docker.com/get-started/ +``` + + + 这个输出包含了一些新的东西供我们学习。Docker daemon是一个后端服务,它确保了容器的运行,我们使用Docker client来与daemon交互。现在我们已经与第一个镜像进行了交互,并从该镜像中创建了一个容器。在该容器的执行过程中,我们收到了这样的输出。 + +
    + +
    + +### Exercise 12.2 + + + 这些练习中有一些不需要你写任何代码或配置到文件中。 + + 在这些练习中,你应该使用[script](https://man7.org/linux/man-pages/man1/script.1.html)命令来记录你所使用的命令;自己尝试一下,用_script_开始记录,_echo "hello"_产生一些输出,_exit_停止记录。它将你的操作保存在一个名为 "typescript "的文件中。 + + + 如果_script_不起作用,你可以把你使用的所有命令复制粘贴到一个文本文件中。 + +#### Exercise 12.2: Running your second container + + + > 使用_script_来记录你所做的事情,将文件保存为script-answers/exercise12_2.txt。 + + + hello-world的输出给了我们一个雄心勃勃的任务要做。做到以下几点 + + + 步骤1.用hello-world给出的命令运行一个Ubuntu容器 + + + 第1步将直接用bash连接到容器中。你将可以访问容器内的所有文件和工具。下面的步骤是在容器内运行的。 + + + 第2步。创建目录/usr/src/app。 + + + 第3步。创建一个文件/usr/src/app/index.js。 + + + 第4步。运行exit从容器中退出 + + + 谷歌应该能够帮助你创建目录和文件。 + +
    + +
    + +### Ubuntu image + + + 你刚才用来运行ubuntu容器的命令,_docker container run -it ubuntu bash_,包含了对之前运行的hello-world的一些补充。让我们看看 --help,以获得更好的理解。我将剪掉一些输出,这样我们就可以把注意力集中在相关部分。 + +```bash +$ docker container run --help + +Usage: docker container run [OPTIONS] IMAGE [COMMAND] [ARG...] +Run a command in a new container + +Options: + ... + -i, --interactive Keep STDIN open even if not attached + -t, --tty Allocate a pseudo-TTY + ... +``` + + + 这两个选项,或者说标志,_-它_确保我们可以与容器互动。在这些选项之后,我们定义了要运行的镜像是_ubuntu_。然后,我们有一个命令_bash_,当我们启动容器时在里面执行。 + + + 你可以试试ubuntu镜像可能会执行的其他命令。作为一个例子,尝试_docker container run --rm ubuntu ls_。ls_命令将列出目录中的所有文件,_--rm_标志将在执行后删除容器。通常情况下,容器是不会自动删除的。 + + + 让我们继续看我们的第一个ubuntu容器,它里面有**index.js**文件。自从我们退出后,该容器已经停止运行。我们可以用_container ls -a_列出所有的容器,_-a_(或--all)会列出已经退出的容器。 + +```bash +$ docker container ls -a +CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES +b8548b9faec3 ubuntu "bash" 3 minutes ago Exited (0) 6 seconds ago hopeful_clarke +``` + + + 在寻址一个容器时,我们有两个选择。第一列中的标识符几乎总是可以用来与容器进行交互。另外,大多数命令都接受容器的名字,作为一种更人性化的工作方法。在我的例子中,容器的名字被自动生成为**"hopeful_clarke "**。 + + + 容器已经退出了,然而我们可以用启动命令再次启动它,该命令将接受容器的id或名称作为参数。_start CONTAINER-ID-OR-CONTAINER-NAME_。 + +```bash +$ docker start hopeful_clarke +hopeful_clarke +``` + + + 这个启动命令将启动我们之前的那个容器。不幸的是,我们忘了用标志_--interactive_来启动它,所以我们不能与它交互。 + + + 正如命令_container ls -a_所显示的那样,这个容器实际上已经启动并运行了,但我们无法与它交流。 + +```bash +$ docker container ls -a +CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES +b8548b9faec3 ubuntu "bash" 7 minutes ago Up (0) 15 seconds ago hopeful_clarke +``` + + + 注意,我们也可以在不加标志_a_的情况下执行这个命令,只看那些正在运行的容器。 + +```bash +$ docker container ls +CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES +8f5abc55242a ubuntu "bash" 8 minutes ago Up 1 minutes hopeful_clarke +``` + + + 让我们用_kill CONTAINER-ID-OR-CONTAINER-NAME_命令杀死它,然后再试试。 + +```bash +$ docker kill hopeful_clarke +hopeful_clarke +``` + + + _docker kill_向进程发送一个[信号SIGKILL](https://man7.org/linux/man-pages/man7/signal.7.html),迫使它退出,这将导致容器的停止。我们可以用_container ls -a_来检查它的状态。 + +```bash +$ docker container ls -a +CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES +b8548b9faec3 ubuntu "bash" 26 minutes ago Exited 2 seconds ago hopeful_clarke +``` + + + 现在让我们再次启动容器,但这次是以交互式模式。 + +```bash +$ docker start -i hopeful_clarke +root@b8548b9faec3:/# +``` + + + 让我们编辑文件index.js并添加一些JavaScript代码来执行。我们只是缺少编辑该文件的工具。目前,Nano将是一个很好的文本编辑器。安装说明是从谷歌的第一个结果中找到的。我们将省略使用sudo,因为我们已经是root。 + +```bash +root@b8548b9faec3:/# apt-get update +root@b8548b9faec3:/# apt-get -y install nano +root@b8548b9faec3:/# nano /usr/src/app/index.js +``` + + + 现在我们已经安装了nano,可以开始编辑文件了! + +
    + +
    + +### Exercise 12.3 - 12.4 + +#### Exercise 12.3: Ubuntu 101 + + + > 使用_script_来记录你所做的事情,将文件保存为script-answers/exercise12_3.txt + + + 用现在安装的nano编辑容器内的_/usr/src/app/index.js_文件,添加以下一行 + +```js +console.log('Hello World') +``` + + + 如果你不熟悉Nano,你可以在聊天中或Google上寻求帮助。 + +#### Exercise 12.4: Ubuntu 102 + + + > 使用_script_来记录你所做的事情,将文件保存为script-answers/exercise12_4.txt + + + 在容器内安装Node,在容器内用_node /usr/src/app/index.js_运行索引文件。 + + +安装Node的说明有时很难找到,所以这里是你可以复制粘贴的东西。 + +```bash +curl -sL https://deb.nodesource.com/setup_16.x | bash +apt install -y nodejs +``` + + + 你将需要在容器中安装_curl_。它的安装方式与你安装_nano_的方式相同。 + + + 安装完成后,确保你可以在容器内用命令运行你的代码 + +``` +root@b8548b9faec3:/# node /usr/src/app/index.js +Hello World +``` + +
    + +
    + +### Other docker commands + + + 现在我们已经在容器中安装了Node,我们可以在容器中执行JavaScript了!让我们从容器中创建一个新的镜像。_commit CONTAINER-ID-OR-CONTAINER-NAME NEW-IMAGE-NAME_将创建一个新的镜像,包括我们所做的修改。你可以在这样做之前使用_container diff_来检查原始镜像和容器之间的变化。 + +```bash +$ docker commit hopeful_clarke hello-node-world +``` + + + 你可以用_image ls_列出你的镜像。 + +```bash +$ docker image ls +REPOSITORY TAG IMAGE ID CREATED SIZE +hello-node-world latest eef776183732 9 minutes ago 252MB +ubuntu latest 1318b700e415 2 weeks ago 72.8MB +hello-world latest d1165f221234 5 months ago 13.3kB +``` + + + 你现在可以按以下方式运行新的镜像。 + +```bash +docker run -it hello-node-world bash +root@4d1b322e1aff:/# node /usr/src/app/index.js +``` + + + 有多种方法可以达到相同的结论。让我们来看看一个更好的解决方案。我们将用_container rm_清理石板,以删除旧的容器。 + +```bash +$ docker container ls -a +CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES +b8548b9faec3 ubuntu "bash" 31 minutes ago Exited (0) 9 seconds ago hopeful_clarke + +$ docker container rm hopeful_clarke +hopeful_clarke +``` + + +在你的当前目录下创建一个文件index.js,并在其中写入_console.log(''Hello, World')_。现在还不需要容器。 + + + 接下来,让我们完全跳过安装Node。在Docker Hub中有很多有用的Docker镜像供我们使用。让我们使用[https://hub.docker.com/_/Node](https://hub.docker.com/_/Node)的镜像,它已经安装了Node。我们只需要选择一个版本。 + + + 顺便说一下,_container run_接受_--name_标志,我们可以用它来给容器起个名字。 + +```bash +$ docker container run -it --name hello-node node:16 bash +``` + + +让我们为容器内的代码创建一个目录。 + +``` +root@77d1023af893:/# mkdir /usr/src/app +``` + + + 当我们在这个终端的容器内时,打开另一个终端,使用_container cp_命令将文件从你自己的机器复制到容器内。 + +```bash +$ docker container cp ./index.js hello-node:/usr/src/app/index.js +``` + + + 现在我们可以在容器中运行_node /usr/src/app/index.js_。我们可以将其作为另一个新的镜像提交,但还有一个更好的解决方案。下一节将是关于像专家一样构建你的图像。 + +
    diff --git a/src/content/12/zh/part12b.md b/src/content/12/zh/part12b.md new file mode 100644 index 00000000000..e96a32a4a1b --- /dev/null +++ b/src/content/12/zh/part12b.md @@ -0,0 +1,956 @@ +--- +mainImage: ../../../images/part-12.svg +part: 12 +letter: b +lang: zh +--- + +
    + + + + 在上一节中,我们使用了两个不同的基础镜像:ubuntu和node,并做了一些手工工作来获得一个简单的 "Hello, World!"运行。在这个过程中,我们学到的工具和命令将是有帮助的。在本节中,我们将学习如何为我们的应用构建镜像和配置环境。我们将从一个普通的Express/Node.js后端开始,在此基础上建立其他服务,包括MongoDB数据库。 + +### Dockerfile + + + 我们可以创建一个包含 "Hello, World!"应用的新镜像,而不是通过复制里面的文件来修改一个容器。这方面的工具是Dockerfile。Dockerfile是一个简单的文本文件,包含创建镜像的所有指令。让我们从 "Hello, World!"应用创建一个Dockerfile的例子。 + + + 如果你还没有,在你的机器上创建一个目录,并在该目录下创建一个名为Dockerfile的文件。让我们也在Dockerfile旁边放一个index.js,包含_console.log(''Hello, World!')_。你的目录结构应该是这样的。 + +``` +├── index.js +└── Dockerfile +``` + + + 在这个Docker文件中,我们将告诉镜像三件事。 + + + - 使用node:16作为我们镜像的基础 + + - 在镜像中包含index.js,这样我们就不需要手动将其复制到容器中。 + + - 当我们从镜像中运行一个容器时,使用node来执行index.js文件。 + + + 上面的愿望将转化为一个基本的Docker文件。放置这个文件的最佳位置通常是在项目的根部。 + + + 产生的Dockerfile如下所示: + +```Dockerfile +FROM node:16 + +WORKDIR /usr/src/app + +COPY ./index.js ./index.js + +CMD node index.js +``` + + + FROM指令将告诉Docker,镜像的基础应该是node:16。COPY指令将把主机上的文件index.js复制到镜像中的同名文件。CMD指令讲述了使用_docker run_时的情况。CMD是默认的指令,然后可以用镜像名称后给出的参数来覆盖。如果你忘记了,请看_docker run --help_。 + + + WORKDIR指令是为了确保我们不干扰镜像的内容而悄悄加入的。它将保证所有下面的命令都将/usr/src/app设置为工作目录。如果该目录不存在于基本镜像中,它将被自动创建。 + + + 如果我们不指定一个WORKDIR,我们就有可能意外地覆盖重要的文件。如果你用_docker run node:16 ls_检查node:16镜像的根(_/_),你可以注意到所有已经包含在镜像中的目录和文件。 + + + 现在我们可以使用命令_docker build_来构建一个基于Docker文件的镜像。让我们用一个额外的标志来完善这个命令。_-t_,这将帮助我们命名镜像。 + +```bash +$ docker build -t fs-hello-world . +[+] Building 3.9s (8/8) FINISHED +... +``` + + + 所以结果是 "docker please build with tag fs-hello-world the Dockerfile in this directory"。你可以指向任何Docker文件,但在我们的例子中,一个简单的点将意味着这个目录中的Docker文件。这就是为什么该命令以句号结束。构建完成后,你可以用_docker run fs-hello-world_运行它。 + + + 由于图像只是文件,它们可以被随意移动、下载和删除。你可以用_docker image ls_列出你在本地的图像,用_docker image rm_删除它们。用_docker image --help_查看你还有哪些可用的命令。 + +### More meaningful image + + + 把Express服务器移到一个容器里应该和把 "Hello, World!"应用移到一个容器里一样简单。唯一的区别是,有更多的文件。幸好_COPY_ 指令可以处理所有这些。让我们删除index.js并创建一个新的Express服务器。让我们使用[express-generator](https://expressjs.com/en/starter/generator.html)来创建一个基本的Express应用骨架。 + +```bash +$ npx express-generator + ... + + install dependencies: + $ npm install + + run the app: + $ DEBUG=playground:* npm start +``` + + + 首先,让我们运行这个应用来了解一下我们刚刚创建的东西。注意,运行应用的命令可能与你不同,我的目录被称为playground。 + +```bash +$ npm install +$ DEBUG=playground:* npm start + playground:server Listening on port 3000 +0ms +``` + + + 很好,所以现在我们可以导航到[http://localhost:3000](http://localhost:3000),应用正在那里运行。 + + + 基于之前的例子,容器化应该是比较容易的。 + + + - 使用节点作为基础 + + - 设置工作目录,这样我们就不会干扰到基础镜像的内容了 + + - 把这个目录下的所有文件都复制到镜像上 + + - 用DEBUG=playground:* npm start开始。 + + + + 让我们把下面的Docker文件放在项目的根目录下。 + +```Dockerfile +FROM node:16 + +WORKDIR /usr/src/app + +COPY . . + +CMD DEBUG=playground:* npm start +``` + + + 让我们用_docker build -t express-server ._的命令从Docker文件中构建镜像,然后用_docker run -p 3123:3000 express-server_运行它。_-p_标志将通知Docker,主机上的一个端口应该被打开并指向容器中的一个端口。其格式是_-p host-port:application-port_。 + +```bash +$ docker run -p 3123:3000 express-server + +> playground@0.0.0 start +> node ./bin/www + +Tue, 29 Jun 2021 10:55:10 GMT playground:server Listening on port 3000 +``` + + + > 如果你的标记不起作用,跳到下一节。这里有一个解释,为什么即使你正确地遵循了这些步骤,它也可能不工作。 + + + 应用现在正在运行!让我们通过向[http://localhost:3123/](http://localhost:3123/)发送一个GET请求来测试它。 + + + 目前,关闭它是一个令人头痛的问题。使用另一个终端和_docker kill_命令来杀死这个应用。_docker kill_将向应用发送一个杀戮信号(SIGKILL),迫使它关闭。它需要容器的名称或ID作为参数。 + + + 顺便说一下,当使用id作为参数时,ID的开头足以让Docker知道我们指的是哪个容器。 + +```bash +$ docker container ls + CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES + 48096ca3ffec express-server "docker-entrypoint.s…" 9 seconds ago Up 6 seconds 0.0.0.0:3123->3000/tcp, :::3123->3000/tcp infallible_booth + +$ docker kill 48 + 48 +``` + + + 在未来,让我们在_-p_的两边使用相同的端口。这样我们就不必记住我们碰巧选择了哪一个。 + +#### Fixing potential issues we created by copy-pasting + + + 为了创建一个更全面的Docker文件,我们需要改变几个步骤。甚至可能因为我们跳过了一个重要的步骤,上面的例子并不是在所有情况下都有效。 + + +当我们在机器上运行npm安装时,在某些情况下,**节点包管理器**可能会在安装步骤中安装操作系统的特定依赖。我们可能会不小心用COPY指令将非功能性的部分转移到镜像中。如果我们把node_modules目录复制到镜像中,这很容易发生。 + + + 当我们建立镜像时,这是一个需要记住的关键问题。最好是在构建过程中做大多数事情,比如在容器内运行_npm install_,而不是在构建前做这些事情。简单的经验法则是,只复制你要推送到GitHub的文件。构建工件或依赖关系不应该被复制,因为它们可以在构建过程中被安装。 + + + 我们可以使用.dockerignore来解决这个问题。.dockerignore这个文件与.gitignore非常相似,你可以用它来防止不需要的文件被复制到你的镜像中。该文件应该放在Dockerfile的旁边。下面是一个可能的.dockerignore的内容 + +``` +.dockerignore +.gitignore +node_modules +Dockerfile +``` + + + 然而,在我们的案例中,.dockerignore并不是唯一需要的东西。我们将需要在构建步骤中安装依赖项。_Dockerfile_改变为。 + +```Dockerfile +FROM node:16 + +WORKDIR /usr/src/app + +COPY . . + +RUN npm install # highlight-line + +CMD DEBUG=playground:* npm start +``` + + + npm的安装可能会有风险。与其使用npm install,npm提供了一个更好的安装依赖项的工具,即_ci_命令。 + + + ci和install之间的区别。 + + + - install可能会更新package-lock.json + + - install 可能会安装不同版本的依赖关系,如果你在依赖关系的版本里有 ^ 或 ~。 + + + - 在安装任何东西之前,ci会删除node_modules文件夹 + + - ci将遵循package-lock.json,不改变任何文件。 + + + 所以简而言之:_ci_创建可靠的构建,而_install_是在你想安装新的依赖时使用的。 + + + 由于我们在构建步骤中没有安装任何新的东西,而且我们不希望版本突然改变,我们将使用_ci_。 + +```Dockerfile +FROM node:16 + +WORKDIR /usr/src/app + +COPY . . + +RUN npm ci # highlight-line + +CMD DEBUG=playground:* npm start +``` + + + 甚至更好的是,我们可以使用_npm ci --only=production_来不浪费时间安装开发依赖。 + + + > 正如你在对比列表中注意到的;npm ci会删除node_modules文件夹,所以创建.dockerignore并不重要。然而,当你想优化你的构建过程时,.dockerignore是一个了不起的工具。我们将在后面简要地谈一谈这些优化。 + + + 现在Docker文件应该又能工作了,用_docker build -t express-server . && docker run -p 3000:3000 express-server_试试。 + + + > 注意,我们在这里用&&连接了两个bash命令。我们可以通过单独运行这两个命令来获得(几乎)同样的效果。当用&&串联命令时,如果一个命令失败了,串联中的下一个命令将不会被执行。 + + + 我们在CMD中为npm启动设置了一个环境变量_DEBUG=playground:*_。然而,通过Dockerfiles,我们也可以使用指令ENV来设置环境变量。让我们这么做吧。 + +```Dockerfile +FROM node:16 + +WORKDIR /usr/src/app + +COPY . . + +RUN npm ci + +ENV DEBUG=playground:* # highlight-line + +CMD npm start # highlight-line +``` + + + > 如果你想知道DEBUG环境变量的作用,请阅读[这里](http://expressjs.com/en/guide/debugging.html#debugging-express)。。 + +#### Dockerfile best practices + + + 在创建图像时,你应该遵循两条经验法则。 + + + - 尽量创建一个**安全的**的图像 + + - 尽量创造一个**小的图像 + + + 较小的映像由于具有较少的攻击表面积而更加安全,较小的映像在部署管道中也移动得更快。 + + + Snyk有一个伟大的清单,列出了节点/表达式容器化的10个最佳实践。阅读这些[这里](https://snyk.io/blog/10-best-practices-to-containerize-nodejs-web-applications-with-docker/)。 + + + 我们还有一个很大的疏忽,就是以root身份运行应用,而不是使用一个权限较低的用户。让我们对Docker文件做一个最后的修正。 + +```Dockerfile +FROM node:16 + +WORKDIR /usr/src/app + +COPY --chown=node:node . . # highlight-line + +RUN npm ci + +ENV DEBUG=playground:* + +USER node # highlight-line + +CMD npm start +``` + +
    + +
    + +### Exercise 12.5. + +#### Exercise 12.5: Containerizing a Node application + + + 你在第一个练习中克隆或复制的仓库包含一个todo-app。请看todo-app/todo-backend并通读README。我们暂时不碰todo-frontend。 + + + 步骤1.通过创建一个todo-app/todo-backend/Dockerfile和构建一个镜像,将todo-backend容器化。 + + + 第2步。在打开正确的端口的情况下运行todo-backend镜像。确保通过浏览器使用 http://localhost:3000/ (或其他端口,如果你这样配置)时,访问计数器会增加。 + + + 提示。在开始容器化之前,在容器外运行应用来检查它。 + +
    + +
    + +### Using docker-compose + + + 在上一节中,我们创建了express-server,并知道它在3000端口运行,然后用_docker build -t express-server . && docker run -p 3000:3000 express-server_运行它。这看起来已经是你需要放到脚本中去记忆的东西了。幸运的是,Docker为我们提供了一个更好的解决方案。 + + + [Docker-compose](https://docs.docker.com/compose/)是另一个神奇的工具,它可以帮助我们管理容器。让我们开始使用docker-compose,因为它可以帮助我们节省一些配置的时间,当我们学习更多关于容器的知识。 + + + 从这个链接安装docker-compose工具: [https://docs.docker.com/compose/install/](https://docs.docker.com/compose/install/) + + + 让我们检查一下它是否工作。 + +```bash +$ docker-compose -v +docker-compose version 1.29.2, build 5becea4c +``` + + + 现在我们可以把之前的咒语变成一个yaml文件。关于yaml文件最好的部分是你可以把这些文件保存到Git仓库里 + + + 创建文件**docker-compose.yml**,并把它放在项目的根目录下,紧挨着Docker文件。该文件内容为 + +```yaml +version: '3.8' # Version 3.8 is quite new and should work + +services: + app: # The name of the service, can be anything + image: express-server # Declares which image to use + build: . # Declares where to build if image is not found + ports: # Declares the ports to publish + - 3000:3000 +``` + + + 每一行的含义都以注释的形式解释。如果你想看到完整的规范,请看[文档](https://docs.docker.com/compose/compose-file/compose-file-v3/)。 + + + 现在我们可以使用_docker-compose up_来构建和运行该应用。如果我们想重建图像,可以使用_docker-compose up --build_。 + + + 你也可以用_docker-compose up -d_ (_-d_表示分离)在后端运行应用,用_docker-compose down_关闭它。 + + + 创建像这样的文件,声明你想要的东西,而不是你需要按特定顺序/特定次数运行的脚本文件,通常是一个很好的做法。 + +
    + +
    + +### Exercise 12.6. + +#### Exercise 12.6: docker-compose + + + 创建一个todo-app/todo-backend/docker-compose.yml文件,与之前练习中的节点应用一起使用。 + + + 访问计数器是唯一需要工作的功能。 + +
    + +
    + +### Utilizing containers in development + + + 当你在开发软件时,容器化可以用各种方式来提高你的生活质量。最有用的情况之一是绕过了两次安装和配置工具的需要。 + + + 把你的整个开发环境移到一个容器中可能不是最好的选择,但如果这是你想要的,那是可行的。我们将在本章节的最后重新审视这个想法。但在那之前,在容器之外运行node应用本身。 + + + 我们在前面的练习中遇到的应用使用MongoDB。让我们探索[Docker Hub](https://hub.docker.com/),找到一个MongoDB镜像。Docker Hub是Docker拉取镜像的默认地点,你也可以使用其他注册表,但由于我们已经深入到Docker中,所以这是一个不错的选择。通过快速搜索,我们可以找到[https://hub.docker.com/_/mongo](https://hub.docker.com/_/mongo) + + + 创建一个新的yaml,名为todo-app/todo-backend/docker-compose.dev.yml,看起来如下。 + +```yml +version: '3.8' + +services: + mongo: + image: mongo + ports: + - 3456:27017 + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + MONGO_INITDB_DATABASE: the_database +``` + + + 上面定义的两个第一环境变量的含义在Docker Hub页面上有解释。 + + + > 这些变量结合起来使用,可以创建一个新的用户并设置该用户的密码。这个用户在管理员认证数据库中被创建,并被赋予root的角色,这是一个 "超级用户 "角色。 + + + 最后一个环境变量*MONGO\_INITDB\_DATABASE*将告诉MongoDB以该名称创建一个数据库。 + + +你可以使用_-f_标志来指定一个文件来运行Docker Compose命令,例如:_docker-compose -f docker-compose.dev.yml up_。现在,我们可能有多个它's useful. + + + 现在用_docker-compose -f docker-compose.dev.yml up -d_启动MongoDB。使用_-d_,它将在后端运行。你可以用_docker-compose -f docker-compose.dev.yml logs -f_查看输出日志。那里的_-f_将确保我们遵循日志。 + + + 如前所述,目前我们想在容器中运行Node应用。在应用本身处于容器内时进行开发是一个挑战。我们将在本章节的后面探讨这个选项。 + + + 首先在你的机器上运行老式的_npm install_来设置Node应用。然后用相关的环境变量启动应用。你可以修改代码,将它们设置为默认值,或者使用.env文件。把这些密钥放到GitHub上没有什么坏处,因为它们只在你的本地开发环境中使用。我只是把它们和_npm run dev_放在一起,帮助你复制粘贴。 + +```bash +$ MONGO_URL=mongodb://localhost:3456/the_database npm run dev +``` + + + 这还不够,我们需要创建一个用户,以便在容器中获得授权。url http://localhost:3000/todos 导致了一个认证错误。 + +```bash +[nodemon] 2.0.12 +[nodemon] to restart at any time, enter `rs` +[nodemon] watching path(s): *.* +[nodemon] watching extensions: js,mjs,json +[nodemon] starting `node ./bin/www` +(node:37616) UnhandledPromiseRejectionWarning: MongoError: command find requires authentication + at MessageStream.messageHandler (/Users/mluukkai/opetus/docker-fs/container-app/express-app/node_modules/mongodb/lib/cmap/connection.js:272:20) + at MessageStream.emit (events.js:314:20) +``` + +### Bind mount and initializing the database + + + 在[MongoDB Docker Hub](https://hub.docker.com/_/mongo)页面的 "初始化一个新实例 "下有关于如何执行JavaScript来初始化数据库和用户的信息。 + + + 练习项目有文件todo-app/todo-backend/mongo/mongo-init.js,内容如下。 + +```js +db.createUser({ + user: 'the_username', + pwd: 'the_password', + roles: [ + { + role: 'dbOwner', + db: 'the_database', + }, + ], +}); + +db.createCollection('todos'); + +db.todos.insert({ text: 'Write code', done: true }); +db.todos.insert({ text: 'Learn about containers', done: false }); +``` + + + 这个文件将用一个用户和一些todos来初始化数据库。接下来,我们需要在启动时将其放入容器中。 + + + 我们可以创建一个新的镜像 FROM mongo 并将文件复制到里面,或者我们可以使用 bind mount 将文件 mongo-init.js 挂到容器中。让我们来做后者。 + + + 绑定挂载是将主机上的文件与容器中的文件绑定的行为。我们可以用_container run_添加一个_-v_标志。语法是_-v FILE-IN-HOST:FILE-IN-CONTAINER_。因为我们已经学习了Docker Compose,所以我们跳过这个。绑定挂载在docker-compose的volumes键下声明。否则格式是一样的,先是主机,然后是容器。 + +```yml + mongo: + image: mongo + ports: + - 3456:27017 + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + MONGO_INITDB_DATABASE: the_database + # highlight-start + volumes: + - ./mongo/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js + # highlight-end +``` + + + 绑定挂载的结果是,主机的mongo文件夹中的文件mongo-init.js与容器的/docker-entrypoint-initdb.d目录中的mongo-init.js文件相同。对任何一个文件的修改都可以在另一个文件中使用。我们不需要在运行时做任何改变。但这将是在容器中开发软件的关键。 + + + 运行_docker-compose -f docker-compose.dev.yml down --volumes_以确保没有任何东西被留下,然后用_docker-compose -f docker-compose.dev.yml up_从一张白纸开始初始化数据库。 + + + 如果你看到类似这样的错误。 + +```bash +mongo_database | failed to load: /docker-entrypoint-initdb.d/mongo-init.js +mongo_database | exiting with code -3 +``` + + + 你可能有一个读取权限问题。在处理卷的时候,它们并不罕见。在上述情况下,你可以使用_chmod a+r mongo-init.js_,这将给予每个人对该文件的读取权限。使用_chmod_时要小心,因为授予更多的权限可能是一个安全问题。只在你电脑上的mongo-init.js上使用_chmod_。 + + + 现在用正确的环境变量启动Express应用应该可以了。 + +```bash +$ MONGO_URL=mongodb://the_username:the_password@localhost:3456/the_database npm run dev +``` + + + 让我们检查一下 http://localhost:3000/todos 是否返回所有的 todos。它应该返回我们初始化的两个todos。我们可以而且应该使用Postman来测试应用的基本功能,比如添加或删除一个todos。 + +### Persisting data with volumes + + + 默认情况下,容器是不会保存我们的数据的。当你关闭mongo容器时,你可能会也可能不会拿回数据。 + + +这是一种罕见的情况,它确实保留了数据,因为为Mongo制作Docker镜像的开发者已经定义了一个要使用的卷。[https://github.com/docker-library/mongo/blob/cb8a419053858e510fc68ed2d69415b3e50011cb/4.4/Dockerfile#L113](https://github.com/docker-library/mongo/blob/cb8a419053858e510fc68ed2d69415b3e50011cb/4.4/Dockerfile#L113) 这一行将指示Docker保留这些目录中的数据。 + + + 有两种不同的方法来存储数据。 + + - 在你的文件系统中声明一个位置(称为绑定挂载) + + - 让Docker决定存储数据的位置(卷)。 + + + 在大多数情况下,只要你真的需要避免删除数据,我更喜欢第一种选择。让我们看看这两种方式在docker-compose中的应用。 + +```yml +services: + mongo: + image: mongo + ports: + - 3456:27017 + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + MONGO_INITDB_DATABASE: the_database + volumes: + - ./mongo/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js + - ./mongo_data:/data/db # highlight-line +``` + + + 上面将在你的本地文件系统中创建一个名为*mongo/data*的目录,并将其映射到容器中的_/data/db_。这意味着_/data/db_中的数据被存储在容器之外,但仍然可以被容器访问!只要记得把这个目录添加到.gitignore中。 + + + 类似的结果也可以用一个命名的卷来实现。 + +```yml +services: + mongo: + image: mongo + ports: + - 3456:27017 + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + MONGO_INITDB_DATABASE: the_database + volumes: + - ./mongo/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js + - mongo_data:/data/db # highlight-line + +volumes: + mongo_data: +``` + + + 现在卷被创建,但由Docker管理。启动应用(_docker-compose -f docker-compose.dev.yml up_)后,你可以用_docker volume ls_列出卷,用_docker volume inspect_检查其中一个,甚至用_docker volume rm_删除它们。它仍然存储在你的本地文件系统中,但找出哪里可能不像以前的选项那样简单。 + +
    + +
    + +### Exercise 12.7. + +#### Exercise 12.7: Little bit of MongoDB coding + + + 注意,这个练习假定你已经完成了练习12.5之后的材料中的所有配置。你仍然应该在容器外运行todo-app后端,只是MongoDB暂时被容器化。 + + + todo应用没有正确实现获取一个todo(GET /todos/:id)和更新一个todo(PUT /todos/:id)的路由。修复代码。 + +
    + +
    + +### Debugging issues in containers + + + > 在编码时,你很可能最终陷入一切都被破坏的境地。。 + + + > `- Matti Luukkainen + + + 当使用容器开发时,我们需要学习新的调试工具,因为我们不能只是 "console.log "一切。当代码出现错误时,你可能经常处于这样一种状态,即至少有一些东西在工作,所以你可以从那里继续工作。配置最经常处于两种状态中的一种。1.工作或2.损坏。我们将介绍一些工具,当你的应用处于后一种状态时,它们可以提供帮助。 + + + 当开发软件时,你可以安全地一步一步地前进,一直验证你所编码的东西是否符合预期。通常,在做配置的时候,情况并非如此。你所写的配置可能在完成的那一刻才会被破坏。因此,当你写了很长的docker-compose.yml或Dockerfile而它没有工作时,你需要花点时间,想一想你可以通过各种方式来确认某些东西是否工作。 + +Question Everything is still applicable here. As said in [part 3](/en/part3/saving_data_to_mongo_db): The key is to be systematic. Since the problem can exist anywhere, you must question everything, and eliminate all possible sources of error one by one. + + + 对我自己来说,最有价值的调试方法是停下来思考我想完成的任务,而不是一味地对着问题乱撞。通常情况下,有一个简单的、备用的解决方案或快速的谷歌搜索会让我继续前进。 + +#### exec + + + Docker命令[exec](https://docs.docker.com/engine/reference/commandline/exec/)是一个重击手。它可以用来在一个容器运行时直接跳入它。 + + + 让我们在后端启动一个Web服务器,做一点调试,让它运行并在浏览器中显示 "Hello, exec!"的信息。让我们选择[Nginx](https://www.nginx.com/),除此之外,它是一个能够提供静态HTML文件的服务器。它有一个默认的index.html,我们可以替换它。 + +```bash +$ docker container run -d nginx +``` + + + 好的,现在的问题是。 + + + - 我们的浏览器应该去哪里? + + -它甚至在运行吗? + + + 我们知道如何回答后者:通过列出运行中的容器。 + +```bash +$ docker container ls +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +3f831a57b7cc nginx "/docker-entrypoint.…" About a minute ago Up About a minute 80/tcp keen_darwin +``` + + + 是的!我们也得到了第一个问题的答案。从上面的输出中可以看出,它似乎是在监听80端口。 + + + 让我们把它关闭,然后用_-p_标志重新启动,让我们的浏览器访问它。 + +```bash +$ docker container stop keen_darwin +$ docker container rm keen_darwin + +$ docker container run -d -p 8080:80 nginx +``` + + + 让我们到http://localhost:8080,看看这个应用。看来这个应用显示了错误的信息!让我们直接跳到容器中去,解决这个问题。保持你的浏览器打开,我们不需要为这个修复而关闭容器。我们将在容器中执行bash,标志_-it_将确保我们能与容器进行交互。 + +```bash +$ docker container ls +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +7edcb36aff08 nginx "/docker-entrypoint.…" About a minute ago Up About a minute 0.0.0.0:8080->80/tcp, :::8080->80/tcp wonderful_ramanujan + +$ docker exec -it wonderful_ramanujan bash +root@7edcb36aff08:/# +``` + + + 现在我们已经进入了,我们需要找到有问题的文件并替换它。快速的谷歌告诉我们这个文件本身是_/usr/share/nginx/html/index.html_。 + + + 让我们移动到该目录并删除该文件 + +```bash +root@7edcb36aff08:/# cd /usr/share/nginx/html/ +root@7edcb36aff08:/# rm index.html +``` + + + 现在,如果我们去http://localhost:8080/,我们知道我们删除了正确的文件。该页面显示404。让我们用一个包含正确内容的文件来替换它。 + +```bash +root@7edcb36aff08:/# echo "Hello, exec!" > index.html +``` + + + 刷新页面,我们的信息就显示出来了!现在我们知道exec是如何被用来与容器交互的。记住,当容器被删除时,所有的变化都会丢失。为了保留这些变化,你必须使用_commit_,就像我们在[上一节](/en/part12/introduction_to_containers#other-docker-commands)中所做的那样。 + +
    + +
    + +### Exercise 12.8. + +#### Exercise 12.8: Mongo command-line interface + + + > 使用_script_来记录你所做的事情,将文件保存为script-answers/exercise12_8.txt + + + 当前面练习中的MongoDB在运行时,用mongo命令行界面(CLI)访问该数据库。你可以用docker exec来做。然后用CLI添加一个新的todo。 + + + 在容器内打开CLI的命令是_mongo_。 + + + mongo CLI将需要用户名和密码标志来正确认证。标志_-u root -p example_应该可以,这些值来自docker-compose.dev.yml。 + + + * 第一步:运行MongoDB + + * 第2步:使用docker exec来进入容器内部 + + * 第3步:打开mongo cli。 + + + 当你连接到mongo cli,你可以要求它显示里面的dbs。 + +```bash +> show dbs +admin 0.000GB +config 0.000GB +local 0.000GB +the_database 0.000GB +``` + + + 要访问正确的数据库。 + +```bash +> use the_database +``` + + + 最后要找出数据库的集合。 + +```bash +> show collections +todos +``` + + + 我们现在可以访问这些集合中的数据。 + +```bash +> db.todos.find({}) +{ "_id" : ObjectId("611e54b688ddbb7e84d3c46b"), "text" : "Write code", "done" : true } +{ "_id" : ObjectId("611e54b688ddbb7e84d3c46c"), "text" : "Learn about containers", "done" : false } +``` + + + 插入一个新的todo,内容为"增加我的工具带中的工具数量",完成状态为假。请参考[文档](https://docs.mongodb.com/v4.4/reference/method/db.collection.insertOne/#mongodb-method-db.collection.insertOne)以了解如何增加。 + + + 确保你在Express应用中和从Mongo CLI查询时都能看到这个新的todo。 + +
    + +
    + +### Redis + + + [Redis](https://redis.io/)是一个[key-value](https://redis.com/nosql/key-value-databases/)数据库。与MongoDB相比,存储在键值存储中的数据结构较少,例如没有集合或表,它只包含一些数据,可以根据附加在数据上的keyvalue)来获取。 + + + 默认情况下,Redis在内存中工作,这意味着它不会持久性地存储数据。 + + + Redis的一个很好的用例是把它作为一个缓存。缓存通常被用来存储数据,否则获取和保存数据的速度会很慢,直到它不再有效。在缓存失效后,你会再次获取数据并将其存储在缓存中。 + + + Redis与容器毫无关系。但既然我们已经能够在你的应用中添加任何第三方服务,为什么不了解一个新的服务呢。 + +
    + +
    + +### Exercises 12.9. - 12.11. + +#### Exercise 12.9: Set up Redis for the project + + + Express服务器已经被配置为使用Redis,它只缺少*REDIS_URL*环境变量。应用将使用该环境变量来连接到Redis。阅读[Docker Hub的Redis页面](https://hub.docker.com/_/redis),将Redis添加到todo-app/todo-backend/docker-compose.dev.yml中,在mongo之后定义另一个服务。 + +```yml +services: + mongo: + ... + redis: + ??? +``` + + + 由于Docker Hub页面没有所有的信息,我们可以用谷歌来帮助我们。通过这样做可以找到Redis的默认端口。 + +![](../../images/12/redis_port_by_google.png) + + + 除非我们尝试,否则我们不会知道这个配置是否有效。应用不会自己开始使用Redis,这将在下一个练习中发生。 + + + 一旦Redis被配置并启动,重新启动后端并给它一个REDIS\_URL,其形式为redis://host:port。 + +```bash +$ REDIS_URL=insert-redis-url-here MONGO_URL=mongodb://localhost:3456/the_database npm run dev +``` + + + 你现在可以通过在Express服务器上添加以下一行来测试该配置 + +```js +const redis = require('../redis') +``` + + +到Express服务器上,例如,在文件routes/index.js中。如果没有发生什么,说明配置是正确的。如果没有,服务器就会崩溃。 + +```bash +events.js:291 + throw er; // Unhandled 'error' event + ^ + +Error: Redis connection to localhost:637 failed - connect ECONNREFUSED 127.0.0.1:6379 + at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1144:16) +Emitted 'error' event on RedisClient instance at: + at RedisClient.on_error (/Users/mluukkai/opetus/docker-fs/container-app/express-app/node_modules/redis/index.js:342:14) + at Socket. (/Users/mluukkai/opetus/docker-fs/container-app/express-app/node_modules/redis/index.js:223:14) + at Socket.emit (events.js:314:20) + at emitErrorNT (internal/streams/destroy.js:100:8) + at emitErrorCloseNT (internal/streams/destroy.js:68:3) + at processTicksAndRejections (internal/process/task_queues.js:80:21) { + errno: -61, + code: 'ECONNREFUSED', + syscall: 'connect', + address: '127.0.0.1', + port: 6379 +} +[nodemon] app crashed - waiting for file changes before starting... +``` + +#### Exercise 12.10: + + + 该项目已经安装了[https://www.npmjs.com/package/redis](https://www.npmjs.com/package/redis)和两个 " promise "的函数--getAsync和setAsync。 + + + - setAsync函数接收键和值,用键来存储值。 + + + - getAsync函数接收键并在一个 promise 中返回值。 + + + 实现一个todo计数器,将创建的todos的数量保存到Redis。 + + + - 第1步:每当发送一个添加todo的请求时,将计数器增量为1。 + + - 第2步:创建一个GET /statistics端点,在那里你可以询问使用情况元数据。其格式应该是以下JSON。 + +```json +{ + "added_todos": 0, +} +``` + +#### Exercise 12.11: + + + > 使用_script_来记录你所做的,将文件保存为script-answers/exercise12_11.txt + + + 如果应用的行为不符合预期,直接访问数据库可能有利于准确定位问题。让我们试试如何使用[redis-cli](https://redis.io/topics/rediscli)来访问数据库。 + + + - 用_docker exec_进入redis容器,打开redis-cli。 + + - 用_[KEYS *](https://redis.io/commands/keys)_找到你使用的键。 + + - 用[GET](https://redis.io/commands/get)命令检查该键的值 + + - 将计数器的值设为9001,从[这里](https://redis.io/commands/)找到正确的命令 + + - 通过刷新页面,确保新的值能够发挥作用 http://localhost:3000/statistics + + - 用postman创建一个新的todo,并从redis-cli中确保计数器有相应的增加。 + + - 从cli中删除键,并确保当新的todos被添加时计数器工作。 + +
    + +
    + +### Persisting data with Redis + + + 在上一节中提到,默认情况下Redis并不持久化数据。然而,持久化是很容易切换的。我们只需要按照[Docker hub page](https://hub.docker.com/_/redis)的指示,用一个不同的命令来启动Redis。 + +```yml +services: + redis: + # Everything else + command: ['redis-server', '--appendonly', 'yes'] # Overwrite the CMD + volumes: # Declare the volume + - ./redis_data:/data +``` + + + 数据现在将被持久化到主机的redis_data目录。 + + 记得将该目录添加到.gitignore! + +#### Other functionality of Redis + + + 除了对键和值的GET、SET和DEL操作外,Redis还可以做很多事情。例如,它可以自动过期键,当Redis被用作缓存时,这是一个非常有用的功能。 + + + Redis也可以用来实现所谓的[发布-订阅](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern)(或PubSub)模式,这是一种分布式应用的异步通信机制。在这种情况下,Redis作为两个或多个应用之间的消息代理工作。一些应用通过向Redis发送消息来发布消息,当消息到达时,Redis会通知已经订阅这些消息的各方。 + +
    + +
    + +### Exercise 12.12. + +#### Exercise 12.12: Persisting data in Redis + + + 检查数据是否默认不被持久化:在运行_docker-compose -f docker-compose.dev.yml down_和_docker-compose -f docker-compose.dev.yml up_之后,计数器值被重置为0。 + + + 然后为Redis数据创建一个卷(通过modifying todo-app/todo-backend/docker-compose.dev.yml ),并确保数据在运行_docker-compose -f docker-compose.dev.yml down_和_docker-compose -f docker-compose.dev.yml up_之后仍然存在。 + +
    diff --git a/src/content/12/zh/part12c.md b/src/content/12/zh/part12c.md new file mode 100644 index 00000000000..bb33fb3b54a --- /dev/null +++ b/src/content/12/zh/part12c.md @@ -0,0 +1,770 @@ +--- +mainImage: ../../../images/part-12.svg +part: 12 +letter: c +lang: zh +--- + +
    + +### React in container + + + 接下来让我们创建一个React应用并进行容器化。让我们选择npm作为软件包管理器,尽管create-react-app默认为yarn。 + +``` +$ npx create-react-app hello-front --use-npm + ... + + Happy hacking! +``` + + + create-react-app已经为我们安装了所有的依赖项,所以我们不需要在这里运行npm install。 + + + 下一步是将JavaScript代码和CSS,变成可生产的静态文件。create-react-app已经有_build_作为一个npm脚本,所以让我们使用它。 + +``` +$ npm run build + ... + + Creating an optimized production build... + ... + The build folder is ready to be deployed. + ... +``` + + + 很好!最后一步是想出一个办法,使用服务器来提供静态文件。正如你所知,我们可以使用[express.static](https://expressjs.com/en/starter/static-files.html)和Express服务器来提供静态文件。我将把这个问题留给你在家里做练习。相反,我们将继续写我们的Docker文件。 + +```Dockerfile +FROM node:16 + +WORKDIR /usr/src/app + +COPY . . + +RUN npm ci + +RUN npm run build +``` + + + 这看起来是对的。让我们来构建它,看看我们是否在正确的轨道上。我们的目标是让构建成功而不出错。然后我们将使用bash检查容器内部,看看文件是否在那里。 + +```bash +$ docker build . -t hello-front + [+] Building 172.4s (10/10) FINISHED + +$ docker run -it hello-front bash + +root@98fa9483ee85:/usr/src/app# ls + Dockerfile README.md build node_modules package-lock.json package.json public src + +root@98fa9483ee85:/usr/src/app# ls build/ + asset-manifest.json favicon.ico index.html logo192.png logo512.png manifest.json robots.txt static +``` + + + 既然我们在容器中已经有了Node,那么为静态文件提供服务的一个有效选项是[service](https://www.npmjs.com/package/serve)。让我们试着安装serve,并在容器内提供静态文件。 + +```bash +root@98fa9483ee85:/usr/src/app# npm install -g serve + + added 88 packages, and audited 89 packages in 6s + +root@98fa9483ee85:/usr/src/app# serve build + + ┌───────────────────────────────────┐ + │ │ + │ Serving! │ + │ │ + │ Local: http://localhost:5000 │ + │ │ + └───────────────────────────────────┘ + +``` + + + 太好了!让我们用ctrl+c退出,然后把这些添加到我们的Docker文件中。 + + + 服务的安装在Docker文件中变成了一个RUN。这样,在构建过程中就可以安装这个依赖关系。到serve构建目录的命令将成为启动容器的命令。 + +```Dockerfile +FROM node:16 + +WORKDIR /usr/src/app + +COPY . . + +RUN npm ci + +RUN npm run build + +RUN npm install -g serve # highlight-line + +CMD ["serve", "build"] # highlight-line +``` + + + 我们的CMD现在包括方括号,因此我们现在使用了所谓的CMD的exec形式。实际上,CMD有**三种**不同的形式,其中exec形式是首选。阅读[文档](https://docs.docker.com/engine/reference/builder/#cmd)获取更多信息。 + + + 当我们现在用_docker build构建镜像。-t hello-front_并使用_docker run -p 5000:3000 hello-front_运行它,应用将在http://localhost:5000 + +### Using multiple stages + + + 虽然服务是一个有效的选项,我们可以做得更好。一个好的目标是创建Docker镜像,使其不包含任何无关的东西。有了最小数量的依赖,镜像就不太可能随着时间的推移而损坏或变得脆弱。 + + + [多阶段构建](https://docs.docker.com/develop/develop-images/multistage-build/)是为将构建过程分成许多独立的阶段而设计的,在这些阶段中可以限制镜像文件的哪些部分被移动。这为限制图像的大小提供了可能,因为并非所有的构建副产品都是所产生的图像所必需的。较小的图像在上传和下载时更快,它们有助于减少你的软件可能存在的漏洞数量。 + + + 对于多阶段构建,像[Nginx](https://en.wikipedia.org/wiki/Nginx)这样久经考验的解决方案可以用来提供静态文件,而不会有很多麻烦。Docker Hub [Nginx的页面](https://hub.docker.com/_/nginx)告诉我们打开端口和 "托管一些简单的静态内容 "所需的信息。 + + + 让我们使用之前的Docker文件,但改变FROM以包括舞台的名称。 + +```Dockerfile +# The first FROM is now a stage called build-stage +FROM node:16 AS build-stage # highlight-line + +WORKDIR /usr/src/app + +COPY . . + +RUN npm ci + +RUN npm run build + +# This is a new stage, everything before this is gone, except the files we want to COPY +FROM nginx:1.20-alpine # highlight-line + +# COPY the directory build from build-stage to /usr/share/nginx/html +# The target location here was found from the docker hub page +COPY --from=build-stage /usr/src/app/build /usr/share/nginx/html # highlight-line +``` + + + 我们还声明了另一个阶段,其中只移动了第一阶段的相关文件(build目录,包含静态内容)。 + + + 在我们再次构建之后,图像就可以为静态内容提供服务了。Nginx的默认端口将是80,所以像_-p 8000:80_这样的端口也可以工作,所以运行命令的参数需要改变一下。 + + + 多阶段构建还包括一些内部优化,可能会影响你的构建。举个例子,多阶段构建会跳过那些不使用的阶段。如果我们想用一个阶段来代替构建管道的一部分,比如测试或通知,我们必须把**一些**数据传递给下面的阶段。在某些情况下,这是合理的:把测试阶段的代码复制到构建阶段。这可以确保你正在构建经过测试的代码。 + +
    + +
    + +### Exercises 12.13 - 12.14. + +#### Exercise 12.13: Todo application frontend + + + 最后,我们到了todo-frontend。查看todo-app/todo-frontend并阅读README。 + + +先在容器外运行前端,确保它能与后端一起工作。 + + + 通过创建todo-app/todo-frontend/Dockerfile来容器化应用,并使用[ENV](https://docs.docker.com/engine/reference/builder/#env)指令将*REACT\_APP\_BACKEND\_URL*传递给应用并与后端一起运行。后端应该仍然在容器外运行。请注意,你需要在构建前端之前设置*REACT\_APP\_BACKEND\_URL*,否则它就不会在代码中被定义! + +#### Exercise 12.14: Testing during the build process + + + 利用多阶段构建的一个有趣的可能性是为[测试](https://docs.docker.com/language/nodejs/run-tests/)使用一个单独的构建阶段。如果测试阶段失败,整个构建过程也会失败。请注意,将所有的测试在构建镜像的过程中完成可能不是最好的主意,但可能有一些与容器化相关的测试,这可能是一个好主意。 + + + 提取一个代表单一todo的组件Todo。为新的组件写一个测试,并在构建过程中添加运行测试。 + + +用_CI=true npm test_运行测试,否则come-react-app会开始观察变化,你的管道会被卡住。 + + + 如果你想这样做,你可以为测试添加一个新的构建阶段。如果你这样做,记得再读一遍练习12.13前的最后一段话 + +
    + +
    + +### Development in containers + + + 让我们把整个todo应用的开发转移到一个容器中。有几个原因可以说明你为什么要这样做。 + + + - 保持开发和生产环境的相似性,以避免只出现在生产环境中的bug + + - 避免开发人员和他们的个人环境之间的差异导致应用开发的困难 + + - 通过让新的团队成员安装容器运行时间来帮助他们跳入,而不要求其他。 + + + 这些都是很好的理由。权衡之下,我们可能会遇到一些非常规的行为,当我们没有像我们习惯的那样运行应用。我们至少需要做两件事来把应用移到一个容器中。 + + + - 以开发模式启动应用 + + - 用VSCode访问文件 + + + 让我们从前端开始。由于Dockerfile将与生产的Dockerfile有很大的不同,让我们创建一个新的,叫做dev.Dockerfile。 + + + 在开发模式下启动create-react-app应该很容易。让我们从下面开始。 + +```Dockerfile +FROM node:16 + +WORKDIR /usr/src/app + +COPY . . + +# Change npm ci to npm install since we are going to be in development mode +RUN npm install + +# npm start is the command to start the application in development mode +CMD ["npm", "start"] +``` + + + 在构建过程中,标志_-f_将被用来告诉使用哪个文件,否则它将默认为Dockerfile,所以_docker build -f ./dev.Dockerfile -t hello-front-dev ._ 将构建镜像。create-react-app将在3000端口提供服务,所以你可以通过运行一个发布了该端口的容器来测试它是否工作。 + + + 第二个任务,用VSCode访问文件,还没有完成。至少有两种方法可以做到这一点。 + + + - [Visual Studio Code Remote - Containers extension](https://code.visualstudio.com/docs/remote/containers) + + - 卷,与我们用数据库保存数据的方法相同 + + + 让我们来看看后者,因为它也可以在其他编辑器中使用。让我们用标志_-v_做一次试运行,如果成功了,那么我们就把配置移到docker-compose文件中。为了使用_-v_,我们将需要告诉它当前的目录。命令_pwd_应该为你输出当前目录的路径。在你的命令行中用_echo $(pwd)_试试。我们可以用它作为_-v_的左侧,将当前目录映射到容器的内部,或者你可以使用完整的目录路径。 + +```bash +$ docker run -p 3000:3000 -v "$(pwd):/usr/src/app/" hello-front-dev + + Compiled successfully! + + You can now view hello-front in the browser. +``` + + + 现在我们可以编辑文件src/App.js,并且这些变化应该被热加载到浏览器上! + + + 接下来,让我们把配置移到docker-compose.yml。这个文件也应该在项目的根目录下。 + +```yml +services: + app: + image: hello-front-dev + build: + context: . # The context will pick this directory as the "build context" + dockerfile: dev.Dockerfile # This will simply tell which dockerfile to read + volumes: + - ./:/usr/src/app # The path can be relative, so ./ is enough to say "the same location as the docker-compose.yml" + ports: + - 3000:3000 + container_name: hello-front-dev # This will name the container hello-front-dev +``` + + + 有了这个配置,_docker-compose up_可以在开发模式下运行应用。你甚至不需要安装Node来开发它! + + + 对于这样的开发设置来说,安装新的依赖项是一个令人头痛的问题。其中一个更好的选择是将新的依赖关系安装在**容器内**。因此,你必须在运行中的容器中进行安装,例如:_docker exec hello-front-dev npm install axios_,或者将其添加到package.json中并再次运行_docker build_,而不是做例如_npm install axios。 + +
    +
    + +### Exercise 12.15 + +#### Exercise 12.15: Set up a frontend development environment + + + 创建todo-frontend/docker-compose.dev.yml并使用卷来启用todo-frontend的开发,而它正在容器内运行。 + +
    + +
    + +### Communication between containers in a Docker network + + + docker-compose工具在容器之间建立了一个网络,并包括一个DNS来轻松连接两个容器。让我们在docker-compose中添加一个新的服务,我们将看到网络和DNS是如何工作的。 + + + [Busybox](https://www.busybox.net/)是一个小的可执行文件,包含了你可能需要的多种工具。它被称为 "嵌入式Linux的瑞士军刀",而我们绝对可以利用它来发挥我们的优势。 + + + Busybox可以帮助我们调试我们的配置。因此,如果你在本节后面的练习中迷失了方向,你应该用Busybox来找出哪些工作和哪些不工作。让我们用它来探索刚才所说的内容。容器是在一个网络内,你可以很容易地在它们之间进行连接。通过改变docker-compose.yml,可以将Busybox加入到这个组合中。 + +```yml +services: + app: + image: hello-front-dev + build: + context: . + dockerfile: dev.Dockerfile + volumes: + - ./:/usr/src/app + ports: + - 3000:3000 + container_name: hello-front-dev + debug-helper: # highlight-line + image: busybox # highlight-line +``` + + + Busybox容器不会有任何进程在里面运行,这样我们就可以在里面_exec_。正因为如此,_docker-compose up_的输出也会像这样。 + +```bash +$ docker-compose up + Pulling debug-helper (busybox:)... + latest: Pulling from library/busybox + 8ec32b265e94: Pull complete + Digest: sha256:b37dd066f59a4961024cf4bed74cae5e68ac26b48807292bd12198afa3ecb778 + Status: Downloaded newer image for busybox:latest + Starting hello-front-dev ... done + Creating react-app_debug-helper_1 ... done + Attaching to react-app_debug-helper_1, hello-front-dev + react-app_debug-helper_1 exited with code 0 + + hello-front-dev | + hello-front-dev | > react-app@0.1.0 start + hello-front-dev | > react-scripts start +``` + + + 这是预期的,因为它只是一个工具箱。让我们用它来向hello-front-dev发送一个请求,看看DNS是如何工作的。当hello-front-dev运行时,我们可以用[wget](https://en.wikipedia.org/wiki/Wget)进行请求,因为它是Busybox中的一个工具,可以从debug-helper向hello-front-dev发送请求。 + + + 使用Docker Compose,我们可以使用_docker-compose run SERVICE COMMAND_来运行一个具有特定命令的服务。命令wget需要标志_-O_与_-_来输出响应到stdout。 + +```bash +$ docker-compose run debug-helper wget -O - http://app:3000 + + Creating react-app_debug-helper_run ... done + Connecting to hello-front-dev:3000 (172.26.0.2:3000) + writing to stdout + + + + + ... +``` + + + 这里的URL是有趣的部分。我们只是说要连接到服务hello-front-dev和该端口3000。hello-front-dev是容器的名字,这是我们在docker-compose文件中用*container/name*给出的。而使用的端口是应用在该容器中可用的端口。该端口不需要发布,因为同一网络中的其他服务也能连接到它。docker-compose文件中的 "端口 "只用于外部访问。 + + + 让我们改变docker-compose.yml中的端口配置来强调这一点。 + +```yml +services: + app: + image: hello-front-dev + build: + context: . + dockerfile: dev.Dockerfile + volumes: + - ./:/usr/src/app + ports: + - 3210:3000 # highlight-line + container_name: hello-front-dev + debug-helper: + image: busybox +``` + + + 随着_docker-compose up_,应用在主机中可用,但仍然_docker-compose run debug-helper wget -O - http://app:3000_ 工作,因为端口在docker网络内仍然是3000。 + +![](../../images/12/busybox_networking_drawio.png) + + +如上图所示,_docker-compose run_要求debug-helper在网络内发送请求。而主机中的浏览器则从网络外发送请求。 + + + 现在你知道在docker-compose.yml中找到其他服务是多么容易,而且我们没有什么要调试的,我们可以删除debug-helper,并在我们的docker-compose.yml中把端口恢复到3000:3000。 + +
    +
    + +### Exercise 12.16 + +#### Exercise 12.16: Run todo-backend in a development container + + + 使用卷轴和Nodemon来实现todo应用后端的开发,而它是在容器内运行。创建一个todo-backend/dev.Dockerfile并编辑todo-backend/docker-compose.dev.yml。 + + + 你还需要重新考虑后端和MongoDB/Redis之间的连接。值得庆幸的是docker-compose可以包括环境变量,这些变量将被传递给应用。 + +```yaml +services: + server: + image: ... + volumes: + - ... + ports: + - ... + environment: + - REDIS_URL=... + - MONGO_URL=... +``` + + + URLs(localhost)是故意弄错的,你将需要设置正确的值。记住一直看控制台发生了什么。如果事情搞砸了,错误信息会暗示什么地方可能被破坏。 + + + 这是一张可能有帮助的图片,说明了docker网络中的连接。 + +![](../../images/12/ex_12_15_backend_drawio.png) + +
    + +
    + +### Communications between containers in a more ambitious environment + + + 接下来,我们将在我们的docker-compose.yml中添加一个[反向代理](https://en.wikipedia.org/wiki/Reverse_proxy)。根据维基百科的说法 + + +> 反向代理是一种代理服务器,它代表客户从一个或多个服务器中检索资源。这些资源然后被返回给客户,看起来就像它们来自反向代理服务器本身。 + + +所以在我们的案例中,反向代理将是我们应用的单一入口点,而最终的目标是将React前端和Express后端都设置在反向代理后面。 + + + 反向代理的实现有多种不同的选择,如Traefik、Caddy、Nginx和Apache(按初始版本从新到旧排序)。 + + +我们的选择是[Nginx](https://hub.docker.com/_/nginx)。 + + +现在让我们把hello-frontend放在反向代理后面。 + + + 在项目根目录下创建一个文件nginx.conf,以下列模板为起点。我们将需要做一些小的编辑以使我们的应用运行。 + +```bash +# events is required, but defaults are ok +events { } + +# A http server, listening at port 80 +http { + server { + listen 80; + + # Requests starting with root (/) are handled + location / { + # The following 3 lines are required for the hot loading to work (websocket). + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + + # Requests are directed to http://localhost:3000 + proxy_pass http://localhost:3000; + } + } +} +``` + + + 接下来,在docker-compose.yml文件中创建一个Nginx服务。按照Docker Hub页面的指示添加一个卷,右边是_:/etc/nginx/nginx.conf:ro_,最后的ro声明该卷将是只读的。 + +```yml +services: + app: + # ... + nginx: + image: nginx:1.20.1 + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + ports: + - 8080:80 + container_name: reverse-proxy + depends_on: + - app # wait for the frontend container to be started +``` + + +添加了这个,我们可以运行_docker-compose up_,看看会发生什么。 + +```bash +$ docker container ls +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +a02ae58f3e8d nginx:1.20.1 "/docker-entrypoint.…" 4 minutes ago Up 4 minutes 0.0.0.0:8080->80/tcp, :::8080->80/tcp reverse-proxy +5ee0284566b4 hello-front-dev "docker-entrypoint.s…" 4 minutes ago Up 4 minutes 0.0.0.0:3000->3000/tcp, :::3000->3000/tcp hello-front-dev +``` + + + 连接到http://localhost:8080 将导致一个看起来很熟悉的页面,状态为502。 + + + 这是因为将请求指向 http://localhost:3000 没有任何结果,因为Nginx容器没有在3000端口运行的应用。根据定义,localhost指的是当前用于访问的计算机。对于容器来说,localhost对每个容器都是唯一的,导致容器本身。 + + + 让我们通过进入Nginx容器内部,用curl向应用本身发送一个请求来测试一下。在我们的用法中,curl类似于wget,但不需要任何标志。 + +```bash +$ docker exec -it reverse-proxy bash + +root@374f9e62bfa8:/# curl http://localhost:80 + + 502 Bad Gateway + ... +``` + + + 为了帮助我们,当我们运行_docker-compose up_时,docker-compose设置了一个网络。它还将docker-compose.yml中的所有容器添加到网络中。一个DNS确保我们可以找到另一个容器。容器被赋予两个名字:服务名和容器名。 + + + 由于我们在容器内,我们也可以测试DNS!让我们把服务名(app)在3000端口上卷起来 + +```html +root@374f9e62bfa8:/# curl http://app:3000 + + + + ... + + ... +``` + + + 就是这样!让我们把nginx.conf中的proxy_pass地址换成这个。 + + + 如果你仍然遇到502,请确保create-react-app已经被构建。你可以从_docker-compose up_读取日志输出。 + + + 还有一件事:我们在配置中添加了一个选项[depend_on](https://docs.docker.com/compose/compose-file/compose-file-v3/#depends_on),确保_nginx_容器在前端容器_app_被盯上之前不会被启动。 + +```bash +services: + app: + # ... + nginx: + image: nginx:1.20.1 + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + ports: + - 8080:80 + container_name: reverse-proxy + depends_on: // highlight-line + - app // highlight-line +``` + + + 如果我们不使用depends\_on强制执行启动顺序,Nginx有可能在启动时失败,因为它试图重新爱护配置文件中提到的所有DNS名称。 + +```bash +http { + server { + listen 80; + + location / { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + + proxy_pass http://app:3000; // highlight-line + } + } +} +``` + + + + 注意,depends\_on并不保证被依赖的容器中的服务已经准备好了,它只是确保该容器已经被启动(并且相应的条目被添加到DNS中)。如果一个服务需要等待另一个服务在启动前做好准备,应该使用[其他解决方案](https://docs.docker.com/compose/startup-order/)。 + +
    + +
    + +### Exercises 12.17. - 12.19. + +#### Exercise 12.17: Set up an Nginx reverse proxy server in front of todo-frontend + + + 我们要把nginx服务器放在todo-frontend和todo-backend的前面。让我们开始创建一个新的docker-compose文件todo-app/docker-compose.dev.ymltodo-app/nginx.conf。 + +```bash +todo-app +├── todo-frontend +├── todo-backend +├── nginx.conf // highlight-line +└── docker-compose.dev.yml // highlight-line +``` + + + 在todo-app/todo-frontend/dev.Dockerfile中加入用todo-app/docker-compose.dev.yml构建的服务nginx和todo-frontend。 + +![](../../images/12/ex_12_16_nginx_front.png) + +#### Exercise 12.18: Configure the Nginx server to be in front of todo-backend + + + 在开发模式下将服务todo-backend添加到docker-compose文件todo-app/docker-compose.dev.yml。 + + + 在nginx.conf中添加一个新的位置,以便对_/api_的请求被代理到后端。类似这样的东西应该能起到作用。 + +```conf + server { + listen 80; + + # Requests starting with root (/) are handled + location / { + # The following 3 lines are required for the hot loading to work (websocket). + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + + # Requests are directed to http://localhost:3000 + proxy_pass http://localhost:3000; + } + + # Requests starting with /api/ are handled + location /api/ { + ... + } + } +``` + + + *proxy/_pass*指令有一个有趣的功能,就是尾部的斜线。由于我们使用路径_/api_来定位,但后端应用只回答路径_/_或_/todos_,我们希望将_/api_从请求中删除。换句话说,即使浏览器会向_/api/todos/1_发送一个GET请求,我们也希望Nginx能将请求代理到_/todos/1_。为此,在*proxy/_pass*的末尾添加一个尾部斜杠_/_到URL中。 + + +这是一个[常见的问题](https://serverfault.com/questions/562756/how-to-remove-the-path-with-an-nginx-proxy-pass) + +![](../../images/12/nginx_trailing_slash_stackoverflow.png) + + + 这说明了我们正在寻找的东西,如果你有麻烦,可能会有帮助。 + +![](../../images/12/ex_12_17_nginx_back.png) + +#### Exercise 12.19: Connect the services, todo-frontend with todo-backend + + + > 在这个练习中,提交整个开发环境,包括Express和React应用,Dockerfiles和docker-compose.yml。 + + + 确保todo-frontend与todo-backend一起工作。这将需要改变*REACT\_APP\_BACKEND\_URL*环境变量。 + + + 如果你在之前的练习中已经得到了这个工作,你可以跳过这个。 + + + 确保开发环境现在是全功能的,也就是说。 + + - todo应用的所有功能都可以使用 + + - 你可以编辑源文件,如果是前端,通过热重载,如果是后端,通过重载应用,这些变化就会生效。 + +
    + +
    + +### Tools for Production + + + 容器是在开发中使用的有趣工具,但它们的最佳使用情况是在生产环境中。有很多比docker-compose更强大的工具可以在生产中运行容器。 + + + 像[Kubernetes](https://kubernetes.io/)这样的重量级容器编排工具使我们能够在一个全新的水平上管理容器。这些工具隐藏了物理机器,让我们这些开发者少担心基础设施。 + + + 如果你有兴趣更深入地了解容器,请参加[DevOps with Docker](https://devopswithdocker.com)课程,你可以在高级的5学分[DevOps with Kubernetes](https://devopswithkubernetes.com)课程中找到更多关于Kubernetes的信息。你现在应该具备完成这两门课程的技能了! + +
    + +
    + +### Exercises 12.20.-12.22. +#### Exercise 12.20: + + + 创建一个带有所有服务、Nginx、todo-backend、todo-frontend、MongoDB和Redis的生产todo-app/docker-compose.yml。使用Dockerfiles而不是dev.Dockerfiles,并确保以生产模式启动应用。 + + + 请使用以下结构进行练习。 + +```bash +todo-app +├── todo-frontend +├── todo-backend +├── nginx.conf +├── docker-compose.dev.yml +└── docker-compose.yml // highlight-line +``` + +#### Exercise 12.21: + + +为你在课程中或空闲时间创建的一个你自己的全栈应用创建一个类似的容器化开发环境。你应该在你提交的仓库中对该应用进行如下结构设计。 + +```bash +└── my-app + ├── frontend + | └── dev.Dockerfile + ├── backend + | └── dev.Dockerfile + └── docker-compose.dev.yml +``` + +#### Exercise 12.22: + + + 通过创建你自己的全栈应用的容器化生产设置来完成这一部分。 + + 在你提交的仓库中的应用的结构如下。 + +```bash +└── my-app + ├── frontend + | ├── dev.Dockerfile + | └── Dockerfile + ├── backend + | └── dev.Dockerfile + | └── Dockerfile + ├── docker-compose.dev.yml + └── docker-compose.yml +``` +### Submitting exercises and getting the credits + + + 这是本章节的最后一个练习。现在是时候将你的代码推送到GitHub,并将你所有完成的练习标记到[练习提交系统](https://studies.cs.helsinki.fi/stats/courses/fs-containers)。 + + + 这一部分的练习就像前面几部分一样提交,但与第0到7部分不同的是,提交到一个自己的[课程实例](https://studies.cs.helsinki.fi/stats/courses/fs-containers)。记住,你必须完成所有的练习,才能通过这部分的考试! + + +一旦你完成了练习并想获得学分,请通过练习提交系统让我们知道你已经完成了该课程。 + +![Submissions](../../images/11/21.png) + + + 注意,"在Moodle中完成的考试 "说明是指[全栈开放课程的考试](/en/part0/general_info#sign-up-for-the-exam),在你从这部分获得学分之前,必须完成考试。 + + + **注意**你需要对相应的课程部分进行注册,以获得注册的学分,更多信息见[这里](/en/part0/general_info#parts-and-completion)。 + + + 你可以通过点击其中一个标志图标下载完成这部分的证书。旗帜图标与证书的语言相对应。 + +
    + + diff --git a/src/content/13/en/part13.md b/src/content/13/en/part13.md new file mode 100644 index 00000000000..9bbe835372d --- /dev/null +++ b/src/content/13/en/part13.md @@ -0,0 +1,15 @@ +--- +mainImage: ../../../images/part-13.svg +part: 13 +lang: en +--- + +
    + +In the previous sections of the course we used MongoDB for storing data, which is a so called NoSQL database. NoSQL databases became very common just over 10 years ago, when the scaling of the internet started to produce problems for relational databases that utilized the older generation SQL query language. + +Relational databases have since then experienced a new beginning. Problems with scalability have been partially resolved and they have also adopted some of the features of NoSQL databases. In this section we explore different NodeJS applications that use relational databases, we will focus on using the database PostgreSQL which is the number one in the open source world. + +English translation of this part is by [Aarni Pavlidi](https://github.com/aarnipavlidi). + +
    diff --git a/src/content/13/en/part13a.md b/src/content/13/en/part13a.md new file mode 100644 index 00000000000..4c13c5b9299 --- /dev/null +++ b/src/content/13/en/part13a.md @@ -0,0 +1,859 @@ +--- +mainImage: ../../../images/part-13.svg +part: 13 +letter: a +lang: en +--- + +
    + +In this section we will explore node applications that use relational databases. During the section we will build a Node-backend using a relational database for a familiar note application from sections 3-5. To complete this part, you will need a reasonable knowledge of relational databases and SQL. There are many online courses on SQL databases, eg. [SQLbolt](https://sqlbolt.com/) and +[Intro to SQL by Khan Academy](https://www.khanacademy.org/computing/computer-programming/sql). + +There are 24 exercises in this part, and you need to complete each exercise for completing the course. Exercises are submitted via the [submissions system](https://studies.cs.helsinki.fi/stats/courses/fs-psql) just like in the previous parts, but unlike parts 0 to 7, the submission goes to a different "course instance". + +### Advantages and disadvantages of document databases + +We have used the MongoDB database in all the previous sections of the course. Mongo is a [document database](https://en.wikipedia.org/wiki/Document-oriented_database) and one of its most characteristic features is that it is schemaless, i.e. the database has only a very limited awareness of what kind of data is stored in its collections. The schema of the database exists only in the program code, which interprets the data in a specific way, e.g. by identifying that some of the fields are references to objects in another collection. + +In the example application of parts 3 and 4, the database stores notes and users. + +A collection of notes that stores notes looks like the following: + +```js +[ + { + "_id": "600c0e410d10256466898a6c", + "content": "HTML is easy" + "date": 2021-01-23T11:53:37.292+00:00, + "important": false + "__v": 0 + }, + { + "_id": "600c0edde86c7264ace9bb78", + "content": "CSS is hard" + "date": 2021-01-23T11:56:13.912+00:00, + "important": true + "__v": 0 + }, +] +``` + +Users saved in the users collection looks like the following: + +```js +[ + { + "_id": "600c0e410d10256466883a6a", + "username": "mluukkai", + "name": "Matti Luukkainen", + "passwordHash" : "$2b$10$Df1yYJRiQuu3Sr4tUrk.SerVz1JKtBHlBOARfY0PBn/Uo7qr8Ocou", + "__v": 9, + notes: [ + "600c0edde86c7264ace9bb78", + "600c0e410d10256466898a6c" + ] + }, +] +``` + +MongoDB does know the types of the fields of the stored entities, but it has no information about which collection of entities the user record ids are referring to. MongoDB also does not care what fields the entities stored in the collections have. Therefore MongoDB leaves it entirely up to the programmer to ensure that the correct information is being stored in the database. + +There are both advantages and disadvantages to not having a schema. One of the advantages is the flexibility that schema agnosticism brings: since the schema does not need to be defined at the database level, application development may be faster in certain cases, and easier, with less effort needed in defining and modifying the schema in any case. Problems with not having a schema are related to error-proneness: everything is left up to the programmer. The database itself has no way of checking whether the data in it is honest, i.e. whether all mandatory fields have values, whether the reference type fields refer to existing entities of the right type in general, etc. + +The relational databases that are the focus of this section, on the other hand, lean heavily on the existence of a schema, and the advantages and disadvantages of schema databases are almost the opposite compared of the non-schema databases. + +The reason why the the previous sections of the course used MongoDB is precisely because of its schema-less nature, which has made it easier to use the database for someone with little knowledge of relational databases. For most of the use cases of this course, I personally would have chosen to use a relational database. + +### Application database + +For our application we need a relational database. There are many options, but we will be using the currently most popular Open Source solution [PostgreSQL](https://www.postgresql.org/). You can install Postgres (as the database is often called) on your machine, if you wish to do so. + +However, we will be taking advantage of the fact that it is possible to create a Postgres database for the application on the Fly.io and Heroku cloud service platforms, which are familiar from the parts 3 and 4. + +In the theory material of this section, we will be building a Postgres-enabled version from the backend of the notes-storage application, which was built in sections 3 and 4. + +Since we don't need any database in the cloud in this part (we only use the application locally), there is a possibility to use the lessons of the course [part 12](/en/part12) and use Postgres locally with Docker. After the Postgres instructions for cloud services, we also give a short instruction on how to easily get Postgres up and running with Docker. + +#### Fly.io + +Let us create a new Fly.io-app by running the command _fly launch_ in a directory where we shall add the code of the app. Let us also create the Postgres database for the app: + +![](../../images/13/6.png) + +When creating the app, Fly.io reveals the password of the database that will be needed when connecting the app to the database. This is the only time it is shown in plain text so it is essential to save it somewhere (but not in any public place such as GitHub). + +Note that if you only need the database, and are not planning to deploy the app to Fly.io, it is also possible to [just create the database to Fly.io](https://fly.io/docs/reference/postgres/#creating-a-postgres-app). + +A psql console connection to the database can be opened as follows + +```bash +flyctl postgres connect -a +``` + +in my case the app name is fs-psql-lecture so the command is the following: + +```bash +flyctl postgres connect -a fs-psql-lecture-db +``` +#### Heroku + +If Heroku is used, a new Heroku application is created when inside a suitable directory. After that a database is added to to the app: + +```bash +heroku create +# Returns an app-name for the app you just created in heroku. + +heroku addons:create heroku-postgresql:hobby-dev -a +``` + +We can use the _heroku config_ command to get the connect string, which is required to connect to the database: + +```bash +heroku config -a +=== cryptic-everglades-76708 Config Vars +DATABASE_URL: postgres://:thepasswordishere@:5432/ +``` + +The database can be accessed by running _psql_ command on the Heroku server as follows (note that the command parameters depend on the connection url of the Heroku database): + +```bash +heroku run psql -h -p 5432 -U -a +``` + +The commands asks the password and opens the psql console: + +```bash +Password for user : +psql (13.4 (Ubuntu 13.4-1.pgdg20.04+1)) +SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off) +Type "help" for help. + +postgres=# +``` + +#### Docker + +This instruction assumes that you master the basic use of Docker to the extent taught by e.g. [part 12](/en/part12). + +Start Postgres [Docker image](https://hub.docker.com/_/postgres) with the command + +```bash +docker run -e POSTGRES_PASSWORD=mysecretpassword -p 5432:5432 postgres +``` + +A psql console connection to the database can be opened using the _docker exec_ command. First you need to find out the id of the container: + +```bash +$ docker ps +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +ff3f49eadf27 postgres "docker-entrypoint.s…" 31 minutes ago Up 31 minutes 0.0.0.0:5432->5432/tcp great_raman +docker exec -it ff3f49eadf27 psql -U postgres postgres +psql (15.2 (Debian 15.2-1.pgdg110+1)) +Type "help" for help. + +postgres=# +``` + +Defined in this way, the data stored in the database is persisted only as long as the container exists. The data can be preserved by defining a +[volume](/en/part12/building_and_configuring_environments#persisting-data-with-volumes) for the data, see more +[here](https://github.com/docker-library/docs/blob/master/postgres/README.md#pgdata). + +#### Using the psql console + +Particularly when using a relational database, it is essential to access the database directly as well. There are many ways to do this, there are several different graphical user interfaces, such as [pgAdmin](https://www.pgadmin.org/). However, we will be using Postgres [psql](https://www.postgresql.org/docs/current/app-psql.html) command-line tool. + +When the console is opened, let's try the main psql command _\d_, which tells you the contents of the database: + +```bash +Password for user : +psql (13.4 (Ubuntu 13.4-1.pgdg20.04+1)) +SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off) +Type "help" for help. + +postgres=# \d +Did not find any relations. +``` + +As you might guess, there is currently nothing in the database. + +Let's create a table for notes: + +```sql +CREATE TABLE notes ( + id SERIAL PRIMARY KEY, + content text NOT NULL, + important boolean, + date time +); +``` + +A few points: column id is defined as a primary key, which means that the value in the column id must be unique for each row in the table and the value must not be empty. The type for this column is defined as [SERIAL](https://www.postgresql.org/docs/9.1/datatype-numeric.html#DATATYPE-SERIAL), which is not the actual type but an abbreviation for an integer column to which Postgres automatically assigns a unique, increasing value when creating rows. The column named content with type text is defined in such a way that it must be assigned a value. + +Let's look at the situation from the console. First, the _\d_ command, which tells us what tables are in the database: + +```sql +postgres=# \d + List of relations + Schema | Name | Type | Owner +--------+--------------+----------+---------- + public | notes | table | username + public | notes_id_seq | sequence | username +(2 rows) +``` + +In addition to the notes table, Postgres created a subtable called notes\_id\_seq, which keeps track of what value is assigned to the id column when creating the next note. + +With the command _\d notes_, we can see how the notes table is defined: + +```sql +postgres=# \d notes; + Table "public.notes" + Column | Type | Collation | Nullable | Default +-----------+------------------------+-----------+----------+----------------------------------- + id | integer | | not null | nextval('notes_id_seq'::regclass) + content | text | | not null | + important | boolean | | | + date | time without time zone | | | +Indexes: + "notes_pkey" PRIMARY KEY, btree (id) +``` + +Therefore the column id has a default value, which is obtained by calling the internal function of Postgres nextval. + +Let's add some content to the table: + +```sql +insert into notes (content, important) values ('Relational databases rule the world', true); +insert into notes (content, important) values ('MongoDB is webscale', false); +``` + +And let's see what the created content looks like: + +```sql +postgres=# select * from notes; + id | content | important | date +----+-------------------------------------+-----------+------ + 1 | relational databases rule the world | t | + 2 | MongoDB is webscale | f | +(2 rows) +``` + +If we try to store data in the database that is not according to the schema, it will not succeed. The value of a mandatory column cannot be missing: + +```sql +postgres=# insert into notes (important) values (true); +ERROR: null value in column "content" of relation "notes" violates not-null constraint +DETAIL: Failing row contains (9, null, t, null). +``` + +The column value cannot be of the wrong type: + +```sql +postgres=# insert into notes (content, important) values ('only valid data can be saved', 1); +ERROR: column "important" is of type boolean but expression is of type integer +LINE 1: ...tent, important) values ('only valid data can be saved', 1); ^ +``` + +Columns that don't exist in the schema are not accepted either: + +```sql +postgres=# insert into notes (content, important, value) values ('only valid data can be saved', true, 10); +ERROR: column "value" of relation "notes" does not exist +LINE 1: insert into notes (content, important, value) values ('only ... +``` + +Next it's time to move on to accessing the database from the application. + +### Node application using a relational database + +Let's start the application as usual with the npm init and install nodemon as a development dependency and also the following runtime dependencies: + +```bash +npm install express dotenv pg sequelize +``` + +Of these, the latter [sequelize](https://sequelize.org/master/) is the library through which we use Postgres. Sequelize is a so-called [Object relational mapping](https://en.wikipedia.org/wiki/Object%E2%80%93relational_mapping) (ORM) library that allows you to store JavaScript objects in a relational database without using the SQL language itself, similar to Mongoose that we used with MongoDB. + +Let's test that we can connect successfully. Create the file index.js and add the following content: + +```js +require('dotenv').config() +const { Sequelize } = require('sequelize') + +const sequelize = new Sequelize(process.env.DATABASE_URL) + +const main = async () => { + try { + await sequelize.authenticate() + console.log('Connection has been established successfully.') + sequelize.close() + } catch (error) { + console.error('Unable to connect to the database:', error) + } +} + +main() +``` + +Note: if you use Heroku, you might need an extra option in connecting the database + +```js +const sequelize = new Sequelize(process.env.DATABASE_URL, { + // highlight-start + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, + // highlight-end +}) +``` + +The database connect string, that contains the database address and the credentials must be defined in the file .env + +If Heroku is used, the connect string can be seen by using the command _heroku config_. The contents of the file .env should be something like the following: + +```bash +$ cat .env +DATABASE_URL=postgres://:thepasswordishere@ec2-54-83-137-206.compute-1.amazonaws.com:5432/ +``` + +When using Fly.io, the local connection to the database should first be enabled by [tunneling](https://fly.io/docs/reference/postgres/#connecting-to-postgres-from-outside-fly) +the localhost port 5432 to the Fly.io database port using the following command + +```bash +flyctl proxy 5432 -a -db +``` + +in my case the command is + +```bash +flyctl proxy 5432 -a fs-psql-lecture-db +``` + +The command must be left running while the database is used. So do not close the console! + +The Fly.io connect-string is of the form + +```bash +$ cat .env +DATABASE_URL=postgres://postgres:thepasswordishere@127.0.0.1:5432/postgres +``` + +Password was shown when the database was created, so hopefully you have not lost it! + +The last part of the connect string, postgres refers to the database name. The name could be any string but we use here postgres since it is the default database that is automatically created within a Postgres database. If needed, new databases can be created with the command [CREATE DATABASE](https://www.postgresql.org/docs/14/sql-createdatabase.html). + +If you use Docker, the connect string is: + +```bash +DATABASE_URL=postgres://postgres:mysecretpassword@localhost:5432/postgres +``` + +Once the connect string has been set up in the file .env we can test for a connection: + +```bash +$ node index.js +Executing (default): SELECT 1+1 AS result +Connection has been established successfully. +``` + +If and when the connection works, we can then run the first query. Let's modify the program as follows: + +```js +require('dotenv').config() +const { Sequelize, QueryTypes } = require('sequelize') // highlight-line + +const sequelize = new Sequelize(process.env.DATABASE_URL, { + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, +}); + +const main = async () => { + try { + await sequelize.authenticate() + // highlight-start + const notes = await sequelize.query("SELECT * FROM notes", { type: QueryTypes.SELECT }) + console.log(notes) + sequelize.close() + // highlight-end + } catch (error) { + console.error('Unable to connect to the database:', error) + } +} + +main() +``` + +Executing the application should print as follows: + +```js +Executing (default): SELECT * FROM notes +[ + { + id: 1, + content: 'Relational databases rule the world', + important: true, + date: null + }, + { + id: 2, + content: 'MongoDB is webscale', + important: false, + date: null + } +] +``` + +Even though Sequelize is an ORM library, which means there is little need to write SQL yourself when using it, we just used [direct SQL](https://sequelize.org/master/manual/raw-queries.html) with the sequelize method [query](https://sequelize.org/api/v6/class/src/sequelize.js~sequelize#instance-method-query). + +Since everything seems to be working, let's change the application into a web application. + +```js +require('dotenv').config() +const { Sequelize, QueryTypes } = require('sequelize') +const express = require('express') // highlight-line +const app = express() // highlight-line + +const sequelize = new Sequelize(process.env.DATABASE_URL, { + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, +}); + +// highlight-start +app.get('/api/notes', async (req, res) => { + const notes = await sequelize.query("SELECT * FROM notes", { type: QueryTypes.SELECT }) + res.json(notes) +}) + +const PORT = process.env.PORT || 3001 +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +// highlight-end +``` + +The application seems to be working. However, let's now switch to using Sequelize instead of SQL as it is intended to be used. + +### Model + +When using Sequelize, each table in the database is represented by a [model](https://sequelize.org/master/manual/model-basics.html), which is effectively it's own JavaScript class. Let's now define the model Note corresponding to the table notes for the application by changing the code to the following format: + +```js +require('dotenv').config() +const { Sequelize, Model, DataTypes } = require('sequelize') // highlight-line +const express = require('express') +const app = express() + +const sequelize = new Sequelize(process.env.DATABASE_URL, { + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, +}); + +// highlight-start +class Note extends Model {} + +Note.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + content: { + type: DataTypes.TEXT, + allowNull: false + }, + important: { + type: DataTypes.BOOLEAN + }, + date: { + type: DataTypes.DATE + } +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'note' +}) +// highlight-end + +app.get('/api/notes', async (req, res) => { + const notes = await Note.findAll() // highlight-line + res.json(notes) +}) + +const PORT = process.env.PORT || 3001 +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +``` + +A few comments on the code: There is nothing very surprising about the Note definition of the model, each column has a type defined, as well as other properties if necessary, such as whether it is the main key of the table. The second parameter in the model definition contains the sequelize attribute as well as other configuration information. We also defined that the table does not have to use the timestamps columns (created\_at and updated\_at). + +We also defined underscored: true, which means that table names are derived from model names as plural [snake case](https://en.wikipedia.org/wiki/Snake_case) versions. Practically this means that, if the name of the model, as in our case is "Note", then the name of the corresponding table is its plural version written with a lower case initial letter, i.e. notes. If, on the other hand, the name of the model would be "two-part", e.g. StudyGroup, then the name of the table would be study_groups. Sequelize automatically infers table names, but also allows explicitly defining them. + +The same naming policy applies to columns as well. If we had defined that a note is associated with creationYear, i.e. information about the year it was created, we would define it in the model as follows: + +```js +Note.init({ + // ... + creationYear: { + type: DataTypes.INTEGER, + }, +}) +``` + +The name of the corresponding column in the database would be creation_year. In code, reference to the column is always in the same format as in the model, i.e. in "camel case" format. + +We have also defined modelName: 'note', the default "model name" would be capitalized Note. However we want to have a lowercase initial, it will make a few things a bit more convenient going forward. + +The database operation is easy to do using the [query interface](https://sequelize.org/master/manual/model-querying-basics.html) provided by models, the method [findAll](https://sequelize.org/api/v6/class/src/model.js~model#static-method-findAll) works exactly as it is assumed by it's name to work: + +```js +app.get('/api/notes', async (req, res) => { + const notes = await Note.findAll() // highlight-line + res.json(notes) +}) +``` + +The console tells you that the method call Note.findAll() causes the following query: + +```js +Executing (default): SELECT "id", "content", "important", "date" FROM "notes" AS "note"; +``` + +Next, let's implement an endpoint for creating new notes: + +```js +app.use(express.json()) + +// ... + +app.post('/api/notes', async (req, res) => { + console.log(req.body) + const note = await Note.create(req.body) + res.json(note) +}) +``` + +Creating a new note is done by calling the model's Note method [create](https://sequelize.org/master/manual/model-querying-basics.html#simple-insert-queries) and passing as a parameter an object that defines the values of the columns. + +Instead of the create method, it [is also possible](https://sequelize.org/master/manual/model-instances.html#creating-an-instance) to save to a database using the [build](https://sequelize.org/api/v6/class/src/model.js~model#static-method-build) method first to create a Model-object from the desired data, and then calling the [save](https://sequelize.org/master/class/lib/model.js~Model.html#instance-method-save) method on it: + +```js +const note = Note.build(req.body) +await note.save() +``` + +Calling the build method does not save the object in the database yet, so it is still possible to edit the object before the actual save event: + +```js +const note = Note.build(req.body) +note.important = true // highlight-line +await note.save() +``` + +For the use case of the example code, the [create](https://sequelize.org/master/manual/model-querying-basics.html#simple-insert-queries) method is better suited, so let's stick to that. + +If the object being created is not valid, there is an error message as a result. For example, when trying to create a note without content, the operation fails, and the console reveals the reason to be SequelizeValidationError: notNull Violation Note.content cannot be null: + +``` +(node:39109) UnhandledPromiseRejectionWarning: SequelizeValidationError: notNull Violation: Note.content cannot be null + at InstanceValidator._validate (/Users/mluukkai/opetus/fs-psql/node_modules/sequelize/lib/instance-validator.js:78:13) + at processTicksAndRejections (internal/process/task_queues.js:93:5) +``` + +Let's add some simple error handling when adding a new note: + +```js +app.post('/api/notes', async (req, res) => { + try { + const note = await Note.create(req.body) + return res.json(note) + } catch(error) { + return res.status(400).json({ error }) + } +}) +``` + +
    + +
    + +### Exercises 13.1.-13.3. + +In the tasks of this section, we will build a blog application backend similar to the tasks in [section 4](/en/part4), which should be compatible with the frontend in [section 5](/en/part5) except for error handling. We will also add various features to the backend that the frontend in section 5 will not know how to use. + +#### Exercise 13.1. + +Create a GitHub repository for the application and create a new Fly.io or Heroku application for it, as well as a Postgres database. As mentioned [here](/en/part13/using_relational_databases_with_sequelize#application-database) you might set up your database also somewhere else, and in that case the Fly.io or Heroku app is not needed. + +Make sure you are able to establish a connection to the application database. + +#### Exercise 13.2. + +On the command-line, create a blogs table for the application with the following columns: +- id (unique, incrementing id) +- author (string) +- url (string that cannot be empty) +- title (string that cannot be empty) +- likes (integer with default value zero) + +Add at least two blogs to the database. + +Save the SQL-commands you used at the root of the application repository in a file called commands.sql + +#### Exercise 13.3. + +Create a functionality in your application which prints the blogs in the database on the command-line, e.g. as follows: + +```bash +$ node cli.js +Executing (default): SELECT * FROM blogs +Dan Abramov: 'On let vs const', 0 likes +Laurenz Albe: 'Gaps in sequences in PostgreSQL', 0 likes +``` + +
    + +
    + +### Creating database tables automatically + +Our application now has one unpleasant side, it assumes that a database with exactly the right schema exists, i.e. that the table notes has been created with the appropriate create table command. + +Since the program code is being stored on GitHub, it would make sense to also store the commands that create the database in the context of the program code, so that the database schema is definitely the same as what the program code is expecting. Sequelize is actually able to generate a schema automatically from the model definition by using the models method [sync](https://sequelize.org/master/manual/model-basics.html#model-synchronization). + +Let's now destroy the database from the console by entering the following command: + +``` +drop table notes; +``` + +The `\d` command reveals that the table has been lost from the database: + +``` +postgres=# \d +Did not find any relations. +``` + +The application no longer works. + +Let's add the following command to the application immediately after the model Note is defined: + +```js +Note.sync() +``` + +When the application starts, the following is printed on the console: + +``` +Executing (default): CREATE TABLE IF NOT EXISTS "notes" ("id" SERIAL , "content" TEXT NOT NULL, "important" BOOLEAN, "date" TIMESTAMP WITH TIME ZONE, PRIMARY KEY ("id")); +``` + +That is, when the application starts, the command CREATE TABLE IF NOT EXISTS "notes"... is executed which creates the table notes if it does not already exist. + +### Other operations + +Let's complete the application with a few more operations. + +Searching for a single note is possible with the method [findByPk](https://sequelize.org/docs/v6/core-concepts/model-querying-finders/#findbypk), because it is retrieved based on the id of the primary key: + +```js +app.get('/api/notes/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + res.json(note) + } else { + res.status(404).end() + } +}) +``` + +Retrieving a single note causes the following SQL command: + +``` +Executing (default): SELECT "id", "content", "important", "date" FROM "notes" AS "note" WHERE "note". "id" = '1'; +``` + +If no note is found, the operation returns null, and in this case the relevant status code is given. + +Modifying the note is done as follows. Only the modification of the important field is supported, since the application's frontend does not need anything else: + +```js +app.put('/api/notes/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + note.important = req.body.important + await note.save() + res.json(note) + } else { + res.status(404).end() + } +}) +``` + +The object corresponding to the database row is retrieved from the database using the findByPk method, the object is modified and the result is saved by calling the save method of the object corresponding to the database row. + +The current code for the application is in its entirety on [GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-1), branch part13-1. + +### Printing the objects returned by Sequelize to the console + +The JavaScript programmer's most important tool is console.log, whose aggressive use gets even the worst bugs under control. Let's add console printing to the single note path: + + +```js +app.get('/api/notes/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + console.log(note) // highlight-line + res.json(note) + } else { + res.status(404).end() + } +}) +``` + +We can see that the end result is not exactly what we expected: + +```js +note { + dataValues: { + id: 1, + content: 'Notes are attached to a user', + important: true, + date: 2021-10-03T15:00:24.582Z, + }, + _previousDataValues: { + id: 1, + content: 'Notes are attached to a user', + important: true, + date: 2021-10-03T15:00:24.582Z, + }, + _changed: Set(0) {}, + _options: { + isNewRecord: false, + _schema: null, + _schemaDelimiter: '', + raw: true, + attributes: [ 'id', 'content', 'important', 'date' ] + }, + isNewRecord: false +} +``` + +In addition to the note information, all sorts of other things are printed on the console. We can reach the desired result by calling the model-object method [toJSON](https://sequelize.org/api/v6/class/src/model.js~model#instance-method-toJSON): + + +```js +app.get('/api/notes/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + console.log(note.toJSON()) // highlight-line + res.json(note) + } else { + res.status(404).end() + } +}) +``` + +Now the result is exactly what we want: + +```js +{ id: 1, + content: 'MongoDB is webscale', + important: false, + date: 2021-10-09T13:52:58.693Z } +``` + +In the case of a collection of objects, the method toJSON does not work directly, the method must be called separately for each object in the collection: + +```js +app.get('/api/notes', async (req, res) => { + const notes = await Note.findAll() + + console.log(notes.map(n=>n.toJSON())) // highlight-line + + res.json(notes) +}) +``` + +The print looks like the following: + +```js +[ { id: 1, + content: 'MongoDB is webscale', + important: false, + date: 2021-10-09T13:52:58.693Z }, + { id: 2, + content: 'Relational databases rule the world', + important: true, + date: 2021-10-09T13:53:10.710Z } ] +``` + +However, perhaps a better solution is to turn the collection into JSON for printing by using the method [JSON.stringify](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify): + +```js +app.get('/api/notes', async (req, res) => { + const notes = await Note.findAll() + + console.log(JSON.stringify(notes)) // highlight-line + + res.json(notes) +}) +``` + +This way is better especially if the objects in the collection contain other objects. It is also often useful to format the objects on the screen in a slightly more reader-friendly format. This can be done with the following command: + +```json +console.log(JSON.stringify(notes, null, 2)) +``` + +The print looks like the following: + +```js +[ + { + "id": 1, + "content": "MongoDB is webscale", + "important": false, + "date": "2021-10-09T13:52:58.693Z" + }, + { + "id": 2, + "content": "Relational databases rule the world", + "important": true, + "date": "2021-10-09T13:53:10.710Z" + } +] +``` + +
    + +
    + +### Exercise 13.4. + +#### Exercise 13.4. + +Transform your application into a web application that supports the following operations + +- GET api/blogs (list all blogs) +- POST api/blogs (add a new blog) +- DELETE api/blogs/:id (delete a blog) + +
    diff --git a/src/content/13/en/part13b.md b/src/content/13/en/part13b.md new file mode 100644 index 00000000000..38d88608c51 --- /dev/null +++ b/src/content/13/en/part13b.md @@ -0,0 +1,1013 @@ +--- +mainImage: ../../../images/part-13.svg +part: 13 +letter: b +lang: en +--- + +
    + +### Application structuring + +So far, we have written all the code in the same file. Now let's structure the application a little better. Let's create the following directory structure and files: + +``` +index.js +util + config.js + db.js +models + index.js + note.js +controllers + notes.js +``` + +The contents of the files are as follows. The file util/config.js takes care of handling the environment variables: + +```js +require('dotenv').config() + +module.exports = { + DATABASE_URL: process.env.DATABASE_URL, + PORT: process.env.PORT || 3001, +} +``` + +The role of the file index.js is to configure and launch the application: + +```js +const express = require('express') +const app = express() + +const { PORT } = require('./util/config') +const { connectToDatabase } = require('./util/db') + +const notesRouter = require('./controllers/notes') + +app.use(express.json()) + +app.use('/api/notes', notesRouter) + +const start = async () => { + await connectToDatabase() + app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) + }) +} + +start() +``` + +Starting the application is slightly different from what we have seen before, because we want to make sure that the database connection is established successfully before the actual startup. + +The file util/db.js contains the code to initialize the database: + +```js +const Sequelize = require('sequelize') +const { DATABASE_URL } = require('./config') + +const sequelize = new Sequelize(DATABASE_URL) + +const connectToDatabase = async () => { + try { + await sequelize.authenticate() + console.log('connected to the database') + } catch (err) { + console.log('failed to connect to the database') + return process.exit(1) + } + + return null +} + +module.exports = { connectToDatabase, sequelize } +``` + +The notes in the model corresponding to the table to be stored are saved in the file models/note.js + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class Note extends Model {} + +Note.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + content: { + type: DataTypes.TEXT, + allowNull: false + }, + important: { + type: DataTypes.BOOLEAN + }, + date: { + type: DataTypes.DATE + } +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'note' +}) + +module.exports = Note +``` + +The file models/index.js is almost useless at this point, as there is only one model in the application. When we start adding other models to the application, the file will become more useful because it will eliminate the need to import files defining individual models in the rest of the application. + +```js +const Note = require('./note') + +Note.sync() + +module.exports = { + Note +} +``` + +The route handling associated with notes can be found in the file controllers/notes.js: + +```js +const router = require('express').Router() + +const { Note } = require('../models') + +router.get('/', async (req, res) => { + const notes = await Note.findAll() + res.json(notes) +}) + +router.post('/', async (req, res) => { + try { + const note = await Note.create(req.body) + res.json(note) + } catch(error) { + return res.status(400).json({ error }) + } +}) + +router.get('/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + res.json(note) + } else { + res.status(404).end() + } +}) + +router.delete('/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + await note.destroy() + } + res.status(204).end() +}) + +router.put('/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + note.important = req.body.important + await note.save() + res.json(note) + } else { + res.status(404).end() + } +}) + +module.exports = router +``` + +The structure of the application is good now. However, we note that the route handlers that handle a single note contain a bit of repetitive code, as all of them begin with the line that searches for the note to be handled: + +```js +const note = await Note.findByPk(req.params.id) +``` + +Let's refactor this into our own middleware and implement it in the route handlers: + +```js +const noteFinder = async (req, res, next) => { + req.note = await Note.findByPk(req.params.id) + next() +} + +router.get('/:id', noteFinder, async (req, res) => { + if (req.note) { + res.json(req.note) + } else { + res.status(404).end() + } +}) + +router.delete('/:id', noteFinder, async (req, res) => { + if (req.note) { + await req.note.destroy() + } + res.status(204).end() +}) + +router.put('/:id', noteFinder, async (req, res) => { + if (req.note) { + req.note.important = req.body.important + await req.note.save() + res.json(req.note) + } else { + res.status(404).end() + } +}) +``` + +The route handlers now receive three parameters, the first being a string defining the route and second being the middleware noteFinder we defined earlier, which retrieves the note from the database and places it in the note property of the req object. A small amount of copypaste is eliminated and we are satisfied! + +The current code for the application is in its entirety on [GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-2), branch part13-2. + +
    + +
    + +### Exercises 13.5.-13.7. + +#### Exercise 13.5. + +Change the structure of your application to match the example above, or to follow some other similar clear convention. + +#### Exercise 13.6. + +Also, implement support for changing the number of a blog's likes in the application, i.e. the operation + +_PUT /api/blogs/:id_ (modifying the like count of a blog) + +The updated number of likes will be relayed with the request: + +```js +{ + likes: 3 +} +``` + +#### Exercise 13.7. + +Centralize the application error handling in middleware as in [part 3](/en/part3/saving_data_to_mongo_db#moving-error-handling-into-middleware). You can also enable middleware [express-async-errors](https://github.com/davidbanham/express-async-errors) as we did in [part 4](/en/part4/testing_the_backend#eliminating-the-try-catch). + +The data returned in the context of an error message is not very important. + +At this point, the situations that require error handling by the application are creating a new blog and changing the number of likes on a blog. Make sure the error handler handles both of these appropriately. + +
    + +
    + +### User management + +Next, let's add a database table users to the application, where the users of the application will be stored. In addition, we will add the ability to create users and token-based login as we implemented in [part 4](/en/part4/token_authentication). For simplicity, we will adjust the implementation so that all users will have the same password secret. + +The model defining users in the file models/user.js is straightforward + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class User extends Model {} + +User.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + username: { + type: DataTypes.STRING, + unique: true, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'user' +}) + +module.exports = User +``` + +The username field is set to unique. The username could have basically been used as the primary key of the table. However, we decided to create the primary key as a separate field with integer value id. + +The file models/index.js expands slightly: + +```js +const Note = require('./note') +const User = require('./user') // highlight-line + +Note.sync() +User.sync() // highlight-line + +module.exports = { + Note, User // highlight-line +} +``` + +The route handlers that take care of creating a new user in the controllers/users.js file and displaying all users do not contain anything dramatic + +```js +const router = require('express').Router() + +const { User } = require('../models') + +router.get('/', async (req, res) => { + const users = await User.findAll() + res.json(users) +}) + +router.post('/', async (req, res) => { + try { + const user = await User.create(req.body) + res.json(user) + } catch(error) { + return res.status(400).json({ error }) + } +}) + +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id) + if (user) { + res.json(user) + } else { + res.status(404).end() + } +}) + +module.exports = router +``` + +The router handler that handles the login (file controllers/login.js) is as follows: + +```js +const jwt = require('jsonwebtoken') +const router = require('express').Router() + +const { SECRET } = require('../util/config') +const User = require('../models/user') + +router.post('/', async (request, response) => { + const body = request.body + + const user = await User.findOne({ + where: { + username: body.username + } + }) + + const passwordCorrect = body.password === 'secret' + + if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) + } + + const userForToken = { + username: user.username, + id: user.id, + } + + const token = jwt.sign(userForToken, SECRET) + + response + .status(200) + .send({ token, username: user.username, name: user.name }) +}) + +module.exports = router +``` + +The POST request will be accompanied by a username and a password. First, the object corresponding to the username is retrieved from the database using the User model with the [findOne](https://sequelize.org/master/manual/model-querying-finders.html#-code-findone--code-) method: + +```js +const user = await User.findOne({ + where: { + username: body.username + } +}) +``` + +From the console, we can see that the SQL statement corresponds to the method call + +```sql +SELECT "id", "username", "name" +FROM "users" AS "User" +WHERE "User". "username" = 'mluukkai'; +``` + +If the user is found and the password is correct (i.e. _secret_ for all the users), A jsonwebtoken containing the user's information is returned in the response. To do this, we install the dependency + +```js +npm install jsonwebtoken +``` + +The file index.js expands slightly + +```js +const notesRouter = require('./controllers/notes') +const usersRouter = require('./controllers/users') +const loginRouter = require('./controllers/login') + +app.use(express.json()) + +app.use('/api/notes', notesRouter) +app.use('/api/users', usersRouter) +app.use('/api/login', loginRouter) +``` + +The current code for the application is in its entirety on [GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-3), branch part13-3. + +### Connection between the tables + +Users can now be added to the application and users can log in, but this in itself is not a very useful feature yet. We would like to add the features that only a logged-in user can add notes, and that each note is associated with the user who created it. To do this, we need to add a foreign key to the notes table. + +When using Sequelize, a foreign key can be defined by modifying the models/index.js file as follows + +```js +const Note = require('./note') +const User = require('./user') + +// highlight-start +User.hasMany(Note) +Note.belongsTo(User) + +Note.sync({ alter: true }) +User.sync({ alter: true }) +// highlight-end + +module.exports = { + Note, User +} +``` + +So this is how we [define](https://sequelize.org/master/manual/assocs.html#one-to-many-relationships) that there is a _one-to-many_ relationship connection between the users and notes entries. We also changed the options of the sync calls so that the tables in the database match changes made to the model definitions. The database schema looks like the following from the console: + +```js +postgres=# \d users + Table "public.users" + Column | Type | Collation | Nullable | Default +----------+------------------------+-----------+----------+----------------------------------- + id | integer | not null | nextval('users_id_seq'::regclass) + username | character varying(255) | | not null | + name | character varying(255) | | not null | +Indexes: + "users_pkey" PRIMARY KEY, btree (id) +Referenced by: + TABLE "notes" CONSTRAINT "notes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE SET NULL + +postgres=# \d notes + Table "public.notes" + Column | Type | Collation | Nullable | Default +-----------+--------------------------+-----------+----------+----------------------------------- + id | integer | not null | nextval('notes_id_seq'::regclass) + content | text | | not null | + important | boolean | | | | + date | timestamp with time zone | | | | + user_id | integer | | | | +Indexes: + "notes_pkey" PRIMARY KEY, btree (id) +Foreign-key constraints: + "notes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE SET NULL +``` + +The foreign key user_id has been created in the notes table, which refers to rows of the users table. + +Now let's make every insertion of a new note be associated to a user. Before we do the proper implementation (where we associate the note with the logged-in user's token), let's hard code the note to be attached to the first user found in the database: + +```js + +router.post('/', async (req, res) => { + try { + // highlight-start + const user = await User.findOne() + const note = await Note.create({...req.body, userId: user.id}) + // highlight-end + res.json(note) + } catch(error) { + return res.status(400).json({ error }) + } +}) +``` + +Pay attention to how there is now a user\_id column in the notes at the database level. The corresponding object in each database row is referred to by Sequelize's naming convention as opposed to camel case (userId) as typed in the source code. + +Making a join query is very easy. Let's change the route that returns all users so that each user's notes are also shown: + +```js +router.get('/', async (req, res) => { + // highlight-start + const users = await User.findAll({ + include: { + model: Note + } + }) + // highlight-end + res.json(users) +}) +``` + +So the join query is done using the [include](https://sequelize.org/master/manual/assocs.html#eager-loading-example) option as a query parameter. + +The SQL statement generated from the query is seen on the console: + +``` +SELECT "User". "id", "User". "username", "User". "name", "Notes". "id" AS "Notes.id", "Notes". "content" AS "Notes.content", "Notes". "important" AS "Notes.important", "Notes". "date" AS "Notes.date", "Notes". "user_id" AS "Notes.UserId" +FROM "users" AS "User" LEFT OUTER JOIN "notes" AS "Notes" ON "User". "id" = "Notes". "user_id"; +``` + +The end result is also as you might expect + +![](../../images/13/1.png) + +### Proper insertion of notes + +Let's change the note insertion by making it work the same as in [part 4](/en/part4), i.e. the creation of a note can only be successful if the request corresponding to the creation is accompanied by a valid token from login. The note is then stored in the list of notes created by the user identified by the token: + +```js +// highlight-start +const tokenExtractor = (req, res, next) => { + const authorization = req.get('authorization') + if (authorization && authorization.toLowerCase().startsWith('bearer ')) { + try { + req.decodedToken = jwt.verify(authorization.substring(7), SECRET) + } catch{ + return res.status(401).json({ error: 'token invalid' }) + } + } else { + return res.status(401).json({ error: 'token missing' }) + } + next() +} +// highlight-end + +router.post('/', tokenExtractor, async (req, res) => { + try { + // highlight-start + const user = await User.findByPk(req.decodedToken.id) + const note = await Note.create({...req.body, userId: user.id, date: new Date()}) + // highlight-end + res.json(note) + } catch(error) { + return res.status(400).json({ error }) + } +}) +``` + +The token is retrieved from the request headers, decoded and placed in the req object by the tokenExtractor middleware. When creating a note, a date field is also given indicating the time it was created. + +### Fine-tuning + +Our backend currently works almost the same way as the Part 4 version of the same application, except for error handling. Before we make a few extensions to backend, let's change the routes for retrieving all notes and all users slightly. + +We will add to each note information about the user who added it: + +```js +router.get('/', async (req, res) => { + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: User, + attributes: ['name'] + } + }) + res.json(notes) +}) +``` + +We have also [restricted](https://sequelize.org/master/manual/model-querying-basics.html#specifying-attributes-for-select-queries) the values of which fields we want. For each note, we return all fields including the name of the user associated with the note but excluding the userId. + +Let's make a similar change to the route that retrieves all users, removing the unnecessary field userId from the notes associated with the user: + +```js +router.get('/', async (req, res) => { + const users = await User.findAll({ + include: { + model: Note, + attributes: { exclude: ['userId'] } // highlight-line + } + }) + res.json(users) +}) +``` + +The current code for the application is in its entirety on [GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-4), branch part13-4. + +### Attention to the definition of the models + +The most perceptive will have noticed that despite the added column user_id, we did not make a change to the model that defines notes, but we can still add a user to note objects: + +```js +const user = await User.findByPk(req.decodedToken.id) +const note = await Note.create({ ...req.body, userId: user.id, date: new Date() }) +``` + +The reason for this is that we specified in the file models/index.js that there is a one-to-many connection between users and notes: + +```js +const Note = require('./note') +const User = require('./user') + +User.hasMany(Note) +Note.belongsTo(User) + +// ... +``` + +Sequelize will automatically create an attribute called userId on the Note model to which, when referenced gives access to the database column user_id. + +Keep in mind, that we could also create a note as follows using the [build](https://sequelize.org/api/v6/class/src/model.js~model#static-method-build) method: + +```js +const user = await User.findByPk(req.decodedToken.id) + +// create a note without saving it yet +const note = Note.build({ ...req.body, date: new Date() }) + // put the user id in the userId property of the created note +note.userId = user.id +// store the note object in the database +await note.save() +``` + +This is how we explicitly see that userId is an attribute of the notes object. + +We could define the model as follows to get the same result: + +```js +Note.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + content: { + type: DataTypes.TEXT, + allowNull: false + }, + important: { + type: DataTypes.BOOLEAN + }, + date: { + type: DataTypes.DATE + }, + // highlight-start + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + } + // highlight-end +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'note' +}) + +module.exports = Note +``` + +Defining at the class level of the model as above is usually unnecessary + +```js +User.hasMany(Note) +Note.belongsTo(User) +``` + +Instead we can achieve the same with this. Using one of the two methods is necessary otherwise Sequelize does not know how at the code level to connect the tables to each other. + +
    + +
    + +### Exercises 13.8.-13.12. + +#### Exercise 13.8. + +Add support for users to the application. In addition to ID, users have the following fields: + +- name (string, must not be empty) +- username (string, must not be empty) + +Unlike in the material, do not prevent Sequelize from creating [timestamps](https://sequelize.org/master/manual/model-basics.html#timestamps) created\_at and updated\_at for users + +All users can have the same password as the material. You can also choose to properly implement passwords as in [part 4](/en/part4/user_administration). + +Implement the following routes + +- _POST api/users_ (adding a new user) +- _GET api/users_ (listing all users) +- _PUT api/users/:username_ (changing a username, keep in mind that the parameter is not id but username) + +Make sure that the timestamps created\_at and updated\_at automatically set by Sequelize work correctly when creating a new user and changing a username. + +#### Exercise 13.9. + +Sequelize provides a set of pre-defined [validations](https://sequelize.org/master/manual/validations-and-constraints.html) for the model fields, which it performs before storing the objects in the database. + +It's decided to change the user creation policy so that only a valid email address is valid as a username. Implement validation that verifies this issue during the creation of a user. + +Modify the error handling middleware to provide a more descriptive error message of the situation (for example, using the Sequelize error message), e.g. + +```js +{ + "error": [ + "Validation isEmail on username failed" + ] +} +``` + +#### Exercise 13.10. + +Expand the application so that the current logged-in user identified by a token is linked to each blog added. To do this you will also need to implement a login endpoint _POST /api/login_, which returns the token. + +#### Exercise 13.11. + +Make deletion of a blog only possible for the user who added the blog. + +#### Exercise 13.12. + +Modify the routes for retrieving all blogs and all users so that each blog shows the user who added it and each user shows the blogs they have added. + +
    + +
    + +### More queries + +So far our application has been very simple in terms of queries, queries have searched for either a single row based on the primary key using the method [findByPk](https://sequelize.org/master/class/lib/model.js~Model.html#static-method-findByPk) or they have searched for all rows in the table using the method [findAll](https://sequelize.org/master/class/lib/model.js~Model.html#static-method-findAll). These are sufficient for the frontend of the application made in Section 5, but let's expand the backend so that we can also practice making slightly more complex queries. + +Let's first implement the possibility to retrieve only important or non-important notes. Let's implement this using the [query-parameter](http://expressjs.com/en/5x/api.html#req.query) important: + +```js +router.get('/', async (req, res) => { + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: User, + attributes: ['name'] + }, + // highlight-start + where: { + important: req.query.important === "true" + } + // highlight-end + }) + res.json(notes) +}) +``` + +Now the backend can retrieve important notes with a request to http://localhost:3001/api/notes?important=true and non-important notes with a request to http://localhost:3001/api/notes?important=false + +The SQL query generated by Sequelize contains a WHERE clause that filters rows that would normally be returned: + +```sql +SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id" +WHERE "note". "important" = true; +``` + +Unfortunately, this implementation will not work if the request is not interested in whether the note is important or not, i.e. if the request is made to http://localhost:3001/api/notes. The correction can be done in several ways. One, but perhaps not the best way to do the correction would be as follows: + +```js +const { Op } = require('sequelize') + +router.get('/', async (req, res) => { + // highlight-start + let important = { + [Op.in]: [true, false] + } + + if ( req.query.important ) { + important = req.query.important === "true" + } + // highlight-end + + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: User, + attributes: ['name'] + }, + where: { + important // highlight-line + } + }) + res.json(notes) +}) +``` + +The important object now stores the query condition. The default query is + +```js +where: { + important: { + [Op.in]: [true, false] + } +} +``` + +i.e. the important column can be true or false, using one of the many Sequelize operators [Op.in](https://sequelize.org/master/manual/model-querying-basics.html#operators). If the query parameter req.query.important is specified, the query changes to one of the two forms + +```js +where: { + important: true +} +``` + +or + +```js +where: { + important: false +} +``` + +depending on the value of the query parameter. + +The database might now contain some note rows that do not have the value for the column +important set. After the above changes, these notes can not be found with the queries. Let us set the missing values in the psql console and change the schema so that the column does not allow a null value: + +```js +Note.init( + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + content: { + type: DataTypes.TEXT, + allowNull: false, + }, + important: { + type: DataTypes.BOOLEAN, + allowNull: false, // highlight-line + }, + date: { + type: DataTypes.DATE, + }, + }, + // ... +) +``` + +The functionality can be further expanded by allowing the user to specify a required keyword when retrieving notes, e.g. a request to http://localhost:3001/api/notes?search=database will return all notes mentioning database or a request to http://localhost:3001/api/notes?search=javascript&important=true will return all notes marked as important and mentioning javascript. The implementation is as follows + +```js +router.get('/', async (req, res) => { + let important = { + [Op.in]: [true, false] + } + + if ( req.query.important ) { + important = req.query.important === "true" + } + + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: User, + attributes: ['name'] + }, + where: { + important, + // highlight-start + content: { + [Op.substring]: req.query.search ? req.query.search : '' + } + // highlight-end + } + }) + + res.json(notes) +}) +``` + +Sequelize's [Op.substring](https://sequelize.org/master/manual/model-querying-basics.html#operators) generates the query we want using the LIKE keyword in SQL. For example, if we make a query to http://localhost:3001/api/notes?search=database&important=true we will see that the SQL query it generates is exactly as we expect. + +```sql +SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id" +WHERE "note". "important" = true AND "note". "content" LIKE '%database%'; +``` + +There is still a beautiful flaw in our application that we see if we make a request to http://localhost:3001/api/notes, i.e. we want all the notes, our implementation will cause an unnecessary WHERE in the query, which may (depending on the implementation of the database engine) unnecessarily affect the query efficiency: + +```sql +SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id" +WHERE "note". "important" IN (true, false) AND "note". "content" LIKE '%%'; +``` + +Let's optimize the code so that the WHERE conditions are used only if necessary: + +```js +router.get('/', async (req, res) => { + const where = {} + + if (req.query.important) { + where.important = req.query.important === "true" + } + + if (req.query.search) { + where.content = { + [Op.substring]: req.query.search + } + } + + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: User, + attributes: ['name'] + }, + where + }) + + res.json(notes) +}) +``` + +If the request has search conditions e.g. http://localhost:3001/api/notes?search=database&important=true, a query containing WHERE is formed + +```sql +SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id" +WHERE "note". "important" = true AND "note". "content" LIKE '%database%'; +``` + +If the request has no search conditions http://localhost:3001/api/notes, then the query does not have an unnecessary WHERE + +```sql +SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id"; +``` + +The current code for the application is in its entirety on [GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-5), branch part13-5. + +
    + +
    + +### Exercises 13.13.-13.16 + +#### Exercise 13.13. + +Implement filtering by keyword in the application for the route returning all blogs. The filtering should work as follows +- _GET /api/blogs?search=react_ returns all blogs with the search word react in the title field, the search word is case-insensitive +- _GET /api/blogs_ returns all blogs + + +[This](https://sequelize.org/master/manual/model-querying-basics.html#operators) should be useful for this task and the next one. +#### Exercise 13.14. + +Expand the filter to search for a keyword in either the title or author fields, i.e. + +_GET /api/blogs?search=jami_ returns blogs with the search word jami in the title field or in the author field +#### Exercise 13.15. + +Modify the blogs route so that it returns blogs based on likes in descending order. Search the [documentation](https://sequelize.org/master/manual/model-querying-basics.html) for instructions on ordering, + +#### Exercise 13.16. + +Make a route for the application _/api/authors_ that returns the number of blogs for each author and the total number of likes. Implement the operation directly at the database level. You will most likely need the [group by](https://sequelize.org/master/manual/model-querying-basics.html#grouping) functionality, and the [sequelize.fn](https://sequelize.org/master/manual/model-querying-basics.html#specifying-attributes-for-select-queries) aggregator function. + +The JSON returned by the route might look like the following, for example: + +``` +[ + { + author: "Jami Kousa", + articles: "3", + likes: "10" + }, + { + author: "Kalle Ilves", + articles: "1", + likes: "2" + }, + { + author: "Dan Abramov", + articles: "1", + likes: "4" + } +] +``` + +Bonus task: order the data returned based on the number of likes, do the ordering in the database query. + +
    diff --git a/src/content/13/en/part13c.md b/src/content/13/en/part13c.md new file mode 100644 index 00000000000..0f970dcd974 --- /dev/null +++ b/src/content/13/en/part13c.md @@ -0,0 +1,1519 @@ +--- +mainImage: ../../../images/part-13.svg +part: 13 +letter: c +lang: en +--- + +
    + +### Migrations + +Let's keep expanding the backend. We want to implement support for allowing users with admin status to put users of their choice in disabled mode, preventing them from logging in and creating new notes. In order to implement this, we need to add boolean fields to the users' database table indicating whether the user is an admin and whether the user is disabled. + +We could proceed as before, i.e. change the model that defines the table and rely on Sequelize to synchronize the changes to the database. This is specified by these lines in the file models/index.js + +```js +const Note = require('./note') +const User = require('./user') + +Note.belongsTo(User) +User.hasMany(Note) + +Note.sync({ alter: true }) // highlight-line +User.sync({ alter: true }) // highlight-line + +module.exports = { + Note, User +} +``` + +However, this approach does not make sense in the long run. Let's remove the lines that do the synchronization and move to using a much more robust way, [migrations](https://sequelize.org/master/manual/migrations.html) provided by Sequelize (and many other libraries). + +In practice, a migration is a single JavaScript file that describes some modification to a database. A separate migration file is created for each single or multiple changes at once. Sequelize keeps a record of which migrations have been performed, i.e. which changes caused by the migrations are synchronized to the database schema. When creating new migrations, Sequelize keeps up to date on which changes to the database schema are yet to be made. In this way, changes are made in a controlled manner, with the program code stored in version control. + +First, let's create a migration that initializes the database. The code for the migration is as follows + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.createTable('notes', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + content: { + type: DataTypes.TEXT, + allowNull: false + }, + important: { + type: DataTypes.BOOLEAN, + allowNull: false + }, + date: { + type: DataTypes.DATE + }, + }) + await queryInterface.createTable('users', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + username: { + type: DataTypes.STRING, + unique: true, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + }) + await queryInterface.addColumn('notes', 'user_id', { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.dropTable('notes') + await queryInterface.dropTable('users') + }, +} +``` + +The migration file [defines](https://sequelize.org/master/manual/migrations.html#migration-skeleton) the functions up and down, the first of which defines how the database should be modified when the migration is performed. The function down tells you how to undo the migration if there is a need to do so. + +Our migration contains three operations, the first creates a notes table, the second creates a users table and the third adds a foreign key to the notes table referencing the creator of the note. Changes in the schema are defined by calling the [queryInterface](https://sequelize.org/master/manual/query-interface.html) object methods. + +When defining migrations, it is essential to remember that unlike models, column and table names are written in snake case form: + +```js +await queryInterface.addColumn('notes', 'user_id', { // highlight-line + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, +}) +``` + +So in migrations, the names of the tables and columns are written exactly as they appear in the database, while models use Sequelize's default camelCase naming convention. + +Save the migration code in the file migrations/20211209\_00\_initialize\_notes\_and\_users.js. Migration file names should always be named alphabetically when created so that previous changes are always before newer changes. One good way to achieve this order is to start the migration file name with the date and a sequence number. + +We could run the migrations from the command line using the [Sequelize command line tool](https://github.com/sequelize/cli). However, we choose to perform the migrations manually from the program code using the [Umzug](https://github.com/sequelize/umzug) library. Let's install the library + +```js +npm install umzug +``` + +Let's change the file util/db.js that handles the connection to the database as follows: + +```js +const Sequelize = require('sequelize') +const { DATABASE_URL } = require('./config') +const { Umzug, SequelizeStorage } = require('umzug') // highlight-line + +const sequelize = new Sequelize(DATABASE_URL) + +// highlight-start +const runMigrations = async () => { + const migrator = new Umzug({ + migrations: { + glob: 'migrations/*.js', + }, + storage: new SequelizeStorage({ sequelize, tableName: 'migrations' }), + context: sequelize.getQueryInterface(), + logger: console, + }) + + const migrations = await migrator.up() + + console.log('Migrations up to date', { + files: migrations.map((mig) => mig.name), + }) +} +// highlight-end + +const connectToDatabase = async () => { + try { + await sequelize.authenticate() + /* highlight-start */ + await runMigrations() + /* highlight-end */ + console.log('connected to the database') + } catch (err) { + console.log('failed to connect to the database') + console.log(err) + return process.exit(1) + } + + return null +} + +module.exports = { connectToDatabase, sequelize } +``` + +The runMigrations function that performs migrations is now executed every time the application opens a database connection when it starts. Sequelize keeps track of which migrations have already been completed, so if there are no new migrations, running the runMigrations function does nothing. + +Now let's start with a clean slate and remove all existing database tables from the application: + +```sql +username => drop table notes; +username => drop table users; +username => \d +Did not find any relations. +``` + +Let's start up the application. A message about the migrations status is printed on the log + +```bash +INSERT INTO "migrations" ("name") VALUES ($1) RETURNING "name"; +Migrations up to date { files: [ '20211209_00_initialize_notes_and_users.js' ] } +database connected +``` + +If we restart the application, the log also shows that the migration was not repeated. + +The database schema of the application now looks like this + +```sql +postgres=# \d + List of relations + Schema | Name | Type | Owner +--------+--------------+----------+---------------- + public | migrations | table | username + public | notes | table | username + public | notes_id_seq | sequence | username + public | users | table | username + public | users_id_seq | sequence | username +``` + +So Sequelize has created a migrations table that allows it to keep track of the migrations that have been performed. The contents of the table look as follows: + +```js +postgres=# select * from migrations; + name +------------------------------------------- + 20211209_00_initialize_notes_and_users.js +``` + +Let's create a few users in the database, as well as a set of notes, and after that we are ready to expand the application. + +The current code for the application is in its entirety on [GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-6), branch part13-6. +### Admin user and user disabling + +So we want to add two boolean fields to the users table +- _admin_ tells you whether the user is an admin +- _disabled_ tells you whether the user is disabled from actions + +Let's create the migration that modifies the database in the file migrations/20211209\_01\_admin\_and\_disabled\_to\_users.js: + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.addColumn('users', 'admin', { + type: DataTypes.BOOLEAN, + defaultValue: false + }) + await queryInterface.addColumn('users', 'disabled', { + type: DataTypes.BOOLEAN, + defaultValue: false + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.removeColumn('users', 'admin') + await queryInterface.removeColumn('users', 'disabled') + }, +} +``` + +Make corresponding changes to the model corresponding to the users table: + +```js +User.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + username: { + type: DataTypes.STRING, + unique: true, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + // highlight-start + admin: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + disabled: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + // highlight-end +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'user' +}) +``` + +When the new migration is performed when the code restarts, the schema is changed as desired: + +```sql +username-> \d users + Table "public.users" + Column | Type | Collation | Nullable | Default +----------+------------------------+-----------+----------+----------------------------------- + id | integer | | not null | nextval('users_id_seq'::regclass) + username | character varying(255) | | not null | + name | character varying(255) | | not null | + admin | boolean | | | + disabled | boolean | | | +Indexes: + "users_pkey" PRIMARY KEY, btree (id) + "users_username_key" UNIQUE CONSTRAINT, btree (username) +Referenced by: + TABLE "notes" CONSTRAINT "notes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) +``` + +Now let's expand the controllers as follows. We prevent logging in if the user field disabled is set to true: + +```js +loginRouter.post('/', async (request, response) => { + const body = request.body + + const user = await User.findOne({ + where: { + username: body.username + } + }) + + const passwordCorrect = body.password === 'secret' + + if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) + } + +// highlight-start + if (user.disabled) { + return response.status(401).json({ + error: 'account disabled, please contact admin' + }) + } + // highlight-end + + const userForToken = { + username: user.username, + id: user.id, + } + + const token = jwt.sign(userForToken, process.env.SECRET) + + response + .status(200) + .send({ token, username: user.username, name: user.name }) +}) +``` + +Let's disable the user jakousa using his ID: + +```sql +username => update users set disabled=true where id=3; +UPDATE 1 +username => update users set admin=true where id=1; +UPDATE 1 +username => select * from users; + id | username | name | admin | disabled +----+----------+------------------+-------+---------- + 2 | lynx | Kalle Ilves | | + 3 | jakousa | Jami Kousa | f | t + 1 | mluukkai | Matti Luukkainen | t | +``` + +And make sure that logging in is no longer possible + +![](../../images/13/2.png) + +Let's create a route that will allow an admin to change the status of a user's account: + +```js +const isAdmin = async (req, res, next) => { + const user = await User.findByPk(req.decodedToken.id) + if (!user.admin) { + return res.status(401).json({ error: 'operation not allowed' }) + } + next() +} + +router.put('/:username', tokenExtractor, isAdmin, async (req, res) => { + const user = await User.findOne({ + where: { + username: req.params.username + } + }) + + if (user) { + user.disabled = req.body.disabled + await user.save() + res.json(user) + } else { + res.status(404).end() + } +}) +``` + +There are two middleware used, the first called tokenExtractor is the same as the one used by the note-creation route, i.e. it places the decoded token in the decodedToken field of the request-object. The second middleware isAdmin checks whether the user is an admin and if not, the request status is set to 401 and an appropriate error message is returned. + +Note how two middleware are chained to the route, both of which are executed before the actual route handler. It is possible to chain an arbitrary number of middleware to a request. + +The middleware tokenExtractor is now moved to util/middleware.js as it is used from multiple locations. + +```js +const jwt = require('jsonwebtoken') +const { SECRET } = require('./config.js') + +const tokenExtractor = (req, res, next) => { + const authorization = req.get('authorization') + if (authorization && authorization.toLowerCase().startsWith('bearer ')) { + try { + req.decodedToken = jwt.verify(authorization.substring(7), SECRET) + } catch{ + return res.status(401).json({ error: 'token invalid' }) + } + } else { + return res.status(401).json({ error: 'token missing' }) + } + next() +} + +module.exports = { tokenExtractor } +``` + +An admin can now re-enable the user jakousa by making a PUT request to _/api/users/jakousa_, where the request comes with the following data: + +```js +{ + "disabled": false +} +``` + +As noted in [the end of Part 4](/en/part4/token_authentication#problems-of-token-based-authentication), the way we implement disabling users here is problematic. Whether or not the user is disabled is only checked at _login_, if the user has a token at the time the user is disabled, the user may continue to use the same token, since no lifetime has been set for the token and the disabled status of the user is not checked when creating notes. + +Before we proceed, let's make an npm script for the application, which allows us to undo the previous migration. After all, not everything always goes right the first time when developing migrations. + +Let's modify the file util/db.js as follows: + +```js +const Sequelize = require('sequelize') +const { DATABASE_URL } = require('./config') +const { Umzug, SequelizeStorage } = require('umzug') + +const sequelize = new Sequelize(DATABASE_URL, { + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, +}); + +const connectToDatabase = async () => { + try { + await sequelize.authenticate() + await runMigrations() + console.log('connected to the database') + } catch (err) { + console.log('failed to connect to the database') + return process.exit(1) + } + + return null +} + +// highlight-start +const migrationConf = { + migrations: { + glob: 'migrations/*.js', + }, + storage: new SequelizeStorage({ sequelize, tableName: 'migrations' }), + context: sequelize.getQueryInterface(), + logger: console, +} + +const runMigrations = async () => { + const migrator = new Umzug(migrationConf) + const migrations = await migrator.up() + console.log('Migrations up to date', { + files: migrations.map((mig) => mig.name), + }) +} + +const rollbackMigration = async () => { + await sequelize.authenticate() + const migrator = new Umzug(migrationConf) + await migrator.down() +} +// highlight-end + +/* highlight-start */ +module.exports = { connectToDatabase, sequelize, rollbackMigration } +/* highlight-end */ +``` + +Let's create a file util/rollback.js, which will allow the npm script to execute the specified migration rollback function: + +```js +const { rollbackMigration } = require('./db') + +rollbackMigration() +``` + +and the script itself: + +```json +{ + "scripts": { + "dev": "nodemon index.js", + "migration:down": "node util/rollback.js" // highlight-line + }, +} +``` + +So we can now undo the previous migration by running _npm run migration:down_ from the command line. + +Migrations are currently executed automatically when the program is started. In the development phase of the program, it might sometimes be more appropriate to disable the automatic execution of migrations and make migrations manually from the command line. + +The current code for the application is in its entirety on [GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-7), branch part13-7. + +
    + +
    + +### Exercises 13.17-13.18. + +#### Exercise 13.17. + +Delete all tables from your application's database. + +Make a migration that initializes the database. Add created\_at and updated\_at [timestamps](https://sequelize.org/master/manual/model-basics.html#timestamps) for both tables. Keep in mind that you will have to add them in the migration yourself. + +**NOTE:** be sure to remove the commands User.sync() and Blog.sync(), which synchronizes the models' schemas from your code, otherwise your migrations will fail. + +**NOTE2:** if you have to delete tables from the command line (i.e. you don't do the deletion by undoing the migration), you will have to delete the contents of the migrations table if you want your program to perform the migrations again. + +#### Exercise 13.18. + +Expand your application (by migration) so that the blogs have a year written attribute, i.e. a field year which is an integer at least equal to 1991 but not greater than the current year. Make sure the application gives an appropriate error message if an incorrect value is attempted to be given for a year written. + +
    + +
    + +### Many-to-many relationships + +We will continue to expand the application so that each user can be added to one or more teams. + +Since an arbitrary number of users can join one team, and one user can join an arbitrary number of teams, we are dealing with a [many-to-many](https://sequelize.org/master/manual/assocs.html#many-to-many-relationships) relationship, which is traditionally implemented in relational databases using a connection table. + +Let's now create the code needed for the teams table as well as the connection table. The migration (saved in file 20211209\_02\_add\_teams\_and\_memberships.js) is as follows: + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.createTable('teams', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + unique: true + }, + }) + await queryInterface.createTable('memberships', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + team_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'teams', key: 'id' }, + }, + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.dropTable('memberships') + await queryInterface.dropTable('teams') + }, +} +``` + +The models contain almost the same code as the migration. The team model in models/team.js: + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class Team extends Model {} + +Team.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + unique: true + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'team' +}) + +module.exports = Team +``` + +The model for the connection table in models/membership.js: + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class Membership extends Model {} + +Membership.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + teamId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'teams', key: 'id' }, + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'membership' +}) + +module.exports = Membership +``` + +So we have given the connection table a name that describes it well, membership. There is not always a relevant name for a connection table, in which case the name of the connection table can be a combination of the names of the tables that are joined, e.g. user\_teams could fit our situation. + +We make a small addition to the models/index.js file to connect teams and users at the code level using the [belongsToMany](https://sequelize.org/docs/v6/core-concepts/assocs/#implementation-2) method. + +```js +const Note = require('./note') +const User = require('./user') +// highlight-start +const Team = require('./team') +const Membership = require('./membership') +// highlight-end + +Note.belongsTo(User) +User.hasMany(Note) + +// highlight-start +User.belongsToMany(Team, { through: Membership }) +Team.belongsToMany(User, { through: Membership }) +// highlight-end + +module.exports = { + Note, User, Team, Membership // highlight-line +} + +``` + +Note the difference between the migration of the connection table and the model when defining foreign key fields. During the migration, fields are defined in snake case form: + +```js +await queryInterface.createTable('memberships', { + // ... + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + team_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'teams', key: 'id' }, + } +}) +``` + +in the model, the same fields are defined in camel case: + +```js +Membership.init({ + // ... + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + teamId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'teams', key: 'id' }, + }, + // ... +}) +``` + +Now let's create a couple of teams from the psql console, as well as a few memberships: + +```js +insert into teams (name) values ('toska'); +insert into teams (name) values ('mosa climbers'); +insert into memberships (user_id, team_id) values (1, 1); +insert into memberships (user_id, team_id) values (1, 2); +insert into memberships (user_id, team_id) values (2, 1); +insert into memberships (user_id, team_id) values (3, 2); +``` + +Information about users' teams is then added to route for retrieving all users + +```js +router.get('/', async (req, res) => { + const users = await User.findAll({ + include: [ + { + model: Note, + attributes: { exclude: ['userId'] } + }, + // highlight-start + { + model: Team, + attributes: ['name', 'id'], + } + // highlight-end + ] + }) + res.json(users) +}) +``` + +The most observant will notice that the query printed to the console now combines three tables. + +The solution is pretty good, but there's a beautiful flaw in it. The result also comes with the attributes of the corresponding row of the connection table, although we do not want this: + +![](../../images/13/3.png) + + +By carefully reading the documentation, you can find a [solution](https://sequelize.org/master/manual/advanced-many-to-many.html#specifying-attributes-from-the-through-table): + +```js +router.get('/', async (req, res) => { + const users = await User.findAll({ + include: [ + { + model: Note, + attributes: { exclude: ['userId'] } + }, + { + model: Team, + attributes: ['name', 'id'], + // highlight-start + through: { + attributes: [] + } + // highlight-end + } + ] + }) + res.json(users) +}) +``` + +The current code for the application is in its entirety on [GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-8), branch part13-8. + +### Note on the properties of Sequelize model objects + +The specification of our models is shown by the following lines: + +```js +User.hasMany(Note) +Note.belongsTo(User) + +User.belongsToMany(Team, { through: Membership }) +Team.belongsToMany(User, { through: Membership }) +``` + +These allow Sequelize to make queries that retrieve, for example, all the notes of users, or all members of a team. + +Thanks to the definitions, we also have direct access to, for example, the user's notes in the code. In the following code, we will search for a user with id 1 and print the notes associated with the user: + +```js +const user = await User.findByPk(1, { + include: { + model: Note + } +}) + +user.notes.forEach(note => { + console.log(note.content) +}) +``` + +The User.hasMany(Note) definition therefore attaches a notes property to the user object, which gives access to the notes made by the user. The User.belongsToMany(Team, { through: Membership })) definition similarly attaches a teams property to the user object, which can also be used in the code: + +```js +const user = await User.findByPk(1, { + include: { + model: team + } +}) + +user.teams.forEach(team => { + console.log(team.name) +}) +``` + +Suppose we would like to return a JSON object from the single user's route containing the user's name, username and number of notes created. We could try the following: + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, { + include: { + model: Note + } + } + ) + + if (user) { + user.note_count = user.notes.length // highlight-line + delete user.notes // highlight-line + res.json(user) + + } else { + res.status(404).end() + } +}) +``` + +So, we tried to add the noteCount field on the object returned by Sequelize and remove the notes field from it. However, this approach does not work, as the objects returned by Sequelize are not normal objects where the addition of new fields works as we intend. + +A better solution is to create a completely new object based on the data retrieved from the database: + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, { + include: { + model: Note + } + } + ) + + if (user) { + res.json({ + username: user.username, // highlight-line + name: user.name, // highlight-line + note_count: user.notes.length // highlight-line + }) + + } else { + res.status(404).end() + } +}) +``` +### Revisiting many-to-many relationships + +Let's make another many-to-many relationship in the application. Each note is associated to the user who created it by a foreign key. It is now decided that the application also supports that the note can be associated with other users, and that a user can be associated with an arbitrary number of notes created by other users. The idea is that these notes are those that the user has marked for himself. + +Let's make a connection table user\_notes for the situation. The migration, that is saved in file 20211209\_03\_add\_user\_notes.js is straightforward: + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.createTable('user_notes', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + note_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'notes', key: 'id' }, + }, + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.dropTable('user_notes') + }, +} +``` + +Also, there is nothing special about the model: + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class UserNotes extends Model {} + +UserNotes.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + noteId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'notes', key: 'id' }, + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'user_notes' +}) + +module.exports = UserNotes +``` + +The file models/index.js, on the other hand, comes with a slight change to what we saw before: + +```js +const Note = require('./note') +const User = require('./user') +const Team = require('./team') +const Membership = require('./membership') +const UserNotes = require('./user_notes') // highlight-line + +Note.belongsTo(User) +User.hasMany(Note) + +User.belongsToMany(Team, { through: Membership }) +Team.belongsToMany(User, { through: Membership }) + +// highlight-start +User.belongsToMany(Note, { through: UserNotes, as: 'marked_notes' }) +Note.belongsToMany(User, { through: UserNotes, as: 'users_marked' }) +// highlight-end + +module.exports = { + Note, User, Team, Membership, UserNotes +} +``` + +Once again belongsToMany is used, which now links users to notes via the UserNotes model corresponding to the connection table. However, this time we give an alias name for the attribute formed using the keyword [as](https://sequelize.org/master/manual/advanced-many-to-many.html#aliases-and-custom-key-names), the default name (a user's notes) would overlap with its previous meaning, i.e. notes created by the user. + +We extend the route for an individual user to return the user's teams, their own notes, and other notes marked by the user: + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, { + attributes: { exclude: [''] } , + include:[{ + model: Note, + attributes: { exclude: ['userId'] } + }, + { + model: Note, + as: 'marked_notes', + attributes: { exclude: ['userId']}, + through: { + attributes: [] + } + }, + { + model: Team, + attributes: ['name', 'id'], + through: { + attributes: [] + } + }, + ] + }) + + if (user) { + res.json(user) + } else { + res.status(404).end() + } +}) +``` + +In the context of the include, we must now use the alias name marked\_notes which we have just defined with the as attribute. + +In order to test the feature, let's create some test data in the database: + +```sql +insert into user_notes (user_id, note_id) values (2, 1); +insert into user_notes (user_id, note_id) values (2, 2); +``` + +The end result is functional: + +![](../../images/13/5a.png) + +What if we wanted to include information about the author of the note in the notes marked by the user as well? This can be done by adding an include to the marked notes: + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, { + attributes: { exclude: [''] } , + include:[{ + model: Note, + attributes: { exclude: ['userId'] } + }, + { + model: Note, + as: 'marked_notes', + attributes: { exclude: ['userId']}, + through: { + attributes: [] + }, + // highlight-start + include: { + model: User, + attributes: ['name'] + } + // highlight-end + }, + { + model: Team, + attributes: ['name', 'id'], + through: { + attributes: [] + } + }, + ] + }) + + if (user) { + res.json(user) + } else { + res.status(404).end() + } +}) +``` + + +The end result is as desired: + +![](../../images/13/4.png) + +The current code for the application is in its entirety on [GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-9), branch part13-9. + + +
    + +
    + +### Exercises 13.19.-13.23. + +#### Exercise 13.19. + +Give users the ability to add blogs on the system to a reading list. When added to the reading list, the blog should be in the unread state. The blog can later be marked as read. Implement the reading list using a connection table. Make database changes using migrations. + +In this task, adding to a reading list and displaying the list need not be successful other than directly using the database. + +#### Exercise 13.20. + +Now add functionality to the application to support the reading list. + +Adding a blog to the reading list is done by making an HTTP POST to the path /api/readinglists, the request will be accompanied with the blog and user id: + +```js +{ + "blogId": 10, + "userId": 3 +} +``` + +Also modify the individual user route _GET /api/users/:id_ to return not only the user's other information but also the reading list, e.g. in the following format: + +```js +{ + name: "Matti Luukkainen", + username: "mluukkai@iki.fi", + readings: [ + { + id: 3, + url: "https://google.com", + title: "Clean React", + author: "Dan Abramov", + likes: 34, + year: null, + }, + { + id: 4, + url: "https://google.com", + title: "Clean Code", + author: "Bob Martin", + likes: 5, + year: null, + } + ] +} +``` + +At this point, information about whether the blog is read or not does not need to be available. + +#### Exercise 13.21. + +Expand the single-user route so that each blog in the reading list shows also whether the blog has been read and the id of the corresponding join table row. + +For example, the information could be in the following form: + +```js +{ + name: "Matti Luukkainen", + username: "mluukkai@iki.fi", + readings: [ + { + id: 3, + url: "https://google.com", + title: "Clean React", + author: "Dan Abramov", + likes: 34, + year: null, + readinglists: [ + { + read: false, + id: 2 + } + ] + }, + { + id: 4, + url: "https://google.com", + title: "Clean Code", + author: "Bob Martin", + likes: 5, + year: null, + readinglists: [ + { + read: false, + id: 3 + } + ] + } + ] +} +``` + +Note: there are several ways to implement this functionality. [This](https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship) should help. + +Note also that despite having an array field readinglists in the example, it should always just contain exactly one object, the join table entry that connects the book to the particular user's reading list. + +#### Exercise 13.22. + +Implement functionality in the application to mark a blog in the reading list as read. Marking as read is done by making a request to the _PUT /api/readinglists/:id_ path, and sending the request with + +```js +{ "read": true } +``` + +The user can only mark the blogs in their own reading list as read. The user is identified as usual from the token accompanying the request. + +#### Exercise 13.23. + +Modify the route that returns a single user's information so that the request can control which of the blogs in the reading list are returned: + +- _GET /api/users/:id_ returns the entire reading list +- _GET /api/users/:id?read=true_ returns blogs that have been read +- _GET /api/users/:id?read=false_ returns blogs that have not been read + +
    + +
    + +### Concluding remarks + +The state of our application is starting to be at least acceptable. However, before the end of the section, let's look at a few more points. + +#### Eager vs lazy fetch + +When we make queries using the include attribute: + +```js +User.findOne({ + include: { + model: note + } +}) +``` + +The so-called [eager fetch](https://sequelize.org/master/manual/assocs.html#basics-of-queries-involving-associations) occurs, i.e. all the rows of the tables attached to the user by the join query, in the example the notes made by the user, are fetched from the database at the same time. This is often what we want, but there are also situations where you want to do a so-called _lazy fetch_, e.g. search for user related teams only if they are needed. + +Let's now modify the route for an individual user so that it fetches the user's teams only if the query parameter teams is set in the request: + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, { + attributes: { exclude: [''] } , + include:[{ + model: note, + attributes: { exclude: ['userId'] } + }, + { + model: Note, + as: 'marked_notes', + attributes: { exclude: ['userId']}, + through: { + attributes: [] + }, + include: { + model: user, + attributes: ['name'] + } + }, + ] + }) + + if (!user) { + return res.status(404).end() + } + + // highlight-start + let teams = undefined + + if (req.query.teams) { + teams = await user.getTeams({ + attributes: ['name'], + joinTableAttributes: [] + }) + } + + res.json({ ...user.toJSON(), teams }) + // highlight-end +}) +``` + +So now, the User.findByPk query does not retrieve teams, but they are retrieved if necessary by the user method getTeams, which is automatically generated by Sequelize for the model object. Similar get- and a few other useful methods [are automatically generated](https://sequelize.org/master/manual/assocs.html#special-methodsmixins-added-to-instances) when defining associations for tables at the Sequelize level. + +#### Features of models + +There are some situations where, by default, we do not want to handle all the rows of a particular table. One such case could be that we don't normally want to display users that have been disabled in our application. In such a situation, we could define the default [scopes](https://sequelize.org/master/manual/scopes.html) for the model like this: + +```js +class User extends Model {} + +User.init({ + // field definition +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'user', + // highlight-start + defaultScope: { + where: { + disabled: false + } + }, + scopes: { + admin: { + where: { + admin: true + } + }, + disabled: { + where: { + disabled: true + } + } + } + // highlight-end +}) + +module.exports = User +``` + +Now the query caused by the function call User.findAll() has the following WHERE condition: + +``` +WHERE "user". "disabled" = false; +``` + +For models, it is possible to define other scopes as well: + +```js +User.init({ + // field definition +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'user', + defaultScope: { + where: { + disabled: false + } + }, + // highlight-start + scopes: { + admin: { + where: { + admin: true + } + }, + disabled: { + where: { + disabled: true + } + }, + name(value) { + return { + where: { + name: { + [Op.iLike]: value + } + } + } + }, + } + // highlight-end +}) +``` + +Scopes are used as follows: + +```js +// all admins +const adminUsers = await User.scope('admin').findAll() + +// all inactive users +const disabledUsers = await User.scope('disabled').findAll() + +// users with the string jami in their name +const jamiUsers = await User.scope({ method: ['name', '%jami%'] }).findAll() +``` + +It is also possible to chain scopes: + +```js +// admins with the string jami in their name +const jamiUsers = await User.scope('admin', { method: ['name', '%jami%'] }).findAll() +``` + +Since Sequelize models are normal [JavaScript classes](https://sequelize.org/master/manual/model-basics.html#taking-advantage-of-models-being-classes), it is possible to add new methods to them. + +Here are two examples: + +```js +const { Model, DataTypes, Op } = require('sequelize') // highlight-line + +const Note = require('./note') +const { sequelize } = require('../util/db') + +class User extends Model { + // highlight-start + async number_of_notes() { + return (await this.getNotes()).length + } + + static async with_notes(limit){ + return await User.findAll({ + attributes: { + include: [[ sequelize.fn("COUNT", sequelize.col("notes.id")), "note_count" ]] + }, + include: [ + { + model: Note, + attributes: [] + }, + ], + group: ['user.id'], + having: sequelize.literal(`COUNT(notes.id) > ${limit}`) + }) + } + // highlight-end +} + +User.init({ + // ... +}) + +module.exports = User +``` + +The first of the methods numberOfNotes is an instance method, meaning that it can be called on instances of the model: + +```js +const jami = await User.findOne({ name: 'Jami Kousa'}) +const cnt = await jami.number_of_notes() +console.log(`Jami has created ${cnt} notes`) +``` + +Within the instance method, the keyword this therefore refers to the instance itself: + +```js +async number_of_notes() { + return (await this.getNotes()).length +} +``` + +The second of the methods, which returns those users who have at least X, the number specified by the parameter, amount of notes is a class method, i.e. it is called directly on the model: + +```js +const users = await User.with_notes(2) +console.log(JSON.stringify(users, null, 2)) +users.forEach(u => { + console.log(u.name) +}) +``` + +#### Repeatability of models and migrations + +We have noticed that the code for models and migrations is very repetitive. For example, the model of teams + +```js +class Team extends Model {} + +Team.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + unique: true + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'team' +}) + +module.exports = Team +``` + +and migration contain much of the same code + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.createTable('teams', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + unique: true + }, + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.dropTable('teams') + }, +} +``` + +Couldn't we optimize the code so that, for example, the model exports the shared parts needed for the migration? + +However, the problem is that the definition of the model may change over time, for example the name field may change or its data type may change. Migrations must be able to be performed successfully at any time from start to end, and if the migrations are relying on the model to have certain content, it may no longer be true in a month or a year's time. Therefore, despite the "copy paste", the migration code should be completely separate from the model code. + +One solution would be to use Sequelize's [command line tool](https://sequelize.org/docs/v6/other-topics/migrations/#creating-the-first-model-and-migration), which generates both models and migration files based on commands given at the command line. For example, the following command would create a User model with name, username, and admin as attributes, as well as the migration that manages the creation of the database table: + +``` +npx sequelize-cli model:generate --name User --attributes name:string,username:string,admin:boolean +``` + +From the command line, you can also run rollbacks, i.e. undo migrations. The command line documentation is unfortunately incomplete and in this course we decided to do both models and migrations manually. The solution may or may not have been a wise one. + +
    + +
    + +### Exercise 13.24. + +#### Exercise 13.24. + +Grand finale: [towards the end of part 4](/en/part4/token_authentication#problems-of-token-based-authentication) there was mention of a token-criticality problem: if a user's access to the system is decided to be revoked, the user may still use the token in possession to use the system. + +The usual solution to this is to store a record of each token issued to the client in the backend database, and to check with each request whether access is still valid. In this case, the validity of the token can be removed immediately if necessary. Such a solution is often referred to as a server-side session. + +Now expand the system so that the user who has lost access will not be able to perform any actions that require login. + +You will probably need at least the following for the implementation +- a boolean value column in the user table to indicate whether the user is disabled + - it is sufficient to disable and enable users directly from the database +- a table that stores active sessions + - a session is stored in the table when a user logs in, i.e. operation _POST /api/login_ + - the existence (and validity) of the session is always checked when the user makes an operation that requires login +- a route that allows the user to "log out" of the system, i.e. to practically remove active sessions from the database, the route can be e.g. _DELETE /api/logout_ + +Keep in mind that actions requiring login should not be successful with an "expired token", i.e. with the same token after logging out. + +You may also choose to use some purpose-built npm library to handle sessions. + +Make the database changes required for this task using migrations. + +### Submitting exercises and getting the credits + +Exercises of this part are submitted just like in the previous parts, but unlike parts 0 to 7, the submission goes to an own [course instance](https://studies.cs.helsinki.fi/stats/courses/fs-psql). Remember that you have to finish all the exercises to pass this part! + +Once you have completed the exercises and want to get the credits, let us know through the exercise submission system that you have completed the course: + +![Submissions](../../images/11/21.png) + +**Note** that you need a registration to the corresponding course part for getting the credits registered, see [here](/en/part0/general_info#parts-and-completion) for more information. + +
    diff --git a/src/content/13/es/part13.md b/src/content/13/es/part13.md new file mode 100644 index 00000000000..0cfc8e2be9b --- /dev/null +++ b/src/content/13/es/part13.md @@ -0,0 +1,15 @@ +--- +mainImage: ../../../images/part-13.svg +part: 13 +lang: es +--- + +
    + +En las secciones anteriores del curso, usamos MongoDB para almacenar datos. MongoDB es una base de datos llamada NoSQL. Las bases de datos NoSQL se volvieron muy comunes hace poco más de 10 años, cuando el crecimiento de Internet comenzó a producir problemas para las bases de datos relacionales que utilizaban el lenguaje de consulta SQL antiguo. + +Las bases de datos relacionales han experimentado desde entonces un nuevo comienzo. Los problemas de escalabilidad se han resuelto parcialmente y también han adoptado algunas de las características de las bases de datos NoSQL. En esta sección exploramos diferentes aplicaciones de NodeJS que usan bases de datos relacionales, nos enfocaremos en usar la base de datos PostgreSQL que es la número uno en el mundo de código abierto. + +La traducción al inglés de esta parte está realizada por [Reiner Mangly](https://github.com/manglynho). + +
    diff --git a/src/content/13/es/part13a.md b/src/content/13/es/part13a.md new file mode 100644 index 00000000000..cb18a644ca1 --- /dev/null +++ b/src/content/13/es/part13a.md @@ -0,0 +1,742 @@ +--- +mainImage: ../../../images/part-13.svg +part: 13 +letter: a +lang: es +--- + +
    + +En esta sección exploraremos las aplicaciones de Node que usan bases de datos relacionales. Durante la sección construiremos un backend en Node utilizando una base de datos relacional para una aplicación de notas familiar de las secciones 3-5. Para completar esta parte, se necesitará un conocimiento razonable de bases de datos relacionales y SQL. Hay muchos cursos en línea sobre bases de datos SQL, por ejemplo. [SQLbolt](https://sqlbolt.com/) y [Introducción a SQL por Khan Academy](https://www.khanacademy.org/computing/computer-programming/sql). + +Hay 24 ejercicios en esta parte, y se debe completar cada ejercicio para completar el curso. Los ejercicios se envían a través del [sistema de envíos](https://studies.cs.helsinki.fi/stats/courses/fs-psql) al igual que en las partes anteriores, pero a diferencia de las partes 0 a 7, el envío va a su propia "instancia de curso". + +### Ventajas y desventajas de las bases de datos de documentos. + +En las secciones anteriores del curso, hemos utilizado la base de datos MongoDB. Mongo es una [base de datos de documentos](https://en.wikipedia.org/wiki/Document-oriented_database) y una de sus características más importante es que no posee esquema, es decir, la base de datos tiene solo un conocimiento muy limitado de qué tipo de datos se almacenan en sus colecciones. El esquema de la base de datos existe solo en el código del programa, que interpreta los datos de una manera específica, por ejemplo, al identificar que algunos de los campos son referencias a objetos en otra colección. + +En la aplicación de ejemplo de las partes 3 y 4, la base de datos almacena notas y usuarios. + +Una colección de notas que almacena notas tiene el siguiente aspecto: + +```js +[ + { + "_id": "600c0e410d10256466898a6c", + "content": "HTML is easy" + "date": 2021-01-23T11:53:37.292+00:00, + "important": false + "__v": 0 + }, + { + "_id": "600c0edde86c7264ace9bb78", + "content": "CSS is hard" + "date": 2021-01-23T11:56:13.912+00:00, + "important": true + "__v": 0 + }, +] +``` + +Los usuarios guardados en la colección users tienen el siguiente aspecto: + +```js +[ + { + "_id": "600c0e410d10256466883a6a", + "username": "mluukkai", + "name": "Matti Luukkainen", + "passwordHash" : "$2b$10$Df1yYJRiQuu3Sr4tUrk.SerVz1JKtBHlBOARfY0PBn/Uo7qr8Ocou", + "__v": 9, + notes: [ + "600c0edde86c7264ace9bb78", + "600c0e410d10256466898a6c" + ] + }, +] +``` + +MongoDB conoce los tipos de los campos de las entidades almacenadas, pero no tiene información sobre a qué colección de entidades se refieren los ID de registro de usuario. A MongoDB tampoco le importa qué campos tienen las entidades almacenadas en las colecciones. Por lo tanto, MongoDB deja totalmente en manos del programador garantizar que la información correcta se almacene en la base de datos. + +Hay ventajas y desventajas de no tener un esquema. Una de las ventajas es la flexibilidad que aporta el agnosticismo de esquema: dado que no es necesario definir el esquema a nivel de la base de datos, el desarrollo de la aplicación puede ser más rápido en ciertos casos y más fácil, con menos esfuerzo necesario para definir y modificar el esquema en cualquier caso. Los problemas de no tener un esquema están relacionados con la propensión a errores: todo se deja en manos del programador. La base de datos en sí no tiene forma de verificar si los datos que contiene son honestos, es decir, si todos los campos obligatorios tienen valores, si los campos de tipo de referencia se refieren a entidades existentes del tipo correcto en general, etc. + +Las bases de datos relacionales en las que se centra esta sección, por otro lado, se basan en gran medida en la existencia de un esquema, y ​​las ventajas y desventajas de las bases de datos de esquema son casi opuestas en comparación con las bases de datos sin esquema. + +La razón por la que las secciones anteriores del curso usaron MongoDB es precisamente por su naturaleza sin esquema, lo que ha facilitado el uso de la base de datos para alguien con poco conocimiento de bases de datos relacionales. Para la mayoría de los casos de uso de este curso, personalmente habría optado por utilizar una base de datos relacional. + +### Base de datos de la aplicacion + +Para nuestra aplicación necesitamos una base de datos relacional. Hay muchas opciones, pero usaremos la solución de código abierto más popular actualmente [PostgreSQL](https://www.postgresql.org/). Puede instalar Postgres (como suele llamarse a la base de datos) en su máquina, si así lo desea. Una opción más fácil sería usar. También puede aprovechar las lecciones del curso [parte 12](/es/part12) y usar Postgres localmente usando Docker. + +Sin embargo, aprovecharemos el hecho de que es posible crear una base de datos de Postgres para la aplicación en la plataforma de servicios en la nube de Heroku, que ya conocemos de las partes 3 y 4. + +En el material teórico de esta sección, crearemos una versión habilitada para Postgres desde el backend de la aplicación de almacenamiento de notas, que se creó en las secciones 3 y 4. + +Ahora vamos a crear un directorio adecuado dentro de la aplicación Heroku, agregarle una base de datos y usar el comando _heroku config_ para obtener la cadena de conexión, que se requiere para conectarse a la base de datos: + +```bash +heroku create +# Returns an app-name for the app you just created in heroku. + +heroku addons:create heroku-postgresql:hobby-dev -a +heroku config -a +=== cryptic-everglades-76708 Config Vars +DATABASE_URL: postgres://:thepasswordishere@:5432/ +``` + +Particularmente cuando se utiliza una base de datos relacional, también es esencial acceder a la base de datos directamente. Hay muchas maneras de hacer esto, hay varias interfaces gráficas de usuario diferentes, como [pgAdmin](https://www.pgadmin.org/). Sin embargo, utilizaremos la herramienta de línea de comandos de Postgres [psql](https://www.postgresql.org/docs/current/app-psql.html). + +Se puede acceder a la base de datos ejecutando el comando _psql_ en el servidor de Heroku de la siguiente manera (tenga en cuenta que los parámetros del comando dependen de la URL de conexión de la base de datos de Heroku): + +```bash +heroku run psql -h -p 5432 -U -a +``` + +Después de ingresar la contraseña, probemos con el comando psql principal _\d_, que le indica el contenido de la base de datos: + +```bash +Password for user : +psql (13.4 (Ubuntu 13.4-1.pgdg20.04+1)) +SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off) +Type "help" for help. + +postgres=# \d +Did not find any relations. +``` + +Como se puede suponer, actualmente no hay nada en la base de datos. + +Vamos a crear una tabla para notas: + +```sql +CREATE TABLE notes ( + id SERIAL PRIMARY KEY, + content text NOT NULL, + important boolean, + date time +); +``` + +Algunos puntos: la columna id se define como una clave principal, lo que significa que el valor de la columna id debe ser único para cada fila de la tabla y el valor no debe estar vacío. El tipo de esta columna se define como [SERIAL](https://www.postgresql.org/docs/9.1/datatype-numeric.html#DATATYPE-SERIAL), que no es el tipo real sino una abreviatura de una columna de enteros al que Postgres asigna automáticamente un valor único y creciente al crear filas. La columna denominada contenido con tipo texto se define de tal manera que se le debe asignar un valor. + +Veamos la situación desde la consola. Primero, el comando _\d_, que nos dice qué tablas hay en la base de datos: + +```sql +postgres=# \d + List of relations + Schema | Name | Type | Owner +--------+--------------+----------+---------------- + public | notes | table | username + public | notes_id_seq | sequence | username +(2 rows) +``` + +Además de la tabla notes, Postgres creó una subtabla llamada notes\_id\_seq, que realiza un seguimiento de qué valor se asigna a la id columna al crear la siguiente nota. + +Con el comando _\d notas_, podemos ver como se define la tabla notas: + +```sql +postgres=# \d notes; + Table "public.notes" + Column | Type | Collation | Nullable | Default +-----------+------------------------+-----------+----------+----------------------------------- + id | integer | not null | nextval('notes_id_seq'::regclass) + content | text | | not null | + important | boolean | | | | + date | time without time zone | | | | +Indexes: + "notes_pkey" PRIMARY KEY, btree (id) +``` + +Por lo tanto, la columna id tiene un valor predeterminado, que se obtiene llamando a la función interna de Postgres nextval. + +Agreguemos algo de contenido a la tabla: + +```sql +insert into notes (content, important) values ('Relational databases rule the world', true); +insert into notes (content, important) values ('MongoDB is webscale', false); +``` + +Y veamos cómo se ve el contenido creado: + +```sql +postgres=# select * from notes; + id | content | important | date +----+-------------------------------------+-----------+------ + 1 | relational databases rule the world | t | + 2 | MongoDB is webscale | f | +(2 rows) +``` + +Si tratamos de almacenar datos en la base de datos que no están de acuerdo con el esquema, no tendrá éxito. No puede faltar el valor de una columna obligatoria: + +```sql +postgres=# insert into notes (important) values (true); +ERROR: null value in column "content" of relation "notes" violates not-null constraint +DETAIL: Failing row contains (9, null, t, null). +``` + +El valor de la columna no puede ser del tipo incorrecto: + +```sql +postgres=# insert into notes (content, important) values ('only valid data can be saved', 1); +ERROR: column "important" is of type boolean but expression is of type integer +LINE 1: ...tent, important) values ('only valid data can be saved', 1); ^ +``` + +Tampoco se aceptan columnas que no existen en el esquema: + +```sql +postgres=# insert into notes (content, important, value) values ('only valid data can be saved', true, 10); +ERROR: column "value" of relation "notes" does not exist +LINE 1: insert into notes (content, important, value) values ('only ... +``` + +A continuación, es hora de pasar a acceder a la base de datos desde la aplicación. + +### Aplicación en Node, usando una base de datos relacional + +Iniciemos la aplicación como de costumbre con npm init e instalemos nodemon como una dependencia de desarrollo y también las siguientes dependencias de tiempo de ejecución: + +```bash +npm install express dotenv pg sequelize +``` + +De estos, el último [sequelize](https://sequelize.org/master/) es la biblioteca a través de la cual usamos Postgres. Sequelize es una biblioteca llamada [Mapeo relacional de objetos] (https://en.wikipedia.org/wiki/Object%E2%80%93relational_mapping) (ORM) que le permite almacenar objetos de JavaScript en una base de datos relacional sin usar el Lenguaje SQL en sí mismo, similar a Mongoose que usamos con MongoDB. + +Probemos que podemos conectarnos con éxito. Cree el archivo index.js y agregue el siguiente contenido: + +```js +require('dotenv').config() +const { Sequelize } = require('sequelize') + +const sequelize = new Sequelize(process.env.DATABASE_URL, { + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, +}) + +const main = async () => { + try { + await sequelize.authenticate() + console.log('Connection has been established successfully.') + sequelize.close() + } catch (error) { + console.error('Unable to connect to the database:', error) + } +} + +main() +``` + +La cadena de conexión de la base de datos, que es revelada por el comando _heroku config_ debe almacenarse en un archivo .env, el contenido debe ser algo como lo siguiente: + +```bash +$ cat .env +DATABASE_URL=postgres://:thepasswordishere@ec2-54-83-137-206.compute-1.amazonaws.com:5432/ +``` + +Probemos una conexión exitosa: + +```bash +$ node index.js +Executing (default): SELECT 1+1 AS result +Connection has been established successfully. +``` + +Si la conexión funciona, podemos ejecutar la primera consulta. Modifiquemos el programa de la siguiente manera: + +```js +require('dotenv').config() +const { Sequelize, QueryTypes } = require('sequelize') // highlight-line + +const sequelize = new Sequelize(process.env.DATABASE_URL, { + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, +}); + +const main = async () => { + try { + await sequelize.authenticate() + // highlight-start + const notes = await sequelize.query("SELECT * FROM notes", { type: QueryTypes.SELECT }) + console.log(notes) + sequelize.close() + // highlight-end + } catch (error) { + console.error('Unable to connect to the database:', error) + } +} + +main() +``` + +La ejecución de la aplicación debe imprimir de la siguiente manera: + +```js +Executing (default): SELECT * FROM notes +[ + { + id: 1, + content: 'Relational databases rule the world', + important: true, + date: null + }, + { + id: 2, + content: 'MongoDB is webscale', + important: false, + date: null + } +] +``` + +Aun cuando Sequelize es una biblioteca ORM, puede existir casos aislados en los que exista la necesidad de escribir SQL, para ello solo usamos [SQL directo] (https://sequelize.org/master/manual/raw-queries.html) con el método de sequelize [query] (https://sequelize.org/api/v6/class/src/sequelize.js~sequelize#instance-method-query). + +Como todo parece estar funcionando, cambiemos la aplicación a una aplicación web. + +```js +require('dotenv').config() +const { Sequelize, QueryTypes } = require('sequelize') +const express = require('express') // highlight-line +const app = express() // highlight-line + +const sequelize = new Sequelize(process.env.DATABASE_URL, { + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, +}); + +// highlight-start +app.get('/api/notes', async (req, res) => { + const notes = await sequelize.query("SELECT * FROM notes", { type: QueryTypes.SELECT }) + res.json(notes) +}) + +const PORT = process.env.PORT || 3001 +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +// highlight-end +``` + +La aplicación parece estar funcionando. Sin embargo, ahora cambiemos a usar Sequelize en lugar de SQL, ya que está destinado a usarse. + +### El Modelo + +Al usar Sequelize, cada tabla en la base de datos está representada por un [modelo] (https://sequelize.org/master/manual/model-basics.html), que es efectivamente su propia clase de JavaScript. Ahora definamos el modelo Nota correspondiente a la tabla notas para la aplicación cambiando el código al siguiente formato: + +```js +require('dotenv').config() +const { Sequelize, Model, DataTypes } = require('sequelize') // highlight-line +const express = require('express') +const app = express() + +const sequelize = new Sequelize(process.env.DATABASE_URL, { + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, +}); + +// highlight-start +class Note extends Model {} + +Note.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + content: { + type: DataTypes.TEXT, + allowNull: false + }, + important: { + type: DataTypes.BOOLEAN + }, + date: { + type: DataTypes.DATE + } +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'note' +}) +// highlight-end + +app.get('/api/notes', async (req, res) => { + const notes = await Note.findAll() // highlight-line + res.json(notes) +}) + +const PORT = process.env.PORT || 3001 +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +``` + +Algunos comentarios sobre el código: No hay nada sorprendente en la definición del modelo Nota, cada columna tiene un tipo definido, así como otras propiedades si es necesario, como si es la clave principal de la tabla. El segundo parámetro en la definición del modelo contiene el atributo sequelize así como otra información de configuración. También definimos que la tabla no tiene que usar las columnas de marcas de tiempo (created\_at and updated\_at). + +También definimos underscored: true, lo que significa que los nombres de las tablas se derivan de los nombres de los modelos como versiones en plural [snake case](https://en.wikipedia.org/wiki/Snake_case). Prácticamente esto significa que, si el nombre del modelo, como en nuestro caso, es "Nota", entonces el nombre de la tabla correspondiente es su versión plural escrita con una letra inicial minúscula, es decir, notas. Si, por el contrario, el nombre del modelo fuera "dos partes", p. StudyGroup, entonces el nombre de la tabla sería study_groups. Sequelize infiere automáticamente los nombres de las tablas, pero también permite definirlos explícitamente. + +La misma política de nomenclatura se aplica a las columnas. Si hubiésemos definido que una nota está asociada a creationYear, es decir, información sobre el año en que fue creada, la definiríamos en el modelo de la siguiente manera: + +```js +Note.init({ + // ... + creationYear: { + type: DataTypes.INTEGER, + }, +}) +``` + +El nombre de la columna correspondiente en la base de datos sería creation_year. En el código, la referencia a la columna siempre tiene el mismo formato que en el modelo, es decir, en formato "camel case". + +También hemos definido modelName: 'note', el "nombre del modelo" predeterminado sería Note en mayúsculas. Sin embargo, queremos tener una inicial en minúscula, esto hará que algunas cosas sean un poco más convenientes en el futuro. + +La operación de la base de datos es fácil de hacer usando la [interfaz de consulta](https://sequelize.org/master/manual/model-querying-basics.html) proporcionada por los modelos, el método [findAll](https://sequelize.org/master/class/lib/model.js~Model.html#static-method-findAll) funciona exactamente como su nombre indica: + +```js +app.get('/api/notes', async (req, res) => { + const notes = await Note.findAll() // highlight-line + res.json(notes) +}) +``` + +La consola le dice que la llamada al método Note.findAll() genera la siguiente consulta: + +```js +Executing (default): SELECT "id", "content", "important", "date" FROM "notes" AS "note"; +``` + +A continuación, implementemos un endpoint para crear nuevas notas: + +```js +app.use(express.json()) + +// ... + +app.post('/api/notes', async (req, res) => { + console.log(req.body) + const note = await Note.create(req.body) + res.json(note) +}) +``` + +La creación de una nueva nota se realiza llamando al método [create](https://sequelize.org/master/manual/model-querying-basics.html#simple-insert-queries) del modelo Note y pasando como parámetro un objeto que define los valores de las columnas. + +En lugar del método create, [también es posible](https://sequelize.org/master/manual/model-instances.html#creating-an-instance) guardar en una base de datos usando primero el método [build](https://sequelize.org/api/v6/class/src/model.js~model#static-method-build) para crear un objeto modelo a partir de los datos deseados y luego llamar al método [save](https://sequelize.org/master/class/lib/model.js~Model.html#instance-method-save) en él: + +```js +const note = Note.build(req.body) +await note.save() +``` + +Llamar al método build aún no guarda el objeto en la base de datos, por lo que aún es posible editar el objeto antes del evento de guardado real: + +```js +const note = Note.build(req.body) +note.important = true // highlight-line +await note.save() +``` + +Para el caso de uso del código de ejemplo, el método [create](https://sequelize.org/master/manual/model-querying-basics.html#simple-insert-queries) es más adecuado, así que sigamos con eso. + +Si el objeto que se está creando no es válido, aparece un mensaje de error como resultado. Por ejemplo, al intentar crear una nota sin contenido, la operación falla y la consola revela que el motivo es SequelizeValidationError: notNull Violation Note.content can be null: + +``` +(node:39109) UnhandledPromiseRejectionWarning: SequelizeValidationError: notNull Violation: Note.content cannot be null + at InstanceValidator._validate (/Users/mluukkai/opetus/fs-psql/node_modules/sequelize/lib/instance-validator.js:78:13) + at processTicksAndRejections (internal/process/task_queues.js:93:5) +``` + +Agreguemos un manejo de errores simple al agregar una nueva nota: + +```js +app.post('/api/notes', async (req, res) => { + try { + const note = await Note.create(req.body) + return res.json(note) + } catch(error) { + return res.status(400).json({ error }) + } +}) +``` + +
    + +
    + +### Ejercicios 13.1.-13.3. + +En las tareas de esta sección, construiremos un backend de aplicación de blog similar a las tareas en la [sección 4](/es/part4), que debería ser compatible con el frontend en la [sección 5](/es/part5) excepto por manejo de errores. También agregaremos varias características al backend que el frontend en la sección 5 no sabrá cómo usar. + +#### Ejercicio 13.1. + +Cree un repositorio de GitHub para la aplicación y cree una nueva aplicación de Heroku para ella, así como una base de datos de Postgres. Asegúrese de poder establecer una conexión con la base de datos de la aplicación. + +#### Ejercicio 13.2. + +En la línea de comandos, cree una tabla blogs para la aplicación con las siguientes columnas: +- id (identificador único e incremental) +- author (cadena de texto) +- url (cadena de texto que no puede estar vacía) +- title (cadena de texto que no puede estar vacía) +- likes (entero con valor predeterminado cero) + +Agregue al menos dos blogs a la base de datos. + +Guarde los comandos SQL que usó en la raíz del repositorio de la aplicación en el archivo llamado commands.sql + +#### Ejercicio 13.3. + +Cree una funcionalidad en su aplicación, que imprima los blogs en la base de datos utilizando la línea de comandos, por ejemplo, como se muestra a continuación: + +```bash +$ node cli.js +Executing (default): SELECT * FROM blogs +Dan Abramov: 'On let vs const', 0 likes +Laurenz Albe: 'Gaps in sequences in PostgreSQL', 0 likes +``` + +
    + +
    + +### Crear tablas automáticamente + +Nuestra aplicación ahora tiene un lado desagradable, asume que existe una base de datos con exactamente el esquema correcto, es decir, que la tabla notes ha sido creada con el comando create table apropiado. + +Dado que el código del programa se almacena en GitHub, tendría sentido almacenar también los comandos que crean la base de datos en el contexto del código del programa, de modo que el esquema de la base de datos sea definitivamente el mismo que espera el código del programa. Sequelize en realidad puede generar un esquema automáticamente a partir de la definición del modelo utilizando el método [sync](https://sequelize.org/master/manual/model-basics.html#model-synchronization). + +Ahora destruyamos la base de datos desde la consola ingresando el siguiente comando: + +``` +drop table notes; +``` + +El comando `\d` revela que la tabla se ha borrado de la base de datos: + +``` +postgres=# \d +Did not find any relations. +``` + +La aplicación ya no funciona. + +Agreguemos el siguiente comando a la aplicación inmediatamente después de definir el modelo Note: + +```js +Note.sync() +``` + +Cuando se inicia la aplicación, se imprime lo siguiente en la consola: + +``` +Executing (default): CREATE TABLE IF NOT EXISTS "notes" ("id" SERIAL , "content" TEXT NOT NULL, "important" BOOLEAN, "date" TIMESTAMP WITH TIME ZONE, PRIMARY KEY ("id")); +``` + +Es decir, cuando se inicia la aplicación, se ejecuta el comando CREATE TABLE IF NOT EXISTS "notes"... que crea la tabla notes si aún no existe. + +### Otras operaciones + +Completemos la aplicación con algunas operaciones más. + +Es posible buscar una sola nota con el método [findByPk](https://sequelize.org/docs/v6/core-concepts/model-querying-finders/#findbypk), porque se recupera en función del identificador de la clave primaria: + +```js +app.get('/api/notes/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + res.json(note) + } else { + res.status(404).end() + } +}) +``` + +La recuperación de una sola nota genera el siguiente comando SQL: + +``` +Executing (default): SELECT "id", "content", "important", "date" FROM "notes" AS "note" WHERE "note". "id" = '1'; +``` + +Si no se encuentra ninguna nota, la operación devuelve null y, en este caso, se proporciona el código de estado correspondiente. + +La modificación de la nota se realiza de la siguiente manera. Solo se admite la modificación del campo important, ya que el frontend de la aplicación no necesita nada más: + +```js +app.put('/api/notes/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + note.important = req.body.important + await note.save() + res.json(note) + } else { + res.status(404).end() + } +}) +``` + +El objeto correspondiente a la fila de la base de datos se recupera de la base de datos utilizando el método findByPk, el objeto se modifica y el resultado se guarda llamando al método save del objeto correspondiente. + +El código actual de la aplicación se encuentra en su totalidad en [GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-1), rama part13-1. + +### Imprimiendo los objetos devueltos por Sequelize a la consola + +La herramienta más importante del programador de JavaScript es console.log, cuyo uso agresivo controla incluso los peores errores. Agreguemos la impresión de consola a la ruta de una sola nota: + +```js +app.get('/api/notes/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + console.log(note) // highlight-line + res.json(note) + } else { + res.status(404).end() + } +}) +``` + +Podemos ver que el resultado final no es exactamente lo que esperábamos: + +```js +note { + dataValues: { + id: 1, + content: 'Notes are attached to a user', + important: true, + date: 2021-10-03T15:00:24.582Z, + }, + _previousDataValues: { + id: 1, + content: 'Notes are attached to a user', + important: true, + date: 2021-10-03T15:00:24.582Z, + }, + _changed: Set(0) {}, + _options: { + isNewRecord: false, + _schema: null, + _schemaDelimiter: '', + raw: true, + attributes: [ 'id', 'content', 'important', 'date' ] + }, + isNewRecord: false +} +``` + +Además de la información de la nota, en la consola se imprimen todo tipo de cosas. Podemos alcanzar el resultado deseado llamando al método modelo-objeto [toJSON](https://sequelize.org/api/v6/class/src/model.js~model#instance-method-toJSON): + +```js +app.get('/api/notes/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + console.log(note.toJSON()) // highlight-line + res.json(note) + } else { + res.status(404).end() + } +}) +``` + +Ahora el resultado es exactamente lo que queremos: + +```js +{ id: 1, + content: 'MongoDB is webscale', + important: false, + date: 2021-10-09T13:52:58.693Z } +``` + +En el caso de una colección de objetos, el método toJSON no funciona directamente, el método debe llamarse por separado para cada objeto de la colección: + +```js +router.get('/', async (req, res) => { + const notes = await Note.findAll() + + console.log(notes.map(n=>n.toJSON())) // highlight-line + + res.json(notes) +}) +``` + +La impresión se parece a lo siguiente: + +```js +[ { id: 1, + content: 'MongoDB is webscale', + important: false, + date: 2021-10-09T13:52:58.693Z }, + { id: 2, + content: 'Relational databases rule the world', + important: true, + date: 2021-10-09T13:53:10.710Z } ] +``` + +Sin embargo, quizás una mejor solución sea convertir la colección en JSON para imprimir usando el método [JSON.stringify](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify): + +```js +router.get('/', async (req, res) => { + const notes = await Note.findAll() + + console.log(JSON.stringify(notes)) // highlight-line + + res.json(notes) +}) +``` + +Esta forma es mejor, especialmente si los objetos de la colección contienen otros objetos. También suele ser útil dar formato a los objetos en la pantalla en un formato un poco más fácil de leer. Esto se puede hacer con el siguiente comando: + +```json +console.log(JSON.stringify(notes, null, 2)) +``` + +La impresión se parece a lo siguiente: + +```js +[ + { + "id": 1, + "content": "MongoDB is webscale", + "important": false, + "date": "2021-10-09T13:52:58.693Z" + }, + { + "id": 2, + "content": "Relational databases rule the world", + "important": true, + "date": "2021-10-09T13:53:10.710Z" + } +] +``` + +
    + +
    + +### Ejercicio 13.4. + +#### Ejercicio 13.4. + +Transforme su aplicación en una aplicación web que admita las siguientes operaciones + +- GET api/blogs (listar todos los blogs) +- POST api/blogs (adicionar un nuevo blog) +- DELETE api/blogs/:id (eliminar un blog) + +
    diff --git a/src/content/13/es/part13b.md b/src/content/13/es/part13b.md new file mode 100644 index 00000000000..941aa303c0f --- /dev/null +++ b/src/content/13/es/part13b.md @@ -0,0 +1,996 @@ +--- +mainImage: ../../../images/part-13.svg +part: 13 +letter: b +lang: es +--- + +
    + +### Estructura de la aplicación + +Hasta ahora, hemos escrito todo el código en el mismo archivo. Ahora vamos a estructurar un poco mejor la aplicación. Vamos a crear la siguiente estructura de directorios y archivos: + +``` +index.js +util + config.js + db.js +models + index.js + note.js +controllers + notes.js +``` + +El contenido de los archivos es el siguiente. El archivo util/config.js se encarga de manejar las variables de entorno: + +```js +require('dotenv').config() + +module.exports = { + DATABASE_URL: process.env.DATABASE_URL, + PORT: process.env.PORT || 3001, +} +``` + +La función del archivo index.js es configurar e iniciar la aplicación: + +```js +const express = require('express') +const app = express() + +const { PORT } = require('./util/config') +const { connectToDatabase } = require('./util/db') + +const notesRouter = require('./controllers/notes') + +app.use(express.json()) + +app.use('/api/notes', notesRouter) + +const start = async () => { + await connectToDatabase() + app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) + }) +} + +start() +``` + +Iniciar la aplicación es ligeramente diferente de lo que hemos visto antes, porque queremos asegurarnos de que la conexión a la base de datos se establezca correctamente antes del inicio real. + +El archivo util/db.js contiene el código para inicializar la base de datos: + +```js +const Sequelize = require('sequelize') +const { DATABASE_URL } = require('./config') + +const sequelize = new Sequelize(DATABASE_URL, { + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, +}); + +const connectToDatabase = async () => { + try { + await sequelize.authenticate() + console.log('connected to the database') + } catch (err) { + console.log('failed to connect to the database') + return process.exit(1) + } + + return null +} + +module.exports = { connectToDatabase, sequelize } +``` + +Las notas del modelo correspondiente a la tabla se guardan en el archivo models/note.js + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class Note extends Model {} + +Note.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + content: { + type: DataTypes.TEXT, + allowNull: false + }, + important: { + type: DataTypes.BOOLEAN + }, + date: { + type: DataTypes.DATE + } +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'note' +}) + +module.exports = Note +``` + +El archivo models/index.js es casi inútil en este momento, ya que solo hay un modelo en la aplicación. Cuando comencemos a agregar otros modelos a la aplicación, el archivo será más útil porque eliminará la necesidad de importar archivos que definan modelos individuales en el resto de la aplicación. + +```js +const Note = require('./note') + +Note.sync() + +module.exports = { + Note +} +``` + +El manejo de rutas asociado con las notas se puede encontrar en el archivo controllers/notes.js: + +```js +const router = require('express').Router() + +const { Note } = require('../models') + +router.get('/', async (req, res) => { + const notes = await Note.findAll() + res.json(notes) +}) + +router.post('/', async (req, res) => { + try { + const note = await Note.create(req.body) + res.json(note) + } catch(error) { + return res.status(400).json({ error }) + } +}) + +router.get('/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + res.json(note) + } else { + res.status(404).end() + } +}) + +router.delete('/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + await note.destroy() + } + res.status(204).end() +}) + +router.put('/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + note.important = req.body.important + await note.save() + res.json(note) + } else { + res.status(404).end() + } +}) + +module.exports = router +``` + +La estructura de la aplicación es buena ahora. Sin embargo, notamos que los manejadores de ruta que manejan una sola nota contienen un poco de código repetitivo, ya que todos comienzan con la línea que busca la nota a manejar: + +```js +const note = await Note.findByPk(req.params.id) +``` + +Vamos a refactorizar esto en nuestro propio middleware e implementarlo en los controladores de ruta: + +```js +const noteFinder = async (req, res, next) => { + req.note = await Note.findByPk(req.params.id) + next() +} + +router.get('/:id', noteFinder, async (req, res) => { + if (req.note) { + res.json(req.note) + } else { + res.status(404).end() + } +}) + +router.delete('/:id', noteFinder, async (req, res) => { + if (req.note) { + await req.note.destroy() + } + res.status(204).end() +}) + +router.put('/:id', noteFinder, async (req, res) => { + if (req.note) { + req.note.important = req.body.important + await req.note.save() + res.json(req.note) + } else { + res.status(404).end() + } +}) +``` + +Los controladores de ruta ahora reciben tres parámetros, el primero es una cadena que define la ruta y el segundo es el noteFinder de middleware que definimos anteriormente, que recupera la nota de la base de datos y la coloca en la propiedad note del objeto req. ¡Se elimina una pequeña cantidad de copypaste y estamos satisfechos! + +El código actual de la aplicación se encuentra en su totalidad en [GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-2), rama part13-2. + +
    + +
    + +### Ejercicios 13.5.-13.7. + +#### Ejercicio 13.5. + +Cambie la estructura de su aplicación para que coincida con el ejemplo anterior, o alguna otra convención similar. + +#### Ejercicio 13.6. + +Además, implemente soporte para cambiar el número de likes de un blog en la aplicación, es decir, la operación + +_PUT /api/blogs/:id_ (modifica el conteno de likes de un blog) + +El número actualizado de li se transmitirá con la solicitud: + +```js +{ + likes: 3 +} +``` + +#### Ejercicio 13.7. + +Centralice el manejo de errores de la aplicación en el middleware como en la [parte 3](/es/part3/guardando_datos_en_mongo_db#mover-el-manejo-de-errores-al-middleware). También puede habilitar el middleware[express-async-errors](https://github.com/davidbanham/express-async-errors) como hicimos en la [parte 4](es/part4/porbando_el_backend#eliminando-el-try-catch). + +Los datos devueltos en el contexto de un mensaje de error no son muy importantes. + +En este punto, las situaciones que requieren el manejo de errores por parte de la aplicación son la creación de un nuevo blog y el cambio de la cantidad de likes en un blog. Asegúrese de que el controlador de errores maneje ambos de manera adecuada. + +
    + +
    + +### Administración de usuario + +A continuación, agreguemos una tabla de base de datos users a la aplicación, donde se almacenarán los usuarios de la aplicación. Además, agregaremos la capacidad de crear usuarios e inicio de sesión basado en tokens como lo implementamos en [parte 4](/en/part4/autenticacion_de_token). Para simplificar, ajustaremos la implementación para que todos los usuarios tengan la misma contraseña secret. + +El modelo que define a los usuarios en el archivo models/user.js es sencillo + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class User extends Model {} + +User.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + username: { + type: DataTypes.STRING, + unique: true, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'user' +}) + +module.exports = User +``` + +El campo username está configurado como único. El nombre de usuario podría haberse utilizado básicamente como la clave principal de la tabla. Sin embargo, decidimos crear la clave principal como un campo separado con un valor entero id. + +El archivo models/index.js se expande ligeramente: + +```js +const Note = require('./note') +const User = require('./user') // highlight-line + +Note.sync() +User.sync() // highlight-line + +module.exports = { + Note, User // highlight-line +} +``` + +Los controladores de ruta que se encargan de crear un nuevo usuario en el archivo controllers/users.js y mostrar a todos los usuarios no contienen nada dramático: + +```js +const router = require('express').Router() + +const { User } = require('../models') + +router.get('/', async (req, res) => { + const users = await User.findAll() + res.json(users) +}) + +router.post('/', async (req, res) => { + try { + const user = await User.create(req.body) + res.json(user) + } catch(error) { + return res.status(400).json({ error }) + } +}) + +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id) + if (user) { + res.json(user) + } else { + res.status(404).end() + } +}) + +module.exports = router +``` + +El controlador del enrutador que maneja el inicio de sesión (archivo controllers/login.js) es el siguiente: + +```js +const jwt = require('jsonwebtoken') +const router = require('express').Router() + +const { SECRET } = require('../util/config') +const User = require('../models/user') + +router.post('/', async (request, response) => { + const body = request.body + + const user = await User.findOne({ + where: { + username: body.username + } + }) + + const passwordCorrect = body.password === 'secret' + + if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) + } + + const userForToken = { + username: user.username, + id: user.id, + } + + const token = jwt.sign(userForToken, SECRET) + + response + .status(200) + .send({ token, username: user.username, name: user.name }) +}) + +module.exports = router +``` + +La solicitud POST irá acompañada de un nombre de usuario y una contraseña. Primero, el objeto correspondiente al nombre de usuario se recupera de la base de datos utilizando el modelo User con el método [findOne](https://sequelize.org/master/manual/model-querying-finders.html#-code-findone--code-): + +```js +const user = await User.findOne({ + where: { + username: body.username + } +}) +``` + +Desde la consola, podemos ver que la instrucción SQL corresponde a la llamada al método + +```sql +SELECT "id", "username", "name" +FROM "users" AS "User" +WHERE "User". "username" = 'mluukkai'; +``` + +Si se encuentra el usuario y la contraseña es correcta (es decir, _secret_ para todos los usuarios), en la respuesta se devuelve un jsonwebtoken que contiene la información del usuario. Para ello instalamos la dependencia + +```js +npm instalar jsonwebtoken +``` + +El archivo index.js se expande ligeramente + +```js +const notesRouter = require('./controllers/notes') +const usersRouter = require('./controllers/users') +const loginRouter = require('./controllers/login') + +app.use(express.json()) + +app.use('/api/notes', notesRouter) +app.use('/api/users', usersRouter) +app.use('/api/login', loginRouter) +``` + +El código actual de la aplicación se encuentra en su totalidad en [GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-3), rama part13-3. + +### Conexión entre las tablas + +Ahora se pueden agregar usuarios a la aplicación y los usuarios pueden iniciar sesión, pero esto en sí mismo no es una característica muy útil todavía. Nos gustaría agregar las características de que solo un usuario registrado puede agregar notas y que cada nota está asociada con el usuario que la creó. Para hacer esto, necesitamos agregar una clave externa a la tabla notes. + +Al usar Sequelize, se puede definir una clave externa modificando el archivo models/index.js de la siguiente manera + +```js +const Note = require('./note') +const User = require('./user') + +// highlight-start +User.hasMany(Note) +Note.belongsTo(User) + +Note.sync({ alter: true }) +User.sync({ alter: true }) +// highlight-end + +module.exports = { + Note, User +} +``` + +Así es como [define](https://sequelize.org/master/manual/assocs.html#one-to-many-relationships) que existe una conexión de relación _one-to-many_ entre users y notes. También cambiamos las opciones de las llamadas sync para que las tablas en la base de datos coincidan con los cambios realizados en las definiciones del modelo. El esquema de la base de datos tiene el siguiente aspecto desde la consola: + +```js +postgres=# \d users + Table "public.users" + Column | Type | Collation | Nullable | Default +----------+------------------------+-----------+----------+----------------------------------- + id | integer | not null | nextval('users_id_seq'::regclass) + username | character varying(255) | | not null | + name | character varying(255) | | not null | +Indexes: + "users_pkey" PRIMARY KEY, btree (id) +Referenced by: + TABLE "notes" CONSTRAINT "notes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE SET NULL + +postgres=# \d notes + Table "public.notes" + Column | Type | Collation | Nullable | Default +-----------+--------------------------+-----------+----------+----------------------------------- + id | integer | not null | nextval('notes_id_seq'::regclass) + content | text | | not null | + important | boolean | | | | + date | timestamp with time zone | | | | + user_id | integer | | | | +Indexes: + "notes_pkey" PRIMARY KEY, btree (id) +Foreign-key constraints: + "notes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE SET NULL +``` + +La clave externa user_id se ha creado en la tabla notes, que hace referencia a las filas de la tabla users. + +Ahora hagamos que cada inserción de una nueva nota se asocie a un usuario. Antes de hacer la implementación adecuada (donde asociamos la nota con el token del usuario que inició sesión), codifiquemos la nota para adjuntarla al primer usuario que se encuentre en la base de datos: + +```js + +router.post('/', async (req, res) => { + try { + // highlight-start + const user = await User.findOne() + const note = await Note.create({...req.body, userId: user.id}) + // highlight-end + res.json(note) + } catch(error) { + return res.status(400).json({ error }) + } +}) +``` + +Preste atención a cómo ahora hay una columna user\_id en las notas a nivel de la base de datos. La convención de nomenclatura de Sequelize hace referencia al objeto correspondiente en cada fila de la base de datos en lugar de mayúsculas y minúsculas (userId) como se escribe en el código fuente. + +Hacer una consulta de unión es muy fácil. Cambiemos la ruta que devuelve a todos los usuarios para que también se muestren las notas de cada usuario: + +```js +router.get('/', async (req, res) => { + // highlight-start + const users = await User.findAll({ + include: { + model: Note + } + }) + // highlight-end + res.json(users) +}) +``` + +Por lo tanto, la consulta de combinación se realiza mediante la opción [include](https://sequelize.org/master/manual/assocs.html#eager-loading-example) como parámetro de consulta. + +La instrucción SQL generada a partir de la consulta se ve en la consola: + +``` +SELECT "User". "id", "User". "username", "User". "name", "Notes". "id" AS "Notes.id", "Notes". "content" AS "Notes.content", "Notes". "important" AS "Notes.important", "Notes". "date" AS "Notes.date", "Notes". "user_id" AS "Notes.UserId" +FROM "users" AS "User" LEFT OUTER JOIN "notes" AS "Notes" ON "User". "id" = "Notes". "user_id"; +``` + +El resultado final también es el que cabría esperar. + +![](../../images/13/1.png) + +### Inserción correcta de notas. + +Cambiemos la inserción de la nota haciendo que funcione igual que en la [parte 4](/es/part4), es decir, la creación de una nota solo puede tener éxito si la solicitud correspondiente a la creación va acompañada de un token válido de inicio de sesión. Luego, la nota se almacena en la lista de notas creada por el usuario identificado por el token: + +```js +// highlight-start +const tokenExtractor = (req, res, next) => { + const authorization = req.get('authorization') + if (authorization && authorization.toLowerCase().startsWith('bearer ')) { + try { + req.decodedToken = jwt.verify(authorization.substring(7), SECRET) + } catch{ + res.status(401).json({ error: 'token invalid' }) + } + } else { + res.status(401).json({ error: 'token missing' }) + } + next() +} +// highlight-end + +router.post('/', tokenExtractor, async (req, res) => { + try { + // highlight-start + const user = await User.findByPk(req.decodedToken.id) + const note = await Note.create({...req.body, userId: user.id, date: new Date()}) + // highlight-end + res.json(note) + } catch(error) { + return res.status(400).json({ error }) + } +}) +``` + +El token se recupera de los encabezados de solicitud, se decodifica y se coloca en el objeto req mediante el middleware tokenExtractor. Al crear una nota, también se proporciona un campo de fecha que indica la hora en que se creó. + +### Afinando + +Nuestro backend actualmente funciona casi de la misma manera que la versión de la Parte 4 de la misma aplicación, excepto por el manejo de errores. Antes de hacer algunas extensiones al backend, cambiemos ligeramente las rutas para recuperar todas las notas y todos los usuarios. + +Agregaremos a cada nota información sobre el usuario que la agregó: + +```js +router.get('/', async (req, res) => { + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: User, + attributes: ['name'] + } + }) + res.json(notes) +}) +``` + +También hemos [restringido](https://sequelize.org/master/manual/model-querying-basics.html#specifying-attributes-for-select-queries) los valores de los campos que queremos. Para cada nota, devolvemos todos los campos, incluido el name del usuario asociado con la nota, pero excluyendo el userId. + +Hagamos un cambio similar a la ruta que recupera a todos los usuarios, eliminando el campo innecesario userId de las notas asociadas con el usuario: + +```js +router.get('/', async (req, res) => { + const users = await User.findAll({ + include: { + model: Note, + attributes: { exclude: ['userId'] } // highlight-line + } + }) + res.json(users) +}) +``` + +El código actual de la aplicación se encuentra en su totalidad en [GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-4), rama part13-4. + +### Atención a la definición de los modelos + +Los más perspicaces habrán notado que a pesar de la columna agregada user_id, no hicimos ningún cambio en el modelo que define las notas, pero aún podemos agregar un usuario a los objetos de nota: + +```js +const user = await User.findByPk(req.decodedToken.id) +const note = await Note.create({ ...req.body, userId: user.id, date: new Date() }) +``` + +La razón de esto es que especificamos en el archivo models/index.js que existe una conexión de uno a muchos entre los usuarios y las notas: + +```js +const Note = require('./note') +const User = require('./user') + +User.hasMany(Note) +Note.belongsTo(User) + +// ... +``` + +SSequelize creará automáticamente un atributo llamado userId en el modelo Note al cual, cuando se hace referencia, da acceso a la columna de la base de datos user_id. + +Tenga en cuenta que también podríamos crear una nota de la siguiente manera usando el método [build](https://sequelize.org/api/v6/class/src/model.js~model#static-method-build): + +```js +const user = await User.findByPk(req.decodedToken.id) + +// create a note without saving it yet +const note = Note.build({ ...req.body, date: new Date() }) + // put the user id in the userId property of the created note +note.userId = user.id +// store the note object in the database +await note.save() +``` + +Así es como vemos explícitamente que userId es un atributo del objeto notas. + +Podríamos definir el modelo de la siguiente manera para obtener el mismo resultado: + +```js +Note.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + content: { + type: DataTypes.TEXT, + allowNull: false + }, + important: { + type: DataTypes.BOOLEAN + }, + date: { + type: DataTypes.DATE + }, + // highlight-start + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + } + // highlight-end +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'note' +}) + +module.exports = Note +``` + +Definir a nivel de clase del modelo como se indicó anteriormente suele ser innecesario + +```js +User.hasMany(Note) +Note.belongsTo(User) +``` + +En cambio, podemos lograr lo mismo con esto. Es necesario usar uno de los dos métodos; de lo contrario, Sequelize no sabe cómo conectar las tablas entre sí a nivel de código. + +
    + +
    + +### Ejercicios 13.8.-13.12. + +#### Ejercicio 13.8. + +Agregue soporte para usuarios a la aplicación. Además de la identificación, los usuarios tienen los siguientes campos: + +- name (cadena de texto, no debe estar vacía) +- username (cadena de texto, no debe estar vacía) + +A diferencia del material, ahora no impida que Sequelize cree [marcas de tiempo](https://sequelize.org/master/manual/model-basics.html#timestamps) created\_at y updated\_at para los usuarios + +Todos los usuarios pueden tener la misma contraseña que el material. También pueden optar por implementar correctamente las contraseñas como en [parte 4](/es/part4/administracion_de_usuarios). + +Implemente las siguientes rutas + +- _POST api/users_ (agregar un nuevo usuario) +- _GET api/users_ (lista de todos los usuarios) +- _PUT api/users/:username_ (cambiando un nombre de usuario, tenga en cuenta que el parámetro no es id sino username) + +Asegúrese de que las marcas de tiempo created\_at y updated\_at establecidas automáticamente por Sequelize funcionen correctamente al crear un nuevo usuario y cambiar un nombre de usuario. + +#### Ejercicio 13.9. + +Sequelize proporciona un conjunto de [validaciones](https://sequelize.org/master/manual/validations-and-constraints.html) para los campos del modelo, que realiza antes de almacenar los objetos en la base de datos. + +Se decide cambiar la política de creación de usuarios para que solo una dirección de correo electrónico válida se pueda utilizar como nombre de usuario. Implemente una validación que verifique este problema durante la creación de un usuario. + +Modifique el middleware de manejo de errores para proporcionar un mensaje de error más descriptivo de la situación (por ejemplo, usando el mensaje de error Sequelize): + +```js +{ + "error": [ + "Validation isEmail on username failed" + ] +} +``` + +#### Ejercicio 13.10. + +Expanda la aplicación para que el usuario conectado actual identificado por un token esté vinculado a cada blog agregado. Para hacer esto, también deberá implementar un endpoint de inicio de sesión _POST /api/login_, que devuelve el token. + +#### Ejercicio 13.11. + +Haga que la eliminación de un blog solo sea posible para el usuario que agregó el blog. + +#### Ejercicio 13.12. + +Modifique las rutas para recuperar todos los blogs y todos los usuarios para: + +1- Que cada blog muestre el usuario que lo agregó. +2- Cada usuario muestre los blogs que agregó. + +
    + +
    + +### Más consultas + +Hasta ahora, nuestra aplicación ha sido muy simple en términos de consultas, las consultas han buscado una sola fila en función de la clave principal utilizando el método [findByPk](https://sequelize.org/master/class/lib/model.js~Model.html#static-method-findByPk) o han buscado todas las filas en la tabla usando el método [findAll](https://sequelize.org/master/class/lib/model.js~Model.html#static-method-findAll). Estos son suficientes para el frontend de la aplicación creada en la Sección 5, pero vamos a expandir el backend para que también podamos practicar haciendo consultas un poco más complejas. + +Primero implementemos la posibilidad de recuperar solo notas importantes o no importantes. Implementemos esto usando el [parámetro de consulta](http://expressjs.com/en/5x/api.html#req.query) important: + +```js +router.get('/', async (req, res) => { + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: user, + attributes: ['name'] + }, + // highlight-start + where: { + important: req.query.important === "true" + } + // highlight-end + }) + res.json(notes) +}) +``` + +Ahora el backend puede recuperar notas importantes con una solicitud a http://localhost:3001/api/notes?important=true y notas no importantes con una solicitud a http://localhost:3001/api/notes?important=false + +La consulta SQL generada por Sequelize contiene una cláusula WHERE que filtra las filas que normalmente se devolverían: + +```sql +SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id" +WHERE "note". "important" = true; +``` + +Desafortunadamente, esta implementación no funcionará si la solicitud no está interesada en si, la nota es importante o no, es decir, si la solicitud se realiza en http://localhost:3001/api/notes. La corrección se puede hacer de varias maneras. Una, pero quizás no la mejor manera de hacer la corrección, sería la siguiente: + +```js +const { Op } = require('sequelize') + +router.get('/', async (req, res) => { + // highlight-start + let important = { + [Op.in]: [true, false] + } + + if ( req.query.important ) { + important = req.query.important === "true" + } + // highlight-end + + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: user, + attributes: ['name'] + }, + where: { + important // highlight-line + } + }) + res.json(notes) +}) +``` + +El objeto important ahora almacena la condición de consulta. La consulta predeterminada es: + +```js +where: { + important: { + [Op.in]: [true, false] + } +} +``` + +es decir, la columna important puede ser true o false, usando uno de los muchos operadores Sequelize [Op.in](https://sequelize.org/master/manual/model-querying-basics.html#operators). Si se especifica el parámetro de consulta req.query.important, la consulta cambia a una de las dos formas + +```js +where: { + important: true +} +``` + +or + +```js +where: { + important: false +} +``` + +dependiendo del valor del parámetro de consulta. + +La funcionalidad se puede ampliar aún más al permitir que el usuario especifique una palabra clave requerida al recuperar notas, p. Una solicitud a http://localhost:3001/api/notes?search=database devolverá todas las notas que mencionen database o una solicitud a http://localhost:3001/api/notes?search=javascript&important=true devolverá todas las notas marcadas como importantes y mencionando javascript. La implementación es la siguiente: + +```js +router.get('/', async (req, res) => { + let important = { + [Op.in]: [true, false] + } + + if ( req.query.important ) { + important = req.query.important === "true" + } + + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: user, + attributes: ['name'] + }, + where: { + important, + // highlight-start + content: { + [Op.substring]: req.query.search ? req.query.search : '' + } + // highlight-end + } + }) + + res.json(notes) +}) +``` + +[Op.substring](https://sequelize.org/master/manual/model-querying-basics.html#operators) de Sequelize genera la consulta que queremos usando la palabra clave LIKE en SQL. Por ejemplo, si hacemos una consulta a http://localhost:3001/api/notes?search=database&important=true veremos que la consulta SQL que genera es exactamente como esperábamos. + +```sql +SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id" +WHERE "note". "important" = true AND "note". "content" LIKE '%database%'; +``` + +Todavía hay una hermosa falla en nuestra aplicación que vemos si hacemos una solicitud a http://localhost:3001/api/notes, es decir, queremos todas las notas, nuestra implementación causará un WHERE innecesario en la consulta, lo que puede (dependiendo de la implementación del motor de la base de datos) afectar innecesariamente la eficiencia de la consulta: + +```sql +SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id" +WHERE "note". "important" IN (true, false) AND "note". "content" LIKE '%%'; +``` + +Optimicemos el código para que las condiciones WHERE se usen solo si es necesario: + +```js +router.get('/', async (req, res) => { + const where = {} + + if (req.query.important) { + where.important = req.query.important === "true" + } + + if (req.query.search) { + where.content = { + [Op.substring]: req.query.search + } + } + + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: user, + attributes: ['name'] + }, + where + }) + + res.json(notes) +}) +``` + +Si la solicitud tiene condiciones de búsqueda, p. http://localhost:3001/api/notes?search=database&important=true, se forma una consulta que contiene WHERE : + +```sql +SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id" +WHERE "note". "important" = true AND "note". "content" LIKE '%database%'; +``` + +Si la solicitud no tiene condiciones de búsqueda http://localhost:3001/api/notes, entonces la consulta no tiene un WHERE innecesario + +```sql +SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id"; +``` + +El código actual de la aplicación se encuentra en su totalidad en [GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-5), rama part13-5. + +
    + +
    + +### Ejercicios 13.13.-13.16 + +#### Ejercicio 13.13. + +Implementar filtrado por palabra clave en la aplicación para la ruta de retorno de todos los blogs. El filtrado debería funcionar de la siguiente manera +- _GET /api/blogs?search=react_ devuelve todos los blogs con la palabra de búsqueda react en el campo title, la palabra de búsqueda no distingue entre mayúsculas y minúsculas +- _GET /api/blogs_ devuelve todos los blogs + + +[Esto](https://sequelize.org/master/manual/model-querying-basics.html#operators) debería ser útil para esta tarea y la siguiente. +#### Ejercicio 13.14. + +Expanda el filtro para buscar una palabra clave en los campos title o author, es decir, + +_GET /api/blogs?search=jami_ devuelve blogs con la palabra de búsqueda jami en el campo title o en el campo author +#### Ejercicio 13.15. + +Modifique la ruta de los blogs para que devuelva los blogs en función de los likes en orden descendente. Busque la [documentación](https://sequelize.org/master/manual/model-querying-basics.html) para obtener instrucciones sobre cómo realizar pedidos, + +#### Ejercicio 13.16. + +Haz una ruta para la aplicación _/api/authors_ que devuelva el número de blogs de cada autor y el número total de likes. Implemente la operación directamente a nivel de la base de datos. Lo más probable es que necesite la función [group by](https://sequelize.org/master/manual/model-querying-basics.html#grouping) y la función de agregación [sequelize.fn](https://sequelize.org/master/manual/model-querying-basics.html#specifying-attributes-for-select-queries). + +El JSON devuelto por la ruta podría tener el siguiente aspecto: + +``` +[ + { + author: "Jami Kousa", + articles: "3", + likes: "10" + }, + { + author: "Kalle Ilves", + articles: "1", + likes: "2" + }, + { + author: "Dan Abramov", + articles: "1", + likes: "4" + } +] +``` + +Tarea de bonificación: ordene los datos devueltos según la cantidad de likes, haga el pedido en la consulta de la base de datos. + +
    diff --git a/src/content/13/es/part13c.md b/src/content/13/es/part13c.md new file mode 100644 index 00000000000..3a9f0fc2fea --- /dev/null +++ b/src/content/13/es/part13c.md @@ -0,0 +1,1521 @@ +--- +mainImage: ../../../images/part-13.svg +part: 13 +letter: c +lang: es +--- + +
    + +### Migraciones + +Sigamos ampliando el backend. Queremos implementar soporte para permitir que los usuarios con estado de administrador pongan a los usuarios de su elección en modo deshabilitado, evitando que inicien sesión y creen nuevas notas. Para implementar esto, necesitamos agregar campos booleanos a la tabla de la base de datos de los usuarios que indiquen si el usuario es un administrador y si el usuario está deshabilitado. + +Podríamos proceder como antes, es decir, cambiar el modelo que define la tabla y confiar en Sequelize para sincronizar los cambios en la base de datos. Esto se especifica mediante estas líneas en el archivo models/index.js + +```js +const Note = require('./note') +const User = require('./user') + +Note.belongsTo(User) +User.hasMany(Note) + +Note.sync({ alter: true }) // highlight-line +User.sync({ alter: true }) // highlight-line + +module.exports = { + Note, User +} +``` + +Sin embargo, este enfoque no tiene sentido a largo plazo. Eliminemos las líneas que realizan la sincronización y pasemos a usar una forma mucho más robusta, [migraciones](https://sequelize.org/master/manual/migrations.html) proporcionada por Sequelize (y muchas otras bibliotecas). + +En la práctica, una migración es un único archivo JavaScript que describe alguna modificación en una base de datos. Se crea un archivo de migración independiente para cada uno o varios cambios a la vez. Sequelize mantiene un registro de las migraciones que se han realizado, es decir, qué cambios provocados por las migraciones se sincronizan con el esquema de la base de datos. Al crear nuevas migraciones, Sequelize se mantiene actualizado sobre los cambios que aún deben realizarse en el esquema de la base de datos. De esta forma, los cambios se realizan de forma controlada, con el código del programa almacenado en el control de versiones. + +Primero, creemos una migración que inicialice la base de datos. El código para la migración es el siguiente: + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.createTable('notes', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + content: { + type: DataTypes.TEXT, + allowNull: false + }, + important: { + type: DataTypes.BOOLEAN + }, + date: { + type: DataTypes.DATE + }, + }) + await queryInterface.createTable('users', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + username: { + type: DataTypes.STRING, + unique: true, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + }) + await queryInterface.addColumn('notes', 'user_id', { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.dropTable('notes') + await queryInterface.dropTable('users') + }, +} +``` + +El archivo de migración [define](https://sequelize.org/master/manual/migrations.html#migration-skeleton) las funciones up y down, la primera de que define cómo se debe modificar la base de datos cuando se realiza la migración. La función down le indica cómo deshacer la migración si es necesario. + +Nuestra migración contiene tres operaciones, la primera crea una tabla de notes, la segunda crea una tabla de users y la tercera agrega una clave externa a las notes que hace referencia al creador de la nota. Los cambios en el esquema se definen llamando a los métodos de objeto [queryInterface](https://sequelize.org/master/manual/query-interface.html). + +Al definir las migraciones, es esencial recordar que, a diferencia de los modelos, los nombres de columnas y tablas se escriben en forma de serpiente (snake case): + +```js +await queryInterface.addColumn('notes', 'user_id', { // highlight-line + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, +}) +``` + +Por lo tanto, en las migraciones, los nombres de las tablas y las columnas se escriben exactamente como aparecen en la base de datos, mientras que los modelos usan la convención de nomenclatura camelCase predeterminada de Sequelize. + +Guarde el código de migración en el archivo migrations/20211209\_00\_initialize\_notes\_and\_users.js. Los nombres de los archivos de migración siempre deben nombrarse alfabéticamente cuando se crean para que los cambios anteriores estén siempre antes de los cambios más nuevos. Una buena forma de lograr este orden es comenzar el nombre del archivo de migración con la fecha y un número de secuencia. + +Podríamos ejecutar las migraciones desde la línea de comandos usando la [herramienta de línea de comandos Sequelize](https://github.com/sequelize/cli). Sin embargo, elegimos realizar las migraciones manualmente desde el código del programa utilizando la biblioteca [Umzug](https://github.com/sequelize/umzug). Instalamos la biblioteca: + +```js +npm install umzug +``` + +Cambiemos el archivo util/db.js que maneja la conexión a la base de datos de la siguiente manera: + +```js +const Sequelize = require('sequelize') +const { DATABASE_URL } = require('./config') +const { Umzug, SequelizeStorage } = require('umzug') // highlight-line + +const sequelize = new Sequelize(DATABASE_URL, { + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, +}); + +// highlight-start +const runMigrations = async () => { + const migrator = new Umzug({ + migrations: { + glob: 'migrations/*.js', + }, + storage: new SequelizeStorage({ sequelize, tableName: 'migrations' }), + context: sequelize.getQueryInterface(), + logger: console, + }) + + const migrations = await migrator.up() + + console.log('Migrations up to date', { + files: migrations.map((mig) => mig.name), + }) +} +// highlight-end + +const connectToDatabase = async () => { + try { + await sequelize.authenticate() + // highlight-start + await runMigrations() + // highlight-end + console.log('connected to the database') + } catch (err) { + console.log('failed to connect to the database') + console.log(err) + return process.exit(1) + } + + return null +} + +module.exports = { connectToDatabase, sequelize } +``` + +La función runMigrations que realiza migraciones ahora se ejecuta cada vez que la aplicación abre una conexión de base de datos cuando se inicia. Sequelize realiza un seguimiento de las migraciones que ya se han completado, por lo que si no hay nuevas migraciones, ejecutar la función runMigrations no hace nada. + +Ahora comencemos con una pizarra limpia y eliminemos todas las tablas de base de datos existentes de la aplicación: + +```sql +username => drop table notes; +username => drop table users; +username => \d +Did not find any relations. +``` + +Iniciemos la aplicación. Se imprime un mensaje sobre el estado de las migraciones en el registro. + +```bash +INSERT INTO "migrations" ("name") VALUES ($1) RETURNING "name"; +Migrations up to date { files: [ '20211209_00_initialize_notes_and_users.js' ] } +database connected +``` + +Si reiniciamos la aplicación, el registro también muestra que la migración no se repitió. + +El esquema de la base de datos de la aplicación ahora se ve así: + +```sql +postgres=# \d + List of relations + Schema | Name | Type | Owner +--------+--------------+----------+---------------- + public | migrations | table | username + public | notes | table | username + public | notes_id_seq | sequence | username + public | users | table | username + public | users_id_seq | sequence | username +``` + +Entonces, Sequelize ha creado una tabla migrations que le permite realizar un seguimiento de las migraciones que se han realizado. El contenido de la tabla queda de la siguiente manera: + +```js +postgres=# select * from migrations; + name +------------------------------------------- + 20211209_00_initialize_notes_and_users.js +``` + +Vamos a crear algunos usuarios en la base de datos, así como un conjunto de notas, y luego estamos listos para expandir la aplicación. + +El código actual de la aplicación se encuentra en su totalidad en [GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-6), rama part13-6. +### Usuario administrador y usuario deshabilitado + +Entonces queremos agregar dos campos booleanos a la tabla users +- _admin_ te dice si el usuario es un administrador +- _disabled_ te dice si el usuario está deshabilitado de las acciones + +Vamos a crear la migración que modifica la base de datos en el archivo migrations/20211209\_01\_admin\_and\_disabled\_to\_users.js: + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.addColumn('users', 'admin', { + type: DataTypes.BOOLEAN, + default: false + }) + await queryInterface.addColumn('users', 'disabled', { + type: DataTypes.BOOLEAN, + default: false + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.removeColumn('users', 'admin') + await queryInterface.removeColumn('users', 'disabled') + }, +} +``` + +Realice los cambios correspondientes en el modelo correspondiente a la tabla users: + +```js +User.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + username: { + type: DataTypes.STRING, + unique: true, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + // highlight-start + admin: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + disabled: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + // highlight-end +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'user' +}) +``` + +Cuando se realiza la nueva migración al reiniciar el código, el esquema se cambia a lo siguiente: + +```sql +username-> \d users + Table "public.users" + Column | Type | Collation | Nullable | Default +----------+------------------------+-----------+----------+----------------------------------- + id | integer | | not null | nextval('users_id_seq'::regclass) + username | character varying(255) | | not null | + name | character varying(255) | | not null | + admin | boolean | | | + disabled | boolean | | | +Indexes: + "users_pkey" PRIMARY KEY, btree (id) + "users_username_key" UNIQUE CONSTRAINT, btree (username) +Referenced by: + TABLE "notes" CONSTRAINT "notes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) +``` + +Ahora vamos a expandir los controladores de la siguiente manera. Evitamos iniciar sesión si el campo de usuario disabled está establecido en true: + +```js +loginRouter.post('/', async (request, response) => { + const body = request.body + + const user = await User.findOne({ + where: { + username: body.username + } + }) + + const passwordCorrect = body.password === 'secret' + + if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) + } + +// highlight-start + if (user.disabled) { + return response.status(401).json({ + error: 'account disabled, please contact admin' + }) + } + // highlight-end + + const userForToken = { + username: user.username, + id: user.id, + } + + const token = jwt.sign(userForToken, process.env.SECRET) + + response + .status(200) + .send({ token, username: user.username, name: user.name }) +}) +``` + +Desactivemos al usuario jakousa usando su ID: + +```sql +username => update users set disabled=true where id=3; +UPDATE 1 +username => update users set admin=true where id=1; +UPDATE 1 +username => select * from users; + id | username | name | admin | disabled +----+----------+------------------+-------+---------- + 2 | lynx | Kalle Ilves | | + 3 | jakousa | Jami Kousa | f | t + 1 | mluukkai | Matti Luukkainen | t | +``` + +Y asegúrese de que ya no sea posible iniciar sesión + +![](../../images/13/2.png) + +Vamos a crear una ruta que permita a un administrador cambiar el estado de la cuenta de un usuario: + +```js +const isAdmin = async (req, res, next) => { + const user = await User.findByPk(req.decodedToken.id) + if (!user.admin) { + return res.status(401).json({ error: 'operation not allowed' }) + } + next() +} + +router.put('/:username', tokenExtractor, isAdmin, async (req, res) => { + const user = await User.findOne({ + where: { + username: req.params.username + } + }) + + if (user) { + user.disabled = req.body.disabled + await user.save() + res.json(user) + } else { + res.status(404).end() + } +}) +``` + +Se utilizan dos middleware, el primero llamado tokenExtractor es el mismo que el utilizado por la ruta de creación de notas, es decir, coloca el token decodificado en el campo decodedToken del objeto request. El segundo middleware isAdmin comprueba si el usuario es un administrador y, en caso contrario, el estado de la solicitud se establece en 401 y se devuelve el mensaje de error correspondiente. + +Observe cómo dos middleware están encadenados a la ruta, los cuales se ejecutan antes que el controlador de ruta real. Es posible encadenar un número arbitrario de middleware a una solicitud. + +El middleware tokenExtractor ahora se ha movido a util/middleware.js ya que se usa desde varias ubicaciones. + +```js +const jwt = require('jsonwebtoken') +const { SECRET } = require('./config.js') + +const tokenExtractor = (req, res, next) => { + const authorization = req.get('authorization') + if (authorization && authorization.toLowerCase().startsWith('bearer ')) { + try { + req.decodedToken = jwt.verify(authorization.substring(7), SECRET) + } catch{ + return res.status(401).json({ error: 'token invalid' }) + } + } else { + return res.status(401).json({ error: 'token missing' }) + } + next() +} + +module.exports = { tokenExtractor } +``` + +Ahora, un administrador puede volver a habilitar al usuario jakousa realizando una solicitud PUT a _/api/users/jakousa_, donde la solicitud incluye los siguientes datos: + +```js +{ + "disabled": false +} +``` + +Como se señaló en [el final de la Parte 4](/es/part4/autenticacion_basada_en_token#limitacion-de-la-creacion-de-nuevas-notas-a-los-usuarios-registrados), la forma en que implementamos la desactivación de usuarios aquí es problemática. Si el usuario está deshabilitado o no, solo se verifica en _login_, si el usuario tiene un token en el momento en que se deshabilita, el usuario puede continuar usando el mismo token, ya que no se ha establecido una vida útil para el token y el estado deshabilitado. El usuario no se comprueba al crear notas. + +Antes de continuar, hagamos un script npm para la aplicación, que nos permita deshacer la migración anterior. Después de todo, no todo sale bien la primera vez cuando se desarrollan migraciones. + +Modifiquemos el archivo util/db.js de la siguiente manera: + +```js +const Sequelize = require('sequelize') +const { DATABASE_URL } = require('./config') +const { Umzug, SequelizeStorage } = require('umzug') + +const sequelize = new Sequelize(DATABASE_URL, { + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, +}); + +const connectToDatabase = async () => { + try { + await sequelize.authenticate() + await runMigrations() + console.log('connected to the database') + } catch (err) { + console.log('failed to connect to the database') + return process.exit(1) + } + + return null +} + +// highlight-start +const migrationConf = { + migrations: { + glob: 'migrations/*.js', + }, + storage: new SequelizeStorage({ sequelize, tableName: 'migrations' }), + context: sequelize.getQueryInterface(), + logger: console, +} + +const runMigrations = async () => { + const migrator = new Umzug(migrationConf) + const migrations = await migrator.up() + console.log('Migrations up to date', { + files: migrations.map((mig) => mig.name), + }) +} + +const rollbackMigration = async () => { + await sequelize.authenticate() + const migrator = new Umzug(migrationConf) + await migrator.down() +} +// highlight-end + +module.exports = { connectToDatabase, sequelize, rollbackMigration } // highlight-line +``` + +Vamos a crear un archivo util/rollback.js, que permitirá que el script npm ejecute la función de reversión de migración especificada: + +```js +const { rollbackMigration } = require('./db') + +rollbackMigration() +``` + +y el script en sí: + +```json +{ + "scripts": { + "dev": "nodemon index.js", + "migration:down": "node util/rollback.js" // highlight-line + }, +} +``` + +Así que ahora podemos deshacer la migración anterior ejecutando _npm run migration:down_ desde la línea de comandos. + +Actualmente, las migraciones se ejecutan automáticamente cuando se inicia el programa. En la fase de desarrollo del programa, en ocasiones puede ser más adecuado deshabilitar la ejecución automática de migraciones y realizarlas manualmente desde la línea de comandos. + +El código actual de la aplicación se encuentra en su totalidad en [GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-7), rama part13-7. + +
    + +
    + +### Ejercicios 13.17-13.18. + +#### Ejercicio 13.17. + +Elimine todas las tablas de la base de datos de su aplicación. + +Realice una migración que inicialice la base de datos. Agregue las created\_at y updated\_at [marcas de tiempo](https://sequelize.org/master/manual/model-basics.html#timestamps) para ambas tablas. Tenga en cuenta que tendrá que agregarlos usted mismo en la migración. + +**NOTA:** asegúrese de eliminar los comandos User.sync() y Blog.sync(), que sincronizan los esquemas de los modelos de su código; de lo contrario, las migraciones fallarán. + +**NOTA2:** si tiene que eliminar tablas desde la línea de comando (es decir, no elimina deshaciendo la migración), deberá eliminar el contenido de la tabla migrations si desea que su programa vuelva a realizar las migraciones. + +#### Ejercicio 13.18. + +Expanda su aplicación (por migración) para que los blogs tengan un atributo de año escrito, es decir, un campo year que es un número entero al menos igual a 1991 pero no mayor que el año actual. Asegúrese de que la aplicación brinde un mensaje de error apropiado si se intenta dar un valor incorrecto para un año escrito. + +
    + +
    + +### Relaciones de muchos a muchos + +Continuaremos expandiendo la aplicación para que cada usuario pueda agregarse a uno o más equipos. + +Dado que una cantidad arbitraria de usuarios puede unirse a un equipo y un usuario puede unirse a una cantidad arbitraria de equipos, estamos tratando con [muchos a muchos](https://sequelize.org/master/manual/assocs.html#many-to-many-relationships), que tradicionalmente se implementa en bases de datos relacionales mediante una tabla de conexiones. + +Ahora vamos a crear el código necesario para la tabla teams y la tabla memberships. La migración es la siguiente: + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.createTable('teams', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + unique: true + }, + }) + await queryInterface.createTable('memberships', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + team_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'teams', key: 'id' }, + }, + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.dropTable('teams') + await queryInterface.dropTable('memberships') + }, +} +``` + +Los modelos contienen casi el mismo código que la migración. El modelo de equipo en models/team.js: + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class Team extends Model {} + +Team.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + unique: true + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'team' +}) + +module.exports = Team +``` + +El modelo para la tabla de conexiones en models/membership.js: + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class Membership extends Model {} + +Membership.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + team_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'teams', key: 'id' }, + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'membership' +}) + +module.exports = Membership +``` + +Así que le hemos dado a la tabla de conexiones un nombre que la describe bien, membership. No siempre hay un nombre relevante para una tabla de conexión, en cuyo caso el nombre de la tabla de conexión puede ser una combinación de los nombres de las tablas que se unen, p. user\_teams podría encajar en nuestra situación. + +Realizamos una pequeña adición al archivo models/index.js para conectar equipos y usuarios a nivel de código mediante el método [belongsToMany](https://sequelize.org/docs/v6/core-concepts/assocs/#implementation-2). + +```js +const Note = require('./note') +const User = require('./user') +// highlight-start +const Team = require('./team') +const Membership = require('./membership') +// highlight-end + +Note.belongsTo(User) +User.hasMany(Note) + +// highlight-start +User.belongsToMany(Team, { through: Membership }) +Team.belongsToMany(User, { through: Membership }) +// highlight-end + +module.exports = { + Note, User, Team, Membership // highlight-line +} + +``` + +Tenga en cuenta la diferencia entre la migración de la tabla de conexión y el modelo al definir campos de clave externa. Durante la migración, los campos se definen en forma de mayúsculas y minúsculas: + +```js +await queryInterface.createTable('memberships', { + // ... + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + team_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'teams', key: 'id' }, + } +}) +``` + +en el modelo, los mismos campos se definen en camel case: + +```js +Membership.init({ + // ... + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + teamId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'teams', key: 'id' }, + }, + // ... +}) +``` + +Ahora vamos a crear un par de equipos desde la consola, así como algunas membresías: + +```js +insert into teams (name) values ('toska'); +insert into teams (name) values ('mosa climbers'); +insert into memberships (user_id, team_id) values (1, 1); +insert into memberships (user_id, team_id) values (1, 2); +insert into memberships (user_id, team_id) values (2, 1); +insert into memberships (user_id, team_id) values (3, 2); +``` + +Luego, se agrega información sobre los equipos de los usuarios a la ruta para recuperar a todos los usuarios. + +```js +router.get('/', async (req, res) => { + const users = await User.findAll({ + include: [ + { + model: Note, + attributes: { exclude: ['userId'] } + }, + // highlight-start + { + model: Team, + attributes: ['name', 'id'], + } + // highlight-end + ] + }) + res.json(users) +}) +``` + +Los más observadores notarán que la consulta impresa en la consola ahora combina tres tablas. + +La solución es bastante buena, pero tiene un hermoso defecto. El resultado también viene con los atributos de la fila correspondiente de la tabla de conexiones, aunque no queremos esto: + +![](../../images/13/3.png) + + +Si lee detenidamente la documentación, puede encontrar una [solución](https://sequelize.org/master/manual/advanced-many-to-many.html#specifying-attributes-from-the-through-table): + +```js +router.get('/', async (req, res) => { + const users = await User.findAll({ + include: [ + { + model: Note, + attributes: { exclude: ['userId'] } + }, + { + model: Team, + attributes: ['name', 'id'], + // highlight-start + through: { + attributes: [] + } + // highlight-end + } + ] + }) + res.json(users) +}) +``` + +El código actual de la aplicación se encuentra en su totalidad en [GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-8), rama part13-8. + +### Nota sobre las propiedades de los objetos del modelo Sequelize + +La especificación de nuestros modelos se muestra en las siguientes líneas: + +```js +User.hasMany(Note) +Note.belongsTo(User) + +User.belongsToMany(Team, { through: Membership }) +Team.belongsToMany(User, { through: Membership }) +``` + +Estos permiten a Sequelize realizar consultas que recuperan, por ejemplo, todas las notas de los usuarios o de todos los miembros de un equipo. + +Gracias a las definiciones, también tenemos acceso directo a, por ejemplo, las notas del usuario en el código. En el siguiente código, buscaremos un usuario con id 1 e imprimiremos las notas asociadas con el usuario: + +```js +const user = await User.findByPk(1, { + include: { + model: Note + } +}) + +user.notes.forEach(note => { + console.log(note.content) +}) +``` + +Por lo tanto, la definición User.hasMany(Note) adjunta una propiedad notes al objeto user, que da acceso a las notas realizadas por el usuario. De manera similar, la definición User.belongsToMany(Team, { through: Membership })) adjunta una propiedad teams al objeto user, que también puede utilizarse en el código: + +```js +const user = await User.findByPk(1, { + include: { + model: team + } +}) + +user.teams.forEach(team => { + console.log(team.name) +}) +``` + +Supongamos que nos gustaría devolver un objeto JSON de la ruta del usuario único que contiene el nombre del usuario, el nombre de usuario y la cantidad de notas creadas. Podríamos intentar lo siguiente: + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, { + include: { + model: Note + } + } + ) + + if (user) { + user.note_count = user.notes.length // highlight-line + delete user.notes // highlight-line + res.json(user) + + } else { + res.status(404).end() + } +}) +``` + +Entonces, intentamos agregar el campo noteCount en el objeto devuelto por Sequelize y eliminar el campo notes de él. Sin embargo, este enfoque no funciona, ya que los objetos devueltos por Sequelize no son objetos normales donde la adición de nuevos campos funciona como pretendemos. + +Una mejor solución es crear un objeto completamente nuevo basado en los datos recuperados de la base de datos: + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, { + include: { + model: Note + } + } + ) + + if (user) { + res.json({ + username: user.username, // highlight-line + name: user.name, // highlight-line + note_count: user.notes.length // highlight-line + }) + + } else { + res.status(404).end() + } +}) +``` +### Revisando las relaciones de muchos a muchos + +Hagamos otra relación de muchos a muchos en la aplicación. Cada nota está asociada al usuario que la creó mediante una clave foránea. Ahora se decide que la aplicación también admite que la nota se pueda asociar con otros usuarios y que un usuario se pueda asociar con un número arbitrario de notas creadas por otros usuarios. La idea es que estas notas sean las que el usuario ha marcado para sí mismo. + +Hagamos una tabla de conexión user_notes para la situación. La migración es sencilla: + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.createTable('user_notes', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + note_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'notes', key: 'id' }, + }, + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.dropTable('user_notes') + }, +} +``` + +Además, no hay nada especial en el modelo: + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class UserNotes extends Model {} + +UserNotes.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + noteId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'notes', key: 'id' }, + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'user_notes' +}) + +module.exports = UserNotes +``` + +El archivo models/index.js, por otro lado, viene con un ligero cambio: + +```js +const Note = require('./note') +const User = require('./user') +const Team = require('./team') +const Membership = require('./membership') +const UserNotes = require('./user_notes') // highlight-line + +Note.belongsTo(User) +User.hasMany(Note) + +User.belongsToMany(Team, { through: Membership }) +Team.belongsToMany(User, { through: Membership }) + +// highlight-start +User.belongsToMany(Note, { through: UserNotes, as: 'marked_notes' }) +Note.belongsToMany(User, { through: UserNotes, as: 'users_marked' }) +// highlight-end + +module.exports = { + Note, User, Team, Membership, UserNotes +} +``` + +Una vez más se utiliza belongsToMany, que ahora vincula a los usuarios con las notas a través del modelo UserNotes correspondiente a la tabla de conexiones. Sin embargo, esta vez damos un nombre de alias para el atributo formado usando la palabra clave [as](https://sequelize.org/master/manual/advanced-many-to-many.html#aliases-and-custom-key-names), el nombre predeterminado (las notes de un usuario) se superpondría con su significado anterior, es decir, notas creadas por el usuario. + +Extendemos la ruta para que un usuario individual devuelva los equipos del usuario, sus propias notas y otras notas marcadas por el usuario: + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, { + attributes: { exclude: [''] } , + include:[{ + model: Note, + attributes: { exclude: ['userId'] } + }, + { + model: Note, + as: 'marked_notes', + attributes: { exclude: ['userId']}, + through: { + attributes: [] + } + }, + { + model: Team, + attributes: ['name', 'id'], + through: { + attributes: [] + } + }, + ] + }) + + if (user) { + res.json(user) + } else { + res.status(404).end() + } +}) +``` + +En el contexto del include, ahora debemos usar el nombre de alias marked\_notes que acabamos de definir con el atributo as. + +Para probar la característica, vamos a crear algunos datos de prueba en la base de datos: + +```sql +insert into user_notes (user_id, note_id) values (1, 4); +insert into user_notes (user_id, note_id) values (1, 5); +``` + +El resultado final es funcional: + +![](../../images/13/5.png) + +¿Y si quisiéramos incluir información sobre el autor de la nota en las notas marcadas por el usuario también? Esto se puede hacer agregando un include a las notas marcadas: + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, { + attributes: { exclude: [''] } , + include:[{ + model: Note, + attributes: { exclude: ['userId'] } + }, + { + model: Note, + as: 'marked_notes', + attributes: { exclude: ['userId']}, + through: { + attributes: [] + }, + // highlight-start + include: { + model: User, + attributes: ['name'] + } + // highlight-end + }, + { + model: Team, + attributes: ['name', 'id'], + through: { + attributes: [] + } + }, + ] + }) + + if (user) { + res.json(user) + } else { + res.status(404).end() + } +}) +``` + +El resultado final es el deseado: + +![](../../images/13/4.png) + +El código actual de la aplicación se encuentra en su totalidad en [GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-9), rama part13-9. + +
    + +
    + +### Ejercicios 13.19.-13.23. + +#### Ejercicio 13.19. + +Ofrezca a los usuarios la capacidad de agregar blogs en el sistema a una lista de lectura. Cuando se agrega a la lista de lectura, el blog debe estar en el estado no leído. El blog se puede marcar más adelante como leído. Implemente la lista de lectura usando una tabla de conexión. Realice cambios en la base de datos mediante migraciones. + +En esta tarea, la adición a una lista de lectura y la visualización de la lista se pueden comprobar usando la base de datos. + +#### Ejercicio 13.20. + +Ahora agregue funcionalidad a la aplicación para admitir la lista de lectura. + +La adición de un blog a la lista de lectura se realiza mediante un HTTP POST a la ruta /api/readinglists, la solicitud se acompañará con el blog y la identificación del usuario: + +```js +{ + "blogId": 10, + "userId": 3 +} +``` + +También modifique la ruta de usuario individual _GET /api/users/:id_ para devolver no solo otra información del usuario sino también la lista de lectura, p. en el siguiente formato: + +```js +{ + name: "Matti Luukkainen", + username: "mluukkai@iki.fi", + readings: [ + { + id: 3, + url: "https://google.com", + title: "Clean React", + author: "Dan Abramov", + likes: 34, + year: null, + }, + { + id: 4, + url: "https://google.com", + title: "Clean Code", + author: "Bob Martin", + likes: 5, + year: null, + } + ] +} +``` + +En este punto, no es necesario que esté disponible la información sobre si el blog es leído o no. + +#### Ejercicio 13.21. + +Expanda la ruta de un solo usuario para que cada blog en la lista de lectura muestre también si el blog ha sido leído y la identificación de la fila de la tabla de unión correspondiente. + +Por ejemplo, la información podría tener la siguiente forma: + +```js +{ + name: "Matti Luukkainen", + username: "mluukkai@iki.fi", + readings: [ + { + id: 3, + url: "https://google.com", + title: "Clean React", + author: "Dan Abramov", + likes: 34, + year: null, + readinglists: [ + { + read: false, + id: 2 + } + ] + }, + { + id: 4, + url: "https://google.com", + title: "Clean Code", + author: "Bob Martin", + likes: 5, + year: null, + readinglists: [ + { + read: false, + id: 3 + } + ] + } + ] +} +``` + +Nota: hay varias formas de implementar esta funcionalidad. [Esto](https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship) debería ayuda. + +Tenga en cuenta también que a pesar de tener un campo de matriz listas de lectura en el ejemplo, siempre debe contener exactamente un objeto, la entrada de la tabla de unión que conecta el libro con la lista de lectura del usuario en particular. + +#### Ejercicio 13.22. + +Implementar funcionalidad en la aplicación para marcar un blog en la lista de lectura como leído. Marcar como leído se hace realizando una solicitud a la ruta _PUT /api/readinglists/:id_ y enviando la solicitud con + +```js +{ "read": true } +``` + +El usuario solo puede marcar los blogs de su propia lista de lectura como leídos. El usuario se identifica como de costumbre a partir del token que acompaña a la solicitud. + +#### Ejericio 13.23. + +Modifique la ruta que devuelve la información de un solo usuario para que la solicitud pueda controlar cuáles de los blogs de la lista de lectura se devuelven: + +- _GET /api/users/:id_ devuelve la lista de lectura completa +- _GET /api/users/:id?read=true_ devuelve blogs que han sido leídos +- _GET /api/users/:id?read=false_ devuelve blogs que no han sido leídos + +
    + +
    + +### Observaciones finales + +El estado de nuestra aplicación empieza a ser al menos aceptable. Sin embargo, antes del final de la sección, veamos algunos puntos más. + +#### Eager vs lazy fetch + +Cuando hacemos consultas usando el atributo include: + +```js +User.findOne({ + include: { + model: note + } +}) +``` + +Se produce la llamada [eager fetch](https://sequelize.org/master/manual/assocs.html#basics-of-queries-involving-associations), es decir, todas las filas de las tablas adjuntas al usuario por el consulta de unión, en el ejemplo, las notas hechas por el usuario, se obtienen de la base de datos al mismo tiempo. A menudo, esto es lo que queremos, pero también hay situaciones en las que desea hacer lo que se conoce como _lazy fetch_, p. Busque equipos relacionados con el usuario solo si son necesarios. + +Ahora modifiquemos la ruta para un usuario individual, para que obtenga los equipos del usuario solo si el parámetro de consulta teams está configurado en la solicitud: + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, { + attributes: { exclude: [''] } , + include:[{ + model: note, + attributes: { exclude: ['userId'] } + }, + { + model: Note, + as: 'marked_notes', + attributes: { exclude: ['userId']}, + through: { + attributes: [] + }, + include: { + model: user, + attributes: ['name'] + } + }, + ] + }) + + if (!user) { + return res.status(404).end() + } + + // highlight-start + let teams = undefined + + if (req.query.teams) { + teams = await user.getTeams({ + attributes: ['name'], + joinTableAttributes: [] + }) + } + + res.json({ ...user.toJSON(), teams }) + // highlight-end +}) +``` + +Ahora, la consulta User.findByPk no recupera equipos, pero se recuperan si es necesario mediante el método user getTeams, que se genera automáticamente por Sequelize para el objeto modelo. Igualmente get y algunos otros métodos útiles [se generan automáticamente](https://sequelize.org/master/manual/assocs.html#special-methods-mixins-added-to-instances) al definir asociaciones para tablas a nivel de Sequelize. + +#### Características de los modelos + +Hay algunas situaciones en las que, por defecto, no queremos manejar todas las filas de una tabla en particular. Uno de esos casos podría ser que normalmente no queremos mostrar usuarios que han sido deshabilitados en nuestra aplicación. En tal situación, podríamos definir los [ámbitos] predeterminados (https://sequelize.org/master/manual/scopes.html) para el modelo de esta manera: + +```js +class User extends Model {} + +User.init({ + // field definition +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'user', + // highlight-start + defaultScope: { + where: { + disabled: false + } + }, + scopes: { + admin: { + where: { + admin: true + } + }, + disabled: { + where: { + disabled: true + } + } + } + // highlight-end +}) + +module.exports = User +``` + +Ahora la consulta causada por la llamada a la función User.findAll() tiene la siguiente condición WHERE: + +``` +WHERE "user". "disabled" = false; +``` + +Para los modelos, también es posible definir otros alcances: + +```js +User.init({ + // field definition +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'user', + defaultScope: { + where: { + disabled: false + } + }, + // highlight-start + scopes: { + admin: { + where: { + admin: true + } + }, + disabled: { + where: { + disabled: true + } + }, + name(value) { + return { + where: { + name: { + [Op.iLike]: value + } + } + } + }, + } + // highlight-end +}) +``` + +Los alcances se utilizan de la siguiente manera: + +```js +// all admins +const adminUsers = await User.scope('admin').findAll() + +// all inactive users +const disabledUsers = await User.scope('disabled').findAll() + +// users with the string jami in their name +const jamiUsers = User.scope({ method: ['name', '%jami%'] }).findAll() +``` + +También es posible encadenar ámbitos: + +```js +// admins with the string jami in their name +const jamiUsers = User.scope('admin', { method: ['name', '%jami%'] }).findAll() +``` + +Dado que los modelos de Sequelize son [clases de JavaScript](https://sequelize.org/master/manual/model-basics.html#take-advantage-of-models-being-classes), es posible agregarles nuevos métodos. + +Aquí hay dos ejemplos: + +```js +const { Model, DataTypes, Op } = require('sequelize') // highlight-line + +const Note = require('./note') +const { sequelize } = require('../util/db') + +class User extends Model { + // highlight-start + async number_of_notes() { + return (await this.getNotes()).length + } + + static async with_notes(limit){ + return await User.findAll({ + attributes: { + include: [[ sequelize.fn("COUNT", sequelize.col("notes.id")), "note_count" ]] + }, + include: [ + { + model: Note, + attributes: [] + }, + ], + group: ['user.id'], + having: sequelize.literal(`COUNT(notes.id) > ${limit}`) + }) + } + // highlight-end +} + +User.init({ + // ... +}) + +module.exports = User +``` + +El primero de los métodos numberOfNotes es un método de instancia, lo que significa que se puede llamar en instancias del modelo: + +```js +const jami = await User.findOne({ name: 'Jami Kousa'}) +const cnt = await jami.number_of_notes() +console.log(`Jami has created ${cnt} notes`) +``` + +Dentro del método de instancia, la palabra clave this se refiere a la instancia misma: + +```js +async number_of_notes() { + return (await this.getNotes()).length +} +``` + +El segundo de los métodos, que devuelve aquellos usuarios que tienen al menos X cantidad de notas es un método de clase, es decir, se llama directamente en el modelo: + +```js +const users = await User.with_notes(2) +console.log(JSON.stringify(users, null, 2)) +users.forEach(u => { + console.log(u.name) +}) +``` + +#### Repetibilidad de modelos y migraciones + +Hemos notado que el código para modelos y migraciones es muy repetitivo. Por ejemplo, el modelo de equipos + +```js +class Team extends Model {} + +Team.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + unique: true + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'team' +}) + +module.exports = Team +``` + +y la migración contienen gran parte del mismo código + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.createTable('teams', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + unique: true + }, + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.dropTable('teams') + }, +} +``` + +¿No podríamos optimizar el código para que, por ejemplo, el modelo exporte las partes compartidas necesarias para la migración? + +Sin embargo, el problema es que la definición del modelo puede cambiar con el tiempo, por ejemplo, el campo name puede cambiar o su tipo de datos puede cambiar. Las migraciones deben poder realizarse correctamente en cualquier momento de principio a fin, y si las migraciones dependen del modelo para tener cierto contenido, es posible que ya no sea cierto en un mes o un año. Por lo tanto, a pesar del "copiar y pegar", el código de migración debe estar completamente separado del código del modelo. + +Una solución sería usar la [herramienta de línea de comandos de Sequelize ](https://sequelize.org/docs/v6/other-topics/migrations/#creating-the-first-model-and-migration), que genera modelos y migración archivos basados ​​en comandos dados en la línea de comandos. Por ejemplo, el siguiente comando crearía un modelo Users con name, username y admin como atributos, como así como la migración que gestiona la creación de la tabla de la base de datos: + +``` +npx sequelize-cli model:generate --name User --attributes name:string,username:string,admin:boolean +``` + +Desde la línea de comandos, también puede ejecutar reversiones, es decir, deshacer migraciones. Desafortunadamente, la documentación de la línea de comandos está incompleta y en este curso decidimos hacer los modelos y las migraciones manualmente. La solución puede o no haber sido sabia. + +
    + +
    + +### Ejercicio 13.24. + +#### Ejercicio 13.24. + +Gran final: [hacia el final de la parte 4](/es/part4/autenticacion_basada_en_token#limitacion-de-la-creacion-de-nuevas-notas-a-los-usuarios-registrados) se mencionó un problema crítico del token: si se decide que el acceso de un usuario al sistema revocado, el usuario aún puede usar el token en posesión para usar el sistema. + +La solución habitual a esto es almacenar un registro de cada token emitido al cliente en la base de datos del servidor y comprobar con cada solicitud si el acceso sigue siendo válido. En este caso, la validez del token se puede eliminar de inmediato si es necesario. Esta solución a menudo se denomina sesión del lado del servidor. + +Ahora expanda el sistema para que el usuario que ha perdido el acceso no pueda realizar ninguna acción que requiera iniciar sesión. + +Probablemente necesitará al menos lo siguiente para la implementación +- una columna de valor booleano en la tabla de usuarios para indicar si el usuario está deshabilitado + - es suficiente deshabilitar y habilitar a los usuarios directamente desde la base de datos +- una tabla que almacena sesiones activas + - una sesión se almacena en la tabla cuando un usuario inicia sesión, es decir, la operación _POST /api/login_ + - la existencia (y validez) de la sesión siempre se comprueba cuando el usuario realiza una operación que requiere inicio de sesión +- una ruta que permite al usuario "cerrar sesión" del sistema, es decir, eliminar prácticamente las sesiones activas de la base de datos, la ruta puede ser, p. _DELETE /api/logout_ + +Tenga en cuenta que las acciones que requieren inicio de sesión no deberían tener éxito con un "token caducado", es decir, con el mismo token después de cerrar sesión. + +También puede optar por usar alguna biblioteca npm especialmente diseñada para manejar las sesiones. + +Realice los cambios de base de datos necesarios para esta tarea mediante migraciones. + +### Envío de ejercicios y obtención de créditos. + +Los ejercicios de esta parte se envían al igual que en las partes anteriores, pero a diferencia de las partes 0 a 7, la presentación va a una [instancia del curso propia](https://studies.cs.helsinki.fi/stats/courses/fs-psql). ¡Recuerda que tienes que terminar todos los ejercicios para aprobar esta parte! + +Una vez que hayas completado los ejercicios y quieras obtener los créditos, infórmanos a través del sistema de envío de ejercicios que has completado el curso: + +![Submissions](../../images/11/21.png) + +**Tenga en cuenta** que necesita registrarse en la parte del curso correspondiente para obtener los créditos registrados, consulte [aquí](/part0/general_info#parts-and-completion) para obtener más información. + +
    diff --git a/src/content/13/fi/osa13.md b/src/content/13/fi/osa13.md new file mode 100644 index 00000000000..8ee42e3ee87 --- /dev/null +++ b/src/content/13/fi/osa13.md @@ -0,0 +1,13 @@ +--- +mainImage: ../../../images/part-13.svg +part: 13 +lang: fi +--- + +
    + +Kurssin aiemmat osat käyttävät tiedon tallentamiseen MongoDB:tä, joka on niin sanottu NoSQL-tietokanta. NoSQL-tietokannat yleistyivät voimakkaasti reilut 10 vuotta sitten kun internet-skaala alkoi tuottaa ongelmia vanhemman generaation SQL-kyselykieltä hyödyntäville relaatiotietokannoille. + +Relaatiotietokannat ovat sittemmin kokeneet uuden tulemisen. Skaalautuvuuden ongelmia on osin ratkaistu ja ne ovat myös omaksuneet eräitä NoSQL-tietokantojen piirteitä. Tässä osassa tutustutaan relaatiotietokantoja käyttäviin NodeJS-sovelluksiin, tietokantana on open source ‑maailman ykkönen PostgreSQL. + +
    diff --git a/src/content/13/fi/osa13a.md b/src/content/13/fi/osa13a.md new file mode 100644 index 00000000000..e2c88c0c36e --- /dev/null +++ b/src/content/13/fi/osa13a.md @@ -0,0 +1,831 @@ +--- +mainImage: ../../../images/part-13.svg +part: 13 +letter: a +lang: fi +--- + +
    + +Tässä osassa tutustutaan relaatiotietokantoja käyttäviin Node-sovelluksiin. Osassa rakennetaan osista 3-5 tutulle muistiinpanosovellukselle relaatiotietokantaa käyttävä Node-backend. Osan suorittaminen edellyttää kohtuullista relaatiotietokantojen ja SQL:n osaamista. Eräs tapa hankkia riittävä osaaminen on kurssi [Tietokantojen perusteet](https://tikape.mooc.fi/). + +Osassa on 24 tehtävää, ja suoritusmerkintä edellyttää kaikkien tekemistä. Toisin kuin osat 0-7, tämä tehtävä palautetaan [palautussovelluksessa](https://studies.cs.helsinki.fi/stats/courses/fs-psql) omaan kurssi-instanssiinsa. + +### Dokumenttitietokantojen edut ja haitat + +Olemme käyttäneet kaikissa kurssin aiemmissa osissa MongoDB-tietokantaa. Mongo on tyypiltään [dokumenttitietokanta](https://en.wikipedia.org/wiki/Document-oriented_database) ja eräs sen ominaisimmista piirteistä on skeemattomuus, eli tietokanta ei ole kuin hyvin rajallisesti tietoinen siitä, minkälaista dataa sen kokoelmiin on talletettu. Tietokannan skeema on olemassa ainoastaan ohjelmakoodissa, joka tulkitsee datan tietyllä tavalla, esim. tunnistaen että jotkut kentät ovat viittauksia toisen kokoelman objekteihin. + +Osien 3 ja 4 esimerkkisovelluksessa tietokantaan on talletettu muistiinpanoja ja käyttäjiä. + +Muistiinpanoja tallettava kokoelma notes näyttää seuraavanlaiselta + +```js +[ + { + "_id": "600c0e410d10256466898a6c", + "content": "HTML is easy", + "date": 2021-01-23T11:53:37.292+00:00, + "important": false, + "user": "600c0e410d10256466883a6a", + "__v": 0 + }, + { + "_id": "600c0edde86c7264ace9bb78", + "content": "CSS is hard", + "date": 2021-01-23T11:56:13.912+00:00, + "important": true, + "user": "600c0e410d10256466883a6a", + "__v": 0 + }, +] +``` + +Käyttäjät tallettava kokoelma users seuraavalta: + +```js +[ + { + "_id": "600c0e410d10256466883a6a", + "username": "mluukkai", + "name": "Matti Luukkainen", + "passwordHash" : "$2b$10$Df1yYJRiQuu3Sr4tUrk.SerVz1JKtBHlBOARfY0PBn/Uo7qr8Ocou", + "__v": 9, + notes: [ + "600c0edde86c7264ace9bb78", + "600c0e410d10256466898a6c" + ] + }, +] +``` + +MongoDB tuntee kyllä talletettujen olioiden kenttien tyypit, mutta sillä ei ole mitään tietoa siitä, minkä kokoelman olioihin käyttäjiin liittyvät muistiinpanojen id:t viittaavat. MongoDB ei myöskään välitä siitä, mitä kenttiä kokoelmiin talletettavilla olioilla on. MongoDB jättääkin täysin ohjelmoijan vastuulle sen, että tietokantaan talletetaan oikeanlaista tietoa. + +Skeemattomuudesta on sekä etua että haittaa. Eräänä etuna on skeemattomuuden tuoma joustavuus: koska skeemaa ei tarvitse tietokantatasolla määritellä, voi sovelluskehitys olla tietyissä tapauksissa nopeampaa, ja helpompaa, skeeman määrittelyssä ja sen muutoksissa on joka tapauksessa nähtävä pieni määrä vaivaa. Skeemattomuuden ongelmat liittyvät virhealttiuteen: kaikki jää ohjelmoijan vastuulle. Tietokannalla ei ole mitään mahdollisuuksia tarkistaa onko siihen talletettu data eheää, eli onko kaikilla pakollisilla kentillä arvot, viittaavatko viitetyyppiset kentät olemassa oleviin ja ylipäätään oikean tyyppisiin olioihin jne. + +Tämän osan fokuksessa olevat relaatiotietokannat taas nojaavat vahvasti skeeman olemassaoloon, ja skeemallisten tietokantojen edut ja haitat ovat lähes päinvastaiset skeemattomiin verrattuna. + +Syy sille miksi kurssin aiemmat osat käyttivät MongoDB:tä liittyvät juuri sen skeemattomuuteen, jonka ansiosta tietokannan käyttö on ollut relaatiotietokantoja heikosti tuntevalle jossain määrin helpompaa. Useimpiin tämänkin kurssin käyttötapauksiin olisin itse valinnut relaatiotietokannan. + +### Sovelluksen tietokanta + +Tarvitsemme sovellustamme varten relaatiotietokannan. Vaihtoehtoja on monia, käytämme kurssilla tämän hetken suosituinta Open Source ‑ratkaisua [PostgreSQL:ää](https://www.postgresql.org/). Voit halutessasi asentaa Postgresin (kuten tietokantaa usein kutsutaan) koneellesi. + +Käytämme nyt hyväksemme sitä, että osista 3 ja 4 tuttuille pilvipalvelualustoille Fly.io ja Heroku on mahdollista luoda sovellukselle Postgres-tietokanta. + +Tämän osan teoriamateriaalissa rakennetaan osissa 3 ja 4 rakennetun muistiinpanoja tallettavan sovelluksen backendendistä Postgresia käyttävä versio. + +Koska emme tarvitse tässä osassa mihinkään pilvessä olevaa tietokantaa (käytämme sovellusta ainoastaan paikallisesti) on eräs mahdollisuus hyödyntää kurssin [osan 12](/en/part12) oppeja ja käyttää Postgresia paikallisesti Dockerin avulla. Pilvipalveluiden Postgresohjeiden jälkeen annamme myös lyhyen ohjeen miten Postgresin saa helposti pystyn Dockerin avulla. + +#### Fly.io + +Luodaan nyt sopivan hakemiston sisällä Fly.io-sovellus komennolla _fly launch_ ja luodaan sovellukselle Postgres-tietokanta: + +![](../../images/13/6.png) + +Luomisen yhteydessä Fly.io kertoo tietokannan salasanan, joka tarvitaan jotta sovellus saa yhteyden tietokantaan. Tämä on ainoa kerta kun salasana on mahdollista nähdä tekstimuodossa, joten se on syytä ottaa talteen! + +Huomaa, että jos et aio laittaa sovellusta ollenkaan Fly.io:hon, on mahdollista luoda palveluun myös pelkkä tietokanta. Ohjeet siihen [täällä](https://fly.io/docs/reference/postgres/#creating-a-postgres-app). + + +Tietokantaan saadaan psql-konsoliyhteys komennolla + +```bash +flyctl postgres connect -a +``` + +omassa tapauksessani sovelluksen nimi on fs-psql-lecture joten komento on seuraava: + +```bash +flyctl postgres connect -a fs-psql-lecture-db +``` + +#### Heroku + +Käytettäessä Herokua luodaan sopivan hakemiston sisällä Heroku-sovellus, lisätään sille tietokanta ja katsotaan komennolla _heroku config_ mikä on tietokantayhteyden muodostamiseen tarvittava connect string: + +```bash +heroku create +heroku addons:create heroku-postgresql:hobby-dev +heroku config +=== cryptic-everglades-76708 Config Vars +DATABASE_URL: postgres://:thepasswordishere@ec2-44-199-83-229.compute-1.amazonaws.com:5432/ +``` + +Tietokantaan saadaan psql-konsoliyhteys suorittamalla _psql_ Herokun palvelimella seuraavasti (huomaa, että komennon parametrit riippuvat Heroku-sovelluksen connect urlista): + +```bash +heroku run psql -h ec2-44-199-83-229.compute-1.amazonaws.com -p 5432 -U +``` + +Komento kysyy salasanaa ja avaa psql-konsolin: + +```bash +Password for user : +psql (13.4 (Ubuntu 13.4-1.pgdg20.04+1)) +SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off) +Type "help" for help. + +postgres=# +``` + +#### Docker + +Tämä ohje olettaa, että hallitset Dockerin peruskäytön esim. [osan 12](/en/part12) opettamassa laajuudessa. + +Käynnistä Postgresin [Docker image](https://hub.docker.com/_/postgres) komennolla + +```bash +docker run -e POSTGRES_PASSWORD=mysecretpassword -p 5432:5432 postgres +``` + +Tietokantaan saadaan psql-konsoliyhteys komennon _docker exec_ avulla. Ensin tulee selvittää kontainerin id: + +```bash +$ docker ps +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +ff3f49eadf27 postgres "docker-entrypoint.s…" 31 minutes ago Up 31 minutes 0.0.0.0:5432->5432/tcp great_raman +docker exec -it ff3f49eadf27 psql -U postgres postgres +psql (15.2 (Debian 15.2-1.pgdg110+1)) +Type "help" for help. + +postgres=# +``` + +Näin määriteltynä tietokantaan talletettu data säilyy ainoastaan niin kauan kontti on olemassa. Data saadaan säilymään määrittelemällä datan talletukseen +[volume](/en/part12/building_and_configuring_environments#persisting-data-with-volumes), katso lisää +[täältä](https://github.com/docker-library/docs/blob/master/postgres/README.md#pgdata). + +#### psql-konsolin käyttöä + +Erityisesti relaatiotietokantaa käytettäessä on oleellista päästä tietokantaan käsiksi myös suoraan. Tapoja tähän on monia, on olemassa mm. useita erilaisia graafisia käyttöliittymiä, kuten [pgAdmin](https://www.pgadmin.org/). Käytetään nyt kuitenkin Postgresin [psql](https://www.postgresql.org/docs/current/app-psql.html)-komentorivityökalua. + +Kun konsoli on avattu, kokeillan psql:n tärkeintä komentoa _\d_, joka kertoo tietokannan sisällön: + +```bash +postgres=# \d +Did not find any relations. +``` + +Kuten arvata saattaa, tietokannassa ei ole mitään. + +Luodaan taulu muistiinpanoja varten: + +```sql +CREATE TABLE notes ( + id SERIAL PRIMARY KEY, + content text NOT NULL, + important boolean, + date time +); +``` + +Muutama huomio: sarake id on määritelty pääavaimeksi (engl. primary key), eli sarakkeen arvon tulee olla jokaisella taulun rivillä uniikki ja arvo ei saa olla tyhjä. Tyypiksi sarakkeelle on määritelty [SERIAL](https://www.postgresql.org/docs/9.1/datatype-numeric.html#DATATYPE-SERIAL), joka ei ole todellinen tyyppi vaan lyhennysmerkintä sille, että kyseessä on kokonaislukuarvoinen sarake, jolle Postgres antaa automaattisesti uniikin, kasvavan arvon rivejä luotaessa. Tekstiarvoinen sarake content on määritelty siten, että sille on pakko antaa arvo. + +Katsotaan tilannetta konsolista käsin. Ensin komento _\d_, joka kertoo mitä tauluja kannassa on: + +```sql +postgres=# \d + List of relations + Schema | Name | Type | Owner +--------+--------------+----------+---------------- + public | notes | table | postgres + public | notes_id_seq | sequence | postgres +(2 rows) +``` + +Taulun notes lisäksi Postgres loi aputaulun notes\_id\_seq, joka pitää kirjaa siitä, mikä arvo sarakkeelle id annetaan seuraavaa muistiinpanoa luotaessa. + +Komennolla _\d notes_ näemme miten taulu notes on määritelty: + +```sql +postgres=# \d notes; + Table "public.notes" + Column | Type | Collation | Nullable | Default +-----------+------------------------+-----------+----------+----------------------------------- + id | integer | | not null | nextval('notes_id_seq'::regclass) + content | text | | not null | + important | boolean | | | + date | time without time zone | | | +Indexes: + "notes_pkey" PRIMARY KEY, btree (id) +``` + +Sarakkeella id on siis oletusarvo (default), joka saadaan kutsumalla Postgresin sisäistä funktiota nextval. + +Lisätään tauluun hieman sisältöä: + +```sql +insert into notes (content, important) values ('Relational databases rule the world', true); +insert into notes (content, important) values ('MongoDB is webscale', false); +``` + +Ja katsotaan miltä luotu sisältö näyttää: + +```sql +postgres=# select * from notes; + id | content | important | date +----+-------------------------------------+-----------+------ + 1 | relational databases rule the world | t | + 2 | MongoDB is webscale | f | +(2 rows) +``` + +Jos yritämme tallentaa tietokantaan dataa, joka ei ole skeeman mukaista, se ei onnistu. Pakollisen sarakkeen arvo ei voi puuttua: + +```sql +postgres=# insert into notes (important) values (true); +ERROR: null value in column "content" of relation "notes" violates not-null constraint +DETAIL: Failing row contains (9, null, t, null). +``` + +Sarakkeen arvo ei voi olla väärää tyyppiä: + +```sql +postgres=# insert into notes (content, important) values ('only valid data can be saved', 1); +ERROR: column "important" is of type boolean but expression is of type integer +LINE 1: ...tent, important) values ('only valid data can be saved', 1); ^ +``` + +Skeemassa olemattomia sarakkeita ei hyväksytä: + +```sql +postgres=# insert into notes (content, important, value) values ('only valid data can be saved', true, 10); +ERROR: column "value" of relation "notes" does not exist +LINE 1: insert into notes (content, important, value) values ('only ... +``` + +Seuraavaksi on aika siirtyä käyttämään tietokantaa sovelluksesta käsin. + +### Relaatiotietokantaa käyttävä Node-sovellus + +Alustetaan sovellus tavalliseen tapaan komennolla npm init ja asennetaan sille kehitysaikaiseksi riippuvuudeksi nodemon sekä seuraavat suoritusaikaiset riippuvuudet: + +```bash +npm install express dotenv pg sequelize +``` + +Näistä jälkimmäinen [Sequelize](https://sequelize.org/master/) on kirjasto, jonka kautta käytämme Postgresia. Sequelize on niin sanottu [Object relational mapping](https://en.wikipedia.org/wiki/Object%E2%80%93relational_mapping) (ORM) ‑kirjasto, joka mahdollistaa JavaScript-olioiden tallentamisen relaatiotietokantaan ilman SQL-kielen käyttöä, samaan tapaan kuin MongoDB:n yhteydessä käyttämämme Mongoose. + +Testataan, että yhteyden muodostaminen onnistuu. Luodaan tiedosto index.js ja sille seuraava sisältö: + +```js +require('dotenv').config() +const { Sequelize } = require('sequelize') + +const sequelize = new Sequelize(process.env.DATABASE_URL) + +const main = async () => { + try { + await sequelize.authenticate() + console.log('Connection has been established successfully.') + sequelize.close() + } catch (error) { + console.error('Unable to connect to the database:', error) + } +} + +main() +``` + +Huom: Herokua käyttäessä yhteyden muodostuksen saattaa joutua konfiguroimaan seuraavasti: + +```js +const sequelize = new Sequelize(process.env.DATABASE_URL, { + // highlight-start + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, + // highlight-end +}) +``` + +Yhteydenmuodostamista varten tulee tiedostoon .env tallentaa connect string, jonka perusteella yhteyden muodostus tapahtuu. + +Herokua käyttäessäsi saat connect stringin selville komennolla _heroku config_, ja tiedoston .env sisältö tulee olemaan seuraavan kaltainen + +```bash +$ cat .env +DATABASE_URL=postgres://:thepasswordishere@ec2-54-83-137-206.compute-1.amazonaws.com:5432/ +``` + +Fly.io:a käyttäessä paikallinen tietokantayhteys täytyy ensin tehdä mahdolliseksi [tunneloimalla](https://fly.io/docs/reference/postgres/#connecting-to-postgres-from-outside-fly) paikallisen koneen portti 5432 Fly.io:n tietokannan porttiin komennolla + +```bash +flyctl proxy 5432 -a -db +``` + +omassa tapauksessani komento on + +```bash +flyctl proxy 5432 -a fs-psql-lecture-db +``` + +Komento tulee jättää päälle siksi aikaa kuin tietokantaa käytetään. Konsoli-ikkunaa siis ei saa sulkea. + +Fly.io:n connect-string on seuraavaa muotoa: + +```bash +$ cat .env +DATABASE_URL=postgres://postgres:thepasswordishere@localhost:5432/postgres +``` + +Salasana on se, joka on otettu talteen tietokantaa luodessa. + +Dockeria käytettäessä connect string on: + +```bash +DATABASE_URL=postgres://postgres:mysecretpassword@localhost:5432/postgres +``` + +Connect stringin viimeinen osa postgres viittaa käytettävään tietokannan nimeen. Nyt se on valmiiksi luotava ja oletusarvoisesti käytössä oleva postgres-niminen tietokanta. Komennolla [CREATE DATABASE](https://www.postgresql.org/docs/14/sql-createdatabase.html) on tarvittaessa mahdollista luoda muita tietokantoja Postgres-tietokantainstanssiin. + +Kun connect string on määritety tiedostoon .env voidaan kokeilla muodostuuko yhteys: + +```bash +$ node index.js +Executing (default): SELECT 1+1 AS result +Connection has been established successfully. +``` + +Jos ja kun yhteys toimii, voimme tehdä ensimmäisen kyselyn. Muutetaan ohjelmaa seuraavasti: + +```js +require('dotenv').config() +const { Sequelize, QueryTypes } = require('sequelize') // highlight-line + +const sequelize = new Sequelize(process.env.DATABASE_URL) + +const main = async () => { + try { + await sequelize.authenticate() + // highlight-start + const notes = await sequelize.query("SELECT * FROM notes", { type: QueryTypes.SELECT }) + console.log(notes) + sequelize.close() + // highlight-end + } catch (error) { + console.error('Unable to connect to the database:', error) + } +} + +main() +``` + +Sovelluksen suorituksen pitäisi tulostaa seuraavasti: + +```js +Executing (default): SELECT * FROM notes +[ + { + id: 1, + content: 'Relational databases rule the world', + important: true, + date: null + }, + { + id: 2, + content: 'MongoDB is webscale', + important: false, + date: null + } +] +``` + + +Vaikka Sequelize on ORM-kirjasto, jota käyttämällä SQL:ää ei juurikaan ole tarvetta itse kirjoittaa, käytimme nyt [suoraan SQL:ää](https://sequelize.org/master/manual/raw-queries.html) Sequelizen metodin [query](https://sequelize.org/master/class/lib/sequelize.js~Sequelize.html#instance-method-query) avulla. + +Koska kaikki näyttää toimivan, muutetaan sovellus web-sovellukseksi. + +```js +require('dotenv').config() +const { Sequelize, QueryTypes } = require('sequelize') +const express = require('express') // highlight-line +const app = express() // highlight-line + +const sequelize = new Sequelize(process.env.DATABASE_URL) + +// highlight-start +app.get('/api/notes', async (req, res) => { + const notes = await sequelize.query("SELECT * FROM notes", { type: QueryTypes.SELECT }) + res.json(notes) +}) + +const PORT = process.env.PORT || 3001 +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +// highlight-end +``` + +Sovellus näyttää toimivan. Siirrytään kuitenkin nyt käyttämään Sequelizeä SQL:n sijaan siten kuin sitä on tarkoitus käyttää. + +### Model + +Sequelizea käytettäessä, jokaista tietokannan taulua edustaa [model](https://sequelize.org/master/manual/model-basics.html), joka on käytännössä oma JavaScript-luokkansa. Määritellään nyt sovellukselle taulua notes vastaava model Note muuttamalla koodi seuraavaan muotoon: + +```js +require('dotenv').config() +const { Sequelize, Model, DataTypes } = require('sequelize') // highlight-line +const express = require('express') +const app = express() + +const sequelize = new Sequelize(process.env.DATABASE_URL) + +// highlight-start +class Note extends Model {} +// highlight-end + +// highlight-start +Note.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + content: { + type: DataTypes.TEXT, + allowNull: false + }, + important: { + type: DataTypes.BOOLEAN + }, + date: { + type: DataTypes.DATE + } +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'note' +}) +// highlight-end + +app.get('/api/notes', async (req, res) => { + const notes = await Note.findAll() // highlight-line + res.json(notes) +}) + +const PORT = process.env.PORT || 3001 +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +``` + +Muutama kommentti koodista. Modelin Note määrittelyssä ei ole mitään kovin yllättävää, jokaiselle sarakkeelle on määritelty tyyppi, sekä tarvittaessa muut ominaisuudet, kuten se onko kyseessä taulun pääavain. Modelin määrittelyssä oleva toinen parametri sisältää sequelize-olion sekä muuta konfiguraatiotietoa. Määrittelimme, että taululla ei ole usein käytettyjä aikaleimasarakkeita (created\_at ja updated\_at). + +Määrittelimme myös underscored: true, joka tarkoittaa sitä, että taulujen nimet johdetaan modelien nimistä monikkomuotoisina [snake case](https://en.wikipedia.org/wiki/Snake_case) ‑versiona. Käytännössä tämä tarkoittaa sitä, että jos modelin nimi on, kuten tapauksessamme, Note päätellään siitä, että vastaavan taulun nimi on pienellä alkukirjaimella kirjoitettu nimen monikko eli notes. Jos taas modelin nimi olisi "kaksiosainen" esim. StudyGroup olisi taulun nimi study_groups. Sequelize mahdollistaa automaattisen taulujen nimien päättelyn sijaan myös eksplisiittisesti määriteltävät taulujen nimet. + +Sama käytäntöä nimityksien osalta koskee myös sarakkeita. Jos olisimme määritelleet, että muistiinpanoon liittyy creationYear, eli tieto sen luomisvuodesta, määrittelisimme sen modeliin seuraavasti: + +```js +Note.init({ + // ... + creationYear: { + type: DataTypes.INTEGER, + }, +}) +``` + +Vastaavan sarakkeen nimi tietokannassa olisi creation_year. Koodissa viittaus sarakkeeseen tapahtuu aina samassa muodossa mikä on modelissa, eli "camel case"-formaatissa. + +Olemme myös määritelleet modelName: 'note', oletusarvoinen "modelin nimi" olisi isolla kirjoitettu Note. Haluamme kuitenkin pienen alkukirjaimen, se tekee muutaman asian jatkossa hieman mukavammaksi. + +Tietokantaoperaatio on helppo tehdä modelien tarjoaman [kyselyrajapinnan](https://sequelize.org/master/manual/model-querying-basics.html) avulla, metodi [findAll](https://sequelize.org/master/class/lib/model.js~Model.html#static-method-findAll) toimii juuri kuten sen nimen perusteella olettaa toimivan: + +```js +app.get('/api/notes', async (req, res) => { + const notes = await Note.findAll() // highlight-line + res.json(notes) +}) +``` + +Konsoli kertoo että metodikutsu Note.findAll() aiheuttaa seuraavan kyselyn: + +```js +Executing (default): SELECT "id", "content", "important", "date" FROM "notes" AS "note"; +``` + +Toteutetaan seuraavaksi endpoint uusien muistiinpanojen luomiseen: + +```js +app.use(express.json()) + +// ... + +app.post('/api/notes', async (req, res) => { + console.log(req.body) + const note = await Note.create(req.body) + res.json(note) +}) +``` + +Uuden muistiinpanon luominen siis tapahtuu kutsumalla modelin Note metodia [create](https://sequelize.org/master/manual/model-querying-basics.html#simple-insert-queries) ja antamalla sille parametriksi sarakkeiden arvot määrittelevän olion. + +Metodin create sijaan tietokantaan tallentaminen [olisi mahdollista tehdä](https://sequelize.org/master/manual/model-instances.html#creating-an-instance) käyttäen ensin metodia [build](https://sequelize.org/api/v6/class/src/model.js~model#static-method-build) luomaan halutusta datasta Model-olio, ja kutsumalla sille metodia [save](https://sequelize.org/master/class/lib/model.js~Model.html#instance-method-save): + +```js +const note = Note.build(req.body) +await note.save() +``` + +Metodin build kutsuminen ei tallenna vielä oliota tietokantaan, joten olio on vielä mahdollista muokata ennen varsinaista talletustapahtumaa: + +```js +const note = Note.build(req.body) +note.important = true // highlight-line +await note.save() +``` + +Esimerkkikoodin käyttötapaukseen metodi [create](https://sequelize.org/master/manual/model-querying-basics.html#simple-insert-queries) sopii paremmin, joten pidättäydytään siinä. + +Jos luotava olio ei ole validi, on seurauksena virheilmoitus. Esim. yritettäessä luoda muistiinpanoa ilman sisältöä +operaatio epäonnistuu, ja konsoli paljastaa syyn olevan SequelizeValidationError: notNull Violation Note.content cannot be null + +```bash +(node:39109) UnhandledPromiseRejectionWarning: SequelizeValidationError: notNull Violation: Note.content cannot be null + at InstanceValidator._validate (/Users/mluukkai/opetus/fs-psql/node_modules/sequelize/lib/instance-validator.js:78:13) + at processTicksAndRejections (internal/process/task_queues.js:93:5) +``` + +Lisätään uuden muistiinpanon lisäämisen yhteyteen vielä yksinkertainen virheenkäsittely: + +```js +app.post('/api/notes', async (req, res) => { + try { + const note = await Note.create(req.body) + return res.json(note) + } catch(error) { + return res.status(400).json({ error }) + } +}) +``` + +
    + + +
    + +### Tehtävät 13.1.-13.3. + +Teemme tämän osan tehtävissä [osan 4](/osa4) tehtävien kanssa samanlaisen blogi-sovelluksen backendin, jonka pitäisi olla virheenkäsittelyä lukuun ottamatta yhteensopiva [osan 5](/osa5) frontendin kanssa. Teemme backendiin myös joukon ominaisuuksia, joita osassa 5 tehty frontend ei osaa hyödyntää. + +#### Tehtävä 13.1. + +Tee sovellukselle GitHub-repositorio ja luo sen sisällä sovellusta varten Fly.io tai Heroku-sovellus sekä Postgres-tietokanta. Varmista, että saat luotua yhteyden sovelluksen tietokantaan. Kuten [täällä](/osa13/relaatiotietokannan_kaytto_sequelize_kirjastolla#sovelluksen-tietokanta) mainittiin, voit hoitaa tietokannan myös esim. Dockerin avulla, tällöin et tarvitse Fly.io- tai Heroku-sovellusta. + +#### Tehtävä 13.2. + +Luo sovellukselle komentoriviltä taulu blogs, jolla on seuraavat sarakkeet +- id (uniikki, kasvava id) +- author (merkkijono) +- url (merkkijono joka ei voi olla tyhjä) +- title (merkkijono joka ei voi olla tyhjä) +- likes (kokonaisluku, jolla oletusarvo nolla) + +Lisää tietokantaan ainakin kaksi blogia. + +Tallenna käyttämäsi SQL-lauseet sovelluksen repositorion juureen tiedostoon commands.sql + +#### Tehtävä 13.3. + +Tee sovellukseen komentoriviltä käytettävä toiminnallisuus, joka tulostaa tietokannassa olevat blogit esimerkiksi seuraavasti: + +```bash +$ node cli.js +Dan Abramov: 'Writing Resilient Components', 0 likes +Martin Fowler: 'Is High Quality Software Worth the Cost?', 0 likes +Robert C. Martin: 'FP vs. OO List Processing', 0 likes +``` + +
    + +
    + +### Tietokantataulujen automaattinen luominen + +Sovelluksessamme on nyt yksi ikävä puoli, se olettaa että täsmälleen oikean skeeman omaava tietokanta on olemassa, eli että taulu notes on luotu sopivalla _create table_ ‑komennolla. + +Koska ohjelmakoodi säilytetään GitHubissa, olisi järkevää säilyttää myös tietokannan luovat komennot ohjelmakoodin yhteydessä, jotta tietokannan skeema on varmasti sama mitä ohjelmakoodi odottaa. Sequelize pystyy itse asiassa generoimaan skeeman automaattisesti modelien määritelmästä modelien metodin [sync](https://sequelize.org/master/manual/model-basics.html#model-synchronization) avulla. + +Tuhotaan nyt tietokanta konsolista käsin antamalla psql-konsolissa seuraava komento: + +```sql +drop table notes; +``` + +Komento _\d_ paljastaa että taulu on hävinnyt tietokannasta: + +```sql +postgres=# \d +Did not find any relations. +``` + +Sovellus ei enää toimi. + +Lisätään sovellukseen seuraava komento heti modelin Note määrittelyn jälkeen: + +```js +Note.sync() +``` + +Kun sovellus käynnistyy, tulostuu konsoliin seuraava: + +```bash +Executing (default): CREATE TABLE IF NOT EXISTS "notes" ("id" SERIAL , "content" TEXT NOT NULL, "important" BOOLEAN, "date" TIMESTAMP WITH TIME ZONE, PRIMARY KEY ("id")); +``` + +Eli sovelluksen käynnistyessä suoritetaan komento CREATE TABLE IF NOT EXISTS "notes"..., joka luo taulun notes, jos se ei ole jo olemassa. + +### Muut operaatiot + +Täydennetään sovellusta vielä muutamalla operaatiolla. + +Yksittäisen muistiinpanon etsiminen onnistuu metodilla [findByPk](https://sequelize.org/master/manual/model-querying-finders.html), koska se haetaan pääavaimena toimivan id:n perusteella: + +```js +app.get('/api/notes/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + res.json(note) + } else { + res.status(404).end() + } +}) +``` + +Yksittäisen muistiinpanon hakeminen aiheuttaa seuraavanlaisen SQL-komennon: + +```bash +Executing (default): SELECT "id", "content", "important", "date" FROM "notes" AS "note" WHERE "note"."id" = '1'; +``` + +Jos muistiinpanoa ei löydy, palauttaa operaatio null, ja tässä tapauksessa annetaan asiaan kuuluva statuskoodi. + +Muistiinpanon muuttaminen tapahtuu seuraavasti. Tuetaan ainoastaan kentän important muutosta, sillä sovelluksen frontend ei muuta tarvitse: + +```js +app.put('/api/notes/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + note.important = req.body.important + await note.save() + res.json(note) + } else { + res.status(404).end() + } +}) +``` + +Tietokantariviä vastaava olio haetaan kannasta findByPk-metodilla, olioon tehdään muutos ja lopputulos tallennetaan kutsumalla tietokantariviä vastaavan olion metodia save. + +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy/part13-notes/tree/part13-1), branchissa part13-1. + +### Sequelizen palauttamien olioiden tulostaminen konsoliin + +JavaScript-ohjelmoijan tärkein apuväline on console.log, jonka aggressiivinen käyttö saa pahimmatkin bugit kuriin. Lisätään yksittäisen muistiinpanon reittiin konsolitulostus: + + +```js +app.get('/api/notes/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + console.log(note) // highlight-line + res.json(note) + } else { + res.status(404).end() + } +}) +``` + +Huomaamme, että lopputulos ei ole ihan se mitä odotimme: + +```js +note { + dataValues: { + id: 1, + content: 'MongoDB is webscale', + important: false, + date: 2021-10-03T15:00:24.582Z, + }, + _previousDataValues: { + id: 1, + content: 'MongoDB is webscale', + important: false, + date: 2021-10-03T15:00:24.582Z, + }, + _changed: Set(0) {}, + _options: { + isNewRecord: false, + _schema: null, + _schemaDelimiter: '', + raw: true, + attributes: [ 'id', 'content', 'important', 'date' ] + }, + isNewRecord: false +} +``` + +Muistiinpanon tietojen lisäksi konsoliin tulostuu kaikenlaista muutakin. Pääsemme toivottuun lopputulokseen kutsumalla model-olion metodia [toJSON](https://sequelize.org/master/class/lib/model.js~Model.html#instance-method-toJSON): + + +```js +app.get('/api/notes/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + console.log(note.toJSON()) // highlight-line + res.json(note) + } else { + res.status(404).end() + } +}) +``` + +Nyt lopputulos on juuri se mitä haluamme. + +```js +{ id: 1, + content: 'MongoDB is webscale', + important: false, + date: 2021-10-09T13:52:58.693Z } +``` + +Jos kyse on kokoelmallisesta oliosta, ei metodi toJSON toimi suoraan, metodia on kutsuttava erikseen jokaiselle kokoelman oliolle: + +```js +router.get('/', async (req, res) => { + const notes = await Note.findAll() + + console.log(notes.map(n=>n.toJSON())) // highlight-line + + res.json(notes) +}) +``` + +Tulostus näyttää seuraavalta: + +```js +[ { id: 1, + content: 'MongoDB is webscale', + important: false, + date: 2021-10-09T13:52:58.693Z }, + { id: 2, + content: 'Relational databases rule the world', + important: true, + date: 2021-10-09T13:53:10.710Z } ] +``` + +Ehkä parempi ratkaisu on kuitenkin muuttaa kokoelma JSON:iksi tulostamista varten metodilla [JSON.stringify](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify): + +```js +router.get('/', async (req, res) => { + const notes = await Note.findAll() + + console.log(JSON.stringify(notes)) // highlight-line + + res.json(notes) +}) +``` + +Tämä tapa on parempi erityisesti, jos kokoelman oliot sisältävät muita olioita. Usein on myös hyödyllistä muotoilla oliot ruudulle sisennetysti lukijaystävällisempään muotoon. Tämä onnistuu komennolla: + +```json +console.log(JSON.stringify(notes, null, 2)) +``` + +Tulostus seuraavassa: + +```js +[ + { + "id": 1, + "content": "MongoDB is webscale", + "important": false, + "date": "2021-10-09T13:52:58.693Z" + }, + { + "id": 2, + "content": "Relational databases rule the world", + "important": true, + "date": "2021-10-09T13:53:10.710Z" + } +] +``` + +
    + +
    + +### Tehtävä 13.4. + +#### Tehtävä 13.4. + +Muuta sovelluksesi web-sovellukseksi, joka tukee seuraavia operaatioita + +- _GET /api/blogs_ (kaikkien blogien listaus) +- _POST /api/blogs_ (uuden blogin lisäys) +- _DELETE /api/blogs/:id_ (blogin poisto) + +
    diff --git a/src/content/13/fi/osa13b.md b/src/content/13/fi/osa13b.md new file mode 100644 index 00000000000..ea16f33a3b2 --- /dev/null +++ b/src/content/13/fi/osa13b.md @@ -0,0 +1,1031 @@ +--- +mainImage: ../../../images/part-13.svg +part: 13 +letter: b +lang: fi +--- + +
    + +### Sovelluksen strukturointi + +Olemme toistaiseksi kirjoittaneet kaiken koodin samaan tiedostoon. Strukturoidaan nyt sovellus hieman paremmin. Luodaan seuraava hakemistorakenne ja tiedostot: + +```bash +index.js +util + config.js + db.js +models + index.js + note.js +controllers + notes.js +``` + +Tiedostojen sisältö on seuraava. Tiedosto util/config.js huolehtii ympäristömuuttujien käsittelystä: + +```js +require('dotenv').config() + +module.exports = { + DATABASE_URL: process.env.DATABASE_URL, + PORT: process.env.PORT || 3001, +} +``` + +Tiedoston index.js rooliksi jää sovelluksen konfigurointi ja käynnistäminen: + +```js +const express = require('express') +const app = express() + +const { PORT } = require('./util/config') +const { connectToDatabase } = require('./util/db') + +const notesRouter = require('./controllers/notes') + +app.use(express.json()) + +app.use('/api/notes', notesRouter) + +const start = async () => { + await connectToDatabase() + app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) + }) +} + +start() +``` + +Sovelluksen käynnistys poikkeaa hieman aiemmin näkemästä, sillä haluamme varmistaa ennen varsinaista käynnistämistä että tietokantayhteys on muodostettu. + +Tiedosto util/db.js sisältää tietokannan alustukseen liittyvän koodin: + +```js +const Sequelize = require('sequelize') +const { DATABASE_URL } = require('./config') + +const sequelize = new Sequelize(DATABASE_URL) + +const connectToDatabase = async () => { + try { + await sequelize.authenticate() + console.log('database connected') + } catch (err) { + console.log('connecting database failed') + return process.exit(1) + } + + return null +} + +module.exports = { connectToDatabase, sequelize } +``` + +Muistiinpanot tallettavaa taulua vastaava model on talletettu tiedostoon models/note.js: + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class Note extends Model {} + +Note.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + content: { + type: DataTypes.TEXT, + allowNull: false + }, + important: { + type: DataTypes.BOOLEAN + }, + date: { + type: DataTypes.DATE + } +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'note' +}) + +module.exports = Note +``` + +Tiedosto models/index.js on tässä vaiheessa lähes turha, sillä sovelluksessa on vasta yksi model. Kun lisäämme sovellukseen muitakin modeleja tulee tiedostolle enemmän käyttöä, sillä tiedoston ansiosta muualla ohjelmassa ei tarvitse importata erikseen yksittäisen modelin määritteleviä tiedostoja. + +```js +const Note = require('./note') + +Note.sync() + +module.exports = { + Note +} +``` + +Muistiinpanoihin liittyvät routejen käsittelijät löytyvät tiedostosta controllers/notes.js: + +```js +const router = require('express').Router() + +const { Note } = require('../models') + +router.get('/', async (req, res) => { + const notes = await Note.findAll() + res.json(notes) +}) + +router.post('/', async (req, res) => { + try { + const note = await Note.create(req.body) + res.json(note) + } catch(error) { + return res.status(400).json({ error }) + } +}) + +router.get('/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + res.json(note) + } else { + res.status(404).end() + } +}) + +router.delete('/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + await note.destroy() + } + res.status(204).end() +}) + +router.put('/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + note.important = req.body.important + await note.save() + res.json(note) + } else { + res.status(404).end() + } +}) + +module.exports = router +``` + +Sovelluksen rakenne on nyt hyvä. Huomaamme kuitenkin että yksittäistä muistiinpanoa käsittelevät reitinkäsittelijät sisältävät aavistuksen verran toisteista koodia, sillä kaikki niistä alkavat käsiteltävän muistiinpanon etsivällä rivillä: + +```js +const note = await Note.findByPk(req.params.id) +``` + +Refaktoroidaan tämä omaan middlewareen ja otetaan se käyttöön reittienkäsittelijöissä: + +```js +// highlight-start +const noteFinder = async (req, res, next) => { + req.note = await Note.findByPk(req.params.id) + next() +} +// highlight-end + +router.get('/:id', noteFinder, async (req, res) => { // highlight-line + if (req.note) { + res.json(req.note) + } else { + res.status(404).end() + } +}) + +router.delete('/:id', noteFinder, async (req, res) => { // highlight-line + if (req.note) { + await req.note.destroy() + } + res.status(204).end() +}) + +router.put('/:id', noteFinder, async (req, res) => { // highlight-line + if (req.note) { + req.note.important = req.body.important + await req.note.save() + res.json(req.note) + } else { + res.status(404).end() + } +}) +``` + +Reitinkäsittelijät saavat nyt kolme parametria, näistä ensimmäinen on reitin määrittelevä merkkijono ja toisena on määrittelemämme middleware noteFinder, joka hakee muistiinpanon tietokannasta ja sijoittaa sen req olion kenttään note. Pieni määrä copypastea poistuu ja olemme tyytyväisiä! + +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy/part13-notes/tree/part13-2), branchissa part13-2. + +
    + +
    + +### Tehtävät 13.5.-13.7. + +#### Tehtävä 13.5. + +Muuta sovelluksesi rakenne edellä olevan esimerkin mukaiseksi, tai noudattamaan jotain muuta vastaavaa selkeää konventiota. + +#### Tehtävä 13.6. + +Toteuta sovellukseen myös tuki blogien like-määrän muuttamiselle, eli operaatio + +_PUT /api/blogs/:id_ (blogin like-määrän muokkaus) + +Likejen päivitetty määrä välitetään pyynnön mukana: + +```js +{ + likes: 3 +} +``` + +#### Tehtävä 13.7. + +Keskitä sovelluksen virheidenkäsittely middlewareen [osan 3](/osa3/tietojen_tallettaminen_mongo_db_tietokantaan#virheidenkasittelyn-keskittaminen-middlewareen) tapaan. Voit ottaa käyttöösi myös middlewaren [express-async-errors](https://github.com/davidbanham/express-async-errors) kuten [osassa 4](/osa4/backendin_testaaminen#try-catchin-eliminointi) tehtiin. + +Virheilmoituksen yhteydessä palautettavalla datalla ei ole suurta merkitystä. + +Tässä vaiheessa sovelluksen virhekäsittelyä vaativat tilanteet ovat uuden blogin luominen sekä blogin tykkäysmäärän muuttaminen. Varmista, että virheidenkäsittelijä hoitaa molemmat asiaankuuluvalla tavalla. + +
    + +
    + +### Käyttäjänhallinta + +Lisätään seuraavaksi sovellukseen tietokantataulu users, johon tallennetaan sovelluksen käyttäjät. Toteutetaan lisäksi mahdollisuus käyttäjien luomiseen sekä token-perustainen kirjautuminen [osan 4](/osa4/token_perustainen_kirjautuminen) tapaan. Yksinkertaisuuden vuoksi teemme toteutuksen nyt niin, että kaikilla käyttäjillä on sama salasana salainen. + +Käyttäjän määrittelevä model tiedostossa models/user.js on melko suoraviivainen + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class User extends Model {} + +User.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + username: { + type: DataTypes.STRING, + unique: true, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'user' +}) + +module.exports = User +``` + +Käyttäjätunnukseen on asetettu ehdoksi että se on uniikki. Käyttäjätunnusta olisi periaatteessa voitu käyttää taulun pääavaimena. Päätimme kuitenkin luoda pääavaimen erillisenä kokonaislukuarvoisena kenttänä id. + + +Tiedosto models/index.js laajenee hieman: + +```js +const Note = require('./note') +const User = require('./user') // highlight-line + +Note.sync() +User.sync() // highlight-line + +module.exports = { + Note, User // highlight-line +} +``` + +Tiedostoon controllers/users.js sijoitettavat uuden käyttäjä luomisesta sekä kaikkien käyttäjien näyttämisestä huolehtivat reitinkäsittelijät eivät sisällä mitään dramaattista + +```js +const router = require('express').Router() + +const { User } = require('../models') + +router.get('/', async (req, res) => { + const users = await User.findAll() + res.json(users) +}) + +router.post('/', async (req, res) => { + try { + const user = await User.create(req.body) + res.json(user) + } catch(error) { + return res.status(400).json({ error }) + } +}) + +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id) + if (user) { + res.json(user) + } else { + res.status(404).end() + } +}) + +module.exports = router +``` + +Kirjautumisen hoitava reitinkäsittelijä (tiedosto controllers/login.js) on seuraavassa: + +```js +const jwt = require('jsonwebtoken') +const router = require('express').Router() + +const { SECRET } = require('../util/config') +const User = require('../models/user') + +router.post('/', async (request, response) => { + const body = request.body + + const user = await User.findOne({ + where: { + username: body.username + } + }) + + const passwordCorrect = body.password === 'salainen' + + if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) + } + + const userForToken = { + username: user.username, + id: user.id, + } + + const token = jwt.sign(userForToken, SECRET) + + response + .status(200) + .send({ token, username: user.username, name: user.name }) +}) + +module.exports = router +``` + +Post-pyynnön mukana vastaanotetaan käyttäjätunnus (username) sekä salasana (password). Ensin käyttäjää vastaava olio haetaan tietokannasta modelin User metodilla [findOne](https://sequelize.org/master/manual/model-querying-finders.html#-code-findone--code-): + +```js +const user = await User.findOne({ + where: { + username: body.username + } +}) +``` + +Konsolista näemme metodikutsua vastaavan SQL-lauseen + +```sql +SELECT "id", "username", "name" +FROM "users" AS "User" +WHERE "User"."username" = 'mluukkai'; +``` + +Jos käyttäjä löytyy ja salasana on oikein (eli kaikkien käyttäjien tapauksessa _salainen_), palautetaan kutsujalle jsonwebtoken, joka sisältää käyttäjän tietot. Tätä varten asennamme riippuvuuden + +```js +npm install jsonwebtoken +``` + +Tiedosto index.js laajenee hiukan + +```js +const notesRouter = require('./controllers/notes') +const usersRouter = require('./controllers/users') // highlight-line +const loginRouter = require('./controllers/login') // highlight-line + +app.use(express.json()) + +app.use('/api/notes', notesRouter) +app.use('/api/users', usersRouter) // highlight-line +app.use('/api/login', loginRouter) // highlight-line +``` + +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy/part13-notes/tree/part13-3), branchissa part13-3. + +### Taulujen välinen liitos + +Sovellukseen voi nyt lisätä käyttäjiä ja käyttäjät voivat kirjautua, mutta itsessään tämä ei ole vielä kovin hyödyllinen ominaisuus. Ideana on se, että ainoastaan kirjautunut käyttäjä voi lisätä muistiinpanoja, ja että jokaiseen muistiinpanoon liitetään sen luonut käyttäjä. Tarvitsemme tätä varten viiteavaimen muistiinpanot tallettavaan tauluun notes. + +Sequelizeä käytettäessä viiteavaimen määrittely onnistuu muuttamalla tiedostoa models/index.js seuraavasti + +```js +const Note = require('./note') +const User = require('./user') + +// highlight-start +User.hasMany(Note) +Note.belongsTo(User) +// highlight-end + +// highlight-start +Note.sync({ alter: true }) +User.sync({ alter: true }) +// highlight-end + +module.exports = { + Note, User +} +``` + +Näin siis [määritellään](https://sequelize.org/master/manual/assocs.html#one-to-one-relationships) että users ja notes rivien välillä on yhden suhde moneen ‑yhteys. Muutimme myös sync-kutsuja siten että ne muuttavat taulut, jos taulujen määrittelyyn on tullut muutoksia. Kun nyt katsotaan tietokannan skeemaa konsolista, se näyttää seuraavalta: + +```js +postgres=# \d users + Table "public.users" + Column | Type | Collation | Nullable | Default +----------+------------------------+-----------+----------+----------------------------------- + id | integer | | not null | nextval('users_id_seq'::regclass) + username | character varying(255) | | not null | + name | character varying(255) | | not null | +Indexes: + "users_pkey" PRIMARY KEY, btree (id) +Referenced by: + TABLE "notes" CONSTRAINT "notes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE SET NULL + +postgres=# \d notes + Table "public.notes" + Column | Type | Collation | Nullable | Default +-----------+--------------------------+-----------+----------+----------------------------------- + id | integer | | not null | nextval('notes_id_seq'::regclass) + content | text | | not null | + important | boolean | | | + date | timestamp with time zone | | | + user_id | integer | | | +Indexes: + "notes_pkey" PRIMARY KEY, btree (id) +Foreign-key constraints: + "notes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE SET NULL +``` + +Eli tauluun notes on luotu viiteavain user_id, joka viittaa taulun users-riviin. + +Tehdään nyt uuden muistiinpanon lisäämiseen sellainen muutos, että muistiinpano liitetään käyttäjään. Ennen kuin teemme kunnollisen toteutuksen (missä liitos tapahtuu tokenin avulla kirjautumisen osoittavaan käyttäjään), liitetään muistiinpano ensimmäiseen tietokannasta löytyvään käyttäjään: + +```js + +router.post('/', async (req, res) => { + try { + // highlight-start + const user = await User.findOne() + const note = await Note.create({ ...req.body, userId: user.id }) + // highlight-end + res.json(note) + } catch(error) { + return res.status(400).json({ error }) + } +}) +``` + +Huomionarvoista koodissa on se, että vaikka tietokannan tasolla muistiinpanoilla on sarake user_id, tietokantariviä vastaavassa oliossa siihen viitataan Sequelizen nimentäkonvention takia camel case muodossa userId. + +Yksinkertaisen liitoskyselyn tekeminen on erittäin helppoa. Muutetaan kaikki käyttäjät näyttävää routea siten, että se näyttää myös jokaisen käyttäjän muistiinpanot: + +```js +router.get('/', async (req, res) => { + // highlight-start + const users = await User.findAll({ + include: { + model: Note + } + }) + // highlight-end + res.json(users) +}) +``` + +Liitoskysely siis tehdään kyselyn parametrina olevaan olioon [include](https://sequelize.org/master/manual/assocs.html#eager-loading-example)-määreen avulla. + +Kyselystä muodostuva sql-lause nähdään konsolissa: + +``` +SELECT "User"."id", "User"."username", "User"."name", "Notes"."id" AS "Notes.id", "Notes"."content" AS "Notes.content", "Notes"."important" AS "Notes.important", "Notes"."date" AS "Notes.date", "Notes"."user_id" AS "Notes.UserId" +FROM "users" AS "User" LEFT OUTER JOIN "notes" AS "Notes" ON "User"."id" = "Notes"."user_id"; +``` + +Lopputulos on myös sen kaltainen kuin odottaa saattaa + +![](../../images/13/1.png) + +### Muistiinpanojen kunnollinen lisääminen + +Muutetaan muistiinpanojen lisäys toimimaan samoin kuin [osassa 4](/osa4), eli muistiinpanon luominen onnistuu ainoastaan jos luomista vastaavan pyynnön mukana on validi, kirjautumisen yhteydessä saatava token. Muistiinpano talletetaan tokenin identifioiman käyttäjän tekemien muistiinpanojen listaan: + +```js +const jwt = require('jsonwebtoken') // highlight-line +const { SECRET } = require('../util/config') // highlight-line + +// highlight-start +const tokenExtractor = (req, res, next) => { + const authorization = req.get('authorization') + if (authorization && authorization.toLowerCase().startsWith('bearer ')) { + try { + console.log(authorization.substring(7)) + req.decodedToken = jwt.verify(authorization.substring(7), SECRET) + } catch (error){ + console.log(error) + return res.status(401).json({ error: 'token invalid' }) + } + } else { + return res.status(401).json({ error: 'token missing' }) + } + + next() +} +// highlight-end + +router.post('/', tokenExtractor, async (req, res) => { // highlight-line + try { + // highlight-start + const user = await User.findByPk(req.decodedToken.id) + const note = await Note.create({...req.body, userId: user.id, date: new Date()}) + // highlight-end + res.json(note) + } catch(error) { + return res.status(400).json({ error }) + } +}) +``` + +Token otetaan ja dekoodataan pyyntöön headereista ja sijoitetaan req-olioon middlewaren tokenExtractor toimesta. Muistiinpanoa luotaessa myös sen luontihetken kertovalle kentälle date annetaan arvo. + +### Hienosäätöä + +Backendimme toimii tällä hetkellä virheidenkäsittelyä lukuun ottamatta lähes samalla tavalla kuin osan 4 versio samasta sovelluksesta. Ennen kun teemme backendiin muutamia laajennuksia, muutetaan kaikkien muistiinpanojen sekä kaikkien käyttäjien routeja hieman. + +Lisätään muistiinpanojen yhteyteen tieto sen lisänneestä käyttäjästä: + +```js +router.get('/', async (req, res) => { + // highlight-start + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: User, + attributes: ['name'] + } + }) + // highlight-end + res.json(notes) +}) +``` + +Olemme myös [rajoittaneet](https://sequelize.org/master/manual/model-querying-basics.html#specifying-attributes-for-select-queries) minkä kenttien arvot haluamme. Muistiinpanoista otetaan kaikki muut kentät paitsi userId ja muistiinpanoon liittyvästä käyttäjästä ainoastaan name eli nimi. + +Tehdään samantapainen muutos kaikkien käyttäjien reittiin, poistetaan käyttäjään liittyvistä muistiinpanoista turha kenttä userId: + +```js +router.get('/', async (req, res) => { + const users = await User.findAll({ + include: { + model: Note, + attributes: { exclude: ['userId'] } // highlight-line + } + }) + res.json(users) +}) +``` + +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy/part13-notes/tree/part13-4), branchissa part13-4. + +### Huomio modelien määrittelystä + +Tarkkasilmäisimmät huomasivat, että sarakkeen user_id lisäämisestä huolimatta emme tehneet muutosta muistiinpanot määrittelevään modeliin, mutta voimme lisätä muistinpano-olioille käyttäjän: + +```js +const user = await User.findByPk(req.decodedToken.id) +const note = await Note.create({ ...req.body, userId: user.id, date: new Date() }) +``` + +Syynä tälle on se, että kun määrittelimme tiedostossa models/index.js, että käyttäjien ja muistiinpanojen välillä on yhdestä moneen ‑yhteys: + +```js +const Note = require('./note') +const User = require('./user') + +User.hasMany(Note) +Note.belongsTo(User) + +// ... +``` + +luo Sequelize automaattisesti modeliin Note attribuutin userId, johon viittaamalla päästään käsiksi tietokannan sarakkeeseen user_id. + +Huomaa, että voisimme luoda muistiinpanon myös seuraavasti metodin [build](https://sequelize.org/api/v6/class/src/model.js~model#static-method-build) avulla: + +```js +const user = await User.findByPk(req.decodedToken.id) + +// luodaan muistiinpano tallettamatta sitä vielä +const note = Note.build({ ...req.body, date: new Date() }) + // sijoitetaan käyttäjän id mustiinpanolle +note.userId = user.id +// talletetaan muistiinpano-olio tietokantaan +await note.save() +``` + +Näin näemme eksplisiittisesti sen, että userId on muistiinpano-olion attribuutti. + + +Voisimme määritellä saman myös modeliin: + +```js +Note.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + content: { + type: DataTypes.TEXT, + allowNull: false + }, + important: { + type: DataTypes.BOOLEAN + }, + date: { + type: DataTypes.DATE + }, + // highlight-start + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + } + // highlight-end +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'note' +}) + +module.exports = Note +``` + +tämä ei kuitenkaan ole välttämätöntä. Model-luokkien tasolla tapahtuva määrittely + +```js +User.hasMany(Note) +Note.belongsTo(User) +``` + +sen sijaan on välttämätön, muuten Sequelize ei osaa koodin tasolla liittää tauluja toisiinsa. + +
    + +
    + +### Tehtävät 13.8.-13.12. + +#### Tehtävä 13.8. + +Lisää sovellukseen tuki käyttäjille. Käyttäjillä on tunnisteen lisäksi seuraavat kentät: + +- name (merkkijono, ei saa olla tyhjä) +- username (merkkijono, ei saa olla tyhjä) + +Toisin kuin materiaalissa älä nyt estä Sequelizea luomasta käyttäjille [aikaleimoja](https://sequelize.org/master/manual/model-basics.html#timestamps) created\_at ja updated\_at + +Kaikilla käyttäjillä voi olla sama salasana materiaalin tapaan. Voit myös halutessasi toteuttaa salasanan kunnolla [osan 4](/osa4/kayttajien_hallinta) tapaan. + +Toteuta seuraavat routet + +- _POST api/users_ (uuden käyttäjän lisäys) +- _GET api/users_ (kaikkien käyttäjien listaus) +- _PUT api/users/:username_ (käyttäjän nimen muutos, huomaa että parametrina ei ole id vaan käyttäjätunnus) + +Varmista, että Sequelizen automaattisesti asettamat aikaleimat created\_at ja updated\_at toimivat oikein kun luot käyttäjän ja muutat käyttäjän nimeä. + +#### Tehtävä 13.9. + +Sequelize tarjoaa joukon valmiiksi määriteltyjä [validointeja](https://sequelize.org/master/manual/validations-and-constraints.html) modelien kentille, jotka se suorittaa ennen olioiden tallentamista tietokantaan. + +Päätetään muuttaa käyttäjätunnuksen luontiperiaatetta siten, että käyttäjätunnukseksi kelpaa ainoastaan validi emailosoite. Tee tunnuksen luomisen yhteyteen validointi, joka tarkastaa asian. + +Muuta virheidenkäsittelymiddlewarea siten, että se antaa tilanteessa kuvaavamman virheilmoituksen (esim. hyödyntäen Sequelizen virheeseen liittyvää viestiä): + +```js +{ + "error": [ + "Validation isEmail on username failed" + ] +} +``` + +#### Tehtävä 13.10. + +Laajenna sovellusta siten, että blogi liitetään tokenin perusteella identifioitavalle kirjautuneelle käyttäjälle. Joudut siis toteuttamaan myös tokenin palauttavan kirjautumisesta huolehtivan endpointin _POST /api/login_ + +#### Tehtävä 13.11. + +Tee blogin poisto mahdolliseksi ainoastaan blogin lisänneelle käyttäjälle. + +#### Tehtävä 13.12. + +Muokkaa blogien ja käyttäjien routea siten, että blogien yhteydessä näytetään tieto blogin lisänneestä käyttäjästä, ja käyttäjän yhteydessä tiedot käyttäjien blogeista. + +
    + +
    + +### Lisää kyselyitä + +Toistaiseksi sovelluksemme on ollut kyselyiden suhteen hyvin yksinkertainen, kyselyt ovat hakeneet joko yksittäisen rivin pääavaimeen perustuen metodia [findByPk](https://sequelize.org/master/class/lib/model.js~Model.html#static-method-findByPk) käyttäen tai ne ovat hakeneet metodilla [findAll](https://sequelize.org/master/class/lib/model.js~Model.html#static-method-findAll) taulun kaikki rivit. Nämä riittävät sovellukselle osassa 5 tehdylle frontendille, mutta laajennetaan backendia siten, että pääsemme myös harjoittelemaan hieman monimutkaisempien kyselyjen tekemistä. + +Toteutetaan ensin mahdollisuus hakea ainoastaan tärkeät tai ei-tärkeät muistiinpanot. Toteutetaan nämä [query-parametrin](http://expressjs.com/en/5x/api.html#req.query) important avulla: + +```js +router.get('/', async (req, res) => { + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: User, + attributes: ['name'] + }, + // highlight-start + where: { + important: req.query.important === "true" + } + // highlight-end + }) + res.json(notes) +}) +``` + +Nyt backendilta voidaan hakea tärkeät muistiinpanot reitiltä /api/notes?important=true ja ei-tärkeät reitiltä /api/notes?important=false + +Sequelizen generoima SQL-kysely sisältää luonnollisesti palautettavia rivejä rajaavan where-määreen: + +```sql +SELECT "note"."id", "note"."content", "note"."important", "note"."date", "user"."id" AS "user.id", "user"."name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note"."user_id" = "user"."id" +WHERE "note"."important" = true; +``` + +Ikävä kyllä tämä toteutus ei toimi jos haettaessa ei olla kiinnostuneita onko muistiinpano tärkeä vai ei, eli jos pyyntö tehdään osoitteeseen http://localhost:3001/api/notes. Korjaus voidaan tehdä monella tapaa. Eräs, mutta ei kenties paras tapa tehdä korjaus olisi seuraavassa: + +```js +const { Op } = require('sequelize') // highlight-line + +router.get('/', async (req, res) => { + // highlight-start + let important = { + [Op.in]: [true, false] + } + + if ( req.query.important ) { + important = req.query.important === "true" + } + // highlight-end + + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: User, + attributes: ['name'] + }, + where: { + important // highlight-line + } + }) + res.json(notes) +}) +``` + +Olio important tallettaa nyt kyselyn ehdon. Se on oletusarvoisesti + +```js +where: { + important: { + [Op.in]: [true, false] + } +} +``` + +eli sarake important voi olla arvoltaan true tai false. Käytössä on yksi monista Sequelizen operaatioista [Op.in](https://sequelize.org/master/manual/model-querying-basics.html#operators). Jos query-parametri req.query.important on määritelty, muuttuu kysely jompaankumpaan muotoon + +```js +where: { + important: true +} +``` + +tai + +```js +where: { + important: false +} +``` + +riippuen query-parametrin arvosta. + +Tietokantaan on saattanut päästä note-rivejä joiden kentällä important ei ole arvoa. Näitä eivät ylläolevien muutosten jälkeen enää pysty kannasta hakemaan. Annetaan tietokantakonsolista puuttuville tärkeyksille jotkut arvot, ja muutetaan skeemaa siten, että tärkeys tulee pakolliseksi: + +```js +Note.init( + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + content: { + type: DataTypes.TEXT, + allowNull: false, + }, + important: { + type: DataTypes.BOOLEAN, + allowNull: false, // highlight-line + }, + date: { + type: DataTypes.DATE, + }, + }, + // ... +) +``` + +Laajennetaan toiminnallisuutta vielä siten, että muistiinpanoja haettaessa voidaan määritellä vaadittu hakusana, eli esim. tekemällä pyyntö http://localhost:3001/api/notes?search=database saadaan kaikki muistiinpanot, joissa mainitaan database tai pyynnöllä http://localhost:3001/api/notes?search=javascript&important=true saadaan kaikki tärkeäksi merkityt muistiinpanot, joissa mainitaan javascript. Toteutus on seuraavassa + +```js +router.get('/', async (req, res) => { + let important = { + [Op.in]: [true, false] + } + + if ( req.query.important ) { + important = req.query.important === "true" + } + + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: User, + attributes: ['name'] + }, + where: { + important, + // highlight-start + content: { + [Op.substring]: req.query.search ? req.query.search : '' + } + // highlight-end + } + }) + + res.json(notes) +}) +``` + +Sequelizen [Op.substring](https://sequelize.org/master/manual/model-querying-basics.html#operators) muodostaa haluamamme kyselyn SQL:n like-avainsanaa käyttäen. Jos esim. teemme pyynnön http://localhost:3001/api/notes?search=database&important=true näemme että sen aikaansaama SQL-kysely on juuri olettamamme kaltainen. + +```sql +SELECT "note"."id", "note"."content", "note"."important", "note"."date", "user"."id" AS "user.id", "user"."name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note"."user_id" = "user"."id" +WHERE "note"."important" = true AND "note"."content" LIKE '%database%'; +``` + +Sovelluksessamme on vielä sellainen kauneusvirhe, että jos teemme pyynnön http://localhost:3001/api/notes eli haluamme kaikki muistiinpanot, toteutuksemme aiheuttaa kyselyyn turhan wheren, joka saattaa (riippuen tietokantamoottorin toteutuksesta) vaikuttaa tarpeettomasti kyselyn tehokkuuteen: + +```sql +SELECT "note"."id", "note"."content", "note"."important", "note"."date", "user"."id" AS "user.id", "user"."name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note"."user_id" = "user"."id" +WHERE "note"."important" IN (true, false) AND "note"."content" LIKE '%%'; +``` + +Optimoidaan koodia vielä siten, että where-ehtoja käytetään ainoastaan tarvittaessa: + +```js +router.get('/', async (req, res) => { + const where = {} + + if (req.query.important) { + where.important = req.query.important === "true" + } + + if (req.query.search) { + where.content = { + [Op.substring]: req.query.search + } + } + + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: User, + attributes: ['name'] + }, + where + }) + + res.json(notes) +}) +``` + +Jos pyynnössä on hakuehtoja esim. http://localhost:3001/api/notes?search=database&important=true muodostuu wheren sisältävä kysely + +```sql +SELECT "note"."id", "note"."content", "note"."important", "note"."date", "user"."id" AS "user.id", "user"."name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note"."user_id" = "user"."id" +WHERE "note"."important" = true AND "note"."content" LIKE '%database%'; +``` + +Jos pyyntö on hakuehdoton http://localhost:3001/api/notes ei kyselyssä ole turhaa whereä + +```sql +SELECT "note"."id", "note"."content", "note"."important", "note"."date", "user"."id" AS "user.id", "user"."name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note"."user_id" = "user"."id"; +``` + +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy/part13-notes/tree/part13-5), branchissa part13-5. + +
    + +
    + +### Tehtävät 13.13.-13.16 + +#### Tehtävä 13.13. + +Toteuta sovellukseen kaikki blogit palauttavaan reittiin filtteröinti hakusanan perusteella. Filtteröinti toimii seuraavasti +- _GET /api/blogs?search=react_ palauttaa ne blogit joiden kentässä title esiintyy hakusana react, hakusana on epäcasesensitiivinen +- _GET /api/blogs_ palauttaa kaikki blogit + + +[Tämä](https://sequelize.org/master/manual/model-querying-basics.html#operators) lienee hyödyksi tässä ja seuraavassa tehtävässä. +#### Tehtävä 13.14. + +Laajenna filtteriä siten, että se etsii hakusanaa kentistä title ja author, eli + +_GET /api/blogs?search=jami_ palauttaa ne blogit joiden kentässä title tai kentässä author esiintyy hakusana jami +#### Tehtävä 13.15. + +Muokkaa blogien reittiä siten, että se palauttaa blogit tykkäysten perusteella laskevassa järjestyksessä. Etsi [dokumentaatiosta](https://sequelize.org/master/manual/model-querying-basics.html) ohjeet järjestämiselle. + +#### Tehtävä 13.16. + +Tee sovellukselle reitti /api/authors, joka palauttaa kustakin authorista blogien lukumäärän sekä tykkäysten yhteenlasketun määrän. Toteuta operaatio suoraan tietokannan tasolla. Tarvitset suurella todennäköisyydellä [group by](https://sequelize.org/master/manual/model-querying-basics.html#grouping)-toiminnallisuutta, sekä [sequelize.fn](https://sequelize.org/master/manual/model-querying-basics.html#specifying-attributes-for-select-queries)-aggregaattorifunktiota. + +Reitin palauttama JSON voi näyttää esim. seuraavalta: + +```js +[ + { + author: "Martin Fowler", + blogs: "2", + likes: "10" + }, + { + author: "Robert C. Martin", + blogs: "1", + likes: "0" + }, + { + author: "Cam Jackson", + blogs: "1", + likes: "2" + }, + { + author: "Dan Abramov", + blogs: "3", + likes: "7" + } +] +``` + +Bonustehtävä: järjestä palautettava data tykkäysten perusteella, tee järjestäminen tietokantakyselyssä. + +
    diff --git a/src/content/13/fi/osa13c.md b/src/content/13/fi/osa13c.md new file mode 100644 index 00000000000..93c0fd2bdc3 --- /dev/null +++ b/src/content/13/fi/osa13c.md @@ -0,0 +1,1512 @@ +--- +mainImage: ../../../images/part-13.svg +part: 13 +letter: c +lang: fi +--- + +
    + +### Migraatiot + +Jatketaan backendin laajentamista. Haluamme toteuttaa tuen sille, että admin-statuksen omaavat käyttäjät voivat asettaa haluamiaan käyttäjiä epäaktiiviseen tilaan, estäen heiltä kirjautumisen ja uusien muistiinpanojen luomisen. Toteuttaaksemme nämä, meidän tulee lisätä käyttäjien tietokantatauluun boolean-arvoinen tieto siitä, onko käyttäjä admin sekä siitä onko käyttäjätunnus epäaktiivinen. + +Voisimme edetä kuten aiemmin, eli muuttaa taulun määrittelevää modelia ja luottaa, että Sequelize synkronoi muutokset tietokantaan. Tämänhän saavat aikaan tiedostossa models/index.js olevat rivit + +```js +const Note = require('./note') +const User = require('./user') + +Note.belongsTo(User) +User.hasMany(Note) + +Note.sync({ alter: true }) // highlight-line +User.sync({ alter: true }) // highlight-line + +module.exports = { + Note, User +} +``` + +Tämä toimintatapa ei kuitenkaan ole pitkässä juoksussa järkevä. Poistetaan synkronoinnin tekevät rivit ja siirrytään käyttämään paljon robustimpaa tapaa, Sequelizen (ja monien muiden kirjastojen) tarjoamia [migraatioita](https://sequelize.org/master/manual/migrations.html). + +Käytännössä migraatio on yksittäinen JavaScript-tiedosto, joka kuvaa jonkin tietokantaan tehtävän muutoksen. Jokaista yksittäistä tai useampaa kerralla tapahtuvaa muutosta varten tehdään oma migraatio-tiedosto. Sequelize pitää kirjaa siitä, mitkä migraatioista on suoritettu, eli minkä migraatioiden aiheuttama muutos on synkronoitu tietokannan skeemaan. Uusien migraatioiden luomisen myötä Sequelize pysyy ajan tasalla siitä, mitkä muutokset kannan skeemaan on vielä tekemättä. Näin muutokset tehdään hallitusti, versionhallintaan talletetulla ohjelmakoodilla. + +Luodaan aluksi migraatio, joka vie tietokannan sen nykyiseen tilaansa. Migraation koodi on seuraavassa: + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.createTable('notes', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + content: { + type: DataTypes.TEXT, + allowNull: false + }, + important: { + type: DataTypes.BOOLEAN, + allowNull: false + }, + date: { + type: DataTypes.DATE + }, + }) + await queryInterface.createTable('users', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + username: { + type: DataTypes.STRING, + unique: true, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + }) + await queryInterface.addColumn('notes', 'user_id', { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.dropTable('notes') + await queryInterface.dropTable('users') + }, +} +``` + +Migraatiotiedostossa on [määriteltynä](https://sequelize.org/master/manual/migrations.html#migration-skeleton) funktiot up ja down joista ensimmäinen määrittelee miten tietokantaa tulee muuttaa migraatiota suorittaessa. Funktio down kertoo taas sen miten migraatio perutaan jos näin on tarvetta tehdä. + +Migraatiomme sisältää kolme operaatiota, ensimmäinen luo taulun notes, toinen taulun users ja kolmas lisää tauluun notes viiteavaimen muistiinpanon luojaan. Skeeman muutokset määritellään [queryInterface](https://sequelize.org/master/manual/query-interface.html)-olion metodeja kutsumalla. + +Migraatioiden määrittelyssä on oleellista muistaa, että toisin kuin modeleissa, sarakkeiden ja taulujen nimet kirjoitetaan snake case ‑muodossa: + +```js +await queryInterface.addColumn('notes', 'user_id', { // highlight-line + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, +}) +``` + +Migraatioissa siis taulujen sekä sarakkeiden nimet kirjoitetaan juuri niin kuin ne tietokantaan tulevat, kun taas modeleissa on käytössä Sequelizen oletusarvoinen camelCase-nimentä. + +Talletetaan migraation koodi tiedostoon migrations/20211209\_00\_initialize\_notes\_and\_users.js. Migraatiotiedostojen nimien tulee olla aakkosjärjestyksessä siten, että aiempi muutos on aina aakkosissa uudempaa muutosta edellä. Eräs hyvä tapa saada tämä järjestys aikaan on aloittaa migraatiotiedoston nimi päivämäärällä sekä järjestysnumerolla. + +Voisimme suorittaa migraatiot komentoriviltä käsin [Sequelizen komentorivityökalun](https://github.com/sequelize/cli) avulla. Päätämme kuitenkin suorittaa migraatiot ohjelmakoodista käsin [Umzug](https://github.com/sequelize/umzug)-kirjastoa käyttäen. Asennetaan kirjasto + +```js +npm install umzug +``` + +Muutetaan tietokantayhteyden muodostavaa tiedostoa utils/db.js seuraavasti: + +```js +const Sequelize = require('sequelize') +const { DATABASE_URL } = require('./config') +const { Umzug, SequelizeStorage } = require('umzug') // highlight-line + +const sequelize = new Sequelize(DATABASE_URL) + + // highlight-start +const runMigrations = async () => { + const migrator = new Umzug({ + migrations: { + glob: 'migrations/*.js', + }, + storage: new SequelizeStorage({ sequelize, tableName: 'migrations' }), + context: sequelize.getQueryInterface(), + logger: console, + }) + + const migrations = await migrator.up() + console.log('Migrations up to date', { + files: migrations.map((mig) => mig.name), + }) +} + // highlight-end + +const connectToDatabase = async () => { + try { + await sequelize.authenticate() + /* highlight-start */ + await runMigrations() + /* highlight-end */ + console.log('database connected') + } catch (err) { + console.log('connecting database failed') + console.log(err) + return process.exit(1) + } + + return null +} + +module.exports = { connectToDatabase, sequelize } +``` + +Migraatiot suorittava funktio runMigrations suoritetaan nyt joka kerta kun sovellus käynnistyessään avaa tietokantayhteyden. Sequelize pitää kirjaa siitä mitkä migraatiot on jo suoritettu, eli jos uusia migratioita ei ole, ei funktion runMigrations suorittaminen tee mitään. + +Aloitetaan nyt puhtaalta pöydältä ja poistetaan sovelluksesta kaikki olemassaolevat tietokantataulut: + +```sql +username => drop table notes; +username => drop table users; +username => \d +Did not find any relations. +``` + +Käynnistetään sovellus. Lokiin tulostuu migraatioiden statuksesta kertova viesti + +```bash +INSERT INTO "migrations" ("name") VALUES ($1) RETURNING "name"; +Migrations up to date { files: [ '20211209_00_initialize_notes_and_users.js' ] } +database connected +``` + +Jos käynnistämme sovelluksen uudelleen, lokistakin on pääteltävissä että migraatiota ei suoriteta. + +Sovelluksen tietokantaskeema näyttää nyt seuraavalta + +```sql +postgres=# \d + List of relations + Schema | Name | Type | Owner +--------+--------------+----------+---------------- + public | migrations | table | username + public | notes | table | username + public | notes_id_seq | sequence | username + public | users | table | username + public | users_id_seq | sequence | username +``` + +Sequelize on siis luonut taulun migrations, jonka avulla se pitää kirjaa suoritetuista migraatiosta. Taulun sisältö näyttää seuraavalta: + +```sql +postgres=# select * from migrations; + name +------------------------------------------- + 20211209_00_initialize_notes_and_users.js +``` + +Luodaan tietokantaan muutama käyttäjä sekä joukko muistiinpanoja, ja sen jälkeen olemme valmiina laajentamaan sovellusta. + +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy/part13-notes/tree/part13-6), branchissa part13-6. +### Admin-käyttäjä ja käyttäjien disablointi + +Haluamme siis lisätä tauluun users kaksi boolean-arvoista kenttää +- _admin_ kertoo onko käyttäjä admin +- _disabled_ taas kertoo sen onko käyttäjätunnus asetettu käyttökieltoon + +Luodaan tietokantaskeeman tekevä migraatio tiedostoon migrations/20211209\_01\_admin\_and\_disabled\_to\_users.js: + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.addColumn('users', 'admin', { + type: DataTypes.BOOLEAN, + default: false + }) + await queryInterface.addColumn('users', 'disabled', { + type: DataTypes.BOOLEAN, + default: false + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.removeColumn('users', 'admin') + await queryInterface.removeColumn('users', 'disabled') + }, +} +``` + +Tehdään vastaavat muutokset taulua users vastaavaan modeliin: + +```js +User.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + username: { + type: DataTypes.STRING, + unique: true, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + // highlight-start + admin: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + disabled: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + // highlight-end +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'user' +}) +``` + +Kun uusi migraatio suoritetaan koodin uudelleenkäynnistymisen yhteydessä, muuttuu skeema halutulla tavalla: + +```sql +username-> \d users + Table "public.users" + Column | Type | Collation | Nullable | Default +----------+------------------------+-----------+----------+----------------------------------- + id | integer | | not null | nextval('users_id_seq'::regclass) + username | character varying(255) | | not null | + name | character varying(255) | | not null | + admin | boolean | | | + disabled | boolean | | | +Indexes: + "users_pkey" PRIMARY KEY, btree (id) + "users_username_key" UNIQUE CONSTRAINT, btree (username) +Referenced by: + TABLE "notes" CONSTRAINT "notes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) +``` + +Laajennetaan nyt kontrollereita seuraavasti. Estetään kirjautuminen jos käyttäjän kentän disabled arvona on true: + +```js +loginRouter.post('/', async (request, response) => { + const body = request.body + + const user = await User.findOne({ + where: { + username: body.username + } + }) + + const passwordCorrect = body.password === 'salainen' + + if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) + } + +// highlight-start + if (user.disabled) { + return response.status(401).json({ + error: 'account disabled, please contact admin' + }) + } + // highlight-end + + const userForToken = { + username: user.username, + id: user.id, + } + + const token = jwt.sign(userForToken, process.env.SECRET) + + response + .status(200) + .send({ token, username: user.username, name: user.name }) +}) +``` + +Disabloidaan käyttäjän jakousa tunnus: + +```sql +username => update users set disabled=true where id=3; +UPDATE 1 +username => update users set admin=true where id=1; +UPDATE 1 +username => select * from users; + id | username | name | admin | disabled +----+----------+------------------+-------+---------- + 2 | lynx | Kalle Ilves | | + 3 | jakousa | Jami Kousa | f | t + 1 | mluukkai | Matti Luukkainen | t | +``` + +Ja varmistetaan että kirjautuminen ei enää onnistu: + +![](../../images/13/2.png) + +Tehdään vielä route, jonka avulla admin pystyy muuttamaan käyttäjän tunnuksen statusta: + +```js +const isAdmin = async (req, res, next) => { + const user = await User.findByPk(req.decodedToken.id) + if (!user.admin) { + return res.status(401).json({ error: 'operation not permitted' }) + } + next() +} + +router.put('/:username', tokenExtractor, isAdmin, async (req, res) => { + const user = await User.findOne({  + where: { + username: req.params.username + } + }) + + if (user) { + user.disabled = req.body.disabled + await user.save() + res.json(user) + } else { + res.status(404).end() + } +}) +``` + +Käytössä on kaksi middlewarea, ensin kutsuttu tokenExtractor on sama mitä myös muistiinpanoja luova route käyttää, eli se asettaa dekoodatun tokenin request-olion kenttään decodedToken. Toisena suoritettava middleware isAdmin tarkastaa onko käyttäjä admin, ja jos ei, pyynnön statukseksi asetetaan 401 ja annetaan asiaan kuuluva virheilmoitus. + +Huomaa, miten reitinkäsittelijään on siis ketjutettu kaksi middlewarea jotka molemmat suoritetaan ennen varsinaista reitinkäsittelijää. Middlewareja on mahdollista ketjuttaa pyyntöjen yhteyteen mielivaltainen määrä. + +Middleware tokenExtractor on nyt siirretty tiedostoon util/middleware.js koska sitä käytetään useasta paikasta. + +```js +const jwt = require('jsonwebtoken') +const { SECRET } = require('./config.js') + +const tokenExtractor = (req, res, next) => { + const authorization = req.get('authorization') + if (authorization && authorization.toLowerCase().startsWith('bearer ')) { + try { + req.decodedToken = jwt.verify(authorization.substring(7), SECRET) + } catch{ + return res.status(401).json({ error: 'token invalid' }) + } + } else { + return res.status(401).json({ error: 'token missing' }) + } + next() +} + +module.exports = { tokenExtractor } +``` + +Admin voi nyt enabloida jakousan tunnuksen tekemällä routeen /api/users/jakousa PUT-pyynnön, missä pyynnön mukana on seuraava data: + +```js +{ + "disabled": false +} +``` + +Kuten [osan 4 loppupuolella](/osa4/token_perustainen_kirjautuminen#token-perustaisen-kirjautumisen-ongelmat) todetaan, tässä toteuttamamme tapa käyttäjätunnusten disablointiin on ongelmallinen. Se onko tunnus disabloitu tarkastetaan ainoastaan kirjautumisen yhteydessä. Jos käyttäjällä on token hallussaan siinä vaiheessa kun tunnus disabloidaan, voi käyttäjä jatkaa saman tokenin käyttöä, sillä tokenille ei ole asetettu elinikää eikä sitä seikkaa, että käyttäjän tunnus on disabloitu tarkasteta muistiinpanojen luomisen yhteydessä. + +Ennen kuin jatkamme eteenpäin, tehdään sovellukselle npm-skripti, jonka avulla edellinen migraatio on mahdollista perua. Kaikki ei nimittäin mene aina ensimmäisellä kerralla oikein migraatioita kehitettäessä. + +Muutetaan tiedostoa util/db.js seuraavasti: + +```js +const Sequelize = require('sequelize') +const { DATABASE_URL } = require('./config') +const { Umzug, SequelizeStorage } = require('umzug') + +const sequelize = new Sequelize(DATABASE_URL, { + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, +}); + +const connectToDatabase = async () => { + try { + await sequelize.authenticate() + await runMigrations() + console.log('database connected') + } catch (err) { + console.log('connecting database failed') + return process.exit(1) + } + + return null +} + +// highlight-start +const migrationConf = { + migrations: { + glob: 'migrations/*.js', + }, + storage: new SequelizeStorage({ sequelize, tableName: 'migrations' }), + context: sequelize.getQueryInterface(), + logger: console, +} + +const runMigrations = async () => { + const migrator = new Umzug(migrationConf) + const migrations = await migrator.up() + console.log('Migrations up to date', { + files: migrations.map((mig) => mig.name), + }) +} + +const rollbackMigration = async () => { + await sequelize.authenticate() + const migrator = new Umzug(migrationConf) + await migrator.down() +} +// highlight-end + +/* highlight-start */ +module.exports = { connectToDatabase, sequelize, rollbackMigration } +/* highlight-end */ +``` + +Tehdään tiedosto util/rollback.js, jonka kautta npm-skripti pääsee suorittamaan määritellyn migraation peruvan funktion: + +```js +const { rollbackMigration } = require('./db') + +rollbackMigration() +``` + +ja itse skripti: + +```json +{ + "scripts": { + "dev": "nodemon index.js", + "migration:down": "node util/rollback.js" // highlight-line + }, +} +``` + +Voimme nyt siis perua edellisen migraation suorittamalla komentoriviltä _npm run migration:down_. + +Migraatiot suoritetaan automaattisesti kun ohjelma käynnistetään. Ohjelman kehitysvaiheessa saattaisi välillä olla tarkoituksenmukaisempaa poistaa migraatioiden automaattinen suoritus ja tehdä migraatiot komentoriviltä käsin. + +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy/part13-notes/tree/part13-7), branchissa part13-7. + +
    + +
    + +### Tehtävät 13.17-13.18. + +#### Tehtävä 13.17. + +Poista sovelluksesi tietokannasta kaikki taulut. + +Tee migraatio, joka asettaa tietokannan tämänhetkiseen tilaan. Luo created\_at ja updated\_at [aikaleimat](https://sequelize.org/master/manual/model-basics.html#timestamps) molemmille tauluille. Huomaa, että joudut luomaan ne migraatiossa itse. + +**HUOM:** muista poistaa koodistasi modelien skeemat synkronoivat komennot User.sync() ja Blog.sync() muuten migraatioiden suorittaminen ei onnistu. + +**HUOM2:** jos joudut poistamaan tauluja komentoriviltä (etkä siis tee poistoa migraation perumisen avulla), joudut poistamaan taulun migrations sisällön, jos haluat että ohjelmasi pystyy suorittamaan migraatiot uudelleen. + +#### Tehtävä 13.18. + +Laajenna sovellusta (migraation avulla) siten, että blogeille tulee kirjoitusvuosi, eli kenttä year joka on kokonaisluku, jonka suuruus on vähintään 1991 mutta ei suurempi kuin menossa oleva vuosi. Varmista että sovellus antaa asiaankuuluvan virheilmoituksen jos kirjoitusvuodelle yritetään antaa virheellinen arvo. + +
    + +
    + +### Monen suhde moneen ‑yhteydet + +Jatketaan sovelluksen laajentamista siten, että jokainen käyttäjä voidaan lisätä yhteen tai useampaan tiimiin. + +Koska yhteen tiimiin voi liittyä mielivaltainen määrä käyttäjiä, ja yksi käyttäjä voi liittyä mielivaltaiseen määrään tiimejä, on kysessä [many-to-many](https://sequelize.org/master/manual/assocs.html#many-to-many-relationships) eli monen-suhde-moneen tyyppinen yhteys, joka perinteisesti toteutetaan relaatiotietokannoissa liitostaulun avulla. + +Luodaan nyt tiimin sekä liitostaulun tarvitsema koodi. Tiedostoon 20211209_02_add_teams_and_memberships.js talletettava migraatio on seuraavassa: + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.createTable('teams', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + unique: true + }, + }) + await queryInterface.createTable('memberships', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + team_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'teams', key: 'id' }, + }, + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.dropTable('teams') + await queryInterface.dropTable('memberships') + }, +} +``` + +Modelit sisältävät lähes saman koodin kuin migraatio. Tiimin modeli models/team.js: + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class Team extends Model {} + +Team.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + unique: true + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'team' +}) + +module.exports = Team +``` + +Liitostaulun modeli models/membership.js: + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class Membership extends Model {} + +Membership.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + teamId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'teams', key: 'id' }, + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'membership' +}) + +module.exports = Membership +``` + +Olemme siis antaneet liitostaululle kuvaavan nimen, membership. Liitostauluille ei aina löydy yhtä osuvaa nimeä, tällöin liitostaulun nimi voidaan muodostaa yhdistelmänä liitettävien taulujen nimistä esim. user\_teams voisi sopia tilanteeseemme. + +Tiedostoon models/index.js tulee pieni lisäys, joka liittää metodin [belongsToMany](https://sequelize.org/docs/v6/core-concepts/assocs/#implementation-2) avulla tiimit ja käyttäjät toisiinsa myös koodin tasolla. + +```js +const Note = require('./note') +const User = require('./user') +// highlight-start +const Team = require('./team') +const Membership = require('./membership') +// highlight-end + +Note.belongsTo(User) +User.hasMany(Note) + +// highlight-start +User.belongsToMany(Team, { through: Membership }) +Team.belongsToMany(User, { through: Membership }) +// highlight-end + +module.exports = { + Note, User, Team, Membership // highlight-line +} +``` + +Huomaa eroavaisuus liitostaulun migraation ja modelin välillä viiteavainkenttien määrittelyssä. Migraatiossa kentät määritellään snake case ‑muodossa: + +```js +await queryInterface.createTable('memberships', { + // ... + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + team_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'teams', key: 'id' }, + } +}) +``` + +modelissa taas samat määritellään camel casena: + +```js +Membership.init({ + // ... + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + teamId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'teams', key: 'id' }, + }, + // ... +}) +``` + + +Luodaan nyt pqql-konsolista pari tiimiä sekä muutama jäsenyys: + +```js +insert into teams (name) values ('toska'); +insert into teams (name) values ('mosa climbers'); +insert into memberships (user_id, team_id) values (1, 1); +insert into memberships (user_id, team_id) values (1, 2); +insert into memberships (user_id, team_id) values (2, 1); +insert into memberships (user_id, team_id) values (3, 2); +``` + +Lisätään sitten kaikkien käyttäjien reittiin tieto käyttäjän joukkueista + +```js +router.get('/', async (req, res) => { + const users = await User.findAll({ + include: [ + { + model: Note, + attributes: { exclude: ['userId'] } + }, + // highlight-start + { + model: Team, + attributes: ['name', 'id'], + } + // highlight-end + ] + }) + res.json(users) +}) +``` + +Tarkkasilmäisimmät huomaavat, että konsoliin tulostuva kysely yhdistää nyt kolme taulua. + +Ratkaisu on aika hyvä, mutta siinä on eräs kauneusvirhe. Tuloksen mukana ovat myös liitostaulun rivin attribuutit vaikka emme niitä halua: + +![](../../images/13/3.png) + + +Dokumentaatiota tarkkaan lukemalla löytyy [ratkaisu](https://sequelize.org/master/manual/advanced-many-to-many.html#specifying-attributes-from-the-through-table): + +```js +router.get('/', async (req, res) => { + const users = await User.findAll({ + include: [ + { + model: Note, + attributes: { exclude: ['userId'] } + }, + { + model: Team, + attributes: ['name', 'id'], + // highlight-start + through: { + attributes: [] + } + // highlight-end + } + ] + }) + res.json(users) +}) +``` + +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy/part13-notes/tree/part13-8), branchissa part13-8. + +### Huomio Sequelizen model-olioiden ominaisuuksista + +Modeliemme määrittely sisälsi mm. seuraavat rivit: + +```js +User.hasMany(Note) +User.hasMany(Note) + +User.belongsToMany(Team, { through: Membership }) +Team.belongsToMany(User, { through: Membership }) +``` + +Näiden ansiosta Sequelize osaa tehdä kyselyt, jotka hakevat esim. käyttäjien kaikki muistiinpanot, tai joukkueen kaikki jäsenet. + +Määrittelyjen ansiosta pääsemme myös koodissa suoraan käsiksi esim. käyttäjän muistiinpanoihin. Seuraavassa haetaan käyttäjä, jonka id on 1 ja tulostetaan käyttäjään liittyvät muistiinpanot: + +```js +const user = await User.findByPk(1, { + include: { + model: Note + } +}) + +user.notes.forEach(note => { + console.log(note.content) +}) +``` + +Määrittely User.hasMany(Note) siis liittää user-olioille attribuutin notes, jonka kautta päästään käsiksi käyttäjän tekemiin muistiinpanoihin. Määrittely User.belongsToMany(Team, { through: Membership })) liittää vastaavasti käyttäjille attribuutin teams jota on myös mahdollisuus hyödyntää koodissa: + +```js +const user = await User.findByPk(1, { + include: { + model: Team + } +}) + +user.teams.forEach(team => { + console.log(team.name) +}) +``` + +Oletetaan että haluaisimme palauttaa yksittäisen käyttäjän reitiltä jsonin, joka sisältää käyttäjän nimen, käyttäjätunnuksen sekä luotujen muistiinpanojen määrän. Voisimme yrittää seuravaa: + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, { + include: { + model: Note + } + } + ) + + if (user) { + user.noteCount = user.notes.length // highlight-line + delete user.notes // highlight-line + res.json(user) + + } else { + res.status(404).end() + } +}) +``` + +Eli yritimme liittää Sequelizen palauttamaan olioon kentän noteCount sekä poistaa siitä muistiinpanot sisältävän kentän notes. Tämä lähestymistapa ei kuitenkaan toimi, sillä Sequelizen palauttamat oliot eivät ole normaaleja olioita, joihin uusien kenttien lisääminen toimii siten kuin haluamme. + +Parempi ratkaisu onkin luoda tietokannasta haetun datan perusteella kokonaan uusi olio: + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, { + include: { + model: Note + } + } + ) + + if (user) { + res.json({ + username: user.username, // highlight-line + name: user.name, // highlight-line + noteCount: user.notes.length // highlight-line + }) + + } else { + res.status(404).end() + } +}) +``` +### Monen suhde moneen uudelleen + +Tehdään sovellukseen vielä toinen monesta moneen ‑yhteys. Jokaiseen muistiinpanoon liittyy sen luonut käyttäjä viiteavaimen kautta. Päätetään, että sovellus tukee myös sitä, että muistiinpanoon voidaan liittää muitakin käyttäjiä, ja että käyttäjään voi liittyä mielivaltainen määrä jonkun muun käyttäjän tekemiä muistiinpanoja. Ajatellaan että nämä muistiinpanot ovat sellaisia, jotka käyttäjä on merkinnyt itselleen. + +Tehdään tilannetta varten liitostaulu user\_notes. Migraatio, joka tallennetaan tiedostoon 20211209\_03\_add\_user\_notes.js on suoraviivainen: + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.createTable('user_notes', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + note_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'notes', key: 'id' }, + }, + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.dropTable('user_notes') + }, +} +``` + +Myöskään modelissa ei ole mitään erikoista: + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class UserNotes extends Model {} + +UserNotes.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + noteId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'notes', key: 'id' }, + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'userNotes' +}) + +module.exports = UserNotes +``` + +Tiedostoon models/index.js sen sijaan tulee hienoinen muutos aiemmin näkemäämme: + +```js +const Note = require('./note') +const User = require('./user') +const Team = require('./team') +const Membership = require('./membership') +const UserNotes = require('./user_notes') // highlight-line + +Note.belongsTo(User) +User.hasMany(Note) + +User.belongsToMany(Team, { through: Membership }) +Team.belongsToMany(User, { through: Membership }) + +// highlight-start +User.belongsToMany(Note, { through: UserNotes, as: 'markedNotes' }) +Note.belongsToMany(User, { through: UserNotes, as: 'usersMarked' }) +// highlight-end + +module.exports = { + Note, User, Team, Membership, UserNotes +} +``` + +Käytössä on taas belongsToMany joka liittää käyttäjän muistiinpanoihin liitostaulua vastaavan modelin UserNotes kautta. Annamme kuitenkin tällä kertaa avainsanaa [as](https://sequelize.org/master/manual/advanced-many-to-many.html#aliases-and-custom-key-names) käyttäen muodostuvalle attribuutille aliasnimen, oletusarvoinen nimi (käyttäjillä notes) menisi päällekkäin sen aiemman merkityksen, eli käyttäjän luomien muistiinpanojen kanssa. + +Laajennetaan yksittäisen käyttäjän routea siten, että se palauttaa käyttäjän joukkueet, omat muistiinpanot sekä käyttäjään liitetyt muut muistiinpanot: + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, {  + attributes: { exclude: [''] } , + include:[{ + model: Note, + attributes: { exclude: ['userId'] } + }, + // highlight-start + { + model: Note, + as: 'markedNotes', + attributes: { exclude: ['userId']}, + through: { + attributes: [] + }, + }, + // highlight-end + { + model: Team, + attributes: ['name', 'id'], + through: { + attributes: [] + } + }, + ] + }) + + if (user) { + res.json(user) + } else { + res.status(404).end() + } +}) +``` + +Includen yhteydessä on nyt mainittava as-määrettä käyttäen äsken määrittelemämme aliasnimi markedNotes. + +Jotta ominaisuutta päästään testaamaan, luodaan tietokantaan hieman testidataa: + +```sql +insert into user_notes (user_id, note_id) values (2, 1); +insert into user_notes (user_id, note_id) values (2, 2); +``` + +Lopputulos on toimiva: + +![](../../images/13/5a.png) + +Entä jos haluaisimme, että käyttäjän merkitsemissä muistiinpanoissa olisi myös tieto muistiinpanon tekijästä? Tämä onnistuu lisäämällä liitetyille muistiinpanoille oma include: + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, {  + attributes: { exclude: [''] } , + include:[{ + model: Note, + attributes: { exclude: ['userId'] } + }, + { + model: Note, + as: 'marked_notes', + attributes: { exclude: ['userId']}, + through: { + attributes: [] + }, + // highlight-start + include: { + model: User, + attributes: ['name'] + } + // highlight-end + }, + { + model: Team, + attributes: ['name', 'id'], + through: { + attributes: [] + } + }, + ] + }) + + if (user) { + res.json(user) + } else { + res.status(404).end() + } +}) +``` + + +Lopputulos on halutun kaltainen: + +![](../../images/13/4.png) + +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy/part13-notes/tree/part13-9), branchissa part13-9. + + +
    + +
    + +### Tehtävät 13.19.-13.23. + +#### Tehtävä 13.19. + +Toteuta käyttäjille mahdollisuus lisätä järjestelmässä olevia blogeja lukulistalle. Lisättäessä lukulistalle, blogi on tilassa lukematon. Blogi voidaan merkata myöhemmin luetuksi. Toteuta lukulista liitostaulun avulla. Tee tietokantamuutokset migraatioiden avulla. + +Tässä tehtävässä lukulistalle lisäämisen ja listan näyttämisen ei tarvitse onnistua muuten kuin suoraan tietokantaa käyttämällä. + +#### Tehtävä 13.20. + +Lisätään nyt lukulistaa tukeva toiminnallisuus sovellukseen. + +Blogin lisääminen lukulistalle tapahtuu tekemällä HTTP POST reitille _/api/readinglists_, pyynnön mukana lähetetään blogin ja käyttäjän id: + +```js +{ + blog_id: 10, + user_id: 3 +} +``` + +Toteuta myös yksittäisen käyttäjän palauttava reitti _GET /api/users/:id_, joka palauttaa käyttäjän muiden tietojen lisäksi myös lukulistan, esim. seuraavassa muodossa: + +```js +{ + name: "Matti Luukkainen", + username: "mluukkai@iki.fi", + readings: [ + { + id: 3, + url: "https://google.com", + title: "Clean React", + author: "Dan Abramov", + likes: 34, + year: null, + }, + { + id: 4, + url: "https://google.com", + title: "Clean Code", + author: "Bob Martin", + likes: 5, + year: null, + } + ] +} +``` + +Tässä vaiheessa tietoa siitä onko blogi luettu vai ei, ei tarvitse olla saatavilla. + +#### Tehtävä 13.21. + +Laajenna yhden käyttäjän näkymää siten, että se kertoo jokaisesta lukulistan blogista myös sen, onko blogi luettu sekä vastaavaa liitostaulun riviä koskevan id:n. + +Tieto voi olla esim. seuraavassa muodossa: + +```js +{ + name: "Matti Luukkainen", + username: "mluukkai@iki.fi", + readings: [ + { + id: 3, + url: "https://google.com", + title: "Clean React", + author: "Dan Abramov", + likes: 34, + year: null, + readinglists: [ + { + read: false, + id: 2 + } + ] + }, + { + id: 4, + url: "https://google.com", + title: "Clean Code", + author: "Bob Martin", + likes: 5, + year: null, + readinglists: [ + { + read: false, + id: 3 + } + ] + } + ] +} +``` + +Huom: tapoja toteuttaa tämä toiminnallisuus on useita. [Tästä](https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship) lienee apua. + +#### Tehtävä 13.22. + +Tee sovellukseen mahdollisuus merkata lukulistalla oleva blogi luetuksi. Luetuksi merkkaaminen tapahtuu tekemällä pyyntö _PUT /api/readinglists/:id_, ja lähettämällä pyynnön mukana + +```js +{ read: true } +``` + +Käyttäjä voi merkata luetuksi ainoastaan omalla lukulistallaan olevia blogeja. Käyttäjä identifioidaan normaaliin tapaan pyynnön mukana olevasta tokenista. + +#### Tehtävä 13.23. + +Muuta yhden käyttäjän tiedot palauttavaa reittiä siten, että pyynnön mukana voidaan säädellä mitkä lukulistan blogeista palautetaan: + +- _GET /api/users/:id_ palauttaa koko lukulistan +- _GET /api/users/:id?read=true_ palauttaa luetut blogit +- _GET /api/users/:id?read=false_ palauttaa lukemattomat blogit + +
    + +
    + +### Loppuhuomioita + +Sovelluksemme alkaa olla vähintään kelvollisessa kunnossa. Ennen osan loppua tarkastellaan kuitenkin vielä muutamaa seikkaa. + +#### Eager vs lazy fetch + +Kun teemme kyselyt käyttäen include-määrettä: + +```js +User.findOne({ + include: { + model: Note + } +}) +``` + +tapahtuu niin sanottu [eager fetch](https://sequelize.org/master/manual/assocs.html#basics-of-queries-involving-associations) eli kaikki haettavaan käyttäjään liitoskyselyllä liitettävien taulujen rivit, esimerkin tapauksessa käyttäjän tekemät muistiinpanot, haetaan samalla tietokannasta. Tämä on usein se mitä haluamme, mutta on myös tilanteita joissa haluttaisiin tehdä ns. lazy fetch eli hakea vaikkapa käyttäjään liittyvät joukkueet ainoastaan jos niitä tarvitaan. + +Muutetaan nyt yksittäisen käyttäjän routea siten, että se hakee kannasta käyttäjän joukkueet ainoastaan jos pyynnölle on asetettu query parametri teams: + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, { + attributes: { exclude: [''] } , + include:[{ + model: note, + attributes: { exclude: ['userId'] } + }, + { + model: Note, + as: 'marked_notes', + attributes: { exclude: ['userId']}, + through: { + attributes: [] + }, + include: { + model: user, + attributes: ['name'] + } + }, + ] + }) + + if (!user) { + return res.status(404).end() + } + + // highlight-start + if (!user) { + return res.status(404).end() + } + + let teams = undefined + + if (req.query.teams) { + teams = await user.getTeams({ + attributes: ['name'], + joinTableAttributes: [] + }) + } + + res.json({ ...user.toJSON(), teams }) + // highlight-end +}) +``` + +Nyt siis User.findByPk-kysely ei hae joukkueita, vaan ne haetaan tarvittaessa tietokantariviä vastaavan olion user metodilla getTeams, jonka Sequelize on generoinut modelin oliolle automaattisesti. Vastaava get- ja muutamia muitakin hyödyllisiä metodeja [generoituu automaattisesti](https://sequelize.org/master/manual/assocs.html#special-methods-mixins-added-to-instances) kun tauluille määritellään Sequelizen tasolla assosiaatioita. + +#### Modelien ominaisuuksia + +On joitain tilanteita, missä emme oletusarvoisesti halua käsitellä kaikkia tietyn taulun rivejä. Eräs tällainen tapaus voisi olla se, että emme normaalisti haluasi näyttää sovelluksessamme niitä käyttäjiä joiden tunnus on suljettu (disabled). Tälläisessä tilanteessa voisimme määritellä modelille oletusarvoisen [scopen](https://sequelize.org/master/manual/scopes.html): + +```js +class User extends Model {} + +User.init({ + // kenttien määrittely +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'user', + // highlight-start + defaultScope: { + where: { + disabled: false + } + }, + // highlight-end +}) + +module.exports = User +``` + +Nyt funktiokutsun User.findAll() aiheuttamassa kyselyssä on seuraava where-ehto: + +``` +WHERE "user"."disabled" = false; +``` + +Modeleille on mahdollista määritellä myös muita scopeja: + +```js +User.init({ + // kenttien määrittely +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'user', + defaultScope: { + where: { + disabled: false + } + }, + // highlight-start + scopes: { + admin: { + where: { + admin: true + } + }, + disabled: { + where: { + disabled: true + } + }, + name(value) { + return { + where: { + name: { + [Op.iLike]: value + } + } + } + }, + } + // highlight-end +}) +``` + +Scopeja käytetään seuraavasti: + +```js +// kaikki adminit +const adminUsers = await User.scope('admin').findAll() + +// kaikki epäaktiiviset käyttäjät +const disabledUsers = await User.scope('disabled').findAll() + +// käyttäjät, joiden nimessä merkkijono jami +const jamiUsers = User.scope({ method: ['name', '%jami%'] }).findAll() +``` + +Scopeja on myös mahdollista ketjuttaa: + +```js +// adminit, joiden nimessä merkkijono jami +const jamiUsers = User.scope('admin', { method: ['name', '%jami%'] }).findAll() +``` + +Koska Sequelizen modelit ovat normaaleja [JavaScript-luokkia](https://sequelize.org/master/manual/model-basics.html#taking-advantage-of-models-being-classes), on niihin mahdollista lisätä uusia metodeja. + +Seuraavassa kaksi esimerkkiä: + +```js +const { Model, DataTypes, Op } = require('sequelize') // highlight-line + +const Note = require('./note') +const { sequelize } = require('../util/db') + +class User extends Model { + // highlight-start + async numberOfNotes() { + return (await this.getNotes()).length + } + // highlight-end + + // highlight-start + static async withNotes(limit){ + return await User.findAll({ + attributes: { + include: [[ sequelize.fn("COUNT", sequelize.col("notes.id")), "note_count" ]] + }, + include: [ + { + model: Note, + attributes: [] + }, + ], + group: ['user.id'], + having: sequelize.literal(`COUNT(notes.id) > ${limit}`) + }) + } + // highlight-end +} + +User.init({ + // ... +}) + +module.exports = User +``` + +Ensimmäinen metodeista numberOfNotes on instanssimetodi, eli sitä voidaan kutsua modelin instansseille: + +```js +const jami = await User.findOne({ name: 'Jami Kousa'}) +const cnt = await jami.numberOfNotes() +console.log(`Jami has created ${cnt} notes`) +``` + +Instanssimetodin sisällä avainsanalla this siis viitataan instanssiin itseensä: + +```js +async numberOfNotes() { + return (await this.getNotes()).length +} +``` + +Metodeista toinen, joka palauttaa ne käyttäjät, joilla on vähintään parametrin verran muistiinpanoja, on taas luokkametodi eli sitä kutsutaan suoraan modelille: + +```js +const users = await User.withNotes(2) +console.log(JSON.stringify(users, null, 2)) +users.forEach(u => { + console.log(u.name) +}) +``` + +#### Modelien ja migraatioiden toisteisuus + +Olemme huomanneet, että modelien ja migraatioiden koodi on hyvin toisteista. Esimerkiksi joukkueiden model + +```js +class Team extends Model {} + +Team.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + unique: true + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'team' +}) + +module.exports = Team +``` + +ja migraatio sisältävät paljon samaa + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.createTable('teams', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + unique: true + }, + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.dropTable('teams') + }, +} +``` + +Emmekö voisi optimoida koodia siten, että esim. model exporttaisi jaetut osat migraation käyttöön? + +Ongelman muodostaa kuitenkin se, että modelin määritelmä voi muuttua ajan myötä, esimerkiksi kenttä name voi muuttaa nimeä tai sen datatyyppi voi vaihtua. Migraatiot tulee pystyä suorittamaan milloin tahansa onnistuneesti alusta loppuun, ja jos migraatiot luottavat että modelilla on tietty sisältö, ei asia enää välttämättä pidä paikkaansa kuukauden tai vuoden kuluttua. Siispä migraatioiden koodin on syytä olla "copy pastesta" huolimatta täysin erillään modelien koodista. + +Eräs ratkaisu asiaan olisi Sequelizen [komentorivityökalun](https://sequelize.org/docs/v6/other-topics/migrations/#creating-the-first-model-and-migration) käyttö, joka luo sekä modelit että migratiotiedostot komentorivillä annettujen komentojen perusteella. Esim. seuraava komento loisi modelin User, jolla on attribuutteina name, username ja admin sekä tietokantataulun luomisen hoitavan migraation: + +```bash +npx sequelize-cli model:generate --name User --attributes name:string,username:string,admin:boolean +``` + +Komentoriviltä käsin voi myös suorittaa sekä rollbackata eli perua migraatioita. Komentorivin dokumentaatio on valitettavan ohkaista ja tällä kurssilla päätimmekin tehdä sekä modelit että migratiot käsin. Ratkaisu saattoi olla viisas tai sitten ei. + +
    + +
    + +### Tehtävä 13.24. + +#### Tehtävä 13.24. + +Grande finale: [osan 4 loppupuolella](/osa4/token_perustainen_kirjautuminen#token-perustaisen-kirjautumisen-ongelmat) oli maininta token-kirjautumiseen liittyvistä ongelmista: jos jonkin käyttäjän käyttöoikeus järjestelmään päätetään poistaa, voi käyttäjä edelleen käyttää hallussaan olevaa tokenia järjestemän käyttämiseen. + +Tavanomainen ratkaisu tähän on tallentaa backendin tietokantaan tieto jokaisesta asiakkaalle myönnetystä tokenista, ja tarkastaa jokaisen pyynnön yhteydessä onko käyttöoikeus edelleen voimassa. Tällöin tokenin voimassaolo voidaan tarvittaessa poistaa välittömästi. Tällaista ratkaisua kutsutaan usein palvelinpuolen sessioksi. + +Laajenna järjestelmää nyt siten, että käyttöoikeuden menettänyt käyttäjä ei pysty tekemään mitään kirjaantumista edellyttäviä toimenpiteitä. + +Tarvitset toteutukseen todennäköisesti ainakin seuraavat +- käyttäjien tauluun boolean-arvoisen sarakkeen, joka kertoo onko tunnus disabloitu + - riittää että tunnusten disablointi ja enablointi onnistuu suoraan tietokannasta +- taulun, joka muistaa aktiiviset sessiot + - sessio tallennetaan tauluun kun käyttäjä tekee kirjautumisen eli operaation POST /api/login + - session olemassaolo (ja validiteetti) tarkistetaan aina käyttäjän tehdessä kirjautumista edellyttävän operaation +- reitin, jonka avulla käyttäjä voi "kirjautua ulos" järjestelmästä, eli käytännössä poistaa tietokannasta aktiiviset sessiot, reitti voi olla esim DELETE /api/logout + +Huomaa, että kirjautumisen ei tule onnistua "vanhentuneella tokenilla", eli samalla tokenilla uloskirjautumisen jälkeen. + +Voit myös halutessasi käyttää jotain tarkoitukseen tehtyä npm-kirjastoa sessioiden hoitamiseen. + +Tee tämän tehtävän edellyttämät tietokantamuutokset migraatioiden avulla. + +### Tehtävien palautus ja suoritusmerkinnän pyytäminen + +Tämän osat palautetaan osista 0-7 poiketen [palautussovelluksessa](https://studies.cs.helsinki.fi/stats/courses/fs-psql) omaan kurssi-instanssiinsa. Huomaa, että tarvitset suoritusmerkintään osan kaikki tehtävät. + +Jos haluat suoritusmerkinnän, merkitse kurssi suoritetuksi: + +![Submissions](../../images/11/21.png) + +**Huomaa**, että suoritusmerkintää ei voida kirjata, ellet ole ilmoittautunut tätä osaa vastaavaan "kurssiin palaan", katso lisätietoja ilmoittautumisesta [täältä](/osa0/yleista#osat-ja-suorittaminen). + +
    diff --git a/src/content/13/zh/part13.md b/src/content/13/zh/part13.md new file mode 100644 index 00000000000..1b81419fdb4 --- /dev/null +++ b/src/content/13/zh/part13.md @@ -0,0 +1,18 @@ +--- +mainImage: ../../../images/part-13.svg +part: 13 +lang: zh +--- + +
    + + + 在本课程的前几节中,我们使用MongoDB来存储数据,这是一种所谓的NoSQL数据库。NoSQL数据库在10多年前变得非常普遍,当时互联网的扩展开始对使用老一代SQL查询语言的关系型数据库产生问题。 + + + 关系型数据库从那时起经历了一个新的开始。可扩展性方面的问题已经得到了部分解决,它们也采用了NoSQL数据库的一些特性。在这一节中,我们将探索使用关系型数据库的不同NodeJS应用,我们将重点使用数据库PostgreSQL,它是开源世界中的第一。 + + + 本章节的英文翻译是由[Aarni Pavlidi](https://github.com/aarnipavlidi)。 + +
    diff --git a/src/content/13/zh/part13a.md b/src/content/13/zh/part13a.md new file mode 100644 index 00000000000..2cc7ed8e8a5 --- /dev/null +++ b/src/content/13/zh/part13a.md @@ -0,0 +1,770 @@ +--- +mainImage: ../../../images/part-13.svg +part: 13 +letter: a +lang: zh +--- + +
    + + + 在本节中,我们将探讨使用关系型数据库的节点应用。在这一节中,我们将为第3-5节中熟悉的笔记应用建立一个使用关系数据库的节点后端。要完成这一部分,你需要对关系型数据库和SQL有合理的了解。有许多关于SQL数据库的在线课程,例如,[SQLbolt](https://sqlbolt.com/)和 + + [可汗学院的SQL介绍](https://www.khanacademy.org/computing/computer-programming/sql)。 + + + 这一部分有24个练习,你需要完成每个练习才能完成课程。练习是通过[提交系统](https://studies.cs.helsinki.fi/stats/courses/fs-psql)提交的,就像前几部分一样,但与第0至7部分不同的是,提交到一个不同的 "课程实例"。 + +### Advantages and disadvantages of document databases + + + 我们在本课程的所有前几部分都使用了MongoDB数据库。Mongo是一个[文档数据库](https://en.wikipedia.org/wiki/Document-oriented_database),它的一个最大特点是它是无模式的,也就是说,数据库对其集合中存储了什么样的数据只有非常有限的认识。数据库的模式只存在于程序代码中,它以一种特定的方式解释数据,例如,通过识别一些字段是对另一个集合中的对象的引用。 + + + 在第3和第4章节的应用实例中,数据库存储了笔记和用户。 + + +一个存储笔记的笔记集合如下所示:下面这样。 + +```js +[ + { + "_id": "600c0e410d10256466898a6c", + "content": "HTML is easy" + "date": 2021-01-23T11:53:37.292+00:00, + "important": false + "__v": 0 + }, + { + "_id": "600c0edde86c7264ace9bb78", + "content": "CSS is hard" + "date": 2021-01-23T11:56:13.912+00:00, + "important": true + "__v": 0 + }, +] +``` + + + 保存在users集合中的用户如下所示:下面这样。 + +```js +[ + { + "_id": "600c0e410d10256466883a6a", + "username": "mluukkai", + "name": "Matti Luukkainen", + "passwordHash" : "$2b$10$Df1yYJRiQuu3Sr4tUrk.SerVz1JKtBHlBOARfY0PBn/Uo7qr8Ocou", + "__v": 9, + notes: [ + "600c0edde86c7264ace9bb78", + "600c0e410d10256466898a6c" + ] + }, +] +``` + + + MongoDB确实知道存储实体的字段类型,但是它没有关于用户记录id指的是哪个实体集合的信息。MongoDB也不关心存储在集合中的实体有哪些字段。因此,MongoDB完全让程序员来确保正确的信息被存储在数据库中。 + + + 没有模式既有优点也有缺点。其中一个优点是模式无关性带来的灵活性:由于模式不需要在数据库级别上定义,所以在某些情况下,应用开发可能会更快,更容易,在任何情况下,定义和修改模式所需的努力都会减少。没有模式的问题与易错性有关:一切都由程序员决定。数据库本身没有办法检查其中的数据是否是诚实的,即是否所有的强制字段都有值,参考类型字段是否引用了一般正确类型的现有实体,等等。 + + + 本节重点讨论的关系型数据库,则在很大程度上依赖于模式的存在,与非模式型数据库相比,模式型数据库的优势和劣势几乎相反。 + + + 本课程的前几节之所以使用MongoDB,正是因为它的无模式特性,这使得对关系型数据库了解不多的人更容易使用该数据库。对于本课程的大部分用例,我个人会选择使用关系型数据库。 + +### Application database + + +对于我们的应用,我们需要一个关系型数据库。有很多选择,但我们将使用目前最流行的开源解决方案[PostgreSQL](https://www.postgresql.org/)。如果你愿意的话,你可以在你的机器上安装Postgres(该数据库通常被称为)。一个更简单的选择是将Postgres作为云服务,例如[ElephantSQL](https://www.elephantsql.com/)。你也可以利用课程[第12部分](/en/part12)中的课程,使用Docker在本地使用Postgres。 + + + 然而,我们将利用在Heroku云服务平台上为应用创建Postgres数据库的优势,这一点在第3和第4章节中已经很熟悉。 + + + 在本节的理论材料中,我们将从第3和第4节中建立的笔记存储应用的后端建立一个支持Postgres的版本。 + + + 现在让我们在Heroku应用中创建一个合适的目录,在其中添加一个数据库,并使用_heroku config_命令来获得连接字符串,这是连接数据库所需要的。 + +```bash +heroku create +# Returns an app-name for the app you just created in heroku. + +heroku addons:create heroku-postgresql:hobby-dev -a +heroku config -a +=== cryptic-everglades-76708 Config Vars +DATABASE_URL: postgres://:thepasswordishere@:5432/ +``` + + + 特别是在使用关系型数据库时,直接访问数据库也是非常必要的。有很多方法可以做到这一点,有几个不同的图形用户界面,如[pgAdmin](https://www.pgadmin.org/)。然而,我们将使用Postgres [psql](https://www.postgresql.org/docs/current/app-psql.html)命令行工具。 + + + 通过在Heroku服务器上运行_psql_命令可以访问数据库,如下所示(注意,命令参数取决于Heroku数据库的连接网址)。 + +```bash +heroku run psql -h -p 5432 -U -a +``` + + + 输入密码后,让我们试试主要的psql命令_d_,它告诉你数据库的内容。 + +```bash +Password for user : +psql (13.4 (Ubuntu 13.4-1.pgdg20.04+1)) +SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off) +Type "help" for help. + +postgres=# \d +Did not find any relations. +``` + + + 正如你可能猜到的,目前数据库中没有任何东西。 + + + 让我们为笔记创建一个表。 + +```sql +CREATE TABLE notes ( + id SERIAL PRIMARY KEY, + content text NOT NULL, + important boolean, + date time +); +``` + + + 几点:列id被定义为主键,这意味着该列的值对于表中的每一行都必须是唯一的,并且该值不能为空。这个列的类型被定义为[SERIAL](https://www.postgresql.org/docs/9.1/datatype-numeric.html#DATATYPE-SERIAL),这不是实际的类型,而是一个整数列的缩写,Postgres在创建行的时候会自动分配一个唯一的、增加的值。类型为text的名为content的列是这样定义的,它必须被分配一个值。 + + + 让我们从控制台看一下这个情况。首先,_d_命令,它告诉我们数据库中有哪些表。 + +```sql +postgres=# \d + List of relations + Schema | Name | Type | Owner +--------+--------------+----------+---------------- + public | notes | table | username + public | notes_id_seq | sequence | username +(2 rows) +``` + +In addition to the notes table, Postgres created a subtable called notes\_id\_seq, which keeps track of what value is assigned to the id column when creating the next note. + +With the command _\d notes_, we can see how the notes table is defined: + +```sql +postgres=# \d notes; + Table "public.notes" + Column | Type | Collation | Nullable | Default +-----------+------------------------+-----------+----------+----------------------------------- + id | integer | not null | nextval('notes_id_seq'::regclass) + content | text | | not null | + important | boolean | | | | + date | time without time zone | | | | +Indexes: + "notes_pkey" PRIMARY KEY, btree (id) +``` + + + 因此列id有一个默认值,它是通过调用Postgres的内部函数nextval获得的。 + + + 让我们在表中添加一些内容。 + +```sql +insert into notes (content, important) values ('Relational databases rule the world', true); +insert into notes (content, important) values ('MongoDB is webscale', false); +``` + + + 让我们看看创建的内容是什么样子的。 + +```sql +postgres=# select * from notes; + id | content | important | date +----+-------------------------------------+-----------+------ + 1 | relational databases rule the world | t | + 2 | MongoDB is webscale | f | +(2 rows) +``` + +If we try to store data in the database that is not according to the schema, it will not succeed. The value of a mandatory column cannot be missing: + +```sql +postgres=# insert into notes (important) values (true); +ERROR: null value in column "content" of relation "notes" violates not-null constraint +DETAIL: Failing row contains (9, null, t, null). +``` + +The column value cannot be of the wrong type: + +```sql +postgres=# insert into notes (content, important) values ('only valid data can be saved', 1); +ERROR: column "important" is of type boolean but expression is of type integer +LINE 1: ...tent, important) values ('only valid data can be saved', 1); ^ +``` + +Columns that don't exist in the schema are not accepted either: + +```sql +postgres=# insert into notes (content, important, value) values ('only valid data can be saved', true, 10); +ERROR: column "value" of relation "notes" does not exist +LINE 1: insert into notes (content, important, value) values ('only ... +``` + +Next it's time to move on to accessing the database from the application. + +### Node application using a relational database + +Let's start the application as usual with the npm init and install nodemon as a development dependency and also the following runtime dependencies: + +```bash +npm install express dotenv pg sequelize +``` + +Of these, the latter [sequelize](https://sequelize.org/master/) is the library through which we use Postgres. Sequelize is a so-called [Object relational mapping](https://en.wikipedia.org/wiki/Object%E2%80%93relational_mapping) (ORM) library that allows you to store JavaScript objects in a relational database without using the SQL language itself, similar to Mongoose that we used with MongoDB. + +Let's test that we can connect successfully. Create the file index.js and add the following content: + +```js +require('dotenv').config() +const { Sequelize } = require('sequelize') + +const sequelize = new Sequelize(process.env.DATABASE_URL, { + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, +}) + +const main = async () => { + try { + await sequelize.authenticate() + console.log('Connection has been established successfully.') + sequelize.close() + } catch (error) { + console.error('Unable to connect to the database:', error) + } +} + +main() +``` + +The database connect string, which is revealed by the _heroku config_ command should be stored in a .env file, the contents should be something like the following: + +```bash +$ cat .env +DATABASE_URL=postgres://:thepasswordishere@ec2-54-83-137-206.compute-1.amazonaws.com:5432/ +``` + +Let's test for a successful connection: + +```bash +$ node index.js +Executing (default): SELECT 1+1 AS result +Connection has been established successfully. +``` + +If and when the connection works, we can then run the first query. Let's modify the program as follows: + +```js +require('dotenv').config() +const { Sequelize, QueryTypes } = require('sequelize') // highlight-line + +const sequelize = new Sequelize(process.env.DATABASE_URL, { + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, +}); + +const main = async () => { + try { + await sequelize.authenticate() + // highlight-start + const notes = await sequelize.query("SELECT * FROM notes", { type: QueryTypes.SELECT }) + console.log(notes) + sequelize.close() + // highlight-end + } catch (error) { + console.error('Unable to connect to the database:', error) + } +} + +main() +``` + +Executing the application should print as follows: + +```js +Executing (default): SELECT * FROM notes +[ + { + id: 1, + content: 'Relational databases rule the world', + important: true, + date: null + }, + { + id: 2, + content: 'MongoDB is webscale', + important: false, + date: null + } +] +``` + +Even though Sequelize is an ORM library, which means there is little need to write SQL yourself when using it, we just used [direct SQL](https://sequelize.org/master/manual/raw-queries.html) with the sequelize method [query](https://sequelize.org/master/class/lib/sequelize.js~Sequelize.html#instance-method-query). + +Since everything seems to be working, let's change the application into a web application. + +```js +require('dotenv').config() +const { Sequelize, QueryTypes } = require('sequelize') +const express = require('express') // highlight-line +const app = express() // highlight-line + +const sequelize = new Sequelize(process.env.DATABASE_URL, { + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, +}); + +// highlight-start +app.get('/api/notes', async (req, res) => { + const notes = await sequelize.query("SELECT * FROM notes", { type: QueryTypes.SELECT }) + res.json(notes) +}) + +const PORT = process.env.PORT || 3001 +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +// highlight-end +``` + +The application seems to be working. However, let's now switch to using Sequelize instead of SQL as it is intended to be used. + +### Model + +When using Sequelize, each table in the database is represented by a [model](https://sequelize.org/master/manual/model-basics.html), which is effectively it's own JavaScript class. Let's now define the model Note corresponding to the table notes for the application by changing the code to the following format: + +```js +require('dotenv').config() +const { Sequelize, Model, DataTypes } = require('sequelize') // highlight-line +const express = require('express') +const app = express() + +const sequelize = new Sequelize(process.env.DATABASE_URL, { + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, +}); + +// highlight-start +class Note extends Model {} + +Note.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + content: { + type: DataTypes.TEXT, + allowNull: false + }, + important: { + type: DataTypes.BOOLEAN + }, + date: { + type: DataTypes.DATE + } +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'note' +}) +// highlight-end + +app.get('/api/notes', async (req, res) => { + const notes = await Note.findAll() // highlight-line + res.json(notes) +}) + +const PORT = process.env.PORT || 3001 +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +``` + +A few comments on the code. There is nothing very surprising about the Note definition of the model, each column has a type defined, as well as other properties if necessary, such as whether it is the main key of the table. The second parameter in the model definition contains the sequelize attribute as well as other configuration information. We also defined that the table does not have frequently used timestamp columns (created\_at and updated\_at). + +We also defined underscored: true, which means that table names are derived from model names as plural [snake case](https://en.wikipedia.org/wiki/Snake_case) versions. Practically this means that, if the name of the model, as in our case is "Note", then the name of the corresponding table is the plural of the name written in a small initial letter, i.e. notes. If, on the other hand, the name of the model would be "two-part", e.g. StudyGroup, then the name of the table would be study_groups. Instead of automatically inferring table names, Sequelize also allows explicitly defining table names. + +The same naming policy applies to columns as well. If we had defined that a note is associated with creationYear, i.e. information about the year it was created, we would define it in the model as follows: + +```js +Note.init({ + // ... + creationYear: { + type: DataTypes.INTEGER, + }, +}) +``` + +The name of the corresponding column in the database would be creation_year. In code, reference to the column is always in the same format as in the model, i.e. in "camel case" format. + +We have also defined modelName: 'note', the default "model name" would be capitalized Note. However we want to have a lowercase initial, it will make a few things a bit more convenient going forward. + +The database operation is easy to do using the [query interface](https://sequelize.org/master/manual/model-querying-basics.html) provided by models, the method [findAll](https://sequelize.org/master/class/lib/model.js~Model.html#static-method-findAll) works exactly as it is assumed by it's name to work: + +```js +app.get('/api/notes', async (req, res) => { + const notes = await Note.findAll() // highlight-line + res.json(notes) +}) +``` + +The console tells you that the method call Note.findAll() causes the following query: + +```js +Executing (default): SELECT "id", "content", "important", "date" FROM "notes" AS "note"; +``` + +Next, let's implement an endpoint for creating new notes: + +```js +app.use(express.json()) + +// ... + +app.post('/api/notes', async (req, res) => { + console.log(req.body) + const note = await Note.create(req.body) + res.json(note) +}) +``` + +Creating a new note is done by calling the model's Note method [create](https://sequelize.org/master/manual/model-querying-basics.html#simple-insert-queries) and passing as a parameter an object that defines the values of the columns. + +Instead of the create method, it [is also possible](https://sequelize.org/master/manual/model-instances.html#creating-an-instance) to save to a database using the [build](https://sequelize.org/api/v6/class/src/model.js~model#static-method-build) method first to create a Model-object from the desired data, and then calling the [save](https://sequelize.org/master/class/lib/model.js~Model.html#instance-method-save) method on it: + +```js +const note = Note.build(req.body) +await note.save() +``` + +Calling the build method does not save the object in the database yet, so it is still possible to edit the object before the actual save event: + +```js +const note = Note.build(req.body) +note.important = true // highlight-line +await note.save() +``` + +For the use case of the example code, the [create](https://sequelize.org/master/manual/model-querying-basics.html#simple-insert-queries) method is better suited, so let's stick to that. + +If the object being created is not valid, there is an error message as a result. For example, when trying to create a note without content, the operation fails, and the console reveals the reason to be SequelizeValidationError: notNull Violation Note.content cannot be null: + +``` +(node:39109) UnhandledPromiseRejectionWarning: SequelizeValidationError: notNull Violation: Note.content cannot be null + at InstanceValidator._validate (/Users/mluukkai/opetus/fs-psql/node_modules/sequelize/lib/instance-validator.js:78:13) + at processTicksAndRejections (internal/process/task_queues.js:93:5) +``` + +Let's add some simple error handling when adding a new note: + +```js +app.post('/api/notes', async (req, res) => { + try { + const note = await Note.create(req.body) + return res.json(note) + } catch(error) { + return res.status(400).json({ error }) + } +}) +``` + +
    + +
    + +### Tasks 13.1.-13.3. + +In the tasks of this section, we will build a blog application backend similar to the tasks in [section 4](/en/part4), which should be compatible with the frontend in [section 5](/en/part5) except for error handling. We will also add various features to the backend that the frontend in section 5 will not know how to use. + +#### Task 13.1. + +Create a GitHub repository for the application and create a new Heroku application for it, as well as a Postgres database. Make sure you are able to establish a connection to the application database. + +#### Task 13.2. + +On the command-line, create a blogs table for the application with the following columns +- id (unique, incrementing id) +- author (string) +- url (string that cannot be empty) +- title (string that cannot be empty) +- likes (integer with default value zero) + +Add at least two blogs to the database. + +Save the SQL-commands you used at the root of the application repository in the file called commands.sql + +#### Exercise 13.3. + +Create functionality in your application, which prints the blogs in the database using the command-line, e.g. as follows: + +```bash +$ node cli.js +Executing (default): SELECT * FROM blogs +Dan Abramov: 'On let vs const', 0 likes +Laurenz Albe: 'Gaps in sequences in PostgreSQL', 0 likes +``` + +
    + +
    + +### Creating database tables automatically + +Our application now has one unpleasant side, it assumes that a database with exactly the right schema exists, i.e. that the table notes has been created with the appropriate create table command. + +Since the program code is being stored on GitHub, it would make sense to also store the commands that create the database in the context of the program code, so that the database schema is definitely the same as what the program code is expecting. Sequelize is actually able to generate a schema automatically from the model definition by using the models method [sync](https://sequelize.org/master/manual/model-basics.html#model-synchronization). + +Let's now destroy the database from the console by entering the following command: + +``` +drop table notes; +``` + +The `\d` command reveals that the table has been lost from the database: + +``` +postgres=# \d +Did not find any relations. +``` + +The application no longer works. + +Let's add the following command to the application immediately after the model Note is defined: + +```js +Note.sync() +``` + +When the application starts, the following is printed on the console: + +``` +Executing (default): CREATE TABLE IF NOT EXISTS "notes" ("id" SERIAL , "content" TEXT NOT NULL, "important" BOOLEAN, "date" TIMESTAMP WITH TIME ZONE, PRIMARY KEY ("id")); +``` + +That is, when the application starts, the command CREATE TABLE IF NOT EXISTS "notes"... is executed which creates the table notes if it does not already exist. + +### Other operations + +Let's complete the application with a few more operations. + +Searching for a single note is possible with the method [findByPk](https://sequelize.org/master/manual/model-querying-finders.html), because it is retrieved based on the id of the primary key: + +```js +app.get('/api/notes/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + res.json(note) + } else { + res.status(404).end() + } +}) +``` + +Retrieving a single note causes the following SQL command: + +``` +Executing (default): SELECT "id", "content", "important", "date" FROM "notes" AS "note" WHERE "note". "id" = '1'; +``` + +If no note is found, the operation returns null, and in this case the relevant status code is given. + +Modifying the note is done as follows. Only the modification of the important field is supported, since the application's frontend does not need anything else: + +```js +app.put('/api/notes/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + note.important = req.body.important + await note.save() + res.json(note) + } else { + res.status(404).end() + } +}) +``` + +The object corresponding to the database row is retrieved from the database using the findByPk method, the object is modified and the result is saved by calling the save method of the object corresponding to the database row. + +The current code for the application is in its entirety on [GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-1), branch part13-1. + +### Printing the objects returned by Sequelize to the console + +The JavaScript programmer's most important tool is console.log, whose aggressive use gets even the worst bugs under control. Let's add console printing to the single note path: + + +```js +app.get('/api/notes/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + console.log(note) // highlight-line + res.json(note) + } else { + res.status(404).end() + } +}) +``` + +We can see that the end result is not exactly what we expected: + +```js +note { + dataValues: { + id: 1, + content: 'Notes are attached to a user', + important: true, + date: 2021-10-03T15:00:24.582Z, + }, + _previousDataValues: { + id: 1, + content: 'Notes are attached to a user', + important: true, + date: 2021-10-03T15:00:24.582Z, + }, + _changed: Set(0) {}, + _options: { + isNewRecord: false, + _schema: null, + _schemaDelimiter: '', + raw: true, + attributes: [ 'id', 'content', 'important', 'date' ] + }, + isNewRecord: false +} +``` + +In addition to the note information, all sorts of other things are printed on the console. We can reach the desired result by calling the model-object method [toJSON](https://sequelize.org/master/class/lib/model.js~Model.html#instance-method-toJSON): + + +```js +app.get('/api/notes/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + console.log(note.toJSON()) // highlight-line + res.json(note) + } else { + res.status(404).end() + } +}) +``` + +Now the result is exactly what we want. + +```js +{ id: 1, + content: 'MongoDB is webscale', + important: false, + date: 2021-10-09T13:52:58.693Z } +``` + +In the case of a collection of objects, the method toJSON does not work directly, the method must be called separately for each object in the collection: + +```js +router.get('/', async (req, res) => { + const notes = await Note.findAll() + + console.log(notes.map(n=>n.toJSON())) // highlight-line + + res.json(notes) +}) +``` + +The print looks like the following: + +```js +[ { id: 1, + content: 'MongoDB is webscale', + important: false, + date: 2021-10-09T13:52:58.693Z }, + { id: 2, + content: 'Relational databases rule the world', + important: true, + date: 2021-10-09T13:53:10.710Z } ] +``` + +However, perhaps a better solution is to turn the collection into JSON for printing by using the method [JSON.stringify](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify): + +```js +router.get('/', async (req, res) => { + const notes = await Note.findAll() + + console.log(JSON.stringify(notes)) // highlight-line + + res.json(notes) +}) +``` + +This way is better especially if the objects in the collection contain other objects. It is also often useful to format the objects on the screen in a slightly more reader-friendly format. This can be done with the following command: + +```json +console.log(JSON.stringify(notes, null, 2)) +``` + +The print looks like the following: + +```js +[ + { + "id": 1, + "content": "MongoDB is webscale", + "important": false, + "date": "2021-10-09T13:52:58.693Z" + }, + { + "id": 2, + "content": "Relational databases rule the world", + "important": true, + "date": "2021-10-09T13:53:10.710Z" + } +] +``` + +
    + +
    + +### Task 13.4. + +#### Task 13.4. + +Transform your application into a web application that supports the following operations + +- GET api/blogs (list all blogs) +- POST api/blogs (add a new blog) +- DELETE api/blogs/:id (delete a blog) + +
    diff --git a/src/content/13/zh/part13b.md b/src/content/13/zh/part13b.md new file mode 100644 index 00000000000..9ada95e5d2e --- /dev/null +++ b/src/content/13/zh/part13b.md @@ -0,0 +1,1089 @@ +--- +mainImage: ../../../images/part-13.svg +part: 13 +letter: b +lang: zh +--- + +
    + +### Application structuring + + + 到目前为止,我们已经在同一个文件中写了所有的代码。现在让我们把应用的结构设计得更好一些。让我们创建以下目录结构和文件。 + +``` +index.js +util + config.js + db.js +models + index.js + note.js +controllers + notes.js +``` + + + 这些文件的内容如下。文件util/config.js负责处理环境变量。 + +```js +require('dotenv').config() + +module.exports = { + DATABASE_URL: process.env.DATABASE_URL, + PORT: process.env.PORT || 3001, +} +``` + + + 文件index.js的作用是配置和启动应用。 + +```js +const express = require('express') +const app = express() + +const { PORT } = require('./util/config') +const { connectToDatabase } = require('./util/db') + +const notesRouter = require('./controllers/notes') + +app.use(express.json()) + +app.use('/api/notes', notesRouter) + +const start = async () => { + await connectToDatabase() + app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) + }) +} + +start() +``` + + + 启动应用与我们之前看到的略有不同,因为我们要确保在实际启动前成功建立数据库连接。 + + + 文件util/db.js包含初始化数据库的代码。 + +```js +const Sequelize = require('sequelize') +const { DATABASE_URL } = require('./config') + +const sequelize = new Sequelize(DATABASE_URL, { + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, +}); + +const connectToDatabase = async () => { + try { + await sequelize.authenticate() + console.log('connected to the database') + } catch (err) { + console.log('failed to connect to the database') + return process.exit(1) + } + + return null +} + +module.exports = { connectToDatabase, sequelize } +``` + + + 与要存储的表相对应的模型中的注释被保存在文件models/note.js中。 + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class Note extends Model {} + +Note.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + content: { + type: DataTypes.TEXT, + allowNull: false + }, + important: { + type: DataTypes.BOOLEAN + }, + date: { + type: DataTypes.DATE + } +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'note' +}) + +module.exports = Note +``` + + + 文件models/index.js在这一点上几乎是无用的,因为应用中只有一个模型。当我们开始向应用添加其他模型时,该文件将变得更加有用,因为它将消除在应用的其他部分导入定义单个模型的文件的需要。 + +```js +const Note = require('./note') + +Note.sync() + +module.exports = { + Note +} +``` + + + 与笔记相关的路由处理可以在文件controllers/notes.js中找到。 + +```js +const router = require('express').Router() + +const { Note } = require('../models') + +router.get('/', async (req, res) => { + const notes = await Note.findAll() + res.json(notes) +}) + +router.post('/', async (req, res) => { + try { + const note = await Note.create(req.body) + res.json(note) + } catch(error) { + return res.status(400).json({ error }) + } +}) + +router.get('/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + res.json(note) + } else { + res.status(404).end() + } +}) + +router.delete('/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + await note.destroy() + } + res.status(204).end() +}) + +router.put('/:id', async (req, res) => { + const note = await Note.findByPk(req.params.id) + if (note) { + note.important = req.body.important + await note.save() + res.json(note) + } else { + res.status(404).end() + } +}) + +module.exports = router +``` + + + 应用的结构现在很好。然而,我们注意到,处理单个笔记的路由处理程序包含了一些重复的代码,因为它们都是以搜索要处理的笔记的行开始的。 + +```js +const note = await Note.findByPk(req.params.id) +``` + + + 让我们把它重构为我们自己的中间件并在路由处理程序中实现它。 + +```js +const noteFinder = async (req, res, next) => { + req.note = await Note.findByPk(req.params.id) + next() +} + +router.get('/:id', noteFinder, async (req, res) => { + if (req.note) { + res.json(req.note) + } else { + res.status(404).end() + } +}) + +router.delete('/:id', noteFinder, async (req, res) => { + if (req.note) { + await req.note.destroy() + } + res.status(204).end() +}) + +router.put('/:id', noteFinder, async (req, res) => { + if (req.note) { + req.note.important = req.body.important + await req.note.save() + res.json(req.note) + } else { + res.status(404).end() + } +}) +``` + + + 路由处理程序现在接收三个参数,第一个是定义路由的字符串,第二个是我们之前定义的中间件noteFinder,它从数据库中检索笔记并把它放在req对象的note属性中。少量的复制粘贴被消除了,我们很满意! + + + 该应用的当前代码全部在[GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-2),分支part13-2。 + +
    + +
    + +### Tasks 13.5.-13.7. + +#### Task 13.5. + + + 改变你的应用的结构,以符合上面的例子,或遵循其他类似的明确约定。 + +#### Task 13.6. + + + 另外,在应用中实现对改变博客的喜欢数的支持,即,操作 + + + _PUT /api/blogs/:id_ (修改一个博客的喜欢数) + + + 更新的喜欢数将与请求一起被转达。 + +```js +{ + likes: 3 +} +``` + +#### Task 13.7. + + + 在中间件中集中处理应用的错误,如[第3章节](/en/part3/saving_data_to_mongo_db#moving-error-handling-into-middleware)。你也可以像我们在[第4章节](/en/part4/testing_the_backend#eliminating-the-try-catch)中那样,启用中间件[express-async-errors](https://github.com/davidbanham/express-async-errors)。 + + +在错误信息的上下文中返回的数据并不十分重要。 + + + 在这一点上,需要应用处理错误的情况是创建一个新的博客和改变一个博客上的喜欢数。确保错误处理程序能适当地处理这两种情况。 + +
    + +
    + +### User management + + + 接下来,让我们为应用添加一个数据库表users,应用的用户将被存储在这里。此外,我们将添加创建用户和基于令牌的登录的功能,正如我们在[第4章节](/en/part4/token_authentication)中实现的那样。为了简单起见,我们将调整实现,使所有用户都有相同的密码secret。 + + + 在文件models/user.js中定义用户的模型是很简单的 + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class User extends Model {} + +User.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + username: { + type: DataTypes.STRING, + unique: true, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'user' +}) + +module.exports = User +``` + + + 用户名字段被设置为唯一。用户名基本上可以作为表的主键使用。然而,我们决定将主键创建为一个独立的字段,其值为整数id。 + + + 文件models/index.js略有扩展。 + +```js +const Note = require('./note') +const User = require('./user') // highlight-line + +User.sync() // highlight-line + +module.exports = { + Note, User // highlight-line +} +``` + + +负责在controllers/users.js文件中创建新用户并显示所有用户的路由处理程序并不包含任何戏剧性的内容。 + +```js +const router = require('express').Router() + +const { User } = require('../models') + +router.get('/', async (req, res) => { + const users = await User.findAll() + res.json(users) +}) + +router.post('/', async (req, res) => { + try { + const user = await User.create(req.body) + res.json(user) + } catch(error) { + return res.status(400).json({ error }) + } +}) + +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id) + if (user) { + res.json(user) + } else { + res.status(404).end() + } +}) + +module.exports = router +``` + + + 处理登录的路由处理程序(文件controllers/login.js)如下。 + +```js +const jwt = require('jsonwebtoken') +const router = require('express').Router() + +const { SECRET } = require('../util/config') +const User = require('../models/user') + +router.post('/', async (request, response) => { + const body = request.body + + const user = await User.findOne({ + where: { + username: body.username + } + }) + + const passwordCorrect = body.password === 'secret' + + if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) + } + + const userForToken = { + username: user.username, + id: user.id, + } + + const token = jwt.sign(userForToken, SECRET) + + response + .status(200) + .send({ token, username: user.username, name: user.name }) +}) + +module.exports = router +``` + + + POST请求将伴随着一个用户名和一个密码。首先,使用[findOne](https://sequelize.org/master/manual/model-querying-finders.html#-code-findone--code-)方法的User模型从数据库中获取与用户名对应的对象。 + +```js +const user = await User.findOne({ + where: { + username: body.username + } +}) +``` + + + 从控制台,我们可以看到SQL语句与方法的调用相对应 + +```sql +SELECT "id", "username", "name" +FROM "users" AS "User" +WHERE "User". "username" = 'mluukkai'; +``` + + + 如果找到了用户并且密码正确(即所有用户的_secret_),在响应中会返回一个包含用户信息的jsonwebtoken。要做到这一点,我们要安装以下依赖关系 + +```js +npm install jsonwebtoken +``` + + + 文件index.js略有扩展 + +```js +const notesRouter = require('./controllers/notes') +const usersRouter = require('./controllers/users') +const loginRouter = require('./controllers/login') + +app.use(express.json()) + +app.use('/api/notes', notesRouter) +app.use('/api/users', usersRouter) +app.use('/api/login', loginRouter) +``` + + + 该应用的当前代码全部在[GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-3),分支part13-3。 + +### Connection between the tables + + + 用户现在可以被添加到应用,用户可以登录,但这本身这还不是一个非常有用的功能。我们想增加这样的功能:只有登录的用户才能添加注释,而且每个注释都与创建它的用户相关联。要做到这一点,我们需要在notes表中添加一个foreign key。 + + + 当使用Sequelize时,一个外键可以通过修改models/index.js文件来定义,如下所示 + +```js +const Note = require('./note') +const User = require('./user') + +// highlight-start +User.hasMany(Note) +Note.belongsTo(User) + +Note.sync({ alter: true }) +User.sync({ alter: true }) +// highlight-end + +module.exports = { + Note, User +} +``` + + + 所以这就是我们如何[定义](https://sequelize.org/master/manual/assocs.html#one-to-many-relationships)在usersnotes条目之间存在一个_一对多_的关系连接。我们还改变了sync调用的选项,以便数据库中的表与模型定义的变化相匹配。从控制台看,数据库模式如下。 + +```js +postgres=# \d users + Table "public.users" + Column | Type | Collation | Nullable | Default +----------+------------------------+-----------+----------+----------------------------------- + id | integer | not null | nextval('users_id_seq'::regclass) + username | character varying(255) | | not null | + name | character varying(255) | | not null | +Indexes: + "users_pkey" PRIMARY KEY, btree (id) +Referenced by: + TABLE "notes" CONSTRAINT "notes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE SET NULL + +postgres=# \d notes + Table "public.notes" + Column | Type | Collation | Nullable | Default +-----------+--------------------------+-----------+----------+----------------------------------- + id | integer | not null | nextval('notes_id_seq'::regclass) + content | text | | not null | + important | boolean | | | | + date | timestamp with time zone | | | | + user_id | integer | | | | +Indexes: + "notes_pkey" PRIMARY KEY, btree (id) +Foreign-key constraints: + "notes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE SET NULL +``` + + + 外键user_id已经在notes表中创建,它指向users表中的行。 + + + 现在让我们把每个插入的新笔记都与一个用户相关联。在我们做适当的实现之前(我们将笔记与登录用户的token联系起来),让我们用硬编码将笔记附在数据库中找到的第一个用户身上。 + +```js + +router.post('/', async (req, res) => { + try { + // highlight-start + const user = await User.findOne() + const note = await Note.create({...req.body, userId: user.id}) + // highlight-end + res.json(note) + } catch(error) { + return res.status(400).json({ error }) + } +}) +``` + + + 注意现在在数据库级别的笔记中如何有一个user\_id列。每个数据库行中的相应对象是通过Sequelize's的命名惯例来提及的,而不是源代码中输入的骆驼字母(userId)。 + + + 制作一个连接查询是非常容易的。让我们改变返回所有用户的路径,以便每个用户的注释也被显示出来。 + +```js +router.get('/', async (req, res) => { + // highlight-start + const users = await User.findAll({ + include: { + model: Note + } + }) + // highlight-end + res.json(users) +}) +``` + + + 所以连接查询是使用[include](https://sequelize.org/master/manual/assocs.html#eager-loading-example)选项作为查询参数完成的。 + + + 从查询产生的SQL语句在控制台中可以看到。 + +``` +SELECT "User". "id", "User". "username", "User". "name", "Notes". "id" AS "Notes.id", "Notes". "content" AS "Notes.content", "Notes". "important" AS "Notes.important", "Notes". "date" AS "Notes.date", "Notes". "user_id" AS "Notes.UserId" +FROM "users" AS "User" LEFT OUTER JOIN "notes" AS "Notes" ON "User". "id" = "Notes". "user_id"; +``` + + + 最终结果也如你所料 + +![](../../images/13/1.png) + +### Proper insertion of notes + + + 让我们改变笔记的插入,使其与[第4章节](/en/part4)中的工作相同,即只有当与创建相对应的请求伴随着来自登录的有效令牌时,笔记的创建才能成功。然后,该笔记被存储在由令牌识别的用户创建的笔记列表中。 + +```js +// highlight-start +const tokenExtractor = (req, res, next) => { + const authorization = req.get('authorization') + if (authorization && authorization.toLowerCase().startsWith('bearer ')) { + try { + req.decodedToken = jwt.verify(authorization.substring(7), SECRET) + } catch{ + res.status(401).json({ error: 'token invalid' }) + } + } else { + res.status(401).json({ error: 'token missing' }) + } + next() +} +// highlight-end + +router.post('/', tokenExtractor, async (req, res) => { + try { + // highlight-start + const user = await User.findByPk(req.decodedToken.id) + const note = await Note.create({...req.body, userId: user.id, date: new Date()}) + // highlight-end + res.json(note) + } catch(error) { + return res.status(400).json({ error }) + } +}) +``` + + + 令牌从请求头信息中获取,经过解码并由tokenExtractor中间件放入req对象。当创建一个注释时,也会给出一个date字段,表明它的创建时间。 + +### Fine-tuning + + + 我们的后端目前的工作方式与同一应用的第四章节版本几乎相同,除了错误处理。在我们对后端进行一些扩展之前,让我们稍微改变一下检索所有笔记和所有用户的路线。 + + + 我们将在每个笔记中添加关于添加它的用户的信息。 + +```js +router.get('/', async (req, res) => { + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: User, + attributes: ['name'] + } + }) + res.json(notes) +}) +``` + + + 我们还[限制](https://sequelize.org/master/manual/model-querying-basics.html#specifying-attributes-for-select-queries)了我们想要的字段的值。对于每个笔记,我们返回所有的字段,包括与该笔记相关的用户的名字,但不包括userId。 + + + 让我们对检索所有用户的路由做一个类似的改变,从与用户相关的笔记中删除不必要的字段userId。 + +```js +router.get('/', async (req, res) => { + const users = await User.findAll({ + include: { + model: Note, + attributes: { exclude: ['userId'] } // highlight-line + } + }) + res.json(users) +}) +``` + + + 该应用的当前代码全部在[GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-4),分支part13-4。 + +### Attention to the definition of the models + + + 最敏锐的人会注意到,尽管增加了user_id列,我们并没有对定义笔记的模型进行修改,但我们仍然可以在笔记对象中添加用户。 + +```js +const user = await User.findByPk(req.decodedToken.id) +const note = await Note.create({ ...req.body, userId: user.id, date: new Date() }) +``` + + + 原因是我们在文件models/index.js中具体说明了用户和笔记之间存在一对多的联系。 + +```js +const Note = require('./note') +const User = require('./user') + +User.hasMany(Note) +Note.belongsTo(User) + +// ... +``` + + + Sequelize将自动在Note模型上创建一个名为userId的属性,当被引用时,可以访问数据库的user_id列。 + + + 请记住,我们也可以使用[build](https://sequelize.org/api/v6/class/src/model.js~model#static-method-build)方法创建一个笔记,如下所示。 + +```js +const user = await User.findByPk(req.decodedToken.id) + +// create a note without saving it yet +const note = Note.build({ ...req.body, date: new Date() }) + // put the user id in the userId property of the created note +note.userId = user.id +// store the note object in the database +await note.save() +``` + + + 这就是我们明确看到userId是笔记对象的一个属性。 + + + 我们可以按以下方式定义模型,以得到同样的结果。 + +```js +Note.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + content: { + type: DataTypes.TEXT, + allowNull: false + }, + important: { + type: DataTypes.BOOLEAN + }, + date: { + type: DataTypes.DATE + }, + // highlight-start + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + } + // highlight-end +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'note' +}) + +module.exports = Note +``` + + + 像上面那样在模型的类层次上进行定义通常是不必要的 + +```js +User.hasMany(Note) +Note.belongsTo(User) +``` + + +相反,我们可以用这个方法来实现同样的效果。使用这两种方法中的一种是必要的,否则Sequelize不知道如何在代码级别上将表相互连接。 + +
    + +
    + +### Tasks 13.8.-13.11. + +#### Task 13.8. + + + 在应用中添加对用户的支持。除了ID之外,用户还有以下字段。 + + + - 名称(字符串,不能为空) + + - 用户名(字符串,不能为空) + + + 与材料中不同,现在不要阻止Sequelize为用户创建[时间戳](https://sequelize.org/master/manual/model-basics.html#timestamps) created/atupdated/at。 + + +所有用户都可以拥有与材料相同的密码。你也可以选择像[第4章节](/en/part4/user_administration)那样正确实现密码。 + + + 实现以下路线 + + + - _POST api/users_ (添加一个新的用户) + + - _GET api/users_ (列出所有用户) + + - _PUT api/users/:username_ (改变一个用户名,记住参数不是id而是用户名) + + + 确保在创建新用户和更改用户名时,Sequelize自动设置的时间戳created\_atupdated\_at能正确工作。 + +#### Exercise 13.9. + + + Sequelize为模型字段提供了一组预定义的[验证](https://sequelize.org/master/manual/validations-and-constraints.html),它在将对象存储到数据库之前执行了这些验证。 + + +我们决定改变用户创建策略,以便只有有效的电子邮件地址才能作为用户名有效。实施验证,在创建用户的过程中验证这个问题。 + + + 修改错误处理中间件,以提供一个更具描述性的错误信息的情况(例如,使用Sequelize错误信息),例如。 + +```js +{ + "error": [ + "Validation isEmail on username failed" + ] +} +``` + +#### Exercise 13.10. + + + 扩展应用,以便将由令牌识别的当前登录用户与每个添加的博客相联系。要做到这一点,你还需要实现一个登录端点_POST /api/login_,它返回令牌。 + +#### Exercise 13.11. + + + 让删除博客只对添加博客的用户有效。 + +#### Task 13.12. + + + 修改检索所有博客和所有用户的路由,使每个博客显示添加它的用户,每个用户显示他们所添加的博客。 + +
    + +
    + +### More queries + + + 到目前为止,我们的应用在查询方面非常简单,查询要么使用[findByPk](https://sequelize.org/master/class/lib/model.js~Model.html#static-method-findByPk)方法根据主键搜索单行,要么使用[findAll](https://sequelize.org/master/class/lib/model.js~Model.html#static-method-findAll)方法搜索表中的所有行。这些对于第5节中的应用的前端来说已经足够了,但是让我们扩展后端,这样我们也可以练习做稍微复杂的查询。 + + + 我们首先来实现只检索重要或不重要的笔记的可能性。让我们用[查询参数](http://expressjs.com/en/5x/api.html#req.query) important来实现。 + +```js +router.get('/', async (req, res) => { + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: user, + attributes: ['name'] + }, + // highlight-start + where: { + important: req.query.important === "true" + } + // highlight-end + }) + res.json(notes) +}) +``` + + + 现在后端可以通过请求http://localhost:3001/api/notes?important=true 来检索重要的笔记,通过请求http://localhost:3001/api/notes?important=false 来检索非重要的笔记。 + + + 由Sequelize生成的SQL查询包含一个WHERE子句,过滤通常会被返回的记录。 + +```sql +SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id" +WHERE "note". "important" = true; +``` + + + 不幸的是,如果请求对笔记是否重要不感兴趣,也就是说,如果请求是向http://localhost:3001/api/notes,那么这个实现将无法工作。更正可以通过几种方式进行。其中一种,但也许不是最好的方式,修正的方式如下。 + +```js +const { Op } = require('sequelize') + +router.get('/', async (req, res) => { + // highlight-start + let important = { + [Op.in]: [true, false] + } + + if ( req.query.important ) { + important = req.query.important === "true" + } + // highlight-end + + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: user, + attributes: ['name'] + }, + where: { + important // highlight-line + } + }) + res.json(notes) +}) +``` + + + 重要对象现在存储了查询条件。默认的查询条件是 + +```js +where: { + important: { + [Op.in]: [true, false] + } +} +``` + + + 即important列可以是truefalse,使用许多Sequelize操作符之一[Op.in](https://sequelize.org/master/manual/model-querying-basics.html#operators)。如果指定了查询参数req.query.important,则查询会变成两种形式之一 + +```js +where: { + important: true +} +``` + + + 或 + +```js +where: { + important: false +} +``` + + +取决于查询参数的值。 + + + 该功能可以进一步扩展,允许用户在检索笔记时指定一个必要的关键词,例如,对http://localhost:3001/api/notes?search=database 的请求将返回所有提到database的笔记,或者对http://localhost:3001/api/notes?search=javascript&important=true 的请求将返回所有标记为重要的笔记并提到javascript。实现方法如下 + +```js +router.get('/', async (req, res) => { + let important = { + [Op.in]: [true, false] + } + + if ( req.query.important ) { + important = req.query.important === "true" + } + + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: user, + attributes: ['name'] + }, + where: { + important, + // highlight-start + content: { + [Op.substring]: req.query.search ? req.query.search : '' + } + // highlight-end + } + }) + + res.json(notes) +}) +``` + + + Sequelize's [Op.substring](https://sequelize.org/master/manual/model-querying-basics.html#operators) 使用SQL中的LIKE关键字生成我们想要的查询。例如,如果我们对http://localhost:3001/api/notes?search=database&important=true,我们会看到它生成的SQL查询与我们期望的完全一样。 + +```sql +SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id" +WHERE "note". "important" = true AND "note". "content" LIKE '%database%'; +``` + + + 在我们的应用中仍然有一个美丽的缺陷,我们看到如果我们向 http://localhost:3001/api/notes 即我们想要所有的笔记,我们的实现将在查询中引起一个不必要的WHERE,这可能(取决于数据库引擎的实现)不必要地影响查询的效率。 + +```sql +SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id" +WHERE "note". "important" IN (true, false) AND "note". "content" LIKE '%%'; +``` + + + 让我们优化代码,使WHERE条件只在必要时使用。 + +```js +router.get('/', async (req, res) => { + const where = {} + + if (req.query.important) { + where.important = req.query.important === "true" + } + + if (req.query.search) { + where.content = { + [Op.substring]: req.query.search + } + } + + const notes = await Note.findAll({ + attributes: { exclude: ['userId'] }, + include: { + model: user, + attributes: ['name'] + }, + where + }) + + res.json(notes) +}) +``` + + + 如果请求有搜索条件,例如:http://localhost:3001/api/notes?search=database&important=true 就会形成一个包含WHERE的查询 + +```sql +SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id" +WHERE "note". "important" = true AND "note". "content" LIKE '%database%'; +``` + + + 如果请求没有搜索条件 http://localhost:3001/api/notes 那么查询就没有不必要的WHERE。 + +```sql +SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" +FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id"; +``` + + + 当前应用的代码全部在[GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-5),分支part13-5。 + +
    + +
    + +### Tasks 13.13.-13.16 + +#### Task 13.13. + + + 在应用中对返回所有博客的路径实施关键字过滤。过滤的工作方式如下 + + - _GET /api/blogs?search=react_ 返回所有在title区域有搜索词react的博客,搜索词不分大小写。 + + - _GET /api/blogs_返回所有博客 + + + + [这个](https://sequelize.org/master/manual/model-querying-basics.html#operators)应该对这个任务和下一个任务有用。 +#### Exercise 13.14. + + + 扩展过滤器以搜索标题作者字段中的关键词,即 + + + _GET /api/blogs?search=jami_ 返回在title字段或author字段中有搜索词jami的博客。 +#### Exercise 13.15. + + + 修改博客路径,使其根据喜欢程度按降序返回博客。在[文档](https://sequelize.org/master/manual/model-querying-basics.html)中搜索关于排序的说明。 + +#### Task 13.16. + + + 为应用_/api/authors_制作一个路由,返回每个作者的博客数量和喜欢的总数量。直接在数据库层面上实现这个操作。你很可能需要[group by](https://sequelize.org/master/manual/model-querying-basics.html#grouping)功能,以及[sequelize.fn](https://sequelize.org/master/manual/model-querying-basics.html#specifying-attributes-for-select-queries)聚合器函数。 + + + 路由返回的JSON可能如下所示:下面这样,例如。 + +``` +[ + { + author: "Jami Kousa", + articles: "3", + likes: "10" + }, + { + author: "Kalle Ilves", + articles: "1", + likes: "2" + }, + { + author: "Dan Abramov", + articles: "1", + likes: "4" + } +] +``` + + + 奖励任务:根据喜欢的数量对返回的数据进行排序,在数据库查询中进行排序。 + +
    diff --git a/src/content/13/zh/part13c.md b/src/content/13/zh/part13c.md new file mode 100644 index 00000000000..a5c312a2a8d --- /dev/null +++ b/src/content/13/zh/part13c.md @@ -0,0 +1,1652 @@ +--- +mainImage: ../../../images/part-13.svg +part: 13 +letter: c +lang: zh +--- + +
    + +### Migrations + + + 让我们继续扩展后端。我们想实现对允许具有管理员身份的用户将他们选择的用户置于禁用模式的支持,防止他们登录和创建新的笔记。为了实现这一点,我们需要在用户的数据库表中添加布尔字段,表明用户是否是管理员和用户是否被禁用。 + + + 我们可以像以前那样进行,即改变定义该表的模型,并依靠Sequelize将变化同步到数据库中。这是由文件models/index.js中的这几行指定的。 + +```js +const Note = require('./note') +const User = require('./user') + +Note.belongsTo(User) +User.hasMany(Note) + +Note.sync({ alter: true }) // highlight-line +User.sync({ alter: true }) // highlight-line + +module.exports = { + Note, User +} +``` + + + 然而,从长远来看,这种方法是没有意义的。让我们删除这些进行同步的行,转而使用一种更稳健的方式,即Sequelize(和许多其他库)提供的[migrations](https://sequelize.org/master/manual/migrations.html)。 + + + 在实践中,迁移是一个单一的JavaScript文件,描述了对数据库的一些修改。一个单独的迁移文件是为每一个单一的或多个变化一次性创建的。Sequelize会记录哪些迁移已经被执行,也就是说,哪些由迁移引起的变化被同步到了数据库模式中。当创建新的迁移时,Sequelize会及时了解哪些数据库模式的变化还没有进行。通过这种方式,修改是以一种可控的方式进行的,程序代码存储在版本控制中。 + + + 首先,让我们创建一个初始化数据库的迁移。迁移的代码如下 + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.createTable('notes', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + content: { + type: DataTypes.TEXT, + allowNull: false + }, + important: { + type: DataTypes.BOOLEAN + }, + date: { + type: DataTypes.DATE + }, + }) + await queryInterface.createTable('users', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + username: { + type: DataTypes.STRING, + unique: true, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + }) + await queryInterface.addColumn('notes', 'user_id', { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.dropTable('notes') + await queryInterface.dropTable('users') + }, +} +``` + + + 迁移文件[定义](https://sequelize.org/master/manual/migrations.html#migration-skeleton)了函数updown,其中第一个函数定义了在执行迁移时应该如何修改数据库。函数down告诉你,如果有必要,如何撤销迁移。 + + + 我们的迁移包含三个操作,第一个创建一个notes表,第二个创建一个users表,第三个给notes表添加一个外键,引用笔记的创建者。模式中的变化是通过调用[queryInterface](https://sequelize.org/master/manual/query-interface.html)对象方法来定义的。 + + + 在定义迁移时,一定要记住,与模型不同,列名和表名是以蛇形大小写的形式书写的。 + +```js +await queryInterface.addColumn('notes', 'user_id', { // highlight-line + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, +}) +``` + + + 所以在迁移中,表和列的名字是按照它们在数据库中出现的样子写的,而模型则使用Sequelize's默认的camelCase命名规则。 + + + 将迁移代码保存在文件migrations/20211209\_00\_initialize\_notes\_and\_users.js。迁移文件名在创建时应该总是按字母顺序命名,这样以前的修改总是在新的修改之前。实现这种顺序的一个好方法是在迁移文件名中以日期和序列号开始。 + + + 我们可以使用[Sequelize命令行工具](https://github.com/sequelize/cli)从命令行中运行迁移。然而,我们选择使用[Umzug](https://github.com/sequelize/umzug)库从程序代码中手动执行迁移。让我们安装这个库 + +```js +npm install umzug +``` + + + 让我们修改处理与数据库连接的util/db.js文件,如下所示。 + +```js +const Sequelize = require('sequelize') +const { DATABASE_URL } = require('./config') +const { Umzug, SequelizeStorage } = require('umzug') // highlight-line + +const sequelize = new Sequelize(DATABASE_URL, { + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, +}); + +// highlight-start +const runMigrations = async () => { + const migrator = new Umzug({ + migrations: { + glob: 'migrations/*.js', + }, + storage: new SequelizeStorage({ sequelize, tableName: 'migrations' }), + context: sequelize.getQueryInterface(), + logger: console, + }) + + const migrations = await migrator.up() + + console.log('Migrations up to date', { + files: migrations.map((mig) => mig.name), + }) +} +// highlight-end + +const connectToDatabase = async () => { + try { + await sequelize.authenticate() + await runMigrations() // highlight-line + console.log('connected to the database') + } catch (err) { + console.log('failed to connect to the database') + console.log(err) + return process.exit(1) + } + + return null +} + +module.exports = { connectToDatabase, sequelize } +``` + + + 执行迁移的runMigrations函数现在会在应用启动时每次打开数据库连接时执行。Sequelize会跟踪哪些迁移已经完成,所以如果没有新的迁移,运行runMigrations函数不会有任何作用。 + + + 现在让我们从一块干净的石板开始,从应用中删除所有现有的数据库表。 + +```sql +username => drop table notes; +username => drop table users; +username => \d +Did not find any relations. +``` + + + 让我们启动应用。一条关于迁移状态的信息被打印在日志上 + +```bash +INSERT INTO "migrations" ("name") VALUES ($1) RETURNING "name"; +Migrations up to date { files: [ '20211209_00_initialize_notes_and_users.js' ] } +database connected +``` + + + 如果我们重新启动应用,日志中也显示迁移没有重复。 + + + 应用的数据库模式现在看起来是这样的 + +```sql +postgres=# \d + List of relations + Schema | Name | Type | Owner +--------+--------------+----------+---------------- + public | migrations | table | username + public | notes | table | username + public | notes_id_seq | sequence | username + public | users | table | username + public | users_id_seq | sequence | username +``` + +So Sequelize has created a migrations table that allows it to keep track of the migrations that have been performed. The contents of the table look as follows: + +```js +postgres=# select * from migrations; + name +------------------------------------------- + 20211209_00_initialize_notes_and_users.js +``` + + + 让我们在数据库中创建几个用户,以及一组笔记,之后我们就可以扩展应用了。 + + + 该应用的当前代码全部在[GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-6),分支part13-6。 +### Admin user and user disabling + + + 所以我们想在users表中添加两个布尔字段 + + - _admin_告诉你该用户是否是一个管理员 + + - _disabled_告诉你该用户是否被禁止行动 + + + 让我们在文件migrations/20211209/01_admin/and/disabled/to/users.js中创建修改数据库的迁移。 + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.addColumn('users', 'admin', { + type: DataTypes.BOOLEAN, + default: false + }) + await queryInterface.addColumn('users', 'disabled', { + type: DataTypes.BOOLEAN, + default: false + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.removeColumn('users', 'admin') + await queryInterface.removeColumn('users', 'disabled') + }, +} +``` + + + 对users表对应的模型做相应的修改。 + +```js +User.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + username: { + type: DataTypes.STRING, + unique: true, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + // highlight-start + admin: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + disabled: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + // highlight-end +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'user' +}) +``` + + +当代码重新启动时进行新的迁移,模式会按要求进行改变。 + +```sql +username-> \d users + Table "public.users" + Column | Type | Collation | Nullable | Default +----------+------------------------+-----------+----------+----------------------------------- + id | integer | | not null | nextval('users_id_seq'::regclass) + username | character varying(255) | | not null | + name | character varying(255) | | not null | + admin | boolean | | | + disabled | boolean | | | +Indexes: + "users_pkey" PRIMARY KEY, btree (id) + "users_username_key" UNIQUE CONSTRAINT, btree (username) +Referenced by: + TABLE "notes" CONSTRAINT "notes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) +``` + +Now let's expand the controllers as follows. We prevent logging in if the user field disabled is set to true: + +```js +loginRouter.post('/', async (request, response) => { + const body = request.body + + const user = await User.findOne({ + where: { + username: body.username + } + }) + + const passwordCorrect = body.password === 'secret' + + if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) + } + +// highlight-start + if (user.disabled) { + return response.status(401).json({ + error: 'account disabled, please contact admin' + }) + } + // highlight-end + + const userForToken = { + username: user.username, + id: user.id, + } + + const token = jwt.sign(userForToken, process.env.SECRET) + + response + .status(200) + .send({ token, username: user.username, name: user.name }) +}) +``` + +Let's disable the user jakousa using his ID: + +```sql +username => update users set disabled=true where id=3; +UPDATE 1 +username => update users set admin=true where id=1; +UPDATE 1 +username => select * from users; + id | username | name | admin | disabled +----+----------+------------------+-------+---------- + 2 | lynx | Kalle Ilves | | + 3 | jakousa | Jami Kousa | f | t + 1 | mluukkai | Matti Luukkainen | t | +``` + + + 并确保登录不再可能发生 + +![](../../images/13/2.png) + + + 让我们创建一个路由,允许管理员改变一个用户的账户状态。 + +```js +const isAdmin = async (req, res, next) => { + const user = await User.findByPk(req.decodedToken.id) + if (!user.admin) { + return res.status(401).json({ error: 'operation not allowed' }) + } + next() +} + +router.put('/:username', tokenExtractor, isAdmin, async (req, res) => { + const user = await User.findOne({ + where: { + username: req.params.username + } + }) + + if (user) { + user.disabled = req.body.disabled + await user.save() + res.json(user) + } else { + res.status(404).end() + } +}) +``` + + + 使用了两个中间件,第一个叫做tokenExtractor的中间件与创建笔记的路由所使用的相同,即它将解码的令牌放在请求对象的decodedToken域中。第二个中间件isAdmin检查用户是否是管理员,如果不是,请求状态被设置为401,并返回一个适当的错误信息。 + + + 请注意两个中间件是如何被链入路由的,这两个中间件都在实际的路由处理程序之前执行。有可能将任意数量的中间件链接到一个请求。 + + + 中间件tokenExtractor现在被移到util/middleware.js,因为它从多个位置被使用。 + +```js +const jwt = require('jsonwebtoken') +const { SECRET } = require('./config.js') + +const tokenExtractor = (req, res, next) => { + const authorization = req.get('authorization') + if (authorization && authorization.toLowerCase().startsWith('bearer ')) { + try { + req.decodedToken = jwt.verify(authorization.substring(7), SECRET) + } catch{ + return res.status(401).json({ error: 'token invalid' }) + } + } else { + return res.status(401).json({ error: 'token missing' }) + } + next() +} + +module.exports = { tokenExtractor } +``` + + + 管理员现在可以通过向_/api/users/jakousa_发出PUT请求来重新启用用户jakousa,该请求带有以下数据。 + +```js +{ + "disabled": false +} +``` + + + 正如在[第四章节的结尾](/en/part4/token_authentication#problems-of-token-based-authentication)所指出的,我们在这里实现禁用用户的方式是有问题的。用户是否被禁用只在_登录_时检查,如果用户在被禁用时有一个令牌,那么用户可以继续使用同一个令牌,因为没有为令牌设置寿命,而且在创建笔记时没有检查用户的禁用状态。 + + + 在我们继续之前,让我们为应用做一个npm脚本,它允许我们撤销之前的迁移。毕竟,在开发迁移时,并不是第一次就能顺利进行的。 + + + 让我们修改文件util/db.js如下。 + +```js +const Sequelize = require('sequelize') +const { DATABASE_URL } = require('./config') +const { Umzug, SequelizeStorage } = require('umzug') + +const sequelize = new Sequelize(DATABASE_URL, { + dialectOptions: { + ssl: { + require: true, + rejectUnauthorized: false + } + }, +}); + +const connectToDatabase = async () => { + try { + await sequelize.authenticate() + await runMigrations() + console.log('connected to the database') + } catch (err) { + console.log('failed to connect to the database') + return process.exit(1) + } + + return null +} + +// highlight-start +const migrationConf = { + migrations: { + glob: 'migrations/*.js', + }, + storage: new SequelizeStorage({ sequelize, tableName: 'migrations' }), + context: sequelize.getQueryInterface(), + logger: console, +} + +const runMigrations = async () => { + const migrator = new Umzug(migrationConf) + const migrations = await migrator.up() + console.log('Migrations up to date', { + files: migrations.map((mig) => mig.name), + }) +} + +const rollbackMigration = async () => { + await sequelize.authenticate() + const migrator = new Umzug(migrationConf) + await migrator.down() +} +// highlight-end + +module.exports = { connectToDatabase, sequelize, rollbackMigration } // highlight-line +``` + + + 让我们创建一个文件util/rollback.js,它将允许npm脚本执行指定的迁移回滚功能。 + +```js +const { rollbackMigration } = require('./db') + +rollbackMigration() +``` + + + 和脚本本身。 + +```json +{ + "scripts": { + "dev": "nodemon index.js", + "migration:down": "node util/rollback.js" // highlight-line + }, +} +``` + + + 所以我们现在可以通过在命令行中运行_npm run migration:down_来撤销之前的迁移。 + + + 迁移目前是在程序启动时自动执行的。在程序的开发阶段,有时禁用迁移的自动执行,从命令行手动进行迁移可能更合适。 + + + 目前该程序的代码全部在[GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-7),分支part13-7。 + +
    + +
    + +### Tasks 13.17-13.18. + +#### Task 13.17. + + +从你的应用的数据库中删除所有表。 + + +进行迁移,使数据库初始化。为两个表添加created\_atupdated\_at [timestamps](https://sequelize.org/master/manual/model-basics.html#timestamps)。请记住,你将不得不在迁移中自己添加它们。 + + + **注意:**一定要删除User.sync()Blog.sync()这两个命令,它们可以从你的代码中同步模型的模式,否则你的迁移将会失败。 + + + **注意2:**如果你必须从命令行中删除表(也就是说,你不是通过撤销迁移来进行删除的),如果你想让你的程序再次执行迁移,你就必须删除migrations表中的内容。 + +#### Task 13.18. + + + 扩展你的应用(通过迁移),使博客有一个写有年份的属性,即一个字段year,它是一个至少等于1991年但不大于当前年份的整数。确保应用在试图给出一个不正确的年份属性值时给出一个适当的错误信息。 + +
    + +
    + +### Many-to-many relationships + + + 我们将继续扩展应用,使每个用户可以被加入一个或多个团队。 + + +由于任意数量的用户可以加入一个团队,而一个用户可以加入任意数量的团队,我们正在处理一个[多对多](https://sequelize.org/master/manual/assocs.html#many-to-many-relationships)的关系,传统上这是在关系型数据库中使用连接表来实现。 + + + 现在让我们为团队表以及连接表创建所需的代码。迁移过程如下。 + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.createTable('teams', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + unique: true + }, + }) + await queryInterface.createTable('memberships', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + team_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'teams', key: 'id' }, + }, + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.dropTable('teams') + await queryInterface.dropTable('memberships') + }, +} +``` + + + 模型包含的代码几乎与迁移相同。团队模型在models/team.js。 + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class Team extends Model {} + +Team.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + unique: true + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'team' +}) + +module.exports = Team +``` + + + 连接表的模型在models/membership.js。 + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class Membership extends Model {} + +Membership.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + team_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'teams', key: 'id' }, + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'membership' +}) + +module.exports = Membership +``` + + + 所以我们给连接表起了一个能很好描述它的名字,membership。连接表并不总是有一个相关的名字,在这种情况下,连接表的名字可以是被连接的表的名字的组合,例如,user/_teams可以适合我们的情况。 + + + 我们对models/index.js文件做了一个小小的补充,使用[belongsToMany](https://sequelize.org/docs/v6/core-concepts/assocs/#implementation-2)方法在代码层连接团队和用户。 + +```js +const Note = require('./note') +const User = require('./user') +// highlight-start +const Team = require('./team') +const Membership = require('./membership') +// highlight-end + +Note.belongsTo(User) +User.hasMany(Note) + +// highlight-start +User.belongsToMany(Team, { through: Membership }) +Team.belongsToMany(User, { through: Membership }) +// highlight-end + +module.exports = { + Note, User, Team, Membership // highlight-line +} + +``` + + + 注意在定义外键字段时,连接表和模型的迁移是不同的。在迁移过程中,字段是以蛇形的形式定义的。 + +```js +await queryInterface.createTable('memberships', { + // ... + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + team_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'teams', key: 'id' }, + } +}) +``` + + + 在模型中,同样的字段是以骆驼的形式定义的。 + +```js +Membership.init({ + // ... + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + teamId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'teams', key: 'id' }, + }, + // ... +}) +``` + + + 现在让我们从控制台创建几个团队,以及一些会员资格。 + +```js +insert into teams (name) values ('toska'); +insert into teams (name) values ('mosa climbers'); +insert into memberships (user_id, team_id) values (1, 1); +insert into memberships (user_id, team_id) values (1, 2); +insert into memberships (user_id, team_id) values (2, 1); +insert into memberships (user_id, team_id) values (3, 2); +``` + + +关于用户团队的信息将被添加到检索所有用户的路径中 + +```js +router.get('/', async (req, res) => { + const users = await User.findAll({ + include: [ + { + model: Note, + attributes: { exclude: ['userId'] } + }, + // highlight-start + { + model: Team, + attributes: ['name', 'id'], + } + // highlight-end + ] + }) + res.json(users) +}) +``` + + +最善于观察的人会注意到,打印到控制台的查询现在结合了三个表。 + + + 这个解决方案相当不错,但其中有一个美丽的缺陷。结果还带有连接表相应行的属性,尽管我们并不希望这样。 + +![](../../images/13/3.png) + + + + 通过仔细阅读文档,你可以找到一个[解决方案](https://sequelize.org/master/manual/advanced-many-to-many.html#specifying-attributes-from-the-through-table)。 + +```js +router.get('/', async (req, res) => { + const users = await User.findAll({ + include: [ + { + model: Note, + attributes: { exclude: ['userId'] } + }, + { + model: Team, + attributes: ['name', 'id'], + // highlight-start + through: { + attributes: [] + } + // highlight-end + } + ] + }) + res.json(users) +}) +``` + + + 该应用的当前代码全部在[GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-8),分支part13-8。 + +### Note on the properties of Sequelize model objects + + + 我们模型的规范由以下几行显示。 + +```js +User.hasMany(Note) +Note.belongsTo(User) + +User.belongsToMany(Team, { through: Membership }) +Team.belongsToMany(User, { through: Membership }) +``` + + + 这些允许Sequelize进行查询,例如,检索用户的所有笔记,或一个团队的所有成员。 + + + 由于这些定义,我们也可以直接访问,例如,代码中用户的笔记。在下面的代码中,我们将搜索一个id为1的用户,并打印与该用户相关的笔记。 + +```js +const user = await User.findByPk(1, { + include: { + model: Note + } +}) + +user.notes.forEach(note => { + console.log(note.content) +}) +``` + + + 因此,User.hasMany(Note)定义给user对象附加了一个notes属性,它可以访问该用户的笔记。User. belongsToMany(Team, { through: Membership }))定义同样将一个teams属性附加到user对象,这也可以在代码中使用。 + +```js +const user = await User.findByPk(1, { + include: { + model: team + } +}) + +user.teams.forEach(team => { + console.log(team.name) +}) +``` + + + 假设我们想从单个用户的路由中返回一个JSON对象,包含用户的名字、用户名和创建的笔记数量。我们可以尝试以下方法。 + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, { + include: { + model: Note + } + } + ) + + if (user) { + user.note_count = user.notes.length // highlight-line + delete user.notes // highlight-line + res.json(user) + + } else { + res.status(404).end() + } +}) +``` + + + 所以,我们尝试在Sequelize返回的对象上添加noteCount字段,并删除其中的notes字段。然而,这种方法并不奏效,因为Sequelize返回的对象并不是正常的对象,在那里添加新的字段会按照我们的意图工作。 + + + 一个更好的解决方案是根据从数据库中获取的数据创建一个全新的对象。 + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, { + include: { + model: Note + } + } + ) + + if (user) { + res.json({ + username: user.username, // highlight-line + name: user.name, // highlight-line + note_count: user.notes.length // highlight-line + }) + + } else { + res.status(404).end() + } +}) +``` +### Revisiting many-to-many relationships + + + 让我们在应用中建立另一个多对多的关系。每个笔记都通过一个外键与创建它的用户相关联。现在决定,应用还支持笔记可以与其他用户相关联,并且一个用户可以与其他用户创建的任意数量的笔记相关联。我们的想法是,这些笔记是用户为自己标记的。 + + + 让我们为这种情况制作一个连接表user_notes。迁移是直截了当的。 + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.createTable('user_notes', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + note_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'notes', key: 'id' }, + }, + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.dropTable('user_notes') + }, +} +``` + + + 另外,这个模型也没有什么特别之处。 + +```js +const { Model, DataTypes } = require('sequelize') + +const { sequelize } = require('../util/db') + +class UserNotes extends Model {} + +UserNotes.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'users', key: 'id' }, + }, + noteId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'notes', key: 'id' }, + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'user_notes' +}) + +module.exports = UserNotes +``` + + + 文件models/index.js,另一方面,与我们之前看到的有一点变化。 + +```js +const Note = require('./note') +const User = require('./user') +const Team = require('./team') +const Membership = require('./membership') +const UserNotes = require('./user_notes') // highlight-line + +Note.belongsTo(User) +User.hasMany(Note) + +User.belongsToMany(Team, { through: Membership }) +Team.belongsToMany(User, { through: Membership }) + +// highlight-start +User.belongsToMany(Note, { through: UserNotes, as: 'marked_notes' }) +Note.belongsToMany(User, { through: UserNotes, as: 'users_marked' }) +// highlight-end + +module.exports = { + Note, User, Team, Membership, UserNotes +} +``` + + + 再次使用了belongsToMany,现在它通过连接表对应的UserNotes模型将用户与笔记联系起来。然而,这一次我们为使用关键字[as](https://sequelize.org/master/manual/advanced-many-to-many.html#aliases-and-custom-key-names)形成的属性给出了一个别名,默认名称(用户的笔记)将与它之前的含义重叠,即由用户创建的笔记。 + + + 我们为单个用户扩展了路线,以返回用户的团队、他们自己的笔记,以及由用户标记的其他笔记。 + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, { + attributes: { exclude: [''] } , + include:[{ + model: Note, + attributes: { exclude: ['userId'] } + }, + { + model: Note, + as: 'marked_notes', + attributes: { exclude: ['userId']}, + through: { + attributes: [] + } + }, + { + model: Team, + attributes: ['name', 'id'], + through: { + attributes: [] + } + }, + ] + }) + + if (user) { + res.json(user) + } else { + res.status(404).end() + } +}) +``` + + + 在include的上下文中,我们现在必须使用别名marked\_notes,我们刚刚用as属性定义了它。 + + + 为了测试这个功能,让我们在数据库中创建一些测试数据。 + +```sql +insert into user_notes (user_id, note_id) values (1, 4); +insert into user_notes (user_id, note_id) values (1, 5); +``` + + + 最终的结果是功能性的。 + +![](../../images/13/5.png) + + + 如果我们想在用户标记的笔记中也包括关于笔记作者的信息呢?这可以通过在标记的笔记中添加一个include来实现。 + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, { + attributes: { exclude: [''] } , + include:[{ + model: Note, + attributes: { exclude: ['userId'] } + }, + { + model: Note, + as: 'marked_notes', + attributes: { exclude: ['userId']}, + through: { + attributes: [] + }, + // highlight-start + include: { + model: User, + attributes: ['name'] + } + // highlight-end + }, + { + model: Team, + attributes: ['name', 'id'], + through: { + attributes: [] + } + }, + ] + }) + + if (user) { + res.json(user) + } else { + res.status(404).end() + } +}) +``` + + + + 最后的结果是如愿以偿。 + +![](../../images/13/4.png) + + + 该应用的当前代码全部在[GitHub](https://github.com/fullstack-hy/part13-notes/tree/part13-9),分支part13-9。 + + +
    + +
    + +### Tasks 13.19.-13.23. + +#### Task 13.19. + + + 让用户有能力将系统中的博客添加到阅读列表。当添加到阅读列表时,该博客应处于未读状态。之后,该博客可以被标记为阅读。使用连接表来实现阅读列表。使用迁移实现数据库的改变。 + + + 在这个任务中,除了直接使用数据库,添加到阅读列表和显示列表不需要成功。 + +#### Exercise 13.20. + + +现在给应用添加功能以支持阅读列表。 + + + 添加博客到阅读列表是通过对路径/api/readinglists进行HTTP POST,该请求将伴随着博客和用户ID。 + +```js +{ + "blogId": 10, + "userId": 3 +} +``` + + + 同时修改单个用户的路由_GET /api/users/:id_,不仅返回用户的其他信息,也返回阅读列表,例如,格式如下。 + +```js +{ + name: "Matti Luukkainen", + username: "mluukkai@iki.fi", + readings: [ + { + id: 3, + url: "https://google.com", + title: "Clean React", + author: "Dan Abramov", + likes: 34, + year: null, + }, + { + id: 4, + url: "https://google.com", + title: "Clean Code", + author: "Bob Martin", + likes: 5, + year: null, + } + ] +} +``` + + + 在这一点上,关于博客是否被阅读的信息不需要提供。 + +#### Task 13.21. + + + 扩展单用户路线,使阅读列表中的每个博客也显示该博客是否被阅读相应连接表行的id。 + + + 例如,这些信息可以是以下形式。 + +```js +{ + name: "Matti Luukkainen", + username: "mluukkai@iki.fi", + readings: [ + { + id: 3, + url: "https://google.com", + title: "Clean React", + author: "Dan Abramov", + likes: 34, + year: null, + readinglists: [ + { + read: false, + id: 2 + } + ] + }, + { + id: 4, + url: "https://google.com", + title: "Clean Code", + author: "Bob Martin", + likes: 5, + year: null, + readinglists: [ + { + read: false, + id: 2 + } + ] + } + ] +} +``` + + + 注意:有几种方法来实现这个功能。[这](https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship)应该有帮助。 + +#### Exercise 13.22. + + + 在应用中实现将阅读列表中的博客标记为已读的功能。标记为已读是通过向_PUT /api/readinglists/:id_路径发出请求来完成的,并在请求中加上 + +```js +{ "read": true } +``` + + + 用户只能将他们自己阅读列表中的博客标记为已读。用户照例从请求附带的标记中被识别出来。 + +#### Exercise 13.23. + + + 修改返回单个用户信息的路由,使请求可以控制阅读列表中的哪些博客被返回。 + + + - _GET /api/users/:id_ 返回整个阅读列表 + + - _GET /api/users/:id?read=true_ 返回已经阅读过的博客 + + - _GET /api/users/:id?read=false_ 返回未被阅读的博客。 + +
    + +
    + +### Concluding remarks + + + 我们的应用的状态开始至少可以接受。然而,在本节结束前,让我们再看几点。 + +#### Eager vs lazy fetch + + +当我们使用include属性进行查询时。 + +```js +User.findOne({ + include: { + model: note + } +}) +``` + + + 所谓的[急切获取](https://sequelize.org/master/manual/assocs.html#basics-of-queries-involving-associations)会发生,也就是说,所有通过连接查询连接到用户的表的行,在这个例子中是用户做的笔记,都会同时从数据库获取。这通常是我们想要的,但也有一些情况,你想做一个所谓的_lazy fetch_,例如,只有在需要时才搜索用户相关的团队。 + + + 现在让我们修改单个用户的路由,以便它只在请求中设置查询参数teams时才获取用户的团队。 + +```js +router.get('/:id', async (req, res) => { + const user = await User.findByPk(req.params.id, { + attributes: { exclude: [''] } , + include:[{ + model: note, + attributes: { exclude: ['userId'] } + }, + { + model: Note, + as: 'marked_notes', + attributes: { exclude: ['userId']}, + through: { + attributes: [] + }, + include: { + model: user, + attributes: ['name'] + } + }, + ] + }) + + if (!user) { + return res.status(404).end() + } + + // highlight-start + let teams = undefined + + if (req.query.teams) { + teams = await user.getTeams({ + attributes: ['name'], + joinTableAttributes: [] + }) + } + + res.json({ ...user.toJSON(), teams }) + // highlight-end +}) +``` + + + 所以现在,User.findByPk查询不会检索团队,但如果有必要,它们会被user方法getTeams检索,该方法是由Sequelize为模型对象自动生成。类似的get-和其他一些有用的方法[是自动生成的](https://sequelize.org/master/manual/assocs.html#special-methods-mixins-added-to-instances),当在Sequelize级别定义表的关联时。 + +#### Features of models + + + 有些情况下,默认情况下,我们不希望处理某个特定表的所有行。其中一种情况是,我们通常不想在我们的应用中显示已被禁用的用户。在这种情况下,我们可以像这样为模型定义默认的[scopes](https://sequelize.org/master/manual/scopes.html)。 + +```js +class User extends Model {} + +User.init({ + // field definition +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'user', + // highlight-start + defaultScope: { + where: { + disabled: false + } + }, + scopes: { + admin: { + where: { + admin: true + } + }, + disabled: { + where: { + disabled: true + } + } + } + // highlight-end +}) + +module.exports = User +``` + + + 现在由函数调用User.findAll()引起的查询有以下WHERE条件。 + +``` +WHERE "user". "disabled" = false; +``` + + + 对于模型,也可以定义其他作用域。 + +```js +User.init({ + // field definition +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'user', + defaultScope: { + where: { + disabled: false + } + }, + // highlight-start + scopes: { + admin: { + where: { + admin: true + } + }, + disabled: { + where: { + disabled: true + } + }, + name(value) { + return { + where: { + name: { + [Op.iLike]: value + } + } + } + }, + } + // highlight-end +}) +``` + + +作用域的使用方法如下。 + +```js +// all admins +const adminUsers = await User.scope('admin').findAll() + +// all inactive users +const disabledUsers = await User.scope('disabled').findAll() + +// users with the string jami in their name +const jamiUsers = User.scope({ method: ['name', '%jami%'] }).findAll() +``` + + + 也可以连锁作用域。 + +```js +// admins with the string jami in their name +const jamiUsers = User.scope('admin', { method: ['name', '%jami%'] }).findAll() +``` + + + 由于Sequelize模型是正常的[JavaScript类](https://sequelize.org/master/manual/model-basics.html#taking-advantage-of-models-being-classes),所以有可能向它们添加新的方法。 + + + 这里有两个例子。 + +```js +const { Model, DataTypes, Op } = require('sequelize') // highlight-line + +const Note = require('./note') +const { sequelize } = require('../util/db') + +class User extends Model { + // highlight-start + async number_of_notes() { + return (await this.getNotes()).length + } + + static async with_notes(limit){ + return await User.findAll({ + attributes: { + include: [[ sequelize.fn("COUNT", sequelize.col("notes.id")), "note_count" ]] + }, + include: [ + { + model: Note, + attributes: [] + }, + ], + group: ['user.id'], + having: sequelize.literal(`COUNT(notes.id) > ${limit}`) + }) + } + // highlight-end +} + +User.init({ + // ... +}) + +module.exports = User +``` + + + 第一个方法numberOfNotes是一个实例方法,意味着它可以在模型的实例中被调用。 + +```js +const jami = await User.findOne({ name: 'Jami Kousa'}) +const cnt = await jami.number_of_notes() +console.log(`Jami has created ${cnt} notes`) +``` + + + 在实例方法中,关键词this因此指的是实例本身。 + +```js +async number_of_notes() { + return (await this.getNotes()).length +} +``` + + + 第二个方法是返回那些至少有X的用户,这个数字是由参数指定的,笔记的数量是一个类方法,也就是说,它是直接在模型上调用的。 + +```js +const users = await User.with_notes(2) +console.log(JSON.stringify(users, null, 2)) +users.forEach(u => { + console.log(u.name) +}) +``` + +#### Repeatability of models and migrations + + + 我们注意到,模型和迁移的代码是非常重复的。例如,团队的模型 + +```js +class Team extends Model {} + +Team.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + unique: true + }, +}, { + sequelize, + underscored: true, + timestamps: false, + modelName: 'team' +}) + +module.exports = Team +``` + + + 和迁移包含了很多相同的代码 + +```js +const { DataTypes } = require('sequelize') + +module.exports = { + up: async ({ context: queryInterface }) => { + await queryInterface.createTable('teams', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + unique: true + }, + }) + }, + down: async ({ context: queryInterface }) => { + await queryInterface.dropTable('teams') + }, +} +``` + + + 难道我们不能优化代码,比如说,模型导出迁移所需的共享部分? + + + 然而,问题是,模型的定义可能会随着时间的推移而改变,例如,name字段可能会改变或其数据类型可能会改变。迁移必须能够在任何时候从头到尾成功执行,如果迁移依赖于模型的某些内容,那么在一个月或一年后,它可能不再是真的。因此,尽管有 "复制粘贴",迁移代码应该与模型代码完全分开。 + + + 一个解决方案是使用Sequelize's [命令行工具](https://sequelize.org/docs/v6/other-topics/migrations/#creating-the-first-model-and-migration),它可以根据命令行给出的命令来生成模型和迁移文件。例如,下面的命令将创建一个User模型,并将nameusernameadmin作为属性,以及管理创建数据库表的迁移。 + +``` +npx sequelize-cli model:generate --name User --attributes name:string,username:string,admin:boolean +``` + + + 从命令行中,你也可以运行回滚,即撤销迁移。不幸的是,命令行文档并不完整,在这个课程中,我们决定手动完成模型和迁移。这个解决方案可能是明智的,也可能不是。 + +
    + +
    + +### Task 13.24. + +#### Task 13.24. + + + 大结局:[在第四章节的末尾](/en/part4/token_authentication#problems-of-token-based-authentication)提到了一个token-criticality问题:如果一个用户对系统的访问被决定撤销,该用户仍然可以使用手中的token来使用该系统。 + + + 对此,通常的解决方案是在后端数据库中存储发给客户的每个令牌的记录,并在每次请求时检查访问是否仍然有效。在这种情况下,必要时可以立即删除令牌的有效性。这样的解决方案通常被称为服务器端会话。 + + + 现在扩展系统,使失去访问权的用户无法执行任何需要登录的操作。 + + + 你可能至少需要以下内容来实现 + + - 在用户表中有一个布尔值列,以表明用户是否被禁用 + + - 直接从数据库中禁用和启用用户就足够了 + + - 一个存储活动会话的表 + + - 当用户登录时, 会话被存储在该表中, 即操作 _POST /api/login_ + + - 当用户进行需要登录的操作时,会话的存在(和有效性)总是被检查。 + + - 一个允许用户 "注销 "系统的路由,即实际上是从数据库中删除活动会话,该路由可以是例如_DELETE /api/logout_。 + + + 请记住,需要登录的操作不应该用 "过期的令牌 "成功,也就是说,在注销后用同样的令牌。 + + + 你也可以选择使用一些特制的npm库来处理sessions。 + + + 使用迁移来进行这项任务所需的数据库修改。 + +### Submitting exercises and getting the credits + + + 这一部分的练习就像前几部分一样提交,但与0到7部分不同的是,提交到一个自己的[课程实例](https://studies.cs.helsinki.fi/stats/courses/fs-psql)。请记住,你必须完成所有的练习才能通过这部分! + + +一旦你完成了练习并想获得学分,请通过练习提交系统告诉我们你已经完成了课程。 + +![Submissions](../../images/11/21.png) + + + 注意 "在Moodle中完成考试 "的说明是指[全栈开放课程的考试](/en/part0/general_info#sign-up-for-the-exam),在你从这部分获得学分之前必须完成。 + + + **注意**,你需要注册相应的课程部分才能获得学分,更多信息请看[这里](/part0/general_info#parts-and-completion)。 + +
    diff --git a/src/content/2/en/part2.md b/src/content/2/en/part2.md index e56ed608185..104646305ea 100644 --- a/src/content/2/en/part2.md +++ b/src/content/2/en/part2.md @@ -7,4 +7,8 @@ lang: en
    Let's continue our introduction to React. First, we will take a look at how to render a data collection, like a list of names, to the screen. After this, we will inspect how a user can submit data to a React application using HTML forms. Next, our focus shifts towards looking at how JavaScript code in the browser can fetch and handle data stored in a remote backend server. Lastly, we will take a quick look at a few simple ways of adding CSS styles to our React applications. -
    \ No newline at end of file + +Part updated on 16th February 2025 +- Node updated to version v22.3.0 + + diff --git a/src/content/2/en/part2a.md b/src/content/2/en/part2a.md index 1e87044b385..0f3621865f7 100644 --- a/src/content/2/en/part2a.md +++ b/src/content/2/en/part2a.md @@ -7,19 +7,20 @@ lang: en
    -Before starting a new topic, let's recap some of the topics that proved difficult last year. +Before starting a new part, let's recap some of the topics that proved difficult last year. ### console.log ***What's the difference between an experienced JavaScript programmer and a rookie? The experienced one uses console.log 10-100 times more.*** -Paradoxically, this seems to be true even though a rookie programmer would need console.log (or any debugging method) more than an experienced one. +Paradoxically, this seems to be true even though a rookie programmer would need console.log (or any debugging method) more than an experienced one. -When something does not work, don't just guess what's wrong. Instead, log or use some other way of debugging. +When something does not work, don't just guess what's wrong. Instead, log or use some other way of debugging. + +**NB** As explained in part 1, when you use the command _console.log_ for debugging, don't concatenate things 'the Java way' with a plus. Instead of writing: -**NB** when you use the command _console.log_ for debugging, don't concatenate things 'the Java way' with a plus. Instead of writing: ```js -console.log('props value is' + props) +console.log('props value is ' + props) ``` separate the things to be printed with a comma: @@ -28,24 +29,25 @@ separate the things to be printed with a comma: console.log('props value is', props) ``` - -If you concatenate an object with a string and log it to the console (like in our first example), the result will be pretty useless: +If you concatenate an object with a string and log it to the console (like in our first example), the result will be pretty useless: ```js -props value is [Object object] +props value is [object Object] ``` On the contrary, when you pass objects as distinct arguments separated by commas to _console.log_, like in our second example above, the content of the object is printed to the developer console as strings that are insightful. -If necessary, read more about debugging React-applications [here](/en/part1/a_more_complex_state_debugging_react_apps#debugging-react-applications). +If necessary, read more about [debugging React applications](/en/part1/a_more_complex_state_debugging_react_apps#debugging-react-applications). ### Protip: Visual Studio Code snippets -With Visual studio code it's easy to create 'snippets', i.e. shortcuts for quickly generating commonly re-used portions of code, much like how 'sout' works in Netbeans. +With Visual Studio Code it's easy to create 'snippets', i.e., shortcuts for quickly generating commonly re-used portions of code, much like how 'sout' works in Netbeans. + Instructions for creating snippets can be found [here](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_creating-your-own-snippets). -Useful, ready-made snippets can also be found as VS Code plugins, for example [here](https://marketplace.visualstudio.com/items?itemName=xabikos.ReactSnippets). +Useful, ready-made snippets can also be found as VS Code plugins, in the [marketplace](https://marketplace.visualstudio.com/items?itemName=dsznajder.es7-react-js-snippets). + +The most important snippet is the one for the console.log() command, for example, clog. This can be created like so: -The most important snippet is the one for the console.log() command, for example clog. This can be created like so: ```js { "console.log": { @@ -58,86 +60,91 @@ The most important snippet is the one for the console.log() command, fo } ``` +Debugging your code using _console.log()_ is so common that Visual Studio Code has that snippet built in. To use it, type _log_ and hit Tab to autocomplete. More fully featured _console.log()_ snippet extensions can be found in the [marketplace](https://marketplace.visualstudio.com/search?term=console.log&target=VSCode&category=All%20categories&sortBy=Relevance). + ### JavaScript Arrays -From here on out, we will be using the functional programming methods of the JavaScript [array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array), such as _find_, _filter_, and _map_ - all of the time. They operate on the same general principles as streams do in Java 8, which have been used during the last few years in both the 'Ohjelmoinnin perusteet' and 'Ohjelmoinnin jatkokurssi' courses at the university's department of Computer Science, and also in the programming MOOC. +From here on out, we will be using the functional programming operators of the JavaScript [array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array), such as _find_, _filter_, and _map_ - all of the time. -If functional programming with arrays feels foreign to you, it is worth watching at least the first three parts of the YouTube video series [Functional Programming in JavaScript](https://www.youtube.com/playlist?list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84): +If operating arrays with functional operators feels foreign to you, it is worth watching at least the first three parts of the YouTube video series [Functional Programming in JavaScript](https://www.youtube.com/playlist?list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84): - [Higher-order functions](https://www.youtube.com/watch?v=BMUiFMZr7vk&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84) - [Map](https://www.youtube.com/watch?v=bCqtb-Z5YGQ&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84&index=2) - [Reduce basics](https://www.youtube.com/watch?v=Wl98eZpkp-c&t=31s) -### Event handlers revisited +### Event Handlers Revisited + +Based on last year's course, event handling has proved to be difficult. -Based on last year's course, event handling has proven to be difficult. -It's worth reading the revision chapter at the end of the previous part [event handlers revisited](/en/part1/a_more_complex_state_debugging_react_apps#event-handling-revisited), if it feels like your own knowledge on the topic needs some brushing up. +It's worth reading the revision chapter at the end of the previous part - [event handlers revisited](/en/part1/a_more_complex_state_debugging_react_apps#event-handling-revisited) - if it feels like your own knowledge on the topic needs some brushing up. Passing event handlers to the child components of the App component has raised some questions. A small revision on the topic can be found [here](/en/part1/a_more_complex_state_debugging_react_apps#passing-event-handlers-to-child-components). -### Rendering collections +### Rendering Collections -We will now do the 'frontend', or the browser-side application logic, in React for an application that's similar to the example application from [part 0](/en/part0) +Now, we will build the frontend, or the user interface (the part users see in their browser), using React, similar to the example application from [part 0](/en/part0). -Let's start with the following: +Let's start with the following (the file App.jsx): ```js -import React from 'react' -import ReactDOM from 'react-dom' +const App = (props) => { + const { notes } = props + + return ( +
    +

    Notes

    +
      +
    • {notes[0].content}
    • +
    • {notes[1].content}
    • +
    • {notes[2].content}
    • +
    +
    + ) +} + +export default App +``` + +The file main.jsx looks like this: + +```js +import ReactDOM from 'react-dom/client' +import App from './App' const notes = [ { id: 1, content: 'HTML is easy', - date: '2019-05-30T17:30:31.098Z', important: true }, { id: 2, - content: 'Browser can execute only Javascript', - date: '2019-05-30T18:39:34.091Z', + content: 'Browser can execute only JavaScript', important: false }, { id: 3, content: 'GET and POST are the most important methods of HTTP protocol', - date: '2019-05-30T19:20:14.298Z', important: true } ] -const App = (props) => { - const { notes } = props - - return ( -
    -

    Notes

    -
      -
    • {notes[0].content}
    • -
    • {notes[1].content}
    • -
    • {notes[2].content}
    • -
    -
    - ) -} - -ReactDOM.render( - , - document.getElementById('root') +ReactDOM.createRoot(document.getElementById('root')).render( + ) ``` -Every note contains its textual content and a timestamp as well as a _boolean_ value for marking whether the note has been categorized as important or not, and also a unique id. +Every note contains its textual content, a _boolean_ value for marking whether the note has been categorized as important or not, and also a unique id. +The example above works due to the fact that there are exactly three notes in the array. -The code functions due to the fact that there are exactly three notes in the array. A single note is rendered by accessing the objects in the array by referring to a hard-coded index number: ```js -
  • {note[1].content}
  • +
  • {notes[1].content}
  • ``` -This is, of course, not practical. The solution can be made general by generating React-elements from the array objects using the [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) function. +This is, of course, not practical. We can improve on this by generating React elements from the array objects using the [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) function. ```js notes.map(note =>
  • {note.content}
  • ) @@ -148,13 +155,12 @@ The result is an array of li elements. ```js [
  • HTML is easy
  • , -
  • Browser can execute only Javascript
  • , +
  • Browser can execute only JavaScript
  • ,
  • GET and POST are the most important methods of HTTP protocol
  • , ] ``` - -Which can then be put inside ul tags: +Which can then be placed inside ul tags: ```js const App = (props) => { @@ -173,7 +179,7 @@ const App = (props) => { } ``` -Because the code generating the li tags is JavaScript, it must be wrapped in curly braces in a JSX template just like all other JavaScript code. +Because the code generating the li tags is JavaScript, it must be wrapped in curly braces in a JSX template just like all other JavaScript code. We will also make the code more readable by separating the arrow function's declaration across multiple lines: @@ -201,11 +207,11 @@ const App = (props) => { ### Key-attribute -Even though the application seems to be working, there is a nasty warning in the console: +Even though the application seems to be working, there is a nasty warning in the console: -![](../../images/2/1a.png) +![unique key prop console error](../../images/2/1a.png) -As the linked [page](https://reactjs.org/docs/lists-and-keys.html#keys) in the error message instructs, the list items, i.e. the elements generated by the _map_ method, must each have a unique key value: an attribute called key. +As the linked [React page](https://react.dev/learn/rendering-lists#keeping-list-items-in-order-with-key) in the error message suggests; the list items, i.e. the elements generated by the _map_ method, must each have a unique key value: an attribute called key. Let's add the keys: @@ -218,11 +224,9 @@ const App = (props) => {

    Notes

      {notes.map(note => - // highlight-start -
    • +
    • // highlight-line {note.content}
    • - // highlight-end )}
    @@ -230,34 +234,31 @@ const App = (props) => { } ``` -And the error message disappears. +And the error message disappears. -React uses the key attributes of objects in an array to determine how to update the view generated by a component when the component is re-rendered. More about this [here](https://reactjs.org/docs/reconciliation.html#recursing-on-children). +React uses the key attributes of objects in an array to determine how to update the view generated by a component when the component is re-rendered. More about this is in the [React documentation](https://react.dev/learn/preserving-and-resetting-state#option-2-resetting-state-with-a-key). ### Map -Understanding how the array method [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) works is crucial for the rest of the course. +Understanding how the array method [`map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) works is crucial for the rest of the course. -The application contains an array called _notes_ +The application contains an array called _notes_: ```js const notes = [ { id: 1, content: 'HTML is easy', - date: '2019-05-30T17:30:31.098Z', important: true }, { id: 2, - content: 'Browser can execute only Javascript', - date: '2019-05-30T18:39:34.091Z', + content: 'Browser can execute only JavaScript', important: false }, { id: 3, content: 'GET and POST are the most important methods of HTTP protocol', - date: '2019-05-30T19:20:14.298Z', important: true } ] @@ -265,7 +266,6 @@ const notes = [ Let's pause for a moment and examine how _map_ works. - If the following code is added to, let's say, the end of the file: ```js @@ -274,8 +274,7 @@ console.log(result) ``` [1, 2, 3] will be printed to the console. - _map_ always creates a new array, the elements of which have been created from the elements of the original array by mapping, using the function given as a parameter to the _map_ method. - + _map_ always creates a new array, the elements of which have been created from the elements of the original array by mapping: using the function given as a parameter to the _map_ method. The function is @@ -283,7 +282,7 @@ The function is note => note.id ``` -Which is an arrow function written in compact form. The full form would be: +Which is an arrow function written in compact form. The full form would be: ```js (note) => { @@ -291,7 +290,7 @@ Which is an arrow function written in compact form. The full form would be: } ``` -The function gets a note object as a parameter, and returns the value of its id field. +The function gets a note object as a parameter and returns the value of its id field. Changing the command to: @@ -299,36 +298,39 @@ Changing the command to: const result = notes.map(note => note.content) ``` -results in an array containing the contents of the notes. +will give you an array containing the contents of the notes. This is already pretty close to the React code we used: ```js notes.map(note => -
  • {note.content}
  • +
  • + {note.content} +
  • ) ``` -which generates a li tag containing the contents of the note from each note object. +which generates an li tag containing the contents of the note from each note object. -Because the function parameter of the _map_ method +Because the function parameter passed to the _map_ method - ```js note =>
  • {note.content}
  • ``` -is used to create view elements, the value of the variable must be rendered inside of curly braces. Try to see what happens if the braces are removed. -The use of curly braces will cause some headache in the beginning, but you will get used to them soon enough. The visual feedback from React is immediate. + - is used to create view elements, the value of the variable must be rendered inside curly braces. Try to see what happens if the braces are removed. -### Anti-pattern: array indexes as keys +The use of curly braces will cause some pain in the beginning, but you will get used to them soon enough. The visual feedback from React is immediate. -We could have made the error message on our console disappear by using the array indexes as keys. The indexes can be retrieved by passing a second parameter to the callback function of the map-method: +### Anti-pattern: Array Indexes as Keys + +We could have made the error message on our console disappear by using the array indexes as keys. The indexes can be retrieved by passing a second parameter to the callback function of the _map_ method: ```js notes.map((note, i) => ...) ``` -When called like this, _i_ is assigned the value of the index of the position in the array where the Note resides. +When called like this, _i_ is assigned the value of the index of the position in the array where the note resides. As such, one way to define the row generation without getting errors is: @@ -342,10 +344,11 @@ As such, one way to define the row generation without getting errors is: ``` -This is, however, **not recommended** and can cause undesired problems even if it seems to be working just fine. -Read more [from here](https://medium.com/@robinpokorny/index-as-a-key-is-an-anti-pattern-e0349aece318). +This is, however, **not recommended** and can create undesired problems even if it seems to be working just fine. + +Read more about this in [this article](https://robinpokorny.com/blog/index-as-a-key-is-an-anti-pattern/). -### Refactoring modules +### Refactoring Modules Let's tidy the code up a bit. We are only interested in the field _notes_ of the props, so let's retrieve that directly using [destructuring](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment): @@ -366,10 +369,9 @@ const App = ({ notes }) => { //highlight-line } ``` -If you have forgotten what destructuring means and how it works, review [this](/en/part1/component_state_event_handlers#destructuring). - +If you have forgotten what destructuring means and how it works, please review the [section on destructuring](/en/part1/component_state_event_handlers#destructuring). -We'll separate displaying a single note into its own component Note: +We'll separate displaying a single note into its own component Note: ```js // highlight-start @@ -396,48 +398,38 @@ const App = ({ notes }) => { } ``` -Note, that the key attribute must now be defined for the Note components, and not for the li tags like before. +Note that the key attribute must now be defined for the Note components, and not for the li tags like before. -A whole React application can be written in a single file. Although that is, of course, not very practical. Common practice is to declare each component in their own file as an ES6-module. +A whole React application can be written in a single file. Although that is, of course, not very practical. Common practice is to declare each component in its own file as an ES6-module. -We have been using modules the whole time. The first few lines of the file: +We have been using modules the whole time. The first few lines of the file main.jsx: ```js -import React from 'react' -import ReactDOM from 'react-dom' +import ReactDOM from "react-dom/client" +import App from "./App" ``` -[imports](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) two modules, enabling them to be used in the code. The react module is placed into a variable called _React_ and react-dom to variable _ReactDOM_. +[import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) two modules, enabling them to be used in that file. The module react-dom/client is placed into the variable _ReactDOM_, and the module that defines the main component of the app is placed into the variable _App_ +Let's move our Note component into its own module. -Let's move our Note component into its own module. +In smaller applications, components are usually placed in a directory called components, which is in turn placed within the src directory. The convention is to name the file after the component. -In smaller applications, components are usually placed in a directory called components , which is in turn placed within the src directory. The convention is to name the file after the component. - -Now we'll create a directory called components for our application and place a file named Note.js inside. -The contents of the Note.js file are as follows: +Now, we'll create a directory called components for our application and place a file named Note.jsx inside. The contents of the file are as follows: ```js -import React from 'react' - const Note = ({ note }) => { - return ( -
  • {note.content}
  • - ) + return
  • {note.content}
  • } export default Note ``` -Because this is a React-component, we must import React. - The last line of the module [exports](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export) the declared module, the variable Note. -Now the file using the component, index.js, can [import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) the module: +Now the file that is using the component - App.jsx - can [import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) the module: ```js -import React from 'react' -import ReactDOM from 'react-dom' import Note from './components/Note' // highlight-line const App = ({ notes }) => { @@ -445,7 +437,7 @@ const App = ({ notes }) => { } ``` -The component exported by the module is now available for use in the variable Note, just as it was earlier. +The component exported by the module is now available for use in the variable Note, just as it was earlier. Note that when importing our own components, their location must be given in relation to the importing file: @@ -453,76 +445,34 @@ Note that when importing our own components, their location must be given in './components/Note' ``` -The period in the beginning refers to the current directory, so the module's location is a file called Note.js in a sub-directory of the current components directory. The filename extension (_.js_) can be omitted. +The period - . - in the beginning refers to the current directory, so the module's location is a file called Note.jsx in the components sub-directory of the current directory. The filename extension _.jsx_ can be omitted. -App is a component as well, so let's declare it in its own module as well. Since it is the root component of the application, we'll place it in the src directory. The contents of the file are as follows: - -```js -import React from 'react' -import Note from './components/Note' - -const App = ({ notes }) => { - return ( -
    -

    Notes

    -
      - {notes.map((note) => - - )} -
    -
    - ) -} - -export default App // highlight-line -``` - -What's left in the index.js file is: - -```js -import React from 'react' -import ReactDOM from 'react-dom' -import App from './App' // highlight-line - -const notes = [ - // ... -] - -ReactDOM.render( - , - document.getElementById('root') -) -``` - -Modules have plenty of other uses other than enabling component declarations to be separated into their own files. We will get back into them later in this course. +Modules have plenty of other uses other than enabling component declarations to be separated into their own files. We will get back to them later in this course. +The current code of the application can be found on [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-1). -The current code of the application can be found on [GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part2-1). +Note that the main branch of the repository contains the code for a later version of the application. The current code is in the branch [part2-1](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-1): +![GitHub branch screenshot](../../images/2/2e.png) -Note that the master branch of the repository contains the code for a later version of the application. The current code is in the branch [part2-1](https://github.com/fullstack-hy2020/part2-notes/tree/part2-1): +If you clone the project, run the command _npm install_ before starting the application with _npm run dev_. -![](../../images/2/2e.png) +### When the Application Breaks -If you clone the project, run the command _npm install_ before starting the application with _npm start_. +Early in your programming career (and even after 30 years of coding like yours truly), what often happens is that the application just completely breaks down. This is even more so the case with dynamically typed languages, such as JavaScript, where the compiler does not check the data type. For instance, function variables or return values. -### When the application breaks +A "React explosion" can, for example, look like this: -Early in your programming career (and even after 30 years of coding like yours truly), what often happens is that the application just completely breaks down. This is even more the case with dynamically typed languages, such as JavaScript, where the compiler does not check the data type of, for instance, function variables or return values. +![react sample error](../../images/2/3-vite.png) +In these situations, your best way out is the console.log method. -A "React explosion" can for example look like this: - -![](../../images/2/3b.png) - - -In these situations your best way out is the console.log. -The piece of code causing the explosion is this: +The piece of code causing the explosion is this: ```js const Course = ({ course }) => (
    -
    +
    ) @@ -539,8 +489,7 @@ const App = () => { } ``` - -We'll hone in on the reason for the breakdown by adding console.log commands to the code. Because the first thing to be rendered is the App component, it's worth putting the first console.log there: +We'll hone in on the reason for the breakdown by adding console.log commands to the code. Because the first thing to be rendered is the App component, it's worth putting the first console.log there: ```js const App = () => { @@ -556,34 +505,34 @@ const App = () => { } ``` -To see the printing on the console, we must scroll up over the long red wall of errors. +To see the printing in the console, we must scroll up over the long red wall of errors. -![](../../images/2/4b.png) +![initial printing of the console](../../images/2/4b.png) -When one thing is found to be working, it's time to log deeper. If the component has been declared as a single statement, or a function without a return, it makes printing to the console harder. +When one thing is found to be working, it's time to log deeper. If the component has been declared as a single statement or a function without a return, it makes printing to the console harder. ```js const Course = ({ course }) => (
    -
    +
    ) ``` -The component should be changed to its longer form in order for us to add the printing: +The component should be changed to its longer form for us to add the printing: ```js const Course = ({ course }) => { console.log(course) // highlight-line return (
    -
    +
    ) } ``` -Quite often the root of the problem is that the props are expected to be of a different type, or called with a different name than they actually are, and destructuring fails as a result. The problem often begins to solve itself when destructuring is removed and we see what the props actually contains. +Quite often the root of the problem is that the props are expected to be of a different type, or called with a different name than they actually have, and destructuring fails as a result. The problem often begins to solve itself when destructuring is removed and we see what the props contain. ```js const Course = (props) => { // highlight-line @@ -591,16 +540,27 @@ const Course = (props) => { // highlight-line const { course } = props return (
    -
    +
    ) } ``` -If the problem has still not been resolved, there really isn't much to do apart from continuing to bug-hunt by sprinkling more _console.log_ statements around your code. +If the problem has still not been resolved, sadly there isn't much to do apart from continuing to bug-hunt by sprinkling more _console.log_ statements around your code. I added this chapter to the material after the model answer for the next question exploded completely (due to props being of the wrong type), and I had to debug it using console.log. +### Web developer's oath + +Before the exercises, let me remind what you promised at the end of the previous part. + +Programming is hard, that is why I will use all the possible means to make it easier + +- I will have my browser developer console open all the time +- I progress with small steps +- I will write lots of _console.log_ statements to make sure I understand how the code behaves and to help pinpoint problems +- If my code does not work, I will not write more code. Instead, I start deleting the code until it works or just return to a state when everything was still working +- When I ask for help in the course Discord channel or elsewhere I formulate my questions properly, see [here](/en/part0/general_info#how-to-get-help-in-discord) how to ask for help @@ -614,21 +574,17 @@ You can submit all of the exercises into the same repository, or use multiple di The exercises are submitted **One part at a time**. When you have submitted the exercises for a part, you can no longer submit any missed exercises for that part. -Note that this part has more exercises than the ones before, so do not submit before you have done all exercises from this part you want to submit. - -**WARNING** create-react-app makes the project automatically into a git-repository, if the project is not created inside of an already existing repository. You probably **do not** want the project to become a repository, so run the command _rm -rf .git_ from its root. - -

    2.1: course contents step6

    +Note that this part has more exercises than the ones before, so do not submit until you have done all the exercises from this part you want to submit. +

    2.1: Course information step 6

    -Let's finish the code for rendering course contents from exercises 1.1 - 1.5. You can start with the code from the model answers. The model answers for part 1 can be found by going to the [submission system](https://studies.cs.helsinki.fi/stats/courses/fullstackopen), click on my submissions at the top, and in the row corresponding to part 1 under the solutions column click on show. To see the solution to the course info exercise, click on _index.js_ under kurssitiedot ("kurssitiedot" means "course info"). +Let's finish the code for rendering course contents from exercises 1.1 - 1.5. You can start with the code from the model answers. The model answers for part 1 can be found by going to the [submission system](https://studies.cs.helsinki.fi/stats/courses/fullstackopen), clicking on my submissions at the top, and in the row corresponding to part 1 under the solutions column clicking on show. To see the solution to the course info exercise, click on _App.jsx_ under courseinfo. +**Note that if you copy a project from one place to another, you might have to delete the node\_modules directory and install the dependencies again with the command _npm install_ before you can start the application.** -**Note that if you copy a project from one place to another, you might have to destroy the node\_modules directory and install the dependencies again with the command _npm install_ before you can start the application.** -It might not be good to copy a project or to put the node\_modules directory into the version control per se. +Generally, it's not recommended that you copy a project's whole contents and/or add the node\_modules directory to the version control system. - -Let's change the App component like so: +Let's change the App component like so: ```js const App = () => { @@ -654,19 +610,17 @@ const App = () => { ] } - return ( -
    - -
    - ) + return } + +export default App ``` -Define a component responsible for formatting a single course called Course. +Define a component responsible for formatting a single course called Course. -The component structure of the application can be, for example, the following: +The component structure of the application can be, for example, the following: -
    +```
     App
       Course
         Header
    @@ -674,28 +628,27 @@ App
           Part
           Part
           ...
    -
    +``` -Hence, the Course component contains the components defined in the previous part, which are responsible for rendering the course name and its parts. +Hence, the Course component contains the components defined in the previous part, which are responsible for rendering the course name and its parts. -The rendered page can, for example, look as follows: +The rendered page can, for example, look as follows: -![](../../images/teht/8e.png) +![half stack application screenshot](../../images/teht/8e.png) -You don't need the sum of the exercises yet. +You don't need the sum of the exercises yet. -The application must work regardless of the number of parts a course has, so make sure the application works if you add or remove parts of a course. +The application must work regardless of the number of parts a course has, so make sure the application works if you add or remove parts of a course. Ensure that the console shows no errors! -

    2.2: Course contents step7

    - +

    2.2: Course information step 7

    -Show also the sum of the exercises of the course. +Show also the sum of the exercises of the course. -![](../../images/teht/9e.png) +![sum of exercises added feature](../../images/teht/9e.png) -

    2.3*: Course contents step8

    +

    2.3*: Course information step 8

    If you haven't done so already, calculate the sum of exercises with the array method [reduce](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce). @@ -703,24 +656,21 @@ If you haven't done so already, calculate the sum of exercises with the array me ```js const total = - parts.reduce( (s, p) => someMagicHere ) + parts.reduce((s, p) => someMagicHere) ``` - -and does not work, it's worth to use console.log, which requires the arrow function to be written in its longer form: + +and does not work, it's worth it to use console.log, which requires the arrow function to be written in its longer form: ```js -const total = parts.reduce( (s, p) => { +const total = parts.reduce((s, p) => { console.log('what is happening', s, p) return someMagicHere }) ``` -**Pro tip2:** There is a [plugin for VS code](https://marketplace.visualstudio.com/items?itemName=cmstead.jsrefactor) that automatically changes short form arrow functions into their longer form, and vice versa. - -![](../../images/2/5b.png) - -

    2.4: Course contents step9

    +**Not working? :** Use your search engine to look up how _reduce_ is used in an **Object Array**. +

    2.4: Course information step 9

    Let's extend our application to allow for an arbitrary number of courses: @@ -779,12 +729,12 @@ const App = () => { } ``` -The application can, for example, look like this: +The application can, for example, look like this: -![](../../images/teht/10e.png) +![arbitrary number of courses feature add-on](../../images/teht/10e.png) -

    2.5: separate module

    +

    2.5: Separate module step 10

    -Declare the Course component as a separate module, which is imported by the App component. You can include all subcomponents of the course into the same module. +Declare the Course component as a separate module, which is imported by the App component. You can include all subcomponents of the course in the same module. diff --git a/src/content/2/en/part2b.md b/src/content/2/en/part2b.md index bfeb1f2893c..711b5c3f076 100644 --- a/src/content/2/en/part2b.md +++ b/src/content/2/en/part2b.md @@ -7,12 +7,14 @@ lang: en
    -Let's continue expanding our application by allowing users to add new notes. +Let's continue expanding our application by allowing users to add new notes. You can find the code for our current application [here](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-1). -In order to get our page to update when new notes are added it's best to store the notes in the App component's state. Let's import the [useState](https://reactjs.org/docs/hooks-state.html) function and use it to define a piece of state that gets initialized with the initial notes array passed in the props. +### Saving the notes in the component state + +To get our page to update when new notes are added it's best to store the notes in the App component's state. Let's import the [useState](https://react.dev/reference/react/useState) function and use it to define a piece of state that gets initialized with the initial notes array passed in the props. ```js -import React, { useState } from 'react' // highlight-line +import { useState } from 'react' // highlight-line import Note from './components/Note' const App = (props) => { // highlight-line @@ -43,8 +45,11 @@ const App = (props) => { } ``` +We can also use React Developer Tools to see that this really happens: + +![browser showing dev react tools window](../../images/2/30.png) -If we wanted to start with an empty list of notes we would set the initial value as an empty array, and since the props would not then be used, we could omit the props parameter from the function definition: +If we wanted to start with an empty list of notes, we would set the initial value as an empty array, and since the props would not be used, we could omit the props parameter from the function definition: ```js const App = () => { @@ -54,10 +59,8 @@ const App = () => { } ``` - Let's stick with the initial value passed in the props for the time being. - Next, let's add an HTML [form](https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms) to the component that will be used for adding new notes. ```js @@ -90,7 +93,7 @@ const App = (props) => { } ``` -We have added the _addNote_ function as an event handler to the form element that will be called when the form is submitted by clicking the submit button. +We have added the _addNote_ function as an event handler to the form element that will be called when the form is submitted, by clicking the submit button. We use the method discussed in [part 1](/en/part1/component_state_event_handlers#event-handling) for defining our event handler: @@ -101,25 +104,23 @@ const addNote = (event) => { } ``` -The event parameter is the [event](https://reactjs.org/docs/handling-events.html) that triggers the call to the event handler function: - - -The event handler immediately calls the event.preventDefault() method, which prevents the default action of submitting a form. The default action would, among other things, cause the page to reload. - +The event parameter is the [event](https://react.dev/learn/responding-to-events) that triggers the call to the event handler function: -The target of the event stored in _event.target_ is logged to the console +The event handler immediately calls the event.preventDefault() method, which prevents the default action of submitting a form. The default action would, [among other things](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit_event), cause the page to reload. -![](../../images/2/6e.png) +The target of the event stored in _event.target_ is logged to the console: +![button clicked with form object console](../../images/2/6e.png) The target in this case is the form that we have defined in our component. How do we access the data contained in the form's input element? -There are many ways to accomplish this; the first method we will take a look at is the use of so-called [controlled components](https://reactjs.org/docs/forms.html#controlled-components). +### Controlled component +There are many ways to accomplish this; the first method we will take a look at is through the use of so-called [controlled components](https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable). -Let's add a new piece of state called newNote for storing the user submitted input **and** let's set it as the input element's value attribute: +Let's add a new piece of state called newNote for storing the user-submitted input **and** let's set it as the input element's value attribute: ```js const App = (props) => { @@ -154,11 +155,11 @@ const App = (props) => { The placeholder text stored as the initial value of the newNote state appears in the input element, but the input text can't be edited. The console displays a warning that gives us a clue as to what might be wrong: -![](../../images/2/7e.png) +![provided value to prop without onchange console error](../../images/2/7e.png) -Since we assigned a piece of the App component's state as the value attribute of the input element, the App component now [controls](https://reactjs.org/docs/forms.html#controlled-components) the behavior of the input element. +Since we assigned a piece of the App component's state as the value attribute of the input element, the App component now [controls](https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable) the behavior of the input element. -In order to enable editing of the input element, we have to register an event handler that synchronizes the changes made to the input with the component's state: +To enable editing of the input element, we have to register an event handler that synchronizes the changes made to the input with the component's state: ```js const App = (props) => { @@ -214,17 +215,17 @@ const handleNoteChange = (event) => { } ``` -The target property of the event object now corresponds to the controlled input element and event.target.value refers to the input value of that element. +The target property of the event object now corresponds to the controlled input element, and event.target.value refers to the input value of that element. -Note that we did not need to call the _event.preventDefault()_ method like we did in the onSubmit event handler. This is because there is no default action that occurs on an input change, unlike on a form submission. +Note that we did not need to call the _event.preventDefault()_ method like we did in the onSubmit event handler. This is because no default action occurs on an input change, unlike a form submission. You can follow along in the console to see how the event handler is called: -![](../../images/2/8e.png) +![multiple console calls with typing text](../../images/2/8e.png) You did remember to install [React devtools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi), right? Good. You can directly view how the state changes from the React Devtools tab: -![](../../images/2/9ea.png) +![state changes in react devtools shows typing too](../../images/2/9ea.png) Now the App component's newNote state reflects the current value of the input, which means that we can complete the addNote function for creating new notes: @@ -233,9 +234,8 @@ const addNote = (event) => { event.preventDefault() const noteObject = { content: newNote, - date: new Date().toISOString(), important: Math.random() < 0.5, - id: notes.length + 1, + id: String(notes.length + 1), } setNotes(notes.concat(noteObject)) @@ -243,15 +243,15 @@ const addNote = (event) => { } ``` -First we create a new object for the note called noteObject that will receive its content from the component's newNote state. The unique identifier id is generated based on the total number of notes. This method works for our application since notes are never deleted. With the help of the Math.random() command, our note has a 50% chance of being marked as important. +First, we create a new object for the note called noteObject that will receive its content from the component's newNote state. The unique identifier id is generated based on the total number of notes. This method works for our application since notes are never deleted. With the help of the Math.random() function, our note has a 50% chance of being marked as important. -The new note is added to the list of notes using the [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat) array method introduced in [part 1](/en/part1/javascript#arrays): +The new note is added to the list of notes using the [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat) array method, introduced in [part 1](/en/part1/java_script#arrays): ```js setNotes(notes.concat(noteObject)) ``` -The method does not mutate the original notes state array, but rather creates a new copy of the array with the new item added to the end. This is important since we must [never mutate state directly](https://reactjs.org/docs/state-and-lifecycle.html#using-state-correctly) in React! +The method does not mutate the original notes array, but rather creates a new copy of the array with the new item added to the end. This is important since we must [never mutate state directly](https://react.dev/learn/updating-objects-in-state#why-is-mutating-state-not-recommended-in-react) in React! The event handler also resets the value of the controlled input element by calling the setNewNote function of the newNote state: @@ -259,7 +259,7 @@ The event handler also resets the value of the controlled input element by calli setNewNote('') ``` -You can find the code for our current application in its entirety in the part2-2 branch of [this github repository](https://github.com/fullstack-hy2020/part2-notes/tree/part2-2). +You can find the code for our current application in its entirety in the part2-2 branch of [this GitHub repository](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-2). ### Filtering Displayed Elements @@ -277,10 +277,10 @@ const App = (props) => { } ``` -Let's change the component so that it stores a list of all the notes to be displayed in the notesToShow variable. The items of the list depend on the state of the component: +Let's change the component so that it stores a list of all the notes to be displayed in the notesToShow variable. The items on the list depend on the state of the component: ```js -import React, { useState } from 'react' +import { useState } from 'react' import Note from './components/Note' const App = (props) => { @@ -328,28 +328,28 @@ const result = condition ? val1 : val2 the result variable will be set to the value of val1 if condition is true. If condition is false, the result variable will be set to the value ofval2. -If the value of showAll is false, the notesToShow variable will be assigned to a list that only contain notes that have the important property set to true. Filtering is done with the help of the array [filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter) method: +If the value of showAll is false, the notesToShow variable will be assigned to a list that only contains notes that have the important property set to true. Filtering is done with the help of the array [filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter) method: ```js notes.filter(note => note.important === true) ``` -The comparison operator is in fact redundant, since the value of note.important is either true or false which means that we can simply write: +The comparison operator is redundant, since the value of note.important is either true or false, which means that we can simply write: ```js notes.filter(note => note.important) ``` -The reason we showed the comparison operator first was to emphasize an important detail: in JavaScript val1 == val2 does not work as expected in all situations and it's safer to use val1 === val2 exclusively in comparisons. You can read more about the topic [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness). +We showed the comparison operator first to emphasize an important detail: in JavaScript val1 == val2 does not always work as expected. When performing comparisons, it's therefore safer to exclusively use val1 === val2. You can read more about the topic [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness). You can test out the filtering functionality by changing the initial value of the showAll state. -Next let's add functionality that enables users to toggle the showAll state of the application from the user interface. +Next, let's add functionality that enables users to toggle the showAll state of the application from the user interface. The relevant changes are shown below: ```js -import React, { useState } from 'react' +import { useState } from 'react' import Note from './components/Note' const App = (props) => { @@ -365,7 +365,7 @@ const App = (props) => { // highlight-start
    // highlight-end @@ -380,8 +380,7 @@ const App = (props) => { } ``` - -The displayed notes (all versus important) is controlled with a button. The event handler for the button is so simple that it has been defined directly in the attribute of the button element. The event handler switches the value of _showAll_ from true to false and vice versa: +The displayed notes (all versus important) are controlled with a button. The event handler for the button is so simple that it has been defined directly in the attribute of the button element. The event handler switches the value of _showAll_ from true to false and vice versa: ```js () => setShowAll(!showAll) @@ -393,33 +392,31 @@ The text of the button depends on the value of the showAll state: show {showAll ? 'important' : 'all'} ``` -You can find the code for our current application in its entirety in the part2-3 branch of [this github repository](https://github.com/fullstack-hy2020/part2-notes/tree/part2-3). +You can find the code for our current application in its entirety in the part2-3 branch of [this GitHub repository](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-3).

    Exercises 2.6.-2.10.

    -In the first exercise, we will start working on an application that will be further developed in the later exercises. In related sets of exercises it is sufficient to return the final version of your application. You may also make a separate commit after you have finished each part of the exercise set, but doing so is not required. - -**WARNING** create-react-app will automatically turn your project into a git-repository unless you create your application inside of an existing git repository. It's likely that you **do not want** your project to be a repository, so simply run the _rm -rf .git_ command at the root of your application. +In the first exercise, we will start working on an application that will be further developed in the later exercises. In related sets of exercises, it is sufficient to return the final version of your application. You may also make a separate commit after you have finished each part of the exercise set, but doing so is not required. -

    2.6: The Phonebook Step1

    +

    2.6: The Phonebook Step 1

    -Let's create a simple phonebook. **In this part we will only be adding names to the phonebook.** +Let's create a simple phonebook. **In this part, we will only be adding names to the phonebook.** -Let us start with implementing the addition of a person to phonebook. +Let us start by implementing the addition of a person to the phonebook. You can use the code below as a starting point for the App component of your application: ```js -import React, { useState } from 'react' +import { useState } from 'react' const App = () => { - const [ persons, setPersons ] = useState([ + const [persons, setPersons] = useState([ { name: 'Arto Hellas' } ]) - const [ newName, setNewName ] = useState('') + const [newName, setNewName] = useState('') return (
    @@ -445,31 +442,30 @@ The newName state is meant for controlling the form input element. Sometimes it can be useful to render state and other variables as text for debugging purposes. You can temporarily add the following element to the rendered component: -``` +```html
    debug: {newName}
    ``` -It's also important to put what we learned in the [debugging React applications](/en/part1/a_more_complex_state_debugging_react_apps) chapter of part one into good use. The [React developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) extension especially, is incredibly useful for tracking changes that occur in the application's state. +It's also important to put what we learned in the [debugging React applications](/en/part1/a_more_complex_state_debugging_react_apps) chapter of part one into good use. The [React developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) extension is incredibly useful for tracking changes that occur in the application's state. After finishing this exercise your application should look something like this: -![](../../images/2/10e.png) +![screenshot of 2.6 finished](../../images/2/10e.png) Note the use of the React developer tools extension in the picture above! **NB:** - -- you can use the person's name as value of the key property +- you can use the person's name as a value of the key property - remember to prevent the default action of submitting HTML forms! -

    2.7: The Phonebook Step2

    +

    2.7: The Phonebook Step 2

    -Prevent the user from being able to add names that already exist in the phonebook. JavaScript arrays have numerous suitable [methods](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array) for accomplishing this task. +Prevent the user from being able to add names that already exist in the phonebook. JavaScript arrays have numerous suitable [methods](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array) for accomplishing this task. Keep in mind [how object equality works](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness) in Javascript. Issue a warning with the [alert](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert) command when such an action is attempted: -![](../../images/2/11e.png) +![browser alert: "user already exists in the phonebook"](../../images/2/11e.png) **Hint:** when you are forming strings that contain values from variables, it is recommended to use a [template string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals): @@ -491,7 +487,7 @@ newName + ' is already added to phonebook' Using template strings is the more idiomatic option and the sign of a true JavaScript professional. -

    2.8: The Phonebook Step3

    +

    2.8: The Phonebook Step 3

    Expand your application by allowing users to add phone numbers to the phone book. You will need to add a second input element to the form (along with its own event handler): @@ -503,29 +499,27 @@ Expand your application by allowing users to add phone numbers to the phone book ``` +At this point, the application could look something like this. The image also displays the application's state with the help of [React developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi): -At this point the application could look something like this. The image also displays the application's state with the help of [React developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi): - -![](../../images/2/12e.png) +![2.8 sample screenshot](../../images/2/12e.png) -

    2.9*: The Phonebook Step4

    +

    2.9*: The Phonebook Step 4

    Implement a search field that can be used to filter the list of people by name: -![](../../images/2/13e.png) +![2.9 search field](../../images/2/13e.png) You can implement the search field as an input element that is placed outside the HTML form. The filtering logic shown in the image is case insensitive, meaning that the search term arto also returns results that contain Arto with an uppercase A. - **NB:** When you are working on new functionality, it's often useful to "hardcode" some dummy data into your application, e.g. ```js const App = () => { const [persons, setPersons] = useState([ - { name: 'Arto Hellas', number: '040-123456' }, - { name: 'Ada Lovelace', number: '39-44-5323523' }, - { name: 'Dan Abramov', number: '12-43-234345' }, - { name: 'Mary Poppendieck', number: '39-23-6423122' } + { name: 'Arto Hellas', number: '040-123456', id: 1 }, + { name: 'Ada Lovelace', number: '39-44-5323523', id: 2 }, + { name: 'Dan Abramov', number: '12-43-234345', id: 3 }, + { name: 'Mary Poppendieck', number: '39-23-6423122', id: 4 } ]) // ... @@ -534,11 +528,11 @@ const App = () => { This saves you from having to manually input data into your application for testing out your new functionality. -

    2.10: The Phonebook Step5

    +

    2.10: The Phonebook Step 5

    If you have implemented your application in a single component, refactor it by extracting suitable parts into new components. Maintain the application's state and all event handlers in the App root component. -It is sufficient to extract **three** components from the application. Good candidates for separate components are, for example, the search filter, the form for adding new people into the phonebook, a component that renders all people from the phonebook, and a component that renders a single person's details. +It is sufficient to extract **three** components from the application. Good candidates for separate components are, for example, the search filter, the form for adding new people to the phonebook, a component that renders all people from the phonebook, and a component that renders a single person's details. The application's root component could look similar to this after the refactoring. The refactored root component below only renders titles and lets the extracted components take care of the rest. @@ -566,9 +560,6 @@ const App = () => { } ``` - -**NB**: You might run into problems in this exercise if you define your components "in the wrong place". Now would be a good time to rehearse -the chapter [do not define a component in another component](/en/part1/a_more_complex_state_debugging_react_apps#do-not-define-components-within-components) -from last part. +**NB**: You might run into problems in this exercise if you define your components "in the wrong place". Now would be a good time to rehearse the chapter [do not define a component in another component](/en/part1/a_more_complex_state_debugging_react_apps#do-not-define-components-within-components) from the last part.
    diff --git a/src/content/2/en/part2c.md b/src/content/2/en/part2c.md index 885ea1616b3..891da581bb7 100644 --- a/src/content/2/en/part2c.md +++ b/src/content/2/en/part2c.md @@ -7,54 +7,47 @@ lang: en
    -For a while now we have only been working on "frontend", i.e. client-side (browser) functionality. We will begin working on "backend", i.e. server-side functionality in the third part of this course. Nonetheless, we will now take a step in that direction by familiarizing ourselves with how code executing in the browser communicates with the backend. +For a while now we have only been working on "frontend", i.e. client-side (browser) functionality. We will begin working on "backend", i.e. server-side functionality in the [third part](/en/part3) of this course. Nonetheless, we will now take a step in that direction by familiarizing ourselves with how the code executing in the browser communicates with the backend. Let's use a tool meant to be used during software development called [JSON Server](https://github.com/typicode/json-server) to act as our server. -Create a file named db.json in the root directory of the project with the following content: +Create a file named db.json in the root directory of the previous notes project with the following content: ```json { "notes": [ { - "id": 1, + "id": "1", "content": "HTML is easy", - "date": "2019-05-30T17:30:31.098Z", "important": true }, { - "id": 2, - "content": "Browser can execute only Javascript", - "date": "2019-05-30T18:39:34.091Z", + "id": "2", + "content": "Browser can execute only JavaScript", "important": false }, { - "id": 3, + "id": "3", "content": "GET and POST are the most important methods of HTTP protocol", - "date": "2019-05-30T19:20:14.298Z", "important": true } ] } ``` -You can [install](https://github.com/typicode/json-server#getting-started) JSON server globally on your machine using the command _npm install -g json-server_. A global installation requires administrative privileges, which means that it is not possible on the faculty computers or freshman laptops. - -However, a global installation is not necessary. From the root directory of your app, we can run the json-server using the command _npx_: +You can start the JSON Server without a separate installation by running the following _npx_ command in the root directory of the application: ```js -npx json-server --port 3001 --watch db.json +npx json-server --port 3001 db.json ``` -The json-server starts running on port 3000 by default; but since projects created using create-react-app reserve port 3000, we must define an alternate port, such as port 3001, for the json-server. - -Let's navigate to the address in the browser. We can see that json-server serves the notes we previously wrote to the file in JSON format: +The JSON Server starts running on port 3000 by default, but we will now define an alternate port 3001. Let's navigate to the address in the browser. We can see that JSON Server serves the notes we previously wrote to the file in JSON format: -![](../../images/2/14e.png) +![notes on json format in the browser at localhost:3001/notes](../../images/2/14new.png) -If your browser doesn't have a way to format the display of JSON-data, then install an appropriate plugin, e.g. [JSONView](https://chrome.google.com/webstore/detail/jsonview/chklaanhfefbnpoihckbnefhakgolnmc) to make your life easier. +If your browser doesn't have a way to format the display of JSON-data, then install an appropriate plugin, e.g. [JSONView](https://chromewebstore.google.com/detail/gmegofmjomhknnokphhckolhcffdaihd) to make your life easier. -Going forward, the idea will be to save the notes to the server, which in this case means saving to the json-server. The React code fetches the notes from the server and renders them to the screen. Whenever a new note is added to the application the React code also sends it to the server to make the new note persist in "memory". +Going forward, the idea will be to save the notes to the server, which in this case means saving them to the json-server. The React code fetches the notes from the server and renders them to the screen. Whenever a new note is added to the application, the React code also sends it to the server to make the new note persist in "memory". json-server stores all the data in the db.json file, which resides on the server. In the real world, data would be stored in some kind of database. However, json-server is a handy tool that enables the use of server-side functionality in the development phase without the need to program any of it. @@ -64,12 +57,11 @@ We will get familiar with the principles of implementing server-side functionali Our first task is fetching the already existing notes to our React application from the address . -In the part0 [example project](/en/part0/fundamentals_of_web_apps#running-application-logic-on-the-browser) we already learned a way to fetch data from a server using JavaScript. The code in the example was fetching the data using [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest), otherwise known as an HTTP request made using an XHR object. This is a technique introduced in 1999, which every browser has supported for a good while now. +In the part0 [example project](/en/part0/fundamentals_of_web_apps#running-application-logic-on-the-browser), we already learned a way to fetch data from a server using JavaScript. The code in the example was fetching the data using [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest), otherwise known as an HTTP request made using an XHR object. This is a technique introduced in 1999, which every browser has supported for a good while now. The use of XHR is no longer recommended, and browsers already widely support the [fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) method, which is based on so-called [promises](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise), instead of the event-driven model used by XHR. -As a reminder from part0 (which one should in fact remember to not use without a pressing reason), data was fetched using XHR in the following way: - +As a reminder from part0 (which one should remember to not use without a pressing reason), data was fetched using XHR in the following way: ```js const xhttp = new XMLHttpRequest() @@ -85,16 +77,16 @@ xhttp.open('GET', '/data.json', true) xhttp.send() ``` -Right at the beginning we register an event handler to the xhttp object representing the HTTP request, which will be called by the JavaScript runtime whenever the state of the xhttp object changes. If the change in state means that the response to the request has arrived, then the data is handled accordingly. +Right at the beginning, we register an event handler to the xhttp object representing the HTTP request, which will be called by the JavaScript runtime whenever the state of the xhttp object changes. If the change in state means that the response to the request has arrived, then the data is handled accordingly. It is worth noting that the code in the event handler is defined before the request is sent to the server. Despite this, the code within the event handler will be executed at a later point in time. Therefore, the code does not execute synchronously "from top to bottom", but does so asynchronously. JavaScript calls the event handler that was registered for the request at some point. -A synchronous way of making requests that's common in Java programming, for instance, would play out as follows (NB this is not actually working Java code): +A synchronous way of making requests that's common in Java programming, for instance, would play out as follows (NB, this is not actually working Java code): ```java HTTPRequest request = new HTTPRequest(); -String url = "https://fullstack-exampleapp.herokuapp.com/data.json"; +String url = "https://studies.cs.helsinki.fi/exampleapp/data.json"; List notes = request.get(url); notes.forEach(m => { @@ -102,15 +94,15 @@ notes.forEach(m => { }); ``` -In Java the code executes line by line and stops to wait for the HTTP request, which means waiting for the command _request.get(...)_ to finish. The data returned by the command, in this case the notes, are then stored in a variable, and we begin manipulating the data in the desired manner. +In Java, the code executes line by line and stops to wait for the HTTP request, which means waiting for the command _request.get(...)_ to finish. The data returned by the command, in this case the notes, are then stored in a variable, and we begin manipulating the data in the desired manner. -On the other hand, JavaScript engines, or runtime environments, follow the [asynchronous model](https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop). In principle, this requires all [IO-operations](https://en.wikipedia.org/wiki/Input/output) (with some exceptions) to be executed as non-blocking. This means that the code execution continues immediately after calling an IO function, without waiting for it to return. +In contrast, JavaScript engines, or runtime environments follow the [asynchronous model](https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop). In principle, this requires all [IO operations](https://en.wikipedia.org/wiki/Input/output) (with some exceptions) to be executed as non-blocking. This means that code execution continues immediately after calling an IO function, without waiting for it to return. -When an asynchronous operation is completed, or more specifically, at some point after its completion, the JavaScript engine calls the event handlers registered to the operation. +When an asynchronous operation is completed, or, more specifically, at some point after its completion, the JavaScript engine calls the event handlers registered to the operation. -Currently, JavaScript engines are single-threaded, which means that they cannot execute code in parallel. As a result, it is a requirement in practise to use a non-blocking model for executing IO operations. Otherwise, the browser would "freeze" during, for instance, the fetching of data from a server. +Currently, JavaScript engines are single-threaded, which means that they cannot execute code in parallel. As a result, it is a requirement in practice to use a non-blocking model for executing IO operations. Otherwise, the browser would "freeze" during, for instance, the fetching of data from a server. -Another consequence of this single threaded nature of Javascript engines is that if some code execution takes up a lot of time, the browser will get stuck for the duration of the execution. If we added the following code at the top of our application: +Another consequence of this single-threaded nature of JavaScript engines is that if some code execution takes up a lot of time, the browser will get stuck for the duration of the execution. If we added the following code at the top of our application: ```js setTimeout(() => { @@ -125,7 +117,7 @@ setTimeout(() => { everything would work normally for 5 seconds. However, when the function defined as the parameter for setTimeout is run, the browser will be stuck for the duration of the execution of the long loop. Even the browser tab cannot be closed during the execution of the loop, at least not in Chrome. -For the browser to remain responsive, i.e. to be able to continuously react to user operations with sufficient speed, the code logic needs to be such that no single computation can take too long. +For the browser to remain responsive, i.e., to be able to continuously react to user operations with sufficient speed, the code logic needs to be such that no single computation can take too long. There is a host of additional material on the subject to be found on the internet. One particularly clear presentation of the topic is the keynote by Philip Roberts called [What the heck is the event loop anyway?](https://www.youtube.com/watch?v=8aGhZQkoFbQ) @@ -135,72 +127,71 @@ In today's browsers, it is possible to run parallelized code with the help of so Let's get back to the topic of fetching data from the server. -We could use the previously mentioned promise based function [fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) to pull the data from the server. Fetch is a great tool. It is standardized and supported by all modern browsers (excluding IE). +We could use the previously mentioned promise-based function [fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) to pull the data from the server. Fetch is a great tool. It is standardized and supported by all modern browsers (excluding IE). -That being said, we will be using the [axios](https://github.com/axios/axios) library instead for communication between the browser and server. It functions like fetch, but is somewhat more pleasant to use. Another good reason to use axios is our getting familiar with adding external libraries, so-called npm packages, to React projects. +That being said, we will be using the [axios](https://github.com/axios/axios) library instead for communication between the browser and server. It functions like fetch but is somewhat more pleasant to use. Another good reason to use Axios is that it helps us get familiar with adding external libraries, or npm packages, to React projects. -Nowadays, practically all JavaScript projects are defined using the node package manager, aka [npm](https://docs.npmjs.com/getting-started/what-is-npm). The projects created using create-react-app also follow the npm format. A clear indicator that a project uses npm is the package.json file located at the root of the project: +Nowadays, practically all JavaScript projects are defined using the node package manager, aka [npm](https://docs.npmjs.com/about-npm). The projects created using Vite also follow the npm format. A clear indicator that a project uses npm is the package.json file located at the root of the project: ```json { - "name": "notes", - "version": "0.1.0", + "name": "part2-notes-frontend", "private": true, - "dependencies": { - "@testing-library/jest-dom": "^4.2.4", - "@testing-library/react": "^9.4.0", - "@testing-library/user-event": "^7.2.1", - "react": "^16.12.0", - "react-dom": "^16.12.0", - "react-scripts": "3.3.0" - }, + "version": "0.0.0", + "type": "module", "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject" + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" }, - "eslintConfig": { - "extends": "react-app" + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.17.0", + "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.16", + "globals": "^15.14.0", + "vite": "^6.0.5" } } ``` -At this point the dependencies part is of most interest to us as it defines what dependencies, or external libraries, the project has. +At this point, the dependencies part is of most interest to us as it defines what dependencies, or external libraries, the project has. We now want to use axios. Theoretically, we could define the library directly in the package.json file, but it is better to install it from the command line. ```js -npm install axios --save +npm install axios ``` - **NB _npm_-commands should always be run in the project root directory**, which is where the package.json file can be found. Axios is now included among the other dependencies: ```json { + "name": "part2-notes-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, "dependencies": { - "@testing-library/jest-dom": "^4.2.4", - "@testing-library/react": "^9.4.0", - "@testing-library/user-event": "^7.2.1", - "axios": "^0.19.1", // highlight-line - "react": "^16.12.0", - "react-dom": "^16.12.0", - "react-scripts": "3.3.0" + "axios": "^1.7.9", // highlight-line + "react": "^18.3.1", + "react-dom": "^18.3.1" }, // ... } @@ -220,11 +211,11 @@ and making a small addition to the scripts part of the package.jsonCannot bind to the port 3001. Please specify another port number either through --port argument or through the json-server.json configuration file +Cannot bind to port 3001. Please specify another port number either through --port argument or through the json-server.json configuration file As we can see, the application is not able to bind itself to the [port](https://en.wikipedia.org/wiki/Port_(computer_networking)). The reason being that port 3001 is already occupied by the previously started json-server. We used the command _npm install_ twice, but with slight differences: ```js -npm install axios --save +npm install axios npm install json-server --save-dev ``` -There is a fine difference in the parameters. axios is installed as a runtime dependency (_--save_) of the application, because the execution of the program requires the existence of the library. On the other hand, json-server was installed as a development dependency (_--save-dev_), since the program itself doesn't require it. It is used for assistance during software development. There will be more on different dependencies in the next part of the course. +There is a fine difference in the parameters. axios is installed as a runtime dependency of the application because the execution of the program requires the existence of the library. On the other hand, json-server was installed as a development dependency (_--save-dev_), since the program itself doesn't require it. It is used for assistance during software development. There will be more on different dependencies in the next part of the course. ### Axios and promises -Now we are ready to use axios. Going forward, json-server is assumed to be running on port 3001. +Now we are ready to use Axios. Going forward, json-server is assumed to be running on port 3001. -The library can be brought into use the same way other libraries, e.g. React, are, i.e. by using an appropriate import statement. +NB: To run json-server and your react app simultaneously, you may need to use two terminal windows. One to keep json-server running and the other to run our React application. +The library can be brought into use the same way other libraries, i.e., by using an appropriate import statement. - -Add the following to the file index.js: +Add the following to the file main.jsx: ```js import axios from 'axios' @@ -276,9 +267,9 @@ const promise2 = axios.get('http://localhost:3001/foobar') console.log(promise2) ``` -This should be printed to the console +If you open in the browser, this should be printed to the console -![](../../images/2/16b.png) +![promises printed to console](../../images/2/16new.png) Axios' method _get_ returns a [promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises). @@ -288,11 +279,13 @@ The documentation on Mozilla's site states the following about promises: In other words, a promise is an object that represents an asynchronous operation. A promise can have three distinct states: -1. The promise is pending: It means that the final value (one of the following two) is not available yet. -2. The promise is fulfilled: It means that the operation has completed and the final value is available, which generally is a successful operation. This state is sometimes also called resolved. -3. The promise is rejected: It means that an error prevented the final value from being determined, which generally represents a failed operation. +- The promise is pending: It means that the asynchronous operation corresponding to the promise has not yet finished and the final value is not available yet. +- The promise is fulfilled: It means that the operation has been completed and the final value is available, which generally is a successful operation. +- The promise is rejected: It means that an error prevented the final value from being determined, which generally represents a failed operation. + +There are many details related to promises, but understanding these three states is sufficient for us for now. If you want, you can read more about promises in [Mozilla's documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise). -The first promise in our example is fulfilled, representing a successful axios.get('http://localhost:3001/notes') request. The second one, however, is rejected, and the console tells us the reason. It looks like we were trying to make an HTTP GET request to a non-existent address. +The first promise in our example is fulfilled, representing a successful _axios.get('http://localhost:3001/notes')_ request. The second one, however, is rejected, and the console tells us the reason. It looks like we were trying to make an HTTP GET request to a non-existent address. If, and when, we want to access the result of the operation represented by the promise, we must register an event handler to the promise. This is achieved using the method then: @@ -303,11 +296,12 @@ promise.then(response => { console.log(response) }) ``` + The following is printed to the console: -![](../../images/2/17e.png) +![json object data printed to console](../../images/2/17new.png) -The Javascript runtime environment calls the callback function registered by the then method providing it with a response object as a parameter. The response object contains all the essential data related to the response of an HTTP GET request, which would include the returned data, status code, and headers. +The JavaScript runtime environment calls the callback function registered by the then method providing it with a response object as a parameter. The response object contains all the essential data related to the response of an HTTP GET request, which would include the returned data, status code, and headers. Storing the promise object in a variable is generally unnecessary, and it's instead common to chain the then method call to the axios method call, so that it follows it directly: @@ -318,11 +312,7 @@ axios.get('http://localhost:3001/notes').then(response => { }) ``` - - -The callback function now takes the data contained within the response, stores it in a variable and prints the notes to the console. - - +The callback function now takes the data contained within the response, stores it in a variable, and prints the notes to the console. A more readable way to format chained method calls is to place each call on its own line: @@ -335,25 +325,20 @@ axios }) ``` -The data returned by the server is plain text, basically just one long string. The axios library is still able to parse the data into a Javascript array, since the server has specified that the data format is application/json; charset=utf-8 (see previous image) using the content-type header. +The data returned by the server is plain text, basically just one long string. The axios library is still able to parse the data into a JavaScript array, since the server has specified that the data format is application/json; charset=utf-8 (see the previous image) using the content-type header. We can finally begin using the data fetched from the server. -Let's do it "poorly" first by putting the App component representing the application inside the callback function. This is done by changing index.js to the following form: +Let's try and request the notes from our local server and render them, initially as the App component. Please note that this approach has many issues, as we're rendering the entire App component only when we successfully retrieve a response: ```js -import ReactDOM from 'react-dom' -import React from 'react' -import App from './App' - +import ReactDOM from 'react-dom/client' import axios from 'axios' +import App from './App' axios.get('http://localhost:3001/notes').then(response => { const notes = response.data - ReactDOM.render( - , - document.getElementById('root') - ) + ReactDOM.createRoot(document.getElementById('root')).render() }) ``` @@ -361,30 +346,32 @@ This method could be acceptable in some circumstances, but it's somewhat problem What's not immediately obvious, however, is where the command axios.get should be placed within the component. - ### Effect-hooks -We have already used [state hooks](https://reactjs.org/docs/hooks-state.html) that were introduced along with React version [16.8.0](https://www.npmjs.com/package/react/v/16.8.0), which provide state to React components defined as functions. Version 16.8.0 also introduces the [effect hooks](https://reactjs.org/docs/hooks-effect.html) as a new feature. In the words of the docs: +We have already used [state hooks](https://react.dev/learn/state-a-components-memory) that were introduced along with React version [16.8.0](https://www.npmjs.com/package/react/v/16.8.0), which provide state to React components defined as functions - the so-called functional components. Version 16.8.0 also introduces [effect hooks](https://react.dev/reference/react/hooks#effect-hooks) as a new feature. As per the official docs: -> The Effect Hook lets you perform side effects in function components. -> Data fetching, setting up a subscription, and manually changing the DOM in React components are all examples of side effects. +> Effects let a component connect to and synchronize with external systems. +> This includes dealing with network, browser DOM, animations, widgets written using a different UI library, and other non-React code. As such, effect hooks are precisely the right tool to use when fetching data from a server. -Let's remove the fetching of data from index.js. There is no longer a need to pass data as props to the App component. So index.js simplifies to: +Let's remove the fetching of data from main.jsx. Since we're going to be retrieving the notes from the server, there is no longer a need to pass data as props to the App component. So main.jsx can be simplified to: ```js -ReactDOM.render(, document.getElementById('root')) +import ReactDOM from "react-dom/client"; +import App from "./App"; + +ReactDOM.createRoot(document.getElementById("root")).render(); ``` The App component changes as follows: ```js -import React, { useState, useEffect } from 'react' // highlight-line +import { useState, useEffect } from 'react' // highlight-line import axios from 'axios' // highlight-line import Note from './components/Note' -const App = () => { +const App = () => { // highlight-line const [notes, setNotes] = useState([]) // highlight-line const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) @@ -409,18 +396,19 @@ const App = () => { We have also added a few helpful prints, which clarify the progression of the execution. -This is printed to the console +This is printed to the console: -
    +```
     render 0 notes
     effect
     promise fulfilled
     render 3 notes
    -
    +``` -First the body of the function defining the component is executed and the component is rendered for the first time. At this point render 0 notes is printed, meaning data hasn't been fetched from the server yet. +First, the body of the function defining the component is executed and the component is rendered for the first time. At this point render 0 notes is printed, meaning data hasn't been fetched from the server yet. The following function, or effect in React parlance: + ```js () => { console.log('effect') @@ -475,19 +463,19 @@ const hook = () => { useEffect(hook, []) ``` -Now we can see more clearly that the function [useEffect](https://reactjs.org/docs/hooks-reference.html#useeffect) actually takes two parameters. The first is a function, the effect itself. According to the documentation: +Now we can see more clearly that the function [useEffect](https://react.dev/reference/react/useEffect) takes two parameters. The first is a function, the effect itself. According to the documentation: > By default, effects run after every completed render, but you can choose to fire it only when certain values have changed. -So by default the effect is always run after the component has been rendered. In our case, however, we only want to execute the effect along with the first render. +So by default, the effect is always run after the component has been rendered. In our case, however, we only want to execute the effect along with the first render. -The second parameter of useEffect is used to [specify how often the effect is run](https://reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect). If the second parameter is an empty array [], then the effect is only run along with the first render of the component. +The second parameter of useEffect is used to [specify how often the effect is run](https://react.dev/reference/react/useEffect#parameters). If the second parameter is an empty array [], then the effect is only run along with the first render of the component. -There are many possible use cases for effect hook other than fetching data from the server. This suffices us for now. +There are many possible use cases for an effect hook other than fetching data from the server. However, this use is sufficient for us, for now. Think back to the sequence of events we just discussed. Which parts of the code are run? In what order? How often? Understanding the order of events is critical! -Note that we could have also written the code of the effect function this way: +Note that we could have also written the code for the effect function this way: ```js useEffect(() => { @@ -503,7 +491,7 @@ useEffect(() => { }, []) ``` -A reference to an event handler function is assigned to the variable eventHandler. The promise returned by the get method of Axios is stored in the variable promise. The registration of the callback happens by giving the eventHandler variable, referring to the event-handler function, as a parameter to the then method of the promise. It isn't usually necessary to assign functions and promises to variables, and a more compact way of representing things, as seen further above, is sufficient. +A reference to an event handler function is assigned to the variable eventHandler. The promise returned by the get method of Axios is stored in the variable promise. The registration of the callback happens by giving the eventHandler variable, referring to the event-handler function, as an argument to the then method of the promise. It isn't usually necessary to assign functions and promises to variables, and a more compact way of representing things, as seen below, is sufficient. ```js useEffect(() => { @@ -517,19 +505,19 @@ useEffect(() => { }, []) ``` -We still have a problem in our application. When adding new notes, they are not stored on the server. +We still have a problem with our application. When adding new notes, they are not stored on the server. -The code so far for the application can be found in full on [github](https://github.com/fullstack-hy2020/part2-notes/tree/part2-4) in the branch part2-4. +The code for the application, as described so far, can be found in full on [github](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-4), on branch part2-4. -### The development runtime environment +### The development runtime environment -The configuration for the whole of our application has steadily grown more complex. Let's review what happens and where. The following image describes the makeup of the application +The configuration for the whole application has steadily grown more complex. Let's review what happens and where. The following image describes the makeup of the application -![](../../images/2/18e.png) +![diagram of composition of react app](../../images/2/18e.png) -The JavaScript code making up our React application is run in the browser. The browser gets the Javascript from the React dev server, which is the application that runs after running the command npm start. The dev-server transforms the JavaScript into a format understood by the browser. Among other things, it stitches together Javascript from different files into one file. We'll discuss the dev-server in more detail in part 7 of the course. +The JavaScript code making up our React application is run in the browser. The browser gets the JavaScript from the React dev server, which is the application that runs after running the command npm run dev. The dev-server transforms the JavaScript into a format understood by the browser. Among other things, it stitches together JavaScript from different files into one file. We'll discuss the dev-server in more detail in part 7 of the course. -The React application running in the browser fetches the JSON formatted data from json-server running on port 3001 on the machine. json-server gets its data from the file db.json. +The React application running in the browser fetches the JSON formatted data from json-server running on port 3001 on the machine. The server we query the data from - json-server - gets its data from the file db.json. At this point in development, all the parts of the application happen to reside on the software developer's machine, otherwise known as localhost. The situation changes when the application is deployed to the internet. We will do this in part 3. @@ -537,13 +525,9 @@ At this point in development, all the parts of the application happen to reside
    +

    Exercise 2.11.

    -

    Exercises 2.11.-2.14.

    - - -

    2.11: The Phonebook Step6

    - - +

    2.11: The Phonebook Step 6

    We continue with developing the phonebook. Store the initial state of the application in the file db.json, which should be placed in the root of the project. @@ -553,22 +537,22 @@ We continue with developing the phonebook. Store the initial state of the applic { "name": "Arto Hellas", "number": "040-123456", - "id": 1 + "id": "1" }, { "name": "Ada Lovelace", "number": "39-44-5323523", - "id": 2 + "id": "2" }, { "name": "Dan Abramov", "number": "12-43-234345", - "id": 3 + "id": "3" }, { "name": "Mary Poppendieck", "number": "39-23-6423122", - "id": 4 + "id": "4" } ] } @@ -576,7 +560,6 @@ We continue with developing the phonebook. Store the initial state of the applic Start json-server on port 3001 and make sure that the server returns the list of people by going to the address in the browser. - If you receive the following error message: ```js @@ -591,75 +574,6 @@ Error: listen EADDRINUSE 0.0.0.0:3001 it means that port 3001 is already in use by another application, e.g. in use by an already running json-server. Close the other application, or change the port in case that doesn't work. -Modify the application such that the initial state of the data is fetched from the server using the axios-library. Complete the fetching with an [Effect hook](https://reactjs.org/docs/hooks-effect.html). - -

    2.12* Data for countries, step1

    - -The API [https://restcountries.eu](https://restcountries.eu) provides a lot data for different countries in a machine readable format, a so-called REST API. +Modify the application such that the initial state of the data is fetched from the server using the axios-library. Complete the fetching with an [Effect hook](https://react.dev/reference/react/useEffect). -Create an application, in which one can look at data of various countries. The application should probably get the data from the endpoint [all](https://restcountries.eu/#api-endpoints-all). - -The user interface is very simple. The country to be shown is found by typing a search query into the search field. - -If there are too many (over 10) countries that match the query, then the user is prompted to make their query more specific: - -![](../../images/2/19b1.png) - -If there are fewer than ten countries, but more than one, then all countries matching the query are shown: - -![](../../images/2/19b2.png) - -When there is only one country matching the query, then the basic data of the country, its flag and the languages spoken in that country are shown: - -![](../../images/2/19b3.png) - -**NB**: it is enough that your application works for most of the countries. Some countries, like Sudan, can cause trouble, since the name of the country is part of the name of another country, South Sudan. You need not worry about these edge cases. - -**WARNING** create-react-app will automatically turn your project into a git-repository unless you create your application inside of an existing git repository. **Most likely you do not want each of your projects to be a separate repository**, so simply run the _rm -rf .git_ command at the root of your application. - -

    2.13*: Data for countries, step2

    - -**There is still a lot to do in this part, so don't get stuck on this exercise!** - -Improve on the application in the previous exercise, such that when the names of multiple countries are shown on the page there is a button next to the name of the country, which when pressed shows the view for that country: - -![](../../images/2/19b4.png) - -In this exercise it is also enough that your application works for most of the countries. Countries whose name appears in the name of another country, like Sudan can be ignored. - -

    2.14*: Data for countries, step3

    - -**There is still a lot to do in this part, so don't get stuck on this exercise!** - -Add to the view showing the data of a single country the weather report for the capital of that country. There are dozens of providers for weather data. I used [https://weatherstack.com/](https://weatherstack.com/). - -![](../../images/2/19ba.png) - - -**NB:** You need an api-key to use almost every weather service. Do not save the api-key to source control! Nor hardcode the api-key to your source code. Instead use an [environment variable](https://create-react-app.dev/docs/adding-custom-environment-variables/) to save the key. - - -Assuming the api-key is t0p53cr3t4p1k3yv4lu3, when the application is started like so: - -```bash -REACT_APP_API_KEY=t0p53cr3t4p1k3yv4lu3 npm start // For Linux/macOS Bash -($env:REACT_APP_API_KEY=t0p53cr3t4p1k3yv4lu3) -and (npm start) // For Windows PowerShell -set REACT_APP_API_KEY=t0p53cr3t4p1k3yv4lu3 && npm start // For Windows cmd.exe -``` - - -you can access the value of the key from the _process.env_ object: - -```js -const api_key = process.env.REACT_APP_API_KEY -// variable api_key has now the value set in startup -``` - -Note that if you created the application using `npx create-react-app ...` and you want to use a different name for your environment variable then the environment variable name must still begin with `REACT_APP_`. You can also use a `.env` file rather than defining it on the command line each time by creating a file entitled '.env' in the root of the project and adding the following. - -``` -# .env - -REACT_APP_API_KEY=t0p53cr3t4p1k3yv4lu3 -```
    diff --git a/src/content/2/en/part2d.md b/src/content/2/en/part2d.md index 69264396ab2..71ef873f9fd 100644 --- a/src/content/2/en/part2d.md +++ b/src/content/2/en/part2d.md @@ -7,36 +7,34 @@ lang: en
    - When creating notes in our application, we would naturally want to store them in some backend server. The [json-server](https://github.com/typicode/json-server) package claims to be a so-called REST or RESTful API in its documentation: > Get a full fake REST API with zero coding in less than 30 seconds (seriously) The json-server does not exactly match the description provided by the textbook [definition](https://en.wikipedia.org/wiki/Representational_state_transfer) of a REST API, but neither do most other APIs claiming to be RESTful. -We will take a closer look at REST in the [next part](/en/part3) of the course, but it's important to familiarize ourselves at this point with some of the [conventions](https://en.wikipedia.org/wiki/Representational_state_transfer#Applied_to_Web_services) used by json-server and REST APIs in general. In particular, we will be taking a look at the conventional use of [routes](https://github.com/typicode/json-server#routes), aka URLs and HTTP request types, in REST. +We will take a closer look at REST in the [next part](/en/part3) of the course. But it's important to familiarize ourselves at this point with some of the [conventions](https://en.wikipedia.org/wiki/REST#Applied_to_web_services) used by json-server and REST APIs in general. In particular, we will be taking a look at the conventional use of [routes](https://github.com/typicode/json-server#routes), aka URLs and HTTP request types, in REST. ### REST -In REST terminology, we refer to individual data objects, such as the notes in our application, as resources. Every resource has a unique address associated with it - its URL. According to a general convention used by json-server, we would be able to locate an individual note at the resource URL notes/3, where 3 is the id of the resource. The notes url, on the other hand, would point to a resource collection containing all the notes. +In REST terminology, we refer to individual data objects, such as the notes in our application, as resources. Every resource has a unique address associated with it - its URL. According to a general convention used by json-server, we would be able to locate an individual note at the resource URL notes/3, where 3 is the id of the resource. The notes URL, on the other hand, would point to a resource collection containing all the notes. Resources are fetched from the server with HTTP GET requests. For instance, an HTTP GET request to the URL notes/3 will return the note that has the id number 3. An HTTP GET request to the notes URL would return a list of all notes. Creating a new resource for storing a note is done by making an HTTP POST request to the notes URL according to the REST convention that the json-server adheres to. The data for the new note resource is sent in the body of the request. -json-server requires all data to be sent in JSON format. What this means in practice is that the data must be a correctly formatted string, and that the request must contain the Content-Type request header with the value application/json. +json-server requires all data to be sent in JSON format. What this means in practice is that the data must be a correctly formatted string and that the request must contain the Content-Type request header with the value application/json. ### Sending Data to the Server Let's make the following changes to the event handler responsible for creating a new note: ```js -addNote = event => { +const addNote = event => { event.preventDefault() const noteObject = { content: newNote, - date: new Date(), - important: Math.random() > 0.5, + important: Math.random() < 0.5, } // highlight-start @@ -49,34 +47,39 @@ addNote = event => { } ``` - -We create a new object for the note but omit the id property, since it's better to let the server generate ids for our resources! +We create a new object for the note but omit the id property since it's better to let the server generate ids for our resources. The object is sent to the server using the axios post method. The registered event handler logs the response that is sent back from the server to the console. -When we try to create a new note, the following output pops up in console: +When we try to create a new note, the following output pops up in the console: -![](../../images/2/20e.png) +![data json output in console](../../images/2/20new.png) The newly created note resource is stored in the value of the data property of the _response_ object. -Sometimes it can be useful to inspect HTTP requests in the Network tab of Chrome developer tools, which was used heavily at the beginning of [part 0](/en/part0/fundamentals_of_web_apps#http-get): +Quite often it is useful to inspect HTTP requests in the Network tab of Chrome developer tools, which was used heavily at the beginning of [part 0](/en/part0/fundamentals_of_web_apps#http-get). -![](../../images/2/21e.png) +We can use the inspector to check that the headers sent in the POST request are what we expected them to be: - -We can use the inspector to check that the headers sent in the POST request are what we expected them to be, and that their values are correct. +![dev tools header shows 201 created for localhost:3001/notes](../../images/2/21new1.png) Since the data we sent in the POST request was a JavaScript object, axios automatically knew to set the appropriate application/json value for the Content-Type header. -The new note is not rendered to the screen yet. This is because we did not update the state of the App component when we created the new note. Let's fix this: +The tab payload can be used to check the request data: + +![devtools payload tab shows content and important fields from above](../../images/2/21new2.png) + +Also the tab response is useful, it shows what was the data the server responded with: + +![devtools response tab shows same content as payload but with id field too](../../images/2/21new3.png) + +The new note is not rendered to the screen yet. This is because we did not update the state of the App component when we created it. Let's fix this: ```js -addNote = event => { +const addNote = event => { event.preventDefault() const noteObject = { content: newNote, - date: new Date(), important: Math.random() > 0.5, } @@ -93,25 +96,19 @@ addNote = event => { The new note returned by the backend server is added to the list of notes in our application's state in the customary way of using the setNotes function and then resetting the note creation form. An [important detail](/en/part1/a_more_complex_state_debugging_react_apps#handling-arrays) to remember is that the concat method does not change the component's original state, but instead creates a new copy of the list. +Once the data returned by the server starts to have an effect on the behavior of our web applications, we are immediately faced with a whole new set of challenges arising from, for instance, the asynchronicity of communication. This necessitates new debugging strategies, console logging and other means of debugging become increasingly more important. We must also develop a sufficient understanding of the principles of both the JavaScript runtime and React components. Guessing won't be enough. -Once the data returned by the server starts to have an effect on the behavior of our web applications, we are immediately faced with a whole new set of challenges arising from, for instance, the asynchronicity of communication. This necessitates new debugging strategies, console logging and other means of debugging become increasingly more important, and we must also develop a sufficient understanding of the principles of both the JavaScript runtime and React components. Guessing won't be enough. - -It's beneficial to inspect the state of the backend server e.g. through the browser: - -![](../../images/2/22e.png) +It's beneficial to inspect the state of the backend server, e.g. through the browser: +![JSON data output from backend](../../images/2/22.png) This makes it possible to verify that all the data we intended to send was actually received by the server. -In the next part of the course we will learn to implement our own logic in the backend. We will then take a closer look at tools like [postman](https://chrome.google.com/webstore/detail/postman/fhbjgbiflinjbdggehcddcbncdddomop) that help us to debug our server applications. However, inspecting the state of the json-server through the browser is sufficient for our current needs. - -> **NB:** In the current version of our application the browser adds the creation date property to the note. Since the clock of the machine running the browser can be wrongly configured, it's much wiser to let the backend server generate this timestamp for us. This is in fact what we will do in the next part of the course. +In the next part of the course, we will learn to implement our own logic in the backend. We will then take a closer look at tools like [Postman](https://www.postman.com/downloads/) that helps us to debug our server applications. However, inspecting the state of the json-server through the browser is sufficient for our current needs. +The code for the current state of our application can be found in the part2-5 branch on [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-5). -The code for the current state of our application can be found in the part2-5 branch on [github](https://github.com/fullstack-hy2020/part2-notes/tree/part2-5). - - -### Changing the importance of notes +### Changing the Importance of Notes Let's add a button to every note that can be used for toggling its importance. @@ -133,7 +130,6 @@ const Note = ({ note, toggleImportance }) => { We add a button to the component and assign its event handler as the toggleImportance function passed in the component's props. - The App component defines an initial version of the toggleImportanceOf event handler function and passes it to every Note component: ```js @@ -161,9 +157,9 @@ const App = () => {
      - {notesToShow.map((note, i) => + {notesToShow.map(note => toggleImportanceOf(note.id)} // highlight-line /> @@ -175,32 +171,29 @@ const App = () => { } ``` -Notice how every note receives its own unique event handler function, since the id of every note is unique. +Notice how every note receives its own unique event handler function since the id of every note is unique. - -E.g. if note.id is 3, the event handler function returned by _toggleImportance(note.id)_ will be: +E.g., if note.id is 3, the event handler function returned by _toggleImportance(note.id)_ will be: ```js () => { console.log('importance of 3 needs to be toggled') } ``` -A short reminder here. The string printed by the event handler is defined in Java-like manner by adding the strings: +A short reminder here. The string printed by the event handler is defined in a Java-like manner by adding the strings: ```js console.log('importance of ' + id + ' needs to be toggled') ``` - The [template string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals) syntax added in ES6 can be used to write similar strings in a much nicer way: ```js console.log(`importance of ${id} needs to be toggled`) ``` -We can now use the "dollar-bracket"-syntax to add parts to the string that will evaluate JavaScript expressions, e.g. the value of a variable. Note that the quotation marks used in template strings differ from the quotation marks used in regular JavaScript strings. - -Individual notes stored in the json-server backend can be modified in two different ways by making HTTP requests to the note's unique URL. We can either replace the entire note with an HTTP PUT request, or only change some of the note's properties with an HTTP PATCH request. +We can now use the "dollar-bracket"-syntax to add parts to the string that will evaluate JavaScript expressions, e.g. the value of a variable. Note that we use backticks in template strings instead of quotation marks used in regular JavaScript strings. +Individual notes stored in the json-server backend can be modified in two different ways by making HTTP requests to the note's unique URL. We can either replace the entire note with an HTTP PUT request or only change some of the note's properties with an HTTP PATCH request. The final form of the event handler function is the following: @@ -211,31 +204,26 @@ const toggleImportanceOf = id => { const changedNote = { ...note, important: !note.important } axios.put(url, changedNote).then(response => { - setNotes(notes.map(note => note.id !== id ? note : response.data)) + setNotes(notes.map(note => note.id === id ? response.data : note)) }) } ``` +Almost every line of code in the function body contains important details. The first line defines the unique URL for each note resource based on its id. -Almost every line of code in the function body contains important details. The first line defines the unique url for each note resource based on its id. - - -The array [find](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find) method is used to find the note we want to modify, and we then assign it to the _note_ variable. +The array [find method](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find) is used to find the note we want to modify, and we then assign it to the _note_ variable. +After this, we create a new object that is an exact copy of the old note, apart from the important property that has the value flipped (from true to false or from false to true). -After this we create a new object that is an exact copy of the old note, apart from the important property. - -The code for creating the new object that uses the [object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) syntax -may seem a bit strange: +The code for creating the new object that uses the [object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) syntax may seem a bit strange at first: ```js const changedNote = { ...note, important: !note.important } ``` -In practice { ...note } creates a new object with copies of all the properties from the _note_ object. When we add properties inside the curly braces after the spreaded object, e.g. { ...note, important: true }, then the value of the _important_ property of the new object will be _true_. In our example the important property gets the negation of its previous value in the original object. - +In practice, { ...note } creates a new object with copies of all the properties from the _note_ object. When we add properties inside the curly braces after the spread object, e.g. { ...note, important: true }, then the value of the _important_ property of the new object will be _true_. In our example, the important property gets the negation of its previous value in the original object. -There's a few things to point out. Why did we make a copy of the note object we wanted to modify, when the following code also appears to work: +There are a few things to point out. Why did we make a copy of the note object we wanted to modify when the following code also appears to work? ```js const note = notes.find(n => n.id === id) @@ -245,44 +233,34 @@ axios.put(url, note).then(response => { // ... ``` +This is not recommended because the variable note is a reference to an item in the notes array in the component's state, and as we recall we must [never mutate state directly](https://react.dev/learn/updating-objects-in-state#why-is-mutating-state-not-recommended-in-react) in React. -This is not recommended because the variable note is a reference to an item in the notes array in the component's state, and as we recall we must never mutate state directly in React. - - -It's also worth noting that the new object _changedNote_ is only a so-called [shallow copy](https://en.wikipedia.org/wiki/Object_copying#Shallow_copy), meaning that the values of the new object are the same as the values of the old object. If the values of the old object were objects themselves, then the copied values in new object would reference the same objects that were in the old object. - +It's also worth noting that the new object _changedNote_ is only a so-called [shallow copy](https://en.wikipedia.org/wiki/Object_copying#Shallow_copy), meaning that the values of the new object are the same as the values of the old object. If the values of the old object were objects themselves, then the copied values in the new object would reference the same objects that were in the old object. The new note is then sent with a PUT request to the backend where it will replace the old object. - The callback function sets the component's notes state to a new array that contains all the items from the previous notes array, except for the old note which is replaced by the updated version of it returned by the server: ```js axios.put(url, changedNote).then(response => { - setNotes(notes.map(note => note.id !== id ? note : response.data)) + setNotes(notes.map(note => note.id === id ? response.data : note)) }) ``` - This is accomplished with the map method: ```js -notes.map(note => note.id !== id ? note : response.data) +notes.map(note => note.id === id ? response.data : note) ``` - -The map method creates a new array by mapping every item from the old array into an item in the new array. In our example, the new array is created conditionally so that if note.id !== id is true, we simply copy the item from the old array into the new array. If the condition is false, then the note object returned by the server is added to the array instead. - +The map method creates a new array by mapping every item from the old array into an item in the new array. In our example, the new array is created conditionally so that if note.id === id is true; the note object returned by the server is added to the array. If the condition is false, then we simply copy the item from the old array into the new array instead. This map trick may seem a bit strange at first, but it's worth spending some time wrapping your head around it. We will be using this method many times throughout the course. - -### Extracting communication with the backend into a separate module - +### Extracting Communication with the Backend into a Separate Module The App component has become somewhat bloated after adding the code for communicating with the backend server. In the spirit of the [single responsibility principle](https://en.wikipedia.org/wiki/Single_responsibility_principle), we deem it wise to extract this communication into its own [module](/en/part2/rendering_a_collection_modules#refactoring-modules). - Let's create a src/services directory and add a file there called notes.js: ```js @@ -308,7 +286,6 @@ export default { } ``` - The module returns an object that has three functions (getAll, create, and update) as its properties that deal with notes. The functions directly return the promises returned by the axios methods. The App component uses import to get access to the module: @@ -343,7 +320,7 @@ const App = () => { noteService .update(id, changedNote) .then(response => { - setNotes(notes.map(note => note.id !== id ? note : response.data)) + setNotes(notes.map(note => note.id === id ? response.data : note)) }) // highlight-end } @@ -352,7 +329,6 @@ const App = () => { event.preventDefault() const noteObject = { content: newNote, - date: new Date().toISOString(), important: Math.random() > 0.5 } @@ -372,7 +348,6 @@ const App = () => { export default App ``` - We could take our implementation a step further. When the App component uses the functions, it receives an object that contains the entire response for the HTTP request: ```js @@ -383,10 +358,8 @@ noteService }) ``` - The App component only uses the response.data property of the response object. - The module would be much nicer to use if, instead of the entire HTTP response, we would only get the response data. Using the module would then look like this: ```js @@ -425,7 +398,6 @@ export default { } ``` - We no longer return the promise returned by axios directly. Instead, we assign the promise to the request variable and call its then method: ```js @@ -435,7 +407,6 @@ const getAll = () => { } ``` - The last row in our function is simply a more compact expression of the same code as shown below: ```js @@ -449,14 +420,11 @@ const getAll = () => { } ``` - -The modified getAll function still returns a promise, as the then method of a promise also [returns a promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then). - +The modified getAll function still returns a promise, as the then method of a promise also [returns a promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then). After defining the parameter of the then method to directly return response.data, we have gotten the getAll function to work like we wanted it to. When the HTTP request is successful, the promise returns the data sent back in the response from the backend. - -We have to update the App component to work with the changes made to our module. We have to fix the callback functions given as parameters to the noteService object's methods, so that they use the directly returned response data: +We have to update the App component to work with the changes made to our module. We have to fix the callback functions given as parameters to the noteService object's methods so that they use the directly returned response data: ```js const App = () => { @@ -466,9 +434,9 @@ const App = () => { noteService .getAll() // highlight-start - .then(initialNotes => { + .then(initialNotes => { setNotes(initialNotes) - // highlight-end + // highlight-end }) }, []) @@ -479,8 +447,8 @@ const App = () => { noteService .update(id, changedNote) // highlight-start - .then(returnedNote => { - setNotes(notes.map(note => note.id !== id ? note : returnedNote)) + .then(returnedNote => { + setNotes(notes.map(note => note.id === id ? returnedNote : note)) // highlight-end }) } @@ -489,16 +457,15 @@ const App = () => { event.preventDefault() const noteObject = { content: newNote, - date: new Date().toISOString(), important: Math.random() > 0.5 } noteService .create(noteObject) - // highlight-start - .then(returnedNote => { + // highlight-start + .then(returnedNote => { setNotes(notes.concat(returnedNote)) - // highlight-end + // highlight-end setNewNote('') }) } @@ -507,21 +474,15 @@ const App = () => { } ``` - This is all quite complicated and attempting to explain it may just make it harder to understand. The internet is full of material discussing the topic, such as [this](https://javascript.info/promise-chaining) one. - -The "Async and performance" book from the [You do not know JS](https://github.com/getify/You-Dont-Know-JS/tree/1st-ed) book series explains the topic [well](https://github.com/getify/You-Dont-Know-JS/blob/1st-ed/async%20%26%20performance/ch3.md), but the explanation is many pages long. - +The "Async and performance" book from the [You do not know JS](https://github.com/getify/You-Dont-Know-JS/tree/1st-ed) book series [explains the topic](https://github.com/getify/You-Dont-Know-JS/blob/1st-ed/async%20%26%20performance/ch3.md) well, but the explanation is many pages long. Promises are central to modern JavaScript development and it is highly recommended to invest a reasonable amount of time into understanding them. +### Cleaner Syntax for Defining Object Literals -### Cleaner syntax for defining object literals - - -The module defining note related services currently exports an object with the properties getAll, create and update that are assigned to functions for handling notes. - +The module defining note-related services currently exports an object with the properties getAll, create, and update that are assigned to functions for handling notes. The module definition was: @@ -551,7 +512,6 @@ export default { } ``` - The module exports the following, rather peculiar looking, object: ```js @@ -562,11 +522,9 @@ The module exports the following, rather peculiar looking, object: } ``` +The labels to the left of the colon in the object definition are the keys of the object, whereas the ones to the right of it are variables that are defined inside the module. -The labels to the left of the colon in the object definition are the keys of the object, whereas the ones to the right of it are variables that are defined inside of the module. - - -Since the names of the keys and the assigned variables are the same, we can write the object definition with more compact syntax: +Since the names of the keys and the assigned variables are the same, we can write the object definition with a more compact syntax: ```js { @@ -576,8 +534,7 @@ Since the names of the keys and the assigned variables are the same, we can writ } ``` - -As a result the module definition gets simplified into the following form: +As a result, the module definition gets simplified into the following form: ```js import axios from 'axios' @@ -601,44 +558,37 @@ const update = (id, newObject) => { export default { getAll, create, update } // highlight-line ``` - In defining the object using this shorter notation, we make use of a [new feature](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer#Property_definitions) that was introduced to JavaScript through ES6, enabling a slightly more compact way of defining objects using variables. - To demonstrate this feature, let's consider a situation where we have the following values assigned to variables: -```js +```js const name = 'Leevi' const age = 0 ``` - In older versions of JavaScript we had to define an object like this: -```js +```js const person = { name: name, age: age } ``` -However, since both the property fields and the variable names in the object are the same, it's enough to simply write the following in ES6 JavaScript: +However, since both the property fields and the variable names in the object are the same, it's enough to simply write the following in ES6 JavaScript: -```js +```js const person = { name, age } ``` - The result is identical for both expressions. They both create an object with a name property with the value Leevi and an age property with the value 0. - -### Promises and errors - +### Promises and Errors If our application allowed users to delete notes, we could end up in a situation where a user tries to change the importance of a note that has already been deleted from the system. - -Let's simulate this situation by making the getAll function of the note service return a "hardcoded" note that does not actually exist in the backend server: +Let's simulate this situation by making the getAll function of the note service return a "hardcoded" note that does not actually exist on the backend server: ```js const getAll = () => { @@ -646,7 +596,6 @@ const getAll = () => { const nonExisting = { id: 10000, content: 'This note is not saved to server', - date: '2019-05-30T17:30:31.098Z', important: true, } return request.then(response => response.data.concat(nonExisting)) @@ -655,19 +604,15 @@ const getAll = () => { When we try to change the importance of the hardcoded note, we see the following error message in the console. The error says that the backend server responded to our HTTP PUT request with a status code 404 not found. -![](../../images/2/23e.png) - -The application should be able to handle these types of error situations gracefully. Users won't be able to tell that an error has actually occurred unless they happen to have their console open. The only way the error can be seen in the application is that clicking the button has no effect on the importance of the note. - +![404 not found error in dev tools](../../images/2/23e.png) -We had [previously](/en/part2/getting_data_from_server#axios-and-promises) mentioned that a promise can be in one of three different states. When an HTTP request fails, the associated promise is rejected. Our current code does not handle this rejection in any way. +The application should be able to handle these types of error situations gracefully. Users won't be able to tell that an error has occurred unless they happen to have their console open. The only way the error can be seen in the application is that clicking the button does not affect the note's importance. +We had [previously](/en/part2/getting_data_from_server#axios-and-promises) mentioned that a promise can be in one of three different states. When an axios HTTP request fails, the associated promise is rejected. Our current code does not handle this rejection in any way. The rejection of a promise is [handled](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises) by providing the then method with a second callback function, which is called in the situation where the promise is rejected. - -The more common way of adding a handler for rejected promises is to use the [catch](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch) method. - +The more common way of adding a handler for rejected promises is to use the [catch](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch) method. In practice, the error handler for rejected promises is defined like this: @@ -682,32 +627,28 @@ axios }) ``` - If the request fails, the event handler registered with the catch method gets called. - The catch method is often utilized by placing it deeper within the promise chain. - -When our application makes an HTTP request, we are in fact creating a [promise chain](https://javascript.info/promise-chaining): +When multiple _.then_ methods are chained together, we are in fact creating a [promise chain](https://javascript.info/promise-chaining): ```js axios - .put(`${baseUrl}/${id}`, newObject) + .get('http://...') .then(response => response.data) - .then(changedNote => { + .then(data => { // ... }) ``` - -The catch method can be used to define a handler function at the end of a promise chain, which is called once any promise in the chain throws an error and the promise becomes rejected. +The catch method can be used to define a handler function at the end of a promise chain, which is called once any promise in the chain throws an error and the promise becomes rejected. ```js axios - .put(`${baseUrl}/${id}`, newObject) + .get('http://...') .then(response => response.data) - .then(changedNote => { + .then(data => { // ... }) .catch(error => { @@ -715,8 +656,7 @@ axios }) ``` - -Let's use this feature and register an error handler in the App component: +Let's take advantage of this feature. We will place our application's error handler in the App component: ```js const toggleImportanceOf = id => { @@ -725,7 +665,7 @@ const toggleImportanceOf = id => { noteService .update(id, changedNote).then(returnedNote => { - setNotes(notes.map(note => note.id !== id ? note : returnedNote)) + setNotes(notes.map(note => note.id === id ? returnedNote : note)) }) // highlight-start .catch(error => { @@ -738,11 +678,9 @@ const toggleImportanceOf = id => { } ``` - The error message is displayed to the user with the trusty old [alert](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert) dialog popup, and the deleted note gets filtered out from the state. - -Removing an already deleted note from the application's state is done with the array [filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter) method, which returns a new array comprising only of the items from the list for which the function that was passed as a parameter returns true for: +Removing an already deleted note from the application's state is done with the array [filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter) method, which returns a new array comprising only the items from the list for which the function that was passed as a parameter returns true for: ```js notes.filter(n => n.id !== id) @@ -750,30 +688,47 @@ notes.filter(n => n.id !== id) It's probably not a good idea to use alert in more serious React applications. We will soon learn a more advanced way of displaying messages and notifications to users. There are situations, however, where a simple, battle-tested method like alert can function as a starting point. A more advanced method could always be added in later, given that there's time and energy for it. -The code for the current state of our application can be found in the part2-6 branch on [github](https://github.com/fullstack-hy2020/part2-notes/tree/part2-6). +The code for the current state of our application can be found in the part2-6 branch on [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-6). + +### Full stack developer's oath + +It is again time for the exercises. The complexity of our app is now increasing since besides just taking care of the React components in the frontend, we also have a backend that is persisting the application data. + +To cope with the increasing complexity we should extend the web developer's oath to a Full stack developer's oath, which reminds us to make sure that the communication between frontend and backend happens as expected. + +So here is the updated oath: + +Full stack development is extremely hard, that is why I will use all the possible means to make it easier + +- I will have my browser developer console open all the time +- I will use the network tab of the browser dev tools to ensure that frontend and backend are communicating as I expect +- I will constantly keep an eye on the state of the server to make sure that the data sent there by the frontend is saved there as I expect +- I will progress with small steps +- I will write lots of _console.log_ statements to make sure I understand how the code behaves and to help pinpoint problems +- If my code does not work, I will not write more code. Instead, I start deleting the code until it works or just return to a state when everything was still working +- When I ask for help in the course Discord channel or elsewhere I formulate my questions properly, see [here](/en/part0/general_info#how-to-get-help-in-discord) how to ask for help
    -

    Exercises 2.15.-2.18.

    +

    Exercises 2.12.-2.15.

    -

    2.15: Phonebook step7

    +

    2.12: The Phonebook step 7

    Let's return to our phonebook application. -Currently the numbers that are added to the phonebook are not saved to a backend server. Fix this situation. - -

    2.16: Phonebook step8

    +Currently, the numbers that are added to the phonebook are not saved to a backend server. Fix this situation. +

    2.13: The Phonebook step 8

    Extract the code that handles the communication with the backend into its own module by following the example shown earlier in this part of the course material. -

    2.17: Phonebook step9

    +

    2.14: The Phonebook step 9

    Make it possible for users to delete entries from the phonebook. The deletion can be done through a dedicated button for each person in the phonebook list. You can confirm the action from the user by using the [window.confirm](https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm) method: -![](../../images/2/24e.png) +![2.17 window confirm feature screeshot](../../images/2/24e.png) The associated resource for a person in the backend can be deleted by making an HTTP DELETE request to the resource's URL. If we are deleting e.g. a person who has the id 2, we would have to make an HTTP DELETE request to the URL localhost:3001/persons/2. No data is sent with the request. @@ -788,12 +743,14 @@ const delete = (id) => { } ``` -

    2.18*: Phonebook step10

    +

    2.15*: The Phonebook step 10

    + +Why is there an asterisk in the exercise? See [here](/en/part0/general_info#taking-the-course) for the explanation. -Change the functionality so that if a number is added to an already existing user, the new number will replace the old number. It's recommended to use the HTTP PUT method for updating the phone number. +Change the functionality so that if a number is added to an already existing user, the new number will replace the old number. It's recommended to use the HTTP PUT method for updating the phone number. -If the person's information is already in the phonebook, the application can confirm the action from the user: +If the person's information is already in the phonebook, the application can ask the user to confirm the action: -![](../../images/teht/16e.png) +![2.18 screenshot alert confirmation](../../images/teht/16e.png)
    diff --git a/src/content/2/en/part2e.md b/src/content/2/en/part2e.md index 4421c975930..2ca3c156a91 100644 --- a/src/content/2/en/part2e.md +++ b/src/content/2/en/part2e.md @@ -7,21 +7,16 @@ lang: en
    +The appearance of our current Notes application is quite modest. In [exercise 0.2](/en/part0/fundamentals_of_web_apps#exercises-0-1-0-6), the assignment was to go through Mozilla's [CSS tutorial](https://developer.mozilla.org/en-US/docs/Learn/Getting_started_with_the_web/CSS_basics). -The appearance of our current application is quite modest. In [exercise 0.2](/en/part0/fundamentals_of_web_apps#exercises-0-1-0-6), the assignment was to go through Mozilla's [CSS tutorial](https://developer.mozilla.org/en-US/docs/Learn/Getting_started_with_the_web/CSS_basics). +Let's take a look at how we can add styles to a React application. There are several different ways of doing this and we will take a look at the other methods later on. First, we will add CSS to our application the old-school way; in a single file without using a [CSS preprocessor](https://developer.mozilla.org/en-US/docs/Glossary/CSS_preprocessor) (although this is not entirely true as we will learn later on). - -Before we move onto the next part, let's take a look at how we can add styles to a React application. There are several different ways of doing this and we will take a look at the other methods later on. At first, we will add CSS to our application the old-school way; in a single file without using a [CSS preprocessor](https://developer.mozilla.org/en-US/docs/Glossary/CSS_preprocessor) (although this is not entirely true as we will learn later on). - - - -Let's add a new index.css file under the src directory and then add it to the application by importing it in the index.js file: +Let's add a new index.css file under the src directory and then add it to the application by importing it in the main.jsx file: ```js import './index.css' ``` - Let's add the following CSS rule to the index.css file: ```css @@ -30,13 +25,10 @@ h1 { } ``` - CSS rules comprise of selectors and declarations. The selector defines which elements the rule should be applied to. The selector above is h1, which will match all of the h1 header tags in our application. - The declaration sets the _color_ property to the value green. - One CSS rule can contain an arbitrary number of properties. Let's modify the previous rule to make the text cursive, by defining the font style as italic: ```css @@ -46,17 +38,15 @@ h1 { } ``` - There are many ways of matching elements by using [different types of CSS selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors). - If we wanted to target, let's say, each one of the notes with our styles, we could use the selector li, as all of the notes are wrapped inside li tags: ```js const Note = ({ note, toggleImportance }) => { const label = note.important ? 'make not important' - : 'make important'; + : 'make important' return (
  • @@ -67,7 +57,6 @@ const Note = ({ note, toggleImportance }) => { } ``` - Let's add the following rule to our style sheet (since my knowledge of elegant web design is close to zero, the styles don't make much sense): ```css @@ -78,27 +67,23 @@ li { } ``` - Using element types for defining CSS rules is slightly problematic. If our application contained other li tags, the same style rule would also be applied to them. - If we want to apply our style specifically to notes, then it is better to use [class selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/Class_selectors). - In regular HTML, classes are defined as the value of the class attribute: ```html
  • some text...
  • ``` - -In React we have to use the [className](https://reactjs.org/docs/dom-elements.html#classname) attribute instead of the class attribute. With this in mind, let's make the following changes to our Note component: +In React we have to use the [className](https://react.dev/learn#adding-styles) attribute instead of the class attribute. With this in mind, let's make the following changes to our Note component: ```js const Note = ({ note, toggleImportance }) => { const label = note.important ? 'make not important' - : 'make important'; + : 'make important' return (
  • // highlight-line @@ -109,7 +94,6 @@ const Note = ({ note, toggleImportance }) => { } ``` - Class selectors are defined with the _.classname_ syntax: ```css @@ -120,16 +104,12 @@ Class selectors are defined with the _.classname_ syntax: } ``` - If you now add other li elements to the application, they will not be affected by the style rule above. - ### Improved error message - We previously implemented the error message that was displayed when the user tried to toggle the importance of a deleted note with the alert method. Let's implement the error message as its own React component. - The component is quite simple: ```js @@ -139,17 +119,14 @@ const Notification = ({ message }) => { } return ( -
    +
    {message}
    ) } ``` - - -If the value of the message prop is null, then nothing is rendered to the screen, and in other cases the message gets rendered inside of a div element. - +If the value of the message prop is null, then nothing is rendered to the screen, and in other cases, the message gets rendered inside of a div element. Let's add a new piece of state called errorMessage to the App component. Let's initialize it with some error message so that we can immediately test our component: @@ -191,7 +168,6 @@ Then let's add a style rule that suits an error message: } ``` - Now we are ready to add the logic for displaying the error message. Let's change the toggleImportanceOf function in the following way: ```js @@ -200,7 +176,7 @@ Now we are ready to add the logic for displaying the error message. Let's change const changedNote = { ...note, important: !note.important } noteService - .update(changedNote).then(returnedNote => { + .update(id, changedNote).then(returnedNote => { setNotes(notes.map(note => note.id !== id ? note : returnedNote)) }) .catch(error => { @@ -217,70 +193,69 @@ Now we are ready to add the logic for displaying the error message. Let's change } ``` - -When the error occurs we add a descriptive error message to the errorMessage state. At the same time we start a timer, that will set the errorMessage state to null after five seconds. - +When the error occurs we add a descriptive error message to the errorMessage state. At the same time, we start a timer, that will set the errorMessage state to null after five seconds. The result looks like this: -![](../../images/2/26e.png) - - -The code for the current state of our application can be found in the part2-7 branch on [github](https://github.com/fullstack-hy2020/part2-notes/tree/part2-7). +![error removed from server screenshot from app](../../images/2/26e.png) +The code for the current state of our application can be found in the part2-7 branch on [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-7). ### Inline styles - React also makes it possible to write styles directly in the code as so-called [inline styles](https://react-cn.github.io/react/tips/inline-styles.html). +The idea behind defining inline styles is extremely simple. Any React component or element can be provided with a set of CSS properties as a JavaScript object through the [style](https://react.dev/reference/react-dom/components/common#applying-css-styles) attribute. -The idea behind defining inline styles is extremely simple. Any React component or element can be provided with a set of CSS properties as a JavaScript object through the [style](https://reactjs.org/docs/dom-elements.html#style) attribute. - - -CSS rules are defined slightly differently in JavaScript than in normal CSS files. Let's say that we wanted to give some element the color green and italic font that's 16 pixels in size. In CSS, it would look like this: +CSS rules are defined slightly differently in JavaScript than in normal CSS files. Let's say that we wanted to give some element the color green and italic font. In CSS, it would look like this: ```css { color: green; font-style: italic; - font-size: 16px; } ``` - -But as a React inline style object it would look like this: +But as a React inline-style object it would look like this: ```js - { +{ color: 'green', - fontStyle: 'italic', - fontSize: 16 + fontStyle: 'italic' } ``` - Every CSS property is defined as a separate property of the JavaScript object. Numeric values for pixels can be simply defined as integers. One of the major differences compared to regular CSS, is that hyphenated (kebab case) CSS properties are written in camelCase. - -Next, we could add a "bottom block" to our application by creating a Footer component and define the following inline styles for it: +Let's add a footer component, Footer, to our application and define inline styles for it. The component is defined in the file _components/Footer.jsx_ and used in the file _App.jsx_ as follows: ```js const Footer = () => { const footerStyle = { color: 'green', - fontStyle: 'italic', - fontSize: 16 + fontStyle: 'italic' } return (

    - Note app, Department of Computer Science, University of Helsinki 2020 -
    +

    + Note app, Department of Computer Science, University of Helsinki 2025 +

    +
    ) } +export default Footer +``` + +```js +import { useState, useEffect } from 'react' +import Footer from './components/Footer' // highlight-line +import Note from './components/Note' +import Notification from './components/Notification' +import noteService from './services/notes' + const App = () => { // ... @@ -300,39 +275,363 @@ const App = () => { Inline styles come with certain limitations. For instance, so-called [pseudo-classes](https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes) can't be used straightforwardly. -Inline styles and some of the other ways of adding styles to React components go completely against the grain of old conventions. Traditionally, it has been considered the best practice to entirely separate CSS from the content (HTML) and functionality (JavaScript). According to this older school of thought, the goal was to write CSS, HTML, and JavaScript into their separate files. - +Inline styles and some of the other ways of adding styles to React components go completely against the grain of old conventions. Traditionally, it has been considered best practice to entirely separate CSS from the content (HTML) and functionality (JavaScript). According to this older school of thought, the goal was to write CSS, HTML, and JavaScript into their separate files. The philosophy of React is, in fact, the polar opposite of this. Since the separation of CSS, HTML, and JavaScript into separate files did not seem to scale well in larger applications, React bases the division of the application along the lines of its logical functional entities. - The structural units that make up the application's functional entities are React components. A React component defines the HTML for structuring the content, the JavaScript functions for determining functionality, and also the component's styling; all in one place. This is to create individual components that are as independent and reusable as possible. -The code of the final version of our application can be found in the part2-8 branch on [github](https://github.com/fullstack-hy2020/part2-notes/tree/part2-8). +The code of the final version of our application can be found in the part2-8 branch on [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-8).
  • -

    Exercises 2.19.-2.20.

    +

    Exercises 2.16.-2.17.

    -

    2.19: Phonebook step11

    +

    2.16: Phonebook step 11

    -Use the [improved error message](/en/part2/adding_styles_to_react_app#improved-error-message) example from part 2 as a guide to show a notification that lasts for a few seconds after a successful operation is executed (a person is added or a number is changed): +Use the [improved error message](/en/part2/adding_styles_to_react_app#improved-error-message) example from part 2 as a guide to show a notification that lasts for a few seconds after a successful operation is executed (a person is added or a number is changed): -![](../../images/2/27e.png) +![successful green added screenshot](../../images/2/27e.png) -

    2.20*: Phonebook step12

    +

    2.17*: Phonebook step 12

    -Open your application in two browsers. **If you delete a person in browser 1** a short while before attempting to change the person's phone number in browser 2, you will get the following error message: +Open your application in two browsers. **If you delete a person in browser 1** a short while before attempting to change the person's phone number in browser 2, you will get the following error messages: -![](../../images/2/29b.png) +![error message 404 not found when changing multiple browsers](../../images/2/29b.png) Fix the issue according to the example shown in [promise and errors](/en/part2/altering_data_in_server#promises-and-errors) in part 2. Modify the example so that the user is shown a message when the operation does not succeed. The messages shown for successful and unsuccessful events should look different: -![](../../images/2/28e.png) +![error message shown on screen instead of in console feature add-on](../../images/2/28e.png) + +**Note** that even if you handle the exception, the first "404" error message is still printed to the console. But you should not see "Uncaught (in promise) Error". + +
    + +
    + +### Couple of important remarks + +At the end of this part there are a few more challenging exercises. At this stage, you can skip the exercises if they are too much of a headache, we will come back to the same themes again later. The material is worth reading through in any case. + +We have done one thing in our app that is masking away a very typical source of error. + +We set the state _notes_ to have initial value of an empty array: + +```js +const App = () => { + const [notes, setNotes] = useState([]) + + // ... +} +``` + +This is a pretty natural initial value since the notes are a set, that is, there are many notes that the state will store. + +If the state were only saving "one thing", a more appropriate initial value would be _null_ denoting that there is nothing in the state at the start. Let's see what happens if we use this initial value: + +```js +const App = () => { + const [notes, setNotes] = useState(null) // highlight-line + + // ... +} +``` + +The app breaks down: + +![console typerror cannot read properties of null via map from App](../../images/2/31a.png) + +The error message gives the reason and location for the error. The code that caused the problems is the following: + +```js + // notesToShow gets the value of notes + const notesToShow = showAll + ? notes + : notes.filter(note => note.important) + + // ... + + {notesToShow.map(note => // highlight-line + + )} +``` + +The error message is + +```bash +Cannot read properties of null (reading 'map') +``` + +The variable _notesToShow_ is first assigned the value of the state _notes_ and then the code tries to call method _map_ to a nonexisting object, that is, to _null_. + +What is the reason for that? + +The effect hook uses the function _setNotes_ to set _notes_ to have the notes that the backend is returning: + +```js + useEffect(() => { + noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) // highlight-line + }) + }, []) +``` + +However the problem is that the effect is executed only after the first render. +And because _notes_ has the initial value of null: + +```js +const App = () => { + const [notes, setNotes] = useState(null) // highlight-line + + // ... +``` + +on the first render the following code gets executed: + +```js +notesToShow = notes + +// ... + +notesToShow.map(note => ...) +``` + +and this blows up the app since we can not call method _map_ of the value _null_. + +When we set _notes_ to be initially an empty array, there is no error since it is allowed to call _map_ to an empty array. + +So, the initialization of the state "masked" the problem that is caused by the fact that the data is not yet fetched from the backend. + +Another way to circumvent the problem is to use conditional rendering and return null if the component state is not properly initialized: + +```js +const App = () => { + const [notes, setNotes] = useState(null) // highlight-line + // ... + + useEffect(() => { + noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) + }) + }, []) + + // do not render anything if notes is still null + // highlight-start + if (!notes) { + return null + } + // highlight-end + + // ... +} +``` + +So on the first render, nothing is rendered. When the notes arrive from the backend, the effect used function _setNotes_ to set the value of the state _notes_. This causes the component to be rendered again, and at the second render, the notes get rendered to the screen. + +The method based on conditional rendering is suitable in cases where it is impossible to define the state so that the initial rendering is possible. + +The other thing that we still need to have a closer look at is the second parameter of the useEffect: + +```js + useEffect(() => { + noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) + }) + }, []) // highlight-line +``` + +The second parameter of useEffect is used to [specify how often the effect is run](https://react.dev/reference/react/useEffect#parameters). The principle is that the effect is always executed after the first render of the component and when the value of the second parameter changes. + +If the second parameter is an empty array [], its content never changes and the effect is only run after the first render of the component. This is exactly what we want when we are initializing the app state from the server. + +However, there are situations where we want to perform the effect at other times, e.g. when the state of the component changes in a particular way. + +Consider the following simple application for querying currency exchange rates from the [Exchange rate API](https://www.exchangerate-api.com/): + +```js +import { useState, useEffect } from 'react' +import axios from 'axios' + +const App = () => { + const [value, setValue] = useState('') + const [rates, setRates] = useState({}) + const [currency, setCurrency] = useState(null) + + useEffect(() => { + console.log('effect run, currency is now', currency) + + // skip if currency is not defined + if (currency) { + console.log('fetching exchange rates...') + axios + .get(`https://open.er-api.com/v6/latest/${currency}`) + .then(response => { + setRates(response.data.rates) + }) + } + }, [currency]) + + const handleChange = (event) => { + setValue(event.target.value) + } + + const onSearch = (event) => { + event.preventDefault() + setCurrency(value) + } + + return ( +
    +
    + currency: + +
    +
    +        {JSON.stringify(rates, null, 2)}
    +      
    +
    + ) +} + +export default App +``` + +The user interface of the application has a form, in the input field of which the name of the desired currency is written. If the currency exists, the application renders the exchange rates of the currency to other currencies: + +![browser showing currency exchange rates with eur typed and console saying fetching exchange rates](../../images/2/32new.png) + +The application sets the name of the currency entered to the form to the state _currency_ at the moment the button is pressed. + +When the _currency_ gets a new value, the application fetches its exchange rates from the API in the effect function: + +```js +const App = () => { + // ... + const [currency, setCurrency] = useState(null) + + useEffect(() => { + console.log('effect run, currency is now', currency) + + // skip if currency is not defined + if (currency) { + console.log('fetching exchange rates...') + axios + .get(`https://open.er-api.com/v6/latest/${currency}`) + .then(response => { + setRates(response.data.rates) + }) + } + }, [currency]) // highlight-line + // ... +} +``` + +The useEffect hook now has _[currency]_ as the second parameter. The effect function is therefore executed after the first render, and always after the table as its second parameter _[currency]_ changes. That is, when the state _currency_ gets a new value, the content of the table changes and the effect function is executed. + +It is natural to choose _null_ as the initial value for the variable _currency_, because _currency_ represents a single item. The initial value _null_ indicates that there is nothing in the state yet, and it is also easy to check with a simple if statement whether a value has been assigned to the variable. The effect has the following condition + +```js +if (currency) { + // exchange rates are fetched +} +``` + +which prevents requesting the exchange rates just after the first render when the variable _currency_ still has the initial value, i.e. a _null_ value. + +So if the user writes e.g. eur in the search field, the application uses Axios to perform an HTTP GET request to the address and stores the response in the _rates_ state. + +When the user then enters another value in the search field, e.g. usd, the effect function is executed again and the exchange rates of the new currency are requested from the API. + +The way presented here for making API requests might seem a bit awkward. +This particular application could have been made completely without using the useEffect, by making the API requests directly in the form submit handler function: + +```js + const onSearch = (event) => { + event.preventDefault() + axios + .get(`https://open.er-api.com/v6/latest/${value}`) + .then(response => { + setRates(response.data.rates) + }) + } +``` + +However, there are situations where that technique would not work. For example, you might encounter one such a situation in the exercise 2.20 where the use of useEffect could provide a solution. Note that this depends quite much on the approach you selected, e.g. the model solution does not use this trick. + +
    + +
    + +

    Exercises 2.18.-2.20.

    + +

    2.18* Data for countries, step 1

    + +At [https://studies.cs.helsinki.fi/restcountries/](https://studies.cs.helsinki.fi/restcountries/) you can find a service that offers a lot of information related to different countries in a so-called machine-readable format via the REST API. Make an application that allows you to view information from different countries. + +The user interface is very simple. The country to be shown is found by typing a search query into the search field. + +If there are too many (over 10) countries that match the query, then the user is prompted to make their query more specific: + +![too many matches screenshot](../../images/2/19b1.png) + +If there are ten or fewer countries, but more than one, then all countries matching the query are shown: + +![matching countries in a list screenshot](../../images/2/19b2.png) + +When there is only one country matching the query, then the basic data of the country (eg. capital and area), its flag and the languages spoken are shown: + +![flag and additional attributes screenshot](../../images/2/19c3.png) + +**NB**: It is enough that your application works for most countries. Some countries, like Sudan, can be hard to support since the name of the country is part of the name of another country, South Sudan. You don't need to worry about these edge cases. + +

    2.19*: Data for countries, step 2

    + +**There is still a lot to do in this part, so don't get stuck on this exercise!** + +Improve on the application in the previous exercise, such that when the names of multiple countries are shown on the page there is a button next to the name of the country, which when pressed shows the view for that country: + +![attach show buttons for each country feature](../../images/2/19b4.png) + +In this exercise, it is also enough that your application works for most countries. Countries whose name appears in the name of another country, like Sudan, can be ignored. + +

    2.20*: Data for countries, step 3

    + +Add to the view showing the data of a single country, the weather report for the capital of that country. There are dozens of providers for weather data. One suggested API is [https://openweathermap.org](https://openweathermap.org). Note that it might take some minutes until a generated API key is valid. + +![weather report added feature](../../images/2/19x.png) + +If you use Open weather map, [here](https://openweathermap.org/weather-conditions#Icon-list) is the description for how to get weather icons. + +**NB:** In some browsers (such as Firefox) the chosen API might send an error response, which indicates that HTTPS encryption is not supported, although the request URL starts with _http://_. This issue can be fixed by completing the exercise using Chrome. + +**NB:** You need an api-key to use almost every weather service. Do not save the api-key to source control! Nor hardcode the api-key to your source code. Instead use an [environment variable](https://vitejs.dev/guide/env-and-mode.html) to save the key in this exercise. In real-life applications, it's considered insecure sending these keys directly from the browser, as anyone who can open the dev console would be able to intercept your keys! We will focus on implementing a separate backend in the next part of the course. + +Assuming the api-key is 54l41n3n4v41m34rv0, when the application is started like so: + +```bash +export VITE_SOME_KEY=54l41n3n4v41m34rv0 && npm run dev // For Linux/macOS Bash +($env:VITE_SOME_KEY="54l41n3n4v41m34rv0") -and (npm run dev) // For Windows PowerShell +set "VITE_SOME_KEY=54l41n3n4v41m34rv0" && npm run dev // For Windows cmd.exe +``` + +you can access the value of the key from the _import.meta.env_ object: + +```js +const api_key = import.meta.env.VITE_SOME_KEY +// variable api_key now has the value set in startup +``` + +**NB:** To prevent accidentally leaking environment variables to the client, only variables prefixed with VITE_ are exposed to Vite. -**Note** that even if you handle the exception, the error message is printed to the console. +Also remember that if you make changes to environment variables, you need to restart the development server for the changes to take effect. This was the last exercise of this part of the course. It's time to push your code to GitHub and mark all of your finished exercises to the [exercise submission system](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). diff --git a/src/content/2/es/part2.md b/src/content/2/es/part2.md new file mode 100644 index 00000000000..198ace8a5d3 --- /dev/null +++ b/src/content/2/es/part2.md @@ -0,0 +1,13 @@ +--- +mainImage: ../../../images/part-2.svg +part: 2 +lang: es +--- + +
    + +Continuemos nuestra introducción a React. Primero, veremos cómo representar una colección de datos, como una lista de nombres, en la pantalla. Después de esto, inspeccionaremos cómo un usuario puede enviar datos a una aplicación React utilizando formularios HTML. A continuación, nuestro enfoque se centra en ver cómo el código JavaScript en el navegador puede obtener y manejar los datos almacenados en un servidor backend remoto. Por último, echaremos un vistazo rápido a algunas formas sencillas de agregar estilos CSS a nuestras aplicaciones React. + +Parte actualizada el 14 de Agosto de 2023 +- Create React App reemplazado con Vite +
    \ No newline at end of file diff --git a/src/content/2/es/part2a.md b/src/content/2/es/part2a.md new file mode 100644 index 00000000000..1c874544a6a --- /dev/null +++ b/src/content/2/es/part2a.md @@ -0,0 +1,745 @@ +--- +mainImage: ../../../images/part-2.svg +part: 2 +letter: a +lang: es +--- + +
    + +Antes de comenzar una nueva parte, recapitulemos algunos de los temas que resultaron difíciles el año pasado. + +### console.log + +***¿Cuál es la diferencia entre un programador de JavaScript experimentado y un novato? El experimentado usa console.log de 10 a 100 veces más.*** + +Paradójicamente, esto parece ser cierto aunque un programador novato necesitaría _console.log_ (o cualquier método de depuración) más que uno experimentado. + +Cuando algo no funciona, no adivines qué está mal. En su lugar, usa la consola o utiliza alguna otra forma de depuración. + +**NB** Como se explicó en la parte 1, cuando uses el comando _console.log_ para depurar, no concatenes cosas 'al estilo Java' con un signo de sumar. En lugar de escribir: + +```js +console.log('props value is' + props) +``` + +Separa las cosas que se van a imprimir con una coma: + +```js +console.log('props value is', props) +``` + +Si concatenas un objeto con una cadena y lo registras en la consola (como en nuestro primer ejemplo), el resultado será bastante inútil: + +```js +props value is [Object object] +``` + +Por el contrario, cuando pasas objetos como argumentos distintos separados por comas a _console.log_, como en nuestro segundo ejemplo anterior, el contenido del objeto se imprime en la consola del desarrollador como cadenas que nos aportan información util. +Si es necesario, lee más sobre la depuración de aplicaciones React [aquí](/es/part1/un_estado_mas_complejo_depurando_aplicaciones_react#depuracion-de-aplicaciones-react). + +### Protip: fragmentos de código de Visual Studio + +Con Visual Studio Code es fácil crear 'snippets', es decir, accesos directos para generar rápidamente porciones de código que se reutilizan habitualmente, muy parecido a cómo funciona 'sout' en Netbeans. + +Las instrucciones para crear snippets se pueden encontrar [aquí](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_creating-your-own-snippets). + +También se pueden encontrar snippets útiles y listos para usar como complementos de VS Code, por ejemplo, [aquí](https://marketplace.visualstudio.com/items?itemName=xabikos.ReactSnippets). + +El snippet más importante es el del comando console.log(), por ejemplo clog. Esto se puede crear así: + +```js +{ + "console.log": { + "prefix": "clog", + "body": [ + "console.log('$1')", + ], + "description": "Log output to console" + } +} +``` + +Depurar tu código usando _console.log()_ es tan común que Visual Studio Code tiene ese fragmento integrado. Para usarlo, escribe _log_ y presiona tabulador para autocompletar. Extensiones de snippets de _console.log()_ más completos pueden encontrarse en el [marketplace](https://marketplace.visualstudio.com/search?term=console.log&target=VSCode&category=All%20categories&sortBy=Relevance). + +### Matrices JavaScript + +De aquí en adelante, usaremos los métodos de programación funcional de JavaScript [array](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array) -como _find_ , _filter_ y _map_ - todo el tiempo. + +Si la programación funcional con matrices te parece ajena, vale la pena ver al menos las tres primeras partes de la serie de videos de YouTube [Programación funcional en JavaScript](https://www.youtube.com/playlist?list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84): + +- [Higher-order functions](https://www.youtube.com/watch?v=BMUiFMZr7vk&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84) +- [Map](https://www.youtube.com/watch?v=bCqtb-Z5YGQ&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84&index=2) +- [Reduce basics](https://www.youtube.com/watch?v=Wl98eZpkp-c&t=31s) + +### Controladores de eventos revisados + +Según el curso del año pasado, el manejo de eventos ha demostrado ser difícil. + +Vale la pena leer el capítulo de revisión al final de la parte anterior [revisión de los controladores de eventos](/es/part1/un_estado_mas_complejo_depurando_aplicaciones_react#revision-de-los-controladores-de-eventos), si cree que su propio conocimiento sobre el tema necesita algo de mejora. + +Pasar controladores de eventos a los componentes secundarios del componente App ha planteado algunas preguntas. Se puede encontrar una pequeña revisión sobre el tema [aquí](/es/part1/un_estado_mas_complejo_depurando_aplicaciones_react#pasando-controladores-de-eventos-a-componentes-hijos). + +### Renderizando colecciones + +Ahora haremos el 'frontend', o la lógica de la aplicación del lado del navegador, en React para una aplicación que es similar a la aplicación de ejemplo de la [parte 0](/es/part0) + +Comencemos con lo siguiente (en el archivo _App.jsx_): + +```js +const App = (props) => { + const { notes } = props + + return ( +
    +

    Notes

    +
      +
    • {notes[0].content}
    • +
    • {notes[1].content}
    • +
    • {notes[2].content}
    • +
    +
    + ) +} + +export default App +``` + +El archivo _main.jsx_ se ve de esta forma: + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' + +import App from './App' + +const notes = [ + { + id: 1, + content: 'HTML is easy', + important: true + }, + { + id: 2, + content: 'Browser can execute only JavaScript', + important: false + }, + { + id: 3, + content: 'GET and POST are the most important methods of HTTP protocol', + important: true + } +] + +ReactDOM.createRoot(document.getElementById('root')).render( + +) +``` + +Cada nota contiene su contenido textual, y un valor _booleano_ para marcar si la nota ha sido categorizada como importante o no, y también un id único. + +El ejemplo anterior funciona debido al hecho de que hay exactamente tres notas en la matriz. + +Se renderiza una sola nota al acceder a los objetos de la matriz haciendo referencia a un número de índice codificado: + +```js +
  • {notes[1].content}
  • +``` + +Esto, por supuesto, no es práctico. Podemos mejorar esto generando elementos React a partir de los objetos de la matriz usando la función [map](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array/map). + +```js +notes.map(note =>
  • {note.content}
  • ) +``` + +El resultado es una matriz de elementos li. + +```js +[ +
  • HTML is easy
  • , +
  • Browser can execute only JavaScript
  • , +
  • GET and POST are the most important methods of HTTP protocol
  • , +] +``` + +Que luego se puede colocar dentro de las etiquetas ul: + +```js +const App = (props) => { + const { notes } = props + + return ( +
    +

    Notes

    +// highlight-start +
      + {notes.map(note =>
    • {note.content}
    • )} +
    +// highlight-end +
    + ) +} +``` + +Debido a que el código que genera las etiquetas li es JavaScript, debe incluirse entre llaves en una plantilla JSX. + + +También haremos que el código sea más legible separando la declaración de la función de flecha en varias líneas: + +```js +const App = (props) => { + const { notes } = props + + return ( +
    +

    Notes

    +
      + {notes.map(note => + // highlight-start +
    • + {note.content} +
    • + // highlight-end + )} +
    +
    + ) +} +``` + +### Atributo key + +Aunque la aplicación parece estar funcionando, hay una advertencia desagradable en la consola + +![error en la consola: unique key prop](../../images/2/1a.png) + +Como la [página](https://es.react.dev/learn/rendering-lists#keeping-list-items-in-order-with-key) vinculada en el mensaje de error instruye, los elementos de la lista, es decir, los elementos generados por el método _map_, deben tener cada uno un valor de clave única: un atributo llamado key. + +Agreguemos las keys (claves): + +```js +const App = (props) => { + const { notes } = props + + return ( +
    +

    Notes

    +
      + {notes.map(note => +
    • // highlight-line + {note.content} +
    • + )} +
    +
    + ) +} +``` + +Y el mensaje de error desaparece. + +React utiliza los atributos key de los objetos en una matriz para determinar cómo actualizar la vista generada por un componente cuando el componente se vuelve a renderizar. Más sobre esto [aquí](https://es.react.dev/learn/preserving-and-resetting-state#option-2-resetting-state-with-a-key). + +### Map + +Comprender cómo funciona el método de matriz [map](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array/map) es crucial para el resto del curso. + +La aplicación contiene una matriz llamada _notes_: + +```js +const notes = [ + { + id: 1, + content: 'HTML is easy', + important: true + }, + { + id: 2, + content: 'Browser can execute only JavaScript', + important: false + }, + { + id: 3, + content: 'GET and POST are the most important methods of HTTP protocol', + important: true + } +] +``` + +Hagamos una pausa por un momento y examinemos cómo funciona _map_. + +Si se agrega el siguiente código, digamos, al final del archivo: + +```js +const result = notes.map(note => note.id) +console.log(result) +``` + +[1, 2, 3] se imprimirá en la consola. +_map_ siempre crea una nueva matriz, cuyos elementos se han creado a partir de los elementos de la matriz original mediante mapeo: utilizando la función dada como parámetro al método _map_. + +La función es + +```js +note => note.id +``` + +Que es una función de flecha escrita en forma compacta. La forma completa sería: + +```js +note => { + return note.id +} +``` + +La función obtiene un objeto de nota como parámetro, y devuelve el valor de su campo id. + +Cambiar el comando a: + +```js +const result = notes.map(note => note.content) +``` + +resulta en una matriz que contiene el contenido de las notas. + +Esto ya está bastante cerca del código de React que usamos: + +```js +notes.map(note => +
  • + {note.content} +
  • +) +``` + +Que genera una etiqueta li que contiene el contenido de la nota de cada objeto de nota. + +Porque el parámetro de función pasado al método _map_ - + +```js +note =>
  • {note.content}
  • +``` + +  - se utiliza para crear elementos de vista, el valor de la variable debe representarse dentro de llaves. Intenta ver qué sucede si se quitan las llaves. + +El uso de llaves te causará dolor de cabeza al principio, pero pronto te acostumbrarás. La respuesta visual de React es inmediata. + +### Anti-patrón: índices de matriz como claves + +Podríamos haber hecho desaparecer el mensaje de error en nuestra consola usando los índices de matriz como claves. Los índices se pueden recuperar pasando un segundo parámetro a la función de devolución de llamada del método map: + +```js +notes.map((note, i) => ...) +``` + +Cuando se llama así, a _i_ se le asigna el valor del índice de la posición en la matriz donde reside la nota. + +Como tal, una forma de definir la generación de filas sin obtener errores es: + +```js +
      + {notes.map((note, i) => +
    • + {note.content} +
    • + )} +
    +``` + +Sin embargo, **no se recomienda** y puede causar problemas no deseados incluso si parece estar funcionando bien. + +Lee más sobre esto en [este articulo](https://robinpokorny.medium.com/index-as-a-key-is-an-anti-pattern-e0349aece318). + +### Refactorizando módulos + +Ordenemos un poco el código. Solo estamos interesados ​​en el campo _notes_ de los props, así que recuperemos eso directamente usando [desestructuración](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment): + +```js +const App = ({ notes }) => { //highlight-line + return ( +
    +

    Notes

    +
      + {notes.map(note => +
    • + {note.content} +
    • + )} +
    +
    + ) +} +``` + +Si has olvidado lo que significa la desestructuración y cómo funciona, revisa la [sección de desestructuración](/es/part1/estado_del_componente_controladores_de_eventos#desestructuracion). + +Separamos la visualización de una sola nota en su propio componente Note: + +```js +// highlight-start +const Note = ({ note }) => { + return ( +
  • {note.content}
  • + ) +} +// highlight-end + +const App = ({ notes }) => { + return ( +
    +

    Notes

    +
      + // highlight-start + {notes.map(note => + + )} + // highlight-end +
    +
    + ) +} +``` + +Ten en cuenta que el atributo key ahora debe definirse para los componentes Note, y no para las etiquetas li como antes. + +Se puede escribir una aplicación React completa en un solo archivo. Aunque eso, por supuesto, no es muy práctico. La práctica común es declarar cada componente en su propio archivo como un módulo ES6. + +Hemos estado usando módulos todo el tiempo. Las primeras líneas del archivo main.jsx: + +```js +import ReactDOM from "react-dom/client" + +import App from "./App" +``` + +[importan](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Statements/import) dos módulos, lo que les permite ser utilizados en ese archivo. El módulo react-dom/client se coloca en una variable llamada _React-DOM_, y el módulo que define el componente principal de la aplicación se coloca en la variable _App_. + +Movamos nuestro componente Note a su propio módulo. + +En aplicaciones más pequeñas, los componentes generalmente se colocan en un directorio llamado components, que a su vez se ubica dentro del directorio src. La convención es nombrar el archivo después del componente. + +Ahora crearemos un directorio llamado components para nuestra aplicación y colocaremos un archivo llamado Note.jsx dentro. El contenido del archivo es el siguiente: + +```js +const Note = ({ note }) => { + return ( +
  • {note.content}
  • + ) +} + +export default Note +``` + +La última línea del módulo [exporta](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Statements/export) el módulo declarado, la variable Note. + +Ahora el archivo que está usando el componente - App.jsx - puede [importar](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Statements/import) el módulo: + +```js +import Note from './components/Note' // highlight-line + +const App = ({ notes }) => { + // ... +} +``` + +El componente exportado por el módulo ahora está disponible para su uso en la variable Note, al igual que antes. + +Ten en cuenta que al importar nuestros propios componentes, se debe dar su ubicación en relación con el archivo de importación: + +```js +'./components/Note' +``` + +El punto - . - al principio se refiere al directorio actual, por lo que la ubicación del módulo es un archivo llamado Note.jsx en el subdirectorio components del directorio actual. La extensión del nombre de archivo _.jsx_ se puede omitir. + +Los módulos tienen muchos otros usos ademas de permitir separar las declaraciones de componentes en sus archivos propios. Volveremos a este tema más adelante en el curso. + +El código actual de la aplicación puede encontrarse en [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-1). + +Ten en cuenta que la rama _main_ del repositorio contiene el código de una version posterior de la aplicación. El código actual está en la rama [part2-1](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-1): + +![Captura de pantalla de la rama de GitHub](../../images/2/2e.png) + +Si clonas el proyecto, ejecuta el comando _npm install_ antes de iniciar la aplicación con _npm run dev_. + +### Cuando la aplicación se rompe + +Al principio de tu carrera como programador (e incluso después de 30 años codeando como es mi caso), lo que sucede a menudo es que la aplicación simplemente se rompe por completo. Esto es aún más común en el caso de los lenguajes tipados dinámicamente, como JavaScript, donde el compilador no verifica el tipo de datos de, por ejemplo, variables de función o valores de retorno. + +Una "explosión de React" puede, por ejemplo, verse así: + +![error de muestra de react](../../images/2/3-vite.png) + +En estas situaciones, la mejor salida es el método console.log. + +El fragmento de código que causa la explosión es este: + +```js +const Course = ({ course }) => ( +
    +
    +
    +) + +const App = () => { + const course = { + // ... + } + + return ( +
    + +
    + ) +} +``` + +Investigaremos el motivo de la rotura agregando comandos console.log al código. Como lo primero que se renderiza es el componente App, vale la pena poner el primer console.log allí: + +```js +const App = () => { + const course = { + // ... + } + + console.log('App works...') // highlight-line + + return ( + // .. + ) +} +``` + +Para ver la impresión en la consola, debemos desplazarnos hacia arriba sobre la larga pared roja de errores. + +![impresión inicial de la consola](../../images/2/4b.png) + +Cuando se descubre que algo funciona, es hora de profundizar más. Si el componente se ha declarado como una sola declaración o una función sin retorno, hace que la impresión en la consola sea más difícil. + +```js +const Course = ({ course }) => ( +
    +
    +
    +) +``` + +El componente debe cambiarse a su forma más larga para que agreguemos la impresión: + +```js +const Course = ({ course }) => { + console.log(course) // highlight-line + return ( +
    +
    +
    + ) +} +``` + +Muy a menudo, la raíz del problema es que se espera que los props sean de un tipo diferente, o que se llamen con un nombre diferente al que realmente tienen, y la desestructuración falla como resultado. El problema a menudo comienza a resolverse por sí mismo cuando se elimina la desestructuración y vemos lo que realmente contienen los props. + +```js +const Course = (props) => { // highlight-line + console.log(props) // highlight-line + const { course } = props + return ( +
    +
    +
    + ) +} +``` + +Si el problema aún no se ha resuelto, realmente no hay mucho que hacer aparte de continuar la búsqueda de errores esparciendo más declaraciones _console.log_ alrededor de tu código. + +Agregué este capítulo al material después de que la respuesta del modelo para la siguiente pregunta explotara por completo (debido a que los props eran del tipo incorrecto), y tuve que depurarlo usando console.log. + +### Juramento del desarrollador web + +Antes de los ejercicios, permíteme recordarte lo que prometiste al final de la parte anterior. + +La programación es difícil, por eso utilizaré todos los medios posibles para facilitarla: + +- Mantendré abierta la consola de desarrollo de mi navegador todo el tiempo. +- Progresaré con pequeños pasos. +- Escribiré muchas declaraciones de _console.log_ para asegurarme de entender cómo se comporta el código y para ayudar a identificar problemas. +- Si mi código no funciona, no escribiré más código. En cambio, comenzaré a eliminar el código hasta que funcione o simplemente volveré a un estado en el que todo aún funcionaba. +- Cuando pida ayuda en el canal de Discord del curso, o en cualquier otro lugar, formularé mis preguntas adecuadamente; consulta [aquí](/es/part0/informacion_general#como-obtener-ayuda-en-discord) cómo pedir ayuda. + +
    + +
    + +

    Ejercicios 2.1.-2.5.

    + +Los ejercicios se envían a través de GitHub y marcando los ejercicios como completados en el [sistema de envío](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +Puedes enviar todos los ejercicios en el mismo repositorio o usar varios repositorios diferentes. Si envías ejercicios de diferentes partes al mismo repositorio, nombra bien tus directorios. + +Los ejercicios se envían **una parte a la vez**. Cuando hayas enviado los ejercicios de una parte, ya no podrás enviar ejercicios faltantes para esa parte. + +Ten en cuenta que esta parte tiene más ejercicios que las anteriores, así que no envíes hasta que hayas hecho todos los ejercicios de esta parte que deseas enviar. + +

    2.1: Información del curso paso 6

    + +Terminemos el código para renderizar el contenido del curso de los ejercicios 1.1 - 1.5. Puedes comenzar con el código de las respuestas modelo. Las respuestas modelo para la parte 1 se pueden encontrar yendo al [sistema de envío](https://studies.cs.helsinki.fi/stats/courses/fullstackopen), haciendo clic en my submissions en la parte superior, y en la fila correspondiente a la parte 1, bajo la columna solutions, haciendo clic en show. Para ver la solución al ejercicio de courseinfo, haz clic dentro de la carpeta src en _App.jsx_ para visualizar el código. + +**Ten en cuenta que si copias un proyecto de un lugar a otro, es posible que debas eliminar el directorio node\_modules e instalar las dependencias nuevamente con el comando _npm install_ antes de poder iniciar la aplicación.** + +En general, no se recomienda copiar todo el contenido de un proyecto y/o agregar el directorio node\_modules al sistema de control de versiones. + +Cambiemos el componente App de la siguiente manera: + +```js +const App = () => { + const course = { + id: 1, + name: 'Half Stack application development', + parts: [ + { + name: 'Fundamentals of React', + exercises: 10, + id: 1 + }, + { + name: 'Using props to pass data', + exercises: 7, + id: 2 + }, + { + name: 'State of a component', + exercises: 14, + id: 3 + } + ] + } + + return +} + +export default App +``` + +Define un componente responsable de formatear un solo curso llamado Course. + +La estructura de componentes de la aplicación puede ser, por ejemplo, la siguiente: + +``` +App + Course + Header + Content + Part + Part + ... +``` + +Por lo tanto, el componente Course contiene los componentes definidos en la parte anterior, que son responsables de renderizar el nombre del curso y sus partes. + +La página renderizada puede verse, por ejemplo, de la siguiente manera: + +![captura de pantalla de la aplicación Half Stack](../../images/teht/8e.png) + +Aún no necesitas la suma de los ejercicios. + +La aplicación debe funcionar independientemente del número de partes que tenga un curso, así que asegúrate de que la aplicación funcione si agregas o quitas partes de un curso. + +¡Asegúrate de que la consola no muestre errores! + +

    2.2: Información del curso paso 7

    + +Muestra también la suma de los ejercicios del curso. + +![nueva característica añadida de suma de ejercicios](../../images/teht/9e.png) + +

    2.3*: Información del curso paso 8

    + +Si aún no lo has hecho, calcula la suma de los ejercicios con el método de array [reduce](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce). + +**Consejo profesional:** cuando tu código se ve así: + +```js +const total = + parts.reduce((s, p) => someMagicHere) +``` + +y no funciona, vale la pena usar console.log, que requiere que la función de flecha se escriba en su forma más larga: + +```js +const total = parts.reduce((s, p) => { + console.log('what is happening', s, p) + return someMagicHere +}) +``` + +**¿No funciona? :** Utiliza tu motor de búsqueda para buscar cómo se utiliza _reduce_ en un **Array de Objetos**. + +

    2.4: Información del curso paso 9

    + +Ampliemos nuestra aplicación para permitir un número arbitrario de cursos: + +```js +const App = () => { + const courses = [ + { + name: 'Half Stack application development', + id: 1, + parts: [ + { + name: 'Fundamentals of React', + exercises: 10, + id: 1 + }, + { + name: 'Using props to pass data', + exercises: 7, + id: 2 + }, + { + name: 'State of a component', + exercises: 14, + id: 3 + }, + { + name: 'Redux', + exercises: 11, + id: 4 + } + ] + }, + { + name: 'Node.js', + id: 2, + parts: [ + { + name: 'Routing', + exercises: 3, + id: 1 + }, + { + name: 'Middlewares', + exercises: 7, + id: 2 + } + ] + } + ] + + return ( +
    + // ... +
    + ) +} +``` + +La aplicación puede, por ejemplo, verse así: + +![característica adicional de número arbitrario de cursos](../../images/teht/10e.png) + +

    2.5: Módulo separado paso 10

    + +Declara el componente Course como un módulo separado, que se importa en el componente App. Puedes incluir todos los subcomponentes del curso en el mismo módulo. + +
    diff --git a/src/content/2/es/part2b.md b/src/content/2/es/part2b.md new file mode 100644 index 00000000000..646bed4fb49 --- /dev/null +++ b/src/content/2/es/part2b.md @@ -0,0 +1,565 @@ +--- +mainImage: ../../../images/part-2.svg +part: 2 +letter: b +lang: es +--- + +
    + +Continuemos expandiendo nuestra aplicación permitiendo a los usuarios agregar nuevas notas. Puedes encontrar el código de nuestra aplicación actual [aquí](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-1). + +### Guardando las notas en el estado del componente + +Para que nuestra página se actualice cuando se agregan nuevas notas, es mejor almacenar las notas en el estado del componente App. Importemos la función [useState](https://es.react.dev/reference/react/useState) y usémosla para definir una parte del estado que se inicializa con la matriz de notas inicial pasada en los props. + +```js +import { useState } from 'react' // highlight-line +import Note from './components/Note' + +const App = (props) => { // highlight-line + const [notes, setNotes] = useState(props.notes) // highlight-line + + return ( +
    +

    Notes

    +
      + {notes.map(note => + + )} +
    +
    + ) +} + +export default App +``` + +El componente usa la función useState para inicializar la parte de estado almacenada en notes con la matriz de notas pasadas en los props: + +```js +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + + // ... +} +``` + +También podemos utilizar React Developer Tools para comprobar que esto realmente sucede: + +![navegador mostrando la ventana de herramientas de desarrollo de React](../../images/2/30.png) + +Si quisiéramos comenzar con una lista vacía de notas, estableceríamos el valor inicial como una matriz vacía, y dado que los props no se usarían, podríamos omitir el parámetro props de la definición de la función: + +```js +const App = () => { + const [notes, setNotes] = useState([]) + + // ... +} +``` + +Sigamos con el valor inicial pasado en los props por el momento. + +A continuación, agreguemos un [formulario](https://developer.mozilla.org/es/docs/Learn/Forms) HTML al componente que se utilizará para agregar nuevas notas. + +```js +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + +// highlight-start + const addNote = (event) => { + event.preventDefault() + console.log('button clicked', event.target) + } + // highlight-end + + return ( +
    +

    Notes

    +
      + {notes.map(note => + + )} +
    + // highlight-start +
    + + +
    + // highlight-end +
    + ) +} +``` + +Hemos agregado la función _addNote_ como un controlador de eventos al elemento del formulario que se llamará cuando se envíe el formulario, haciendo clic en el botón submit. + +Usamos el método discutido en la [parte 1](/es/part1/estado_del_componente_controladores_de_eventos#control-de-eventos) para definir nuestro controlador de eventos: + +```js +const addNote = (event) => { + event.preventDefault() + console.log('button clicked', event.target) +} +``` + +El parámetro event es el [evento](https://es.react.dev/learn/responding-to-events) que activa la llamada a la función del controlador de eventos: + +El controlador de eventos llama inmediatamente al método event.preventDefault(), que evita la acción predeterminada de enviar un formulario. La acción predeterminada, [entre otras cosas](https://developer.mozilla.org/es/docs/Web/API/HTMLFormElement/submit_event), haría que la página se recargara. + +El objetivo del evento almacenado en _event.target_ se registra en la consola: + +![botón clickeado con objeto de formulario en la consola](../../images/2/6e.png) + +El objetivo (target) en este caso es el formulario que hemos definido en nuestro componente. + +¿Cómo accedemos a los datos contenidos en el elemento input del formulario? + +### Componentes controlados + +Hay muchas maneras de lograr esto; el primer método que veremos es mediante el uso de los llamados [componentes controlados](https://es.react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable). + +Agreguemos un nuevo estado llamado newNote para almacenar la entrada enviada por el usuario **y** configurémoslo como el atributo value del elemento input: + +```js +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + // highlight-start + const [newNote, setNewNote] = useState( + 'a new note...' + ) + // highlight-end + + const addNote = (event) => { + event.preventDefault() + console.log('button clicked', event.target) + } + + return ( +
    +

    Notes

    +
      + {notes.map(note => + + )} +
    +
    + //highlight-line + +
    +
    + ) +} +``` + +El texto del placeholder almacenado como valor inicial del estado newNote aparece en el elemento input, pero el input no se puede editar. La consola muestra una advertencia que nos da una pista de lo que podría estar mal: + +![error de consola al proporcionar un valor a la propiedad sin onchange](../../images/2/7e.png) + +Dado que asignamos una parte del estado del componente App como el atributo value del elemento input, el componente App ahora [controla](https://es.react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable) el comportamiento del input. + +Para habilitar la edición del input, tenemos que registrar un controlador de eventos que sincronice los cambios realizados en la entrada con el estado del componente: + +```js +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + const [newNote, setNewNote] = useState( + 'a new note...' + ) + + // ... + +// highlight-start + const handleNoteChange = (event) => { + console.log(event.target.value) + setNewNote(event.target.value) + } +// highlight-end + + return ( +
    +

    Notes

    +
      + {notes.map(note => + + )} +
    +
    + + +
    +
    + ) +} +``` + +Ahora hemos registrado un controlador de eventos en el atributo onChange del elemento input del formulario: + +```js + +``` + +Se llama al controlador de eventos cada vez que ocurre un cambio en el elemento input. La función del controlador de eventos recibe el objeto de evento como su parámetro event: + +```js +const handleNoteChange = (event) => { + console.log(event.target.value) + setNewNote(event.target.value) +} +``` + +La propiedad target del objeto de evento ahora corresponde al elemento input controlado y event.target.value se refiere al valor de entrada de ese elemento. + +Ten en cuenta que no necesitamos llamar al método _event.preventDefault()_ como hicimos en el controlador de eventos onSubmit. Esto se debe a que no se produce una acción predeterminada en un cambio de input, a diferencia de lo que ocurre con el envío de un formulario. + +Puedes ver en la consola cómo se llama al controlador de eventos: + +![multiples llamados en la consola al escribir texto](../../images/2/8e.png) + +Has recordado instalar [React devtools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi), ¿verdad? Bien. Puedes ver directamente cómo cambia el estado desde la pestaña React Devtools:! + +![cambios en el estado al escribir texto en react devtools](../../images/2/9ea.png) + +Ahora el estado del componente newNote de App refleja el valor actual del input, lo que significa que podemos completar la función addNote para crear nuevas notas: + +```js +const addNote = (event) => { + event.preventDefault() + const noteObject = { + content: newNote, + important: Math.random() < 0.5, + id: notes.length + 1, + } + + setNotes(notes.concat(noteObject)) + setNewNote('') +} +``` + +Primero creamos un nuevo objeto para la nota llamado noteObject que recibirá su contenido del estado del componente newNote. El identificador único id se genera en función del número total de notas. Este método funciona para nuestra aplicación ya que las notas nunca se eliminan. Con la ayuda de la función Math.random(), nuestra nota tiene un 50% de posibilidades de ser marcada como importante. + +La nueva nota se agrega a la lista de notas usando el método de matriz [concat](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array/concat), introducido en la [parte 1](/es/part1/java_script#arrays): + +```js +setNotes(notes.concat(noteObject)) +``` + +El método no muta la matriz notes original, sino que crea una nueva copia de la matriz con el nuevo elemento agregado al final. Esto es importante ya que [nunca debemos mutar el estado directamente](https://es.react.dev/learn/updating-objects-in-state#why-is-mutating-state-not-recommended-in-react) en React! + +El controlador de eventos también restablece el valor del elemento de entrada controlado llamando a la función setNewNote del estado de newNote: + +```js +setNewNote('') +``` + +Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part2-2 de [este repositorio de GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-2). + +### Filtrado de elementos mostrados + +Agreguemos una nueva funcionalidad a nuestra aplicación que nos permita ver solo las notas importantes. + +Agreguemos un fragmento de estado al componente App que realiza un seguimiento de las notas que deben mostrarse: + +```js +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) // highlight-line + + // ... +} +``` + +Cambiemos el componente para que almacene una lista de todas las notas que se mostrarán en la variable notesToShow. Los elementos de la lista dependen del estado del componente: + +```js +import React, { useState } from 'react' +import Note from './components/Note' + +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + + // ... + +// highlight-start + const notesToShow = showAll + ? notes + : notes.filter(note => note.important === true) +// highlight-end + + return ( +
    +

    Notes

    +
      + {notesToShow.map(note => // highlight-line + + )} +
    + // ... +
    + ) +} +``` + +La definición de la variable notesToShow es bastante compacta: + +```js +const notesToShow = showAll + ? notes + : notes.filter(note => note.important === true) +``` + +La definición usa el operador [condicional](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Operators/Conditional_Operator) que también se encuentra en muchos otros lenguajes de programación. + +El operador funciona de la siguiente manera. Si tenemos: + +```js +const result = condition ? val1 : val2 +``` + +la variable result se establecerá en el valor de val1 si la condición (condition) es verdadera. Si la condition es falsa, la variable result se establecerá en el valor de val2. + +Si el valor de showAll es falso, la variable notesToShow se asignará a una lista que solo contiene notas que tienen la propiedad important establecida en true. El filtrado se realiza con la ayuda del método de matriz [filter](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array/filter): + +```js +notes.filter(note => note.important === true) +``` + +El operador de comparación es de hecho redundante, ya que el valor de note.important es true o false lo que significa que simplemente podemos escribir: + +```js +notes.filter(note => note.important) +``` + +La razón por la que mostramos el operador de comparación primero fue para enfatizar un detalle importante: en JavaScript val1 == val2 no funciona como se esperaba en todas las situaciones y es más seguro utilizar val1 === val2 exclusivamente en las comparaciones. Puedes leer más sobre el tema [aquí](https://developer.mozilla.org/es/docs/Web/JavaScript/Equality_comparisons_and_sameness). + +Puedes probar la funcionalidad de filtrado cambiando el valor inicial del estado showAll. + +A continuación, agreguemos una funcionalidad que permita a los usuarios alternar el estado showAll de la aplicación desde la interfaz de usuario. + +Los cambios relevantes se muestran a continuación: + +```js +import React, { useState } from 'react' +import Note from './components/Note' + +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + + // ... + + return ( +
    +

    Notes

    +// highlight-start +
    + +
    +// highlight-end +
      + {notesToShow.map(note => + + )} +
    + // ... +
    + ) +} +``` + +Las notas mostradas (todas versus las importantes) se controlan con un botón. El controlador de eventos para el botón es tan simple que se ha definido directamente en el atributo del elemento del botón. El controlador de eventos cambia el valor de _showAll_ de verdadero a falso y viceversa: + +```js +() => setShowAll(!showAll) +``` + +El texto del botón depende del valor del estado de showAll: + +```js +show {showAll ? 'important' : 'all'} +``` + +Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part2-3 de [este repositorio de GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-3). +
    + +
    + +

    Ejercicios 2.6.-2.10.

    + +En el primer ejercicio, comenzaremos a trabajar en una aplicación que se continuara desarrollando en los ejercicios posteriores. En conjuntos de ejercicios relacionados, es suficiente con presentar la versión final de tu aplicación. También puedes realizar un commit por separado después de haber terminado cada parte del conjunto de ejercicios, pero no es necesario hacerlo. + +

    2.6: La Agenda Telefónica Paso 1

    + +Creemos una agenda telefónica sencilla. **En esta parte solo agregaremos nombres a la agenda.** + +Comencemos por implementar la adición de una persona a la agenda. + +Puedes utilizar el siguiente código como punto de partida para el componente App de tu aplicación: + +```js +import { useState } from 'react' + +const App = () => { + const [persons, setPersons] = useState([ + { name: 'Arto Hellas' } + ]) + const [newName, setNewName] = useState('') + + return ( +
    +

    Phonebook

    +
    +
    + name: +
    +
    + +
    +
    +

    Numbers

    + ... +
    + ) +} + +export default App +``` + +El estado de newName está destinado a controlar el elemento input del formulario. + +A veces puede resultar útil representar el estado y otras variables como texto con fines de depuración. Puedes agregar temporalmente el siguiente elemento al componente renderizado: + +```html +
    debug: {newName}
    +``` + +También es importante poner lo que aprendimos en el capítulo [depuración de aplicaciones React](/es/part1/un_estado_mas_complejo_depurando_aplicaciones_react) de la parte uno en buen uso. La extensión [React developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) especialmente, es increíblemente útil para rastrear los cambios que ocurren en el estado de la aplicación. + +Después de terminar este ejercicio, su aplicación debería verse así: + +![captura de pantalla de 2.6 finalizado](../../images/2/10e.png) + +¡Ten en cuenta el uso de la extensión de herramientas de desarrollo React en la imagen de arriba! + +**NB:** + +- puedes utilizar el nombre de la persona como valor de la propiedad key +- ¡recuerda evitar la acción predeterminada de enviar formularios HTML! + +

    2.7: La Agenda Telefónica Paso 2

    + +Evita que el usuario pueda agregar nombres que ya existen en la agenda telefónica. Los arrays de JavaScript tienen numerosos [métodos](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array) adecuados para realizar esta tarea. + +Emite una advertencia con el comando [alert](https://developer.mozilla.org/es/docs/Web/API/Window/alert) cuando se intente realizar una acción de este tipo: + +![alerta del navegador: "usuario ya existe en la agenda telefónica"](../../images/2/11e.png) + +**Sugerencia:** cuando estés formando cadenas que contienen valores de variables, se recomienda utilizar una [plantilla de cadena](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Template_literals): + +```js +`${newName} is already added to phonebook` +``` + +Si la variable newName tiene el valor Arto Hellas, la expresión de la plantilla de cadena regresa la cadena + +```js +`Arto Hellas is already added to phonebook` +``` + +Lo mismo se podría hacer en una forma más similar a Java usando el operador de sumar: + +```js +newName + ' is already added to phonebook' +``` + +Usar plantillas de cadenas es la opción más idiomática y el signo de un verdadero profesional de JavaScript. + +

    2.8: La Agenda Telefónica Paso 3

    + +Amplía tu aplicación permitiendo a los usuarios agregar números de teléfono a la agenda telefónica. Deberás agregar un segundo elemento input al formulario (junto con su propio controlador de eventos): + +```js +
    +
    name:
    +
    number:
    +
    +
    +``` + +En este punto, la aplicación podría verse así. La imagen también muestra el estado de la aplicación con la ayuda de [React developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi): + +![2.8 captura de pantalla de muestra](../../images/2/12e.png) + +

    2.9*: La Agenda Telefónica Paso 4

    + +Implementa un campo de búsqueda que pueda usarse para filtrar la lista de personas por nombre: + +![2.9 campo de busqueda](../../images/2/13e.png) + +Puedes implementar el campo de búsqueda como un elemento input que se coloca fuera del formulario HTML. La lógica de filtrado que se muestra en la imagen no distingue entre mayúsculas y minúsculas, lo que significa que el término de búsqueda arto también devuelve resultados que contienen Arto con una A mayúscula. + +**NB:** Cuando trabajes en una nueva funcionalidad, a menudo es útil "codificar" algunos datos ficticios en tu aplicación, por ejemplo + +```js +const App = () => { + const [persons, setPersons] = useState([ + { name: 'Arto Hellas', number: '040-123456', id: 1 }, + { name: 'Ada Lovelace', number: '39-44-5323523', id: 2 }, + { name: 'Dan Abramov', number: '12-43-234345', id: 3 }, + { name: 'Mary Poppendieck', number: '39-23-6423122', id: 4 } + ]) + + // ... +} +``` + +Esto evita tener que ingresar datos manualmente en tu aplicación para probar tu nueva funcionalidad. + +

    2.10: La Agenda Telefónica Paso 5

    + +Si has implementado tu aplicación en un solo componente, refactoriza extrayendo las partes adecuadas en nuevos componentes. Mantén el estado de la aplicación y todos los controladores de eventos en el componente raíz de App. + +Es suficiente extraer **tres** componentes de la aplicación. Buenos candidatos para componentes separados son, por ejemplo, el filtro de búsqueda, el formulario para agregar nuevas personas a la agenda telefónica, un componente que muestra a todas las personas de la agenda telefónica y un componente que muestra los detalles de una sola persona. + +El componente raíz de la aplicación podría verse similar a esto después de la refactorización. El componente raíz refactorizado a continuación solo representa los títulos y permite que los componentes extraídos se encarguen del resto. + +```js +const App = () => { + // ... + + return ( +
    +

    Phonebook

    + + + +

    Add a new

    + + + +

    Numbers

    + + +
    + ) +} +``` + +**NB**: Es posible que tengas problemas en este ejercicio si defines tus componentes "en el lugar equivocado". Ahora sería un buen momento para recordar el capítulo [no definir componentes dentro de los componentes](/es/part1/un_estado_mas_complejo_depurando_aplicaciones_react#no-definir-componentes-dentro-de-los-componentes) de la última parte. + +
    diff --git a/src/content/2/es/part2c.md b/src/content/2/es/part2c.md new file mode 100644 index 00000000000..3e7959bc310 --- /dev/null +++ b/src/content/2/es/part2c.md @@ -0,0 +1,585 @@ +--- +mainImage: ../../../images/part-2.svg +part: 2 +letter: c +lang: es +--- + +
    + +Desde hace un tiempo solo hemos estado trabajando en el "frontend", es decir, la funcionalidad del lado del cliente (navegador). Comenzaremos a trabajar en el "backend", es decir, la funcionalidad del lado del servidor en la [tercera parte](/es/part3) de este curso. No obstante, ahora daremos un paso en esa dirección familiarizándonos con cómo el código que se ejecuta en el navegador se comunica con el backend. + +Usemos una herramienta diseñada para ser utilizada durante el desarrollo de software llamada [JSON Server](https://github.com/typicode/json-server) para que actúe como nuestro servidor. + +Crea un archivo llamado db.json en el directorio raíz del proyecto con el siguiente contenido: + +```json +{ + "notes": [ + { + "id": 1, + "content": "HTML is easy", + "important": true + }, + { + "id": 2, + "content": "Browser can execute only JavaScript", + "important": false + }, + { + "id": 3, + "content": "GET and POST are the most important methods of HTTP protocol", + "important": true + } + ] +} +``` + +Puedes [instalar](https://github.com/typicode/json-server#getting-started) el servidor JSON globalmente en tu máquina usando el comando _npm install -g json-server_. Una instalación global requiere privilegios administrativos, lo que significa que no es posible en las computadoras de la facultad o en las computadoras portátiles de primer año. + +Después de instalar, ejecuta el siguiente comando para ejecutar json-server. Por defecto, json-server se inicia en el puerto 3000; ahora definiremos un puerto alternativo 3001, para json-server. La opción --watch busca automáticamente cualquier cambio guardado en db.json. + +```js +json-server --port 3001 --watch db.json +``` + +Sin embargo, no es necesaria una instalación global. Desde el directorio raíz de su aplicación, podemos ejecutar json-server usando el comando _npx_: + +```js +npx json-server --port 3001 --watch db.json +``` + +Naveguemos hasta la dirección en el navegador. Podemos ver que json-server sirve las notas que escribimos previamente en el archivo en formato JSON: + +![notas en formato json en el navegador en la url localhost:3001/notes](../../images/2/14new.png) + +Si tu navegador no puede formatear la visualización de datos JSON, entonces instala una extension apropiada, por ejemplo, [JSONVue](https://chrome.google.com/webstore/detail/jsonview/chklaanhfefbnpoihckbnefhakgolnmc) para hacerte la vida más fácil. + +De ahora en adelante, la idea será guardar las notas en el servidor, lo que en este caso significa guardarlas en json-server. El código de React obtiene las notas del servidor y las muestra en la pantalla. Siempre que se agrega una nueva nota a la aplicación, el código de React también la envía al servidor para que la nueva nota persista en la "memoria". + +json-server almacena todos los datos en el archivo db.json, que reside en el servidor. En el mundo real, los datos se almacenarían en algún tipo de base de datos. Sin embargo, json-server es una herramienta útil que permite el uso de la funcionalidad del lado del servidor en la fase de desarrollo sin la necesidad de programar nada de eso. + +Nos familiarizaremos con los principios de implementación de la funcionalidad del lado del servidor con más detalle en la [parte 3](/es/part3) de este curso. + +### El navegador como entorno de ejecución + +Nuestra primera tarea es recuperar las notas ya existentes en nuestra aplicación React desde la dirección . + +En el [proyecto de ejemplo](/es/part0/fundamentos_de_las_aplicaciones_web#ejecucion-de-la-logica-de-la-aplicacion-en-el-navegador) ya aprendimos una manera de obtener datos de un servidor usando JavaScript. El código del ejemplo obtenía los datos mediante [XMLHttpRequest](https://developer.mozilla.org/es/docs/Web/API/XMLHttpRequest), también conocido como solicitud HTTP realizada mediante un objeto XHR. Esta es una técnica introducida en 1999, que todos los navegadores han admitido durante un buen tiempo. + +Ya no se recomienda el uso de XHR, y los navegadores ya admiten ampliamente el método [fetch](https://developer.mozilla.org/es/docs/Web/API/fetch), que se basa en las llamadas [promesas (promises)](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Promise), en lugar del modelo impulsado por eventos utilizado por XHR. + +Como recordatorio de la parte 0 (que de hecho no deberías usar sin una buena razón), los datos se obtuvieron usando XHR de la siguiente manera: + +```js +const xhttp = new XMLHttpRequest() + +xhttp.onreadystatechange = function() { + if (this.readyState == 4 && this.status == 200) { + const data = JSON.parse(this.responseText) + // handle the response that is saved in variable data + } +} + +xhttp.open('GET', '/data.json', true) +xhttp.send() +``` + +Justo al principio, registramos un controlador de eventos en el objeto xhttp que representa la solicitud HTTP, que será invocada por el entorno de ejecución de JavaScript siempre que el estado del objeto xhttp cambie. Si el cambio de estado significa que ha llegado la respuesta a la solicitud, los datos se manejan en consecuencia. + +Vale la pena señalar que el código en el controlador de eventos se define antes de que la solicitud se envíe al servidor. A pesar de esto, el código dentro del controlador de eventos se ejecutará en un momento posterior. Por lo tanto, el código no se ejecuta de forma síncrona "de arriba a abajo", sino que lo hace asincrónicamente. JavaScript llama al controlador de eventos que se registró para la solicitud en algún momento. + +Una forma síncrona de realizar solicitudes que es común en la programación Java, por ejemplo, se desarrollaría de la siguiente manera (NB, esto no es realmente un código Java que funcione): + +```java +HTTPRequest request = new HTTPRequest(); + +String url = "https://studies.cs.helsinki.fi/exampleapp/data.json"; +List notes = request.get(url); + +notes.forEach(m => { + System.out.println(m.content); +}); +``` + +En Java el código se ejecuta línea por línea y se detiene para esperar la solicitud HTTP, lo que significa esperar a que finalice el comando _request.get(...)_. Los datos devueltos por el comando, en este caso las notas, se almacenan en una variable y comenzamos a manipular los datos de la manera deseada. + +Por otro lado, los motores JavaScript o los entornos de ejecución siguen el [modelo asíncrono](https://developer.mozilla.org/es/docs/Web/JavaScript/Event_loop). En principio, esto requiere que todas las [operaciones IO](https://es.wikipedia.org/wiki/Perif%C3%A9rico_de_entrada/salida) (con algunas excepciones) se ejecuten como no bloqueantes. Esto significa que la ejecución del código continúa inmediatamente después de llamar a una función IO, sin esperar a que regrese. + +Cuando se completa una operación asíncrona, o más específicamente, en algún momento después de su finalización, el motor de JavaScript llama a los controladores de eventos registrados en la operación. + +Actualmente, los motores de JavaScript son de un solo thread, lo que significa que no pueden ejecutar código en paralelo. Como resultado, es un requisito en la práctica utilizar un modelo sin bloqueo para ejecutar operaciones IO. De lo contrario, el navegador se "congelaría" durante, por ejemplo, la obtención de datos de un servidor. + +Otra consecuencia de esta naturaleza de un solo thread de los motores de JavaScript es que si la ejecución de algún código lleva mucho tiempo, el navegador se atascará mientras dure la ejecución. Si agregamos el siguiente código en la parte superior de nuestra aplicación: + +```js +setTimeout(() => { + console.log('loop..') + let i = 0 + while (i < 50000000000) { + i++ + } + console.log('end') +}, 5000) +``` + +todo funcionaría normalmente durante 5 segundos. Sin embargo, cuando se ejecuta la función definida como parámetro para setTimeout, el navegador se bloqueará mientras dure la ejecución del bucle largo. Incluso la pestaña del navegador no se puede cerrar durante la ejecución del bucle, al menos no en Chrome. + +Para que el navegador permanezca receptivo, es decir, para poder reaccionar continuamente a las operaciones del usuario con suficiente velocidad, la lógica del código debe ser tal que ningún cálculo individual pueda llevar demasiado tiempo. + +Existe una gran cantidad de material adicional sobre el tema que se puede encontrar en Internet. Una presentación particularmente clara del tema es el discurso de apertura de Philip Roberts titulado [¿Qué diablos es el ciclo del evento de todos modos?](Https://www.youtube.com/watch?v=8aGhZQkoFbQ) + +En los navegadores actuales, es posible ejecutar código paralelo con la ayuda de los llamados [web workers](https://developer.mozilla.org/es/docs/Web/API/Web_Workers_API/Using_web_workers). Sin embargo, el bucle de eventos de una ventana individual del navegador solo es manejada por un [hilo único](https://medium.com/techtrument/multithreading-javascript-46156179cf9a). + +### npm + +Volvamos al tema de la obtención de datos del servidor. + +Podríamos usar la función basada en promesas [fetch](https://developer.mozilla.org/es/docs/Web/API/fetch) mencionada anteriormente para extraer los datos del servidor. Fetch es una gran herramienta. Está estandarizado y es compatible con todos los navegadores modernos (excepto IE). + +Dicho esto, usaremos la librería [axios](https://github.com/axios/axios) en su lugar para la comunicación entre el navegador y el servidor. Funciona como fetch, pero es algo más agradable de usar. Otra buena razón para usar axios es que nos familiarizamos con la adición de librerías externas, los llamados paquetes npm, a los proyectos de React. + +Hoy en día, prácticamente todos los proyectos de JavaScript se definen utilizando el administrador de paquetes de node, también conocido como [npm](https://docs.npmjs.com/about-npm). Los proyectos creados con Vite también siguen el formato npm. Un indicador claro de que un proyecto usa npm es el archivo package.json ubicado en la raíz del proyecto: + +```json +{ + "name": "notes-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.15", + "@types/react-dom": "^18.2.7", + "@vitejs/plugin-react": "^4.0.3", + "eslint": "^8.45.0", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.3", + "vite": "^4.4.5" + } +} +``` + +En este punto, la parte de dependencies es la de mayor interés para nosotros ya que define qué dependencias, o librerías externas, tiene el proyecto. + +Ahora queremos usar axios. En teoría, podríamos definir la librería directamente en el archivo package.json, pero es mejor instalarlo desde la línea de comandos. + +```js +npm install axios +``` + +**NB los comandos de _npm_ siempre deben ejecutarse en el directorio raíz del proyecto**, que es donde se puede encontrar el archivo package.json. + +Axios ahora se incluye entre las otras dependencias: + +```json +{ + "name": "notes-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.4.0", // highlight-line + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + // ... +} +``` + +Además de agregar axios a las dependencias, el comando npm install también descargó el código de la librería. Al igual que con otras dependencias, el código se puede encontrar en el directorio node\_modules ubicado en la raíz. Como uno podría haber notado, node\_modules contiene una buena cantidad de cosas interesantes. + +Hagamos otra adición. Instala json-server como una dependencia de desarrollo (solo se usa durante el desarrollo) ejecutando el comando: + +```js +npm install json-server --save-dev +``` + +y haciendo una pequeña adición a la parte scripts del archivo package.json: + +```json +{ + // ... + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview", + "server": "json-server -p3001 --watch db.json" // highlight-line + }, +} +``` + +Ahora podemos convenientemente, sin definiciones de parámetros, iniciar json-server desde el directorio raíz del proyecto con el comando: + +```js +npm run server +``` + +Nos familiarizaremos con la herramienta _npm_ en la [tercera parte del curso](/es/part3). + +**NB** El servidor json iniciado previamente debe terminarse antes de iniciar uno nuevo, de lo contrario habrá problemas: + +![error: no se puede enlazar al puerto 3001](../../images/2/15b.png) + +La letra roja en el mensaje de error nos informa sobre el problema: + +Cannot bind to the port 3001. Please specify another port number either through --port argument or through the json-server.json configuration file + +(No se puede vincular al puerto 3001. Especifique otro número de puerto a través del argumento --port o mediante el archivo de configuración json-server.json) + +Como podemos ver, la aplicación no es capaz de vincularse al [puerto](https://es.wikipedia.org/wiki/Puerto_de_red). La razón es que el puerto 3001 ya está ocupado por el servidor json iniciado anteriormente. + +Usamos el comando _npm install_ dos veces, pero con ligeras diferencias: + +```js +npm install axios +npm install json-server --save-dev +``` + +Hay una pequeña diferencia en los parámetros. axios se instala como una dependencia de entorno de ejecución de la aplicación, porque la ejecución del programa requiere la existencia de la librería. Por otro lado, json-server se instaló como una dependencia de desarrollo (_-- save-dev_), ya que el programa en sí no lo requiere. Se utiliza como ayuda durante el desarrollo de software. Habrá más sobre diferentes dependencias en la próxima parte del curso. + +### Axios y promesas + +Ahora estamos listos para usar axios. En el futuro, se asume que json-server se está ejecutando en el puerto 3001. + +NB: Para ejecutar json-server y su aplicación react simultáneamente, es posible que debas usar dos ventanas de terminal. Uno para mantener json-server en ejecución y el otro para ejecutar react-app. + +La librería se puede poner en uso de la misma manera que otras librerías, por ejemplo, React, es decir, utilizando una instrucción import adecuada. + +Agrega lo siguiente al archivo main.jsx: + +```js +import axios from 'axios' + +const promise = axios.get('http://localhost:3001/notes') +console.log(promise) + +const promise2 = axios.get('http://localhost:3001/foobar') +console.log(promise2) +``` + +Si abres en el navegador, esto debería imprimirse en la consola: + +![promesas imprimidas en la consola](../../images/2/16new.png) + +El método de Axios _get_ devuelve una [promesa](https://developer.mozilla.org/es/docs/Web/JavaScript/Guide/Using_promises). + +La documentación del sitio de Mozilla establece lo siguiente sobre las promesas: + +> Una promesa es un objeto que representa la eventual finalización o falla de una operación asíncrona. + +En otras palabras, una promesa es un objeto que representa una operación asíncrona. Una promesa puede tener tres estados distintos: + +- La promesa está pendiente: significa que el valor final (uno de los dos siguientes) aún no está disponible. +- La promesa está cumplida: Significa que la operación se ha completado y el valor final está disponible, que generalmente es una operación exitosa. Este estado a veces también se denomina resuelto. +- La promesa es rechazada: Significa que un error impidió determinar el valor final, que generalmente representa una operación fallida. + +La primera promesa en nuestro ejemplo está cumplida, lo que representa una solicitud a _axios.get('http://localhost:3001/notes')_ exitosa. La segunda, sin embargo, está rechazada y la consola nos dice el motivo. Parece que estábamos intentando realizar una solicitud HTTP GET a una dirección inexistente. + +Si, y cuando, queremos acceder al resultado de la operación representada por la promesa, debemos registrar un controlador de eventos en la promesa. Esto se logra usando el método then: + +```js +const promise = axios.get('http://localhost:3001/notes') + +promise.then(response => { + console.log(response) +}) +``` + +Se imprime lo siguiente en la consola: + +![objeto de datos json impreso en la consola](../../images/2/17new.png) + +El entorno de ejecución de JavaScript llama a la función callback registrada por el método then, proporcionándole un objeto response como parámetro. El objeto response contiene todos los datos esenciales relacionados con la respuesta de una solicitud HTTP GET, que incluiría los datos devueltos, el código de estado (status code) y los encabezados (headers). + +Por lo general, no es necesario almacenar el objeto de la promesa en una variable y, en cambio, es común encadenar la llamada al método then a la llamada al método axios, de modo que la siga directamente: + +```js +axios.get('http://localhost:3001/notes').then(response => { + const notes = response.data + console.log(notes) +}) +``` + +La función de callback ahora toma los datos contenidos en la respuesta, los almacena en una variable e imprime las notas en la consola. + +Una forma más legible de formatear llamadas de método encadenadas es colocar cada llamada en su propia línea: + +```js +axios + .get('http://localhost:3001/notes') + .then(response => { + const notes = response.data + console.log(notes) + }) +``` + +Los datos devueltos por el servidor son texto sin formato, básicamente solo una cadena larga. La librería axios aún puede analizar los datos en una matriz de JavaScript, ya que el servidor ha especificado que el formato de datos es application/json; charset=utf-8 (ver imagen anterior) usando el encabezado content-type. + +Finalmente podemos comenzar a utilizar los datos obtenidos del servidor. + +Intentemos solicitar las notas de nuestro servidor local y renderizarlas, inicialmente como el componente App. Ten en cuenta que este enfoque tiene muchos problemas, ya que estamos procesando todo el componente App solo cuando recuperamos con éxito una respuesta: + +```js +import ReactDOM from 'react-dom/client' +import axios from 'axios' +import App from './App' + +axios.get('http://localhost:3001/notes').then(response => { + const notes = response.data + ReactDOM.createRoot(document.getElementById('root')).render() +}) +``` + +Este método podría ser aceptable en algunas circunstancias, pero es algo problemático. En su lugar, movamos la búsqueda de datos al componente App. + +Sin embargo, lo que no es inmediatamente obvio es dónde se debe colocar el comando axios.get dentro del componente. + +### Effect-hooks + +Ya hemos utilizado [state hooks](https://es.react.dev/learn/state-a-components-memory) que se introdujeron junto con la versión de React [16.8.0](https://www.npmjs.com/package/react/v/16.8.0), que proporciona el estado de los componentes de React definidos como funciones, los llamados componentes funcionales. La versión 16.8.0 también presenta los [hooks de efectos](https://es.react.dev/reference/react/hooks#effect-hooks) como una nueva característica. Según los documentos oficiales: + +> Los efectos permiten que un componente se conecte y se sincronice con sistemas externos. +> Esto incluye manejar la red, el DOM del navegador, animaciones, widgets escritos usando una librería de interfaz de usuario diferente, y otro código que no es de React. + +Como tal, los hooks de efectos son precisamente la herramienta adecuada para usar cuando se obtienen datos de un servidor. + +Eliminemos la obtención de datos de main.jsx. Dado que vamos a obtener las notas del servidor, ya no es necesario pasar datos como props al componente App. Entonces main.jsx se puede simplificar a: + +```js +import ReactDOM from "react-dom/client"; +import App from "./App"; + +ReactDOM.createRoot(document.getElementById("root")).render(); +``` + +El componente App cambia de la siguiente manera: + +```js +import { useState, useEffect } from 'react' // highlight-line +import axios from 'axios' // highlight-line +import Note from './components/Note' + +const App = () => { // highlight-line + const [notes, setNotes] = useState([]) // highlight-line + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + +// highlight-start + useEffect(() => { + console.log('effect') + axios + .get('http://localhost:3001/notes') + .then(response => { + console.log('promise fulfilled') + setNotes(response.data) + }) + }, []) + + console.log('render', notes.length, 'notes') +// highlight-end + + // ... +} +``` + +También hemos agregado algunas impresiones útiles, que aclaran la progresión de la ejecución. + +Esto se imprime en la consola + +``` +render 0 notes +effect +promise fulfilled +render 3 notes +``` + +Primero se ejecuta el cuerpo de la función que define el componente y el componente se renderiza por primera vez. En este punto, se imprime render 0 notes, lo que significa que los datos aún no se han obtenido del servidor. + +La siguiente función, o efecto en el lenguaje de React: + +```js +() => { + console.log('effect') + axios + .get('http://localhost:3001/notes') + .then(response => { + console.log('promise fulfilled') + setNotes(response.data) + }) +} +``` + +se ejecuta inmediatamente después de la renderización. La ejecución de la función da como resultado que effect se imprima en la consola, y el comando axios.get inicia la obtención de datos del servidor y registra la siguiente función como un controlador de eventos para la operación: + +```js +response => { + console.log('promise fulfilled') + setNotes(response.data) +}) +``` + +Cuando llegan datos del servidor, el entorno de ejecución de JavaScript llama a la función registrada como el controlador de eventos, que imprime promise fulfilled en la consola y almacena las notas recibidas del servidor en el estado mediante la función setNotes(response.data). + +Como siempre, una llamada a una función de actualización de estado desencadena la re-renderización del componente. Como resultado, render 3 notes se imprime en la consola y las notas obtenidas del servidor se muestran en la pantalla. + +Finalmente, echemos un vistazo a la definición del hook de efectos como un todo: + +```js +useEffect(() => { + console.log('effect') + axios + .get('http://localhost:3001/notes').then(response => { + console.log('promise fulfilled') + setNotes(response.data) + }) +}, []) +``` + +Reescribamos el código de forma un poco diferente. + +```js +const hook = () => { + console.log('effect') + axios + .get('http://localhost:3001/notes') + .then(response => { + console.log('promise fulfilled') + setNotes(response.data) + }) +} + +useEffect(hook, []) +``` + +Ahora podemos ver más claramente que la función [useEffect](https://es.react.dev/reference/react/useEffect) en realidad toma dos parámetros. El primero es una función, el efecto en sí mismo. Según la documentación: + +> De forma predeterminada, los efectos se ejecutan después de cada renderizado completo, pero puedes elegir activarlo solo cuando ciertos valores han cambiado. + +Por lo tanto, por defecto, el efecto siempre se ejecuta después de que el componente ha sido renderizado. En nuestro caso, sin embargo, solo queremos ejecutar el efecto junto con el primer render. + +El segundo parámetro de useEffect se usa para [especificar la frecuencia con la que se ejecuta el efecto](https://es.react.dev/reference/react/useEffect#parameters). Si el segundo parámetro es una matriz vacía [], entonces el efecto solo se ejecuta junto con el primer renderizado del componente. + +Hay muchos casos de uso posibles para un hook de efecto ademas de la obtención de datos del servidor. Sin embargo, por ahora esto es suficiente para nosotros. + +Piensa en la secuencia de eventos que acabamos de comentar. ¿Qué partes del código se ejecutan? ¿En qué orden? ¿Con qué frecuencia? ¡Entender el orden de los eventos es fundamental! + +Ten en cuenta que también podríamos haber escrito el código de la función de efecto de esta manera: + +```js +useEffect(() => { + console.log('effect') + + const eventHandler = response => { + console.log('promise fulfilled') + setNotes(response.data) + } + + const promise = axios.get('http://localhost:3001/notes') + promise.then(eventHandler) +}, []) +``` + +Se asigna una referencia a una función de controlador de eventos a la variable eventHandler. La promesa devuelta por el método get de Axios se almacena en la variable promise. El registro del callback ocurre dándole la variable eventHandler, refiriéndose a la función del controlador de eventos, como un parámetro para el método then de la promesa. Por lo general, no es necesario asignar funciones y promesas a las variables, y una forma más compacta de representar las cosas, como se ve más arriba, es suficiente. + +```js +useEffect(() => { + console.log('effect') + axios + .get('http://localhost:3001/notes') + .then(response => { + console.log('promise fulfilled') + setNotes(response.data) + }) +}, []) +``` + +Todavía tenemos un problema en nuestra aplicación. Al agregar nuevas notas, no se almacenan en el servidor. + +El código de la aplicación, como se ha descrito hasta ahora, se puede encontrar completo en [github](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-4), en la rama part2-4. + +### El entorno de ejecución de desarrollo + +La configuración de toda nuestra aplicación se ha vuelto cada vez más compleja. Repasemos qué pasa y dónde. La siguiente imagen describe la composición de la aplicación + +![diagrama de la composición de la aplicación react](../../images/2/18e.png) + +El código JavaScript que compone nuestra aplicación React se ejecuta en el navegador. El navegador obtiene el JavaScript del servidor de desarrollo de React, que es la aplicación que se ejecuta después de ejecutar el comando npm run dev. El servidor de desarrollo transforma el JavaScript a un formato comprensible para el navegador. Entre otras cosas, une JavaScript de diferentes archivos en un solo archivo. Analizaremos el servidor de desarrollo con más detalle en la parte 7 del curso. + +La aplicación React que se ejecuta en el navegador obtiene los datos formateados JSON desde json-server que se ejecuta en el puerto 3001 de la máquina. El servidor del que consultamos los datos - json-server - obtiene sus datos del archivo db.json. + +En este punto del desarrollo, todas las partes de la aplicación residen en la máquina del desarrollador de software, también conocida como localhost. La situación cambia cuando la aplicación se despliega en el internet. Haremos esto en la parte 3. + +
    + +
    + +

    Ejercicio 2.11.

    + +

    2.11: La Agenda Telefónica Paso 6

    + +Continuamos con el desarrollo de la agenda telefónica. Almacena el estado inicial de la aplicación en el archivo db.json, que debe ubicarse en la raíz del proyecto. + +```json +{ + "persons":[ + { + "name": "Arto Hellas", + "number": "040-123456", + "id": 1 + }, + { + "name": "Ada Lovelace", + "number": "39-44-5323523", + "id": 2 + }, + { + "name": "Dan Abramov", + "number": "12-43-234345", + "id": 3 + }, + { + "name": "Mary Poppendieck", + "number": "39-23-6423122", + "id": 4 + } + ] +} +``` + +Inicia json-server en el puerto 3001 y asegúrate de que el servidor devuelve la lista de personas yendo a la dirección en el navegador. + +Si recibes el siguiente mensaje de error: + +```js +events.js:182 + throw er; // Unhandled 'error' event + ^ + +Error: listen EADDRINUSE 0.0.0.0:3001 + at Object._errnoException (util.js:1019:11) + at _exceptionWithHostPort (util.js:1041:20) +``` + +significa que el puerto 3001 ya está en uso por otra aplicación, por ejemplo en uso por un servidor json que ya se está ejecutando. Cierra la otra aplicación o cambia el puerto en caso de que no funcione. + +Modifica la aplicación de modo que el estado inicial de los datos se obtenga del servidor mediante la librería axios. Completa la obtención de los datos con un [Effect hook](https://react.dev/reference/react/useEffect). + +
    diff --git a/src/content/2/es/part2d.md b/src/content/2/es/part2d.md new file mode 100644 index 00000000000..ecb56997859 --- /dev/null +++ b/src/content/2/es/part2d.md @@ -0,0 +1,756 @@ +--- +mainImage: ../../../images/part-2.svg +part: 2 +letter: d +lang: es +--- + +
    + +Al crear notas en nuestra aplicación, naturalmente desearíamos almacenarlas en algún servidor backend. El paquete [json-server](https://github.com/typicode/json-server) afirma ser un llamado REST o API RESTful en su documentación: + +> Obtenga una API REST falsa completa sin codificación en menos de 30 segundos (en serio) + +El servidor json no coincide exactamente con la descripción proporcionada por la [definición](https://es.wikipedia.org/wiki/Transferencia_de_Estado_Representacional) de libro de texto de una API REST, pero tampoco la mayoría de las otras API que afirman ser RESTful. + +Veremos más de cerca a REST en la [próxima parte](/es/part3) del curso, pero es importante familiarizarnos en este punto con algunas de las [convenciones](https://en.wikipedia.org/wiki/REST#Applied_to_web_services) utilizadas por json-server y API REST en general. En particular, analizaremos el uso convencional de [rutas (routes)](https://github.com/typicode/json-server#routes), también conocido como URLs y tipos de solicitud HTTP, en REST. + +### REST + +En terminología REST, nos referimos a objetos de datos individuales, como las notas en nuestra aplicación, como recursos. Cada recurso tiene una dirección única asociada: su URL. De acuerdo con una convención general utilizada por json-server, podríamos ubicar una nota individual en la URL del recurso notes/3, donde 3 es el id del recurso. La URL de notes, por otro lado, apuntaría a una colección de recursos que contiene todas las notas. + +Los recursos se obtienen del servidor con solicitudes HTTP GET. Por ejemplo, una solicitud HTTP GET a la URL notes/3 devolverá la nota que tiene el id 3. Una solicitud HTTP GET a la URL notes devolverá una lista de todas las notas. + +La creación de un nuevo recurso para almacenar una nota se realiza mediante una solicitud HTTP POST a la URL notes de acuerdo con la convención REST a la que se adhiere el servidor json. Los datos del nuevo recurso de notas se envían en el cuerpo de la solicitud. + +json-server requiere que todos los datos se envíen en formato JSON. Lo que esto significa en la práctica es que los datos deben ser una cadena con el formato correcto y que la solicitud debe contener el encabezado de solicitud Content-Type con el valor application/json. + +### Envío de datos al servidor + +Realicemos los siguientes cambios en el controlador de eventos responsable de crear una nueva nota: + +```js +addNote = event => { + event.preventDefault() + const noteObject = { + content: newNote, + important: Math.random() < 0.5, + } + +// highlight-start + axios + .post('http://localhost:3001/notes', noteObject) + .then(response => { + console.log(response) + }) +// highlight-end +} +``` + +Creamos un nuevo objeto para la nota pero omitimos la propiedad id, ¡ya que es mejor dejar que el servidor genere identificadores para nuestros recursos! + +El objeto se envía al servidor mediante el método axios post. El controlador de eventos registrado registra la respuesta que se envía desde el servidor a la consola. + +Cuando intentamos crear una nueva nota, lo siguiente aparece en la consola: + +![datos en formato json en la consola](../../images/2/20new.png) + +La nota recién creada se almacena en el valor de la propiedad data del objeto _response_. + +A veces puede resultar útil inspeccionar las solicitudes HTTP en la pestaña Network de las herramientas para desarrolladores de Chrome, que se utilizó mucho al comienzo de la [parte 0](/es/part0/fundamentos_de_las_aplicaciones_web#http-get): + +Podemos usar el inspector para verificar que los encabezados enviados en la solicitud POST sean los que esperábamos que fueran. + +![encabezado en la herramienta de desarrollo muestra 201 created para localhost:3001/notes](../../images/2/21new1.png) + +Dado que los datos que enviamos en la solicitud POST eran un objeto JavaScript, axios supo automáticamente establecer el valor application/json para el encabezado Content-Type. + +La pestaña _payload_ puede ser utilizada para verificar los datos de la solicitud: + +![pestaña payload de las herramientas de desarrollo muestra los campos content e important](../../images/2/21new2.png) + +También la pestaña response es útil, muestra los datos con los que respondió el servidor: + +![pestaña response de las herramientas de desarrollo muestra el mismo content y payload pero con un campo de id](../../images/2/21new3.png) + +La nueva nota aún no se muestra en la pantalla. Esto se debe a que no actualizamos el estado del componente App cuando la creamos. Arreglemos esto: + +```js +addNote = event => { + event.preventDefault() + const noteObject = { + content: newNote, + important: Math.random() > 0.5, + } + + axios + .post('http://localhost:3001/notes', noteObject) + .then(response => { + // highlight-start + setNotes(notes.concat(response.data)) + setNewNote('') + // highlight-end + }) +} +``` + +La nueva nota devuelta por el servidor backend se agrega a la lista de notas en el estado de nuestra aplicación en la forma habitual de usar la función setNotes y luego reseteando el formulario de creación de notas. Un [detalle importante](/es/part1/un_estado_mas_complejo_depurando_aplicaciones_react#manejo-de-arrays) para recordar es que el método concat no cambia el estado original del componente, sino que crea una nueva copia de la lista. + +Una vez que los datos devueltos por el servidor comienzan a tener un efecto en el comportamiento de nuestras aplicaciones web, nos enfrentamos de inmediato a un nuevo conjunto de desafíos que surgen, por ejemplo, de la asincronicidad de la comunicación. Esto requiere nuevas estrategias de depuración, el registro de la consola y otros medios de depuración se vuelven cada vez más importantes, y también debemos desarrollar una comprensión suficiente de los principios tanto del entorno de ejecución de JavaScript como de los componentes de React. Adivinar no será suficiente. + +Es beneficioso inspeccionar el estado del servidor backend, por ejemplo, a través del navegador: + +![salida de datos JSON del backend](../../images/2/22.png) + +Esto hace posible verificar que todos los datos que enviamos fueron recibidos por el servidor. + +En la siguiente parte del curso aprenderemos a implementar nuestra propia lógica en el backend. Luego examinaremos más de cerca herramientas como [postman](https://www.postman.com/downloads/) que nos ayudan a depurar nuestras aplicaciones de servidor. Sin embargo, inspeccionar el estado del servidor json a través del navegador es suficiente para nuestras necesidades actuales. + +El código para el estado actual de nuestra aplicación se puede encontrar en la rama part2-5 en [github](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-5). + +### Cambiar la importancia de las notas + +Agreguemos un botón a cada nota que se pueda usar para alternar su importancia. + +Realizamos los siguientes cambios en el componente Note: + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' : 'make important' + + return ( +
  • + {note.content} + +
  • + ) +} +``` + +Agregamos un botón al componente y asignamos su controlador de eventos como la función toggleImportance pasada en los props del componente. + +El componente App define una versión inicial de la función de controlador de eventos toggleImportanceOf y la pasa a cada componente Note: + +```js +const App = () => { + const [notes, setNotes] = useState([]) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + + // ... + + // highlight-start + const toggleImportanceOf = (id) => { + console.log('importance of ' + id + ' needs to be toggled') + } + // highlight-end + + // ... + + return ( +
    +

    Notes

    +
    + +
    +
      + {notesToShow.map((note, i) => + toggleImportanceOf(note.id)} // highlight-line + /> + )} +
    + // ... +
    + ) +} +``` + +Observe cómo cada nota recibe su función de controlador de eventos única, ya que el id de cada nota es único. + +Por ejemplo, si note.id es 3, la función de controlador de eventos devuelta por _toggleImportance(note.id)_ será: + +```js +() => { console.log('importance of 3 needs to be toggled') } +``` + +Un breve recordatorio aquí. La cadena impresa por el controlador de eventos se define de manera similar a Java agregando las cadenas: + +```js +console.log('importance of ' + id + ' needs to be toggled') +``` + +La sintaxis de [plantillas literales](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Template_literals) agregada en ES6 se puede usar para escribir cadenas similares de una manera mucho más agradable: + +```js +console.log(`importance of ${id} needs to be toggled`) +``` + +Ahora podemos usar la sintaxis de "llave de dólares" para agregar partes a la cadena que evaluarán las expresiones de JavaScript, por ejemplo, el valor de una variable. Ten en cuenta que las comillas utilizadas en las plantillas de cadenas difieren de las comillas utilizadas en las cadenas de JavaScript normales. + +Las notas individuales almacenadas en el backend del servidor json se pueden modificar de dos formas diferentes haciendo solicitudes HTTP a la URL única de la nota. Podemos reemplazar la nota completa con una solicitud HTTP PUT, o solo cambiar algunas de las propiedades de la nota con una solicitud HTTP PATCH. + +La forma final de la función del controlador de eventos es la siguiente: + +```js +const toggleImportanceOf = id => { + const url = `http://localhost:3001/notes/${id}` + const note = notes.find(n => n.id === id) + const changedNote = { ...note, important: !note.important } + + axios.put(url, changedNote).then(response => { + setNotes(notes.map(note => note.id !== id ? note : response.data)) + }) +} +``` + +Casi todas las líneas de código en el cuerpo de la función contienen detalles importantes. La primera línea define la URL única para cada recurso de nota en función de su identificación. + +El método de array [find](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array/find) se usa para encontrar la nota que queremos modificar, y luego asignamos a la variable _note_. + +Después de esto creamos un nuevo objeto que es una copia exacta de la nota anterior, excepto por la propiedad important que tiene su valor cambiado (de true a false o de false a true). + +El código para crear el nuevo objeto que usa la sintaxis de [object spread](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Operators/Spread_syntax) puede parecer un poco extraño: + +```js +const changedNote = { ...note, important: !note.important } +``` + +En la práctica, { ...note } crea un nuevo objeto con copias de todas las propiedades del objeto _note_ . Cuando agregamos propiedades dentro de las llaves después del objeto extendido, por ejemplo, { ...note, important: true }, entonces el valor de la propiedad _important_ del nuevo objeto será _true_. En nuestro ejemplo, la propiedad important obtiene la negación de su valor anterior en el objeto original. + +Hay algunas cosas que señalar. ¿Por qué hicimos una copia del objeto de nota que queríamos modificar, cuando el siguiente código también parece funcionar: + +```js +const note = notes.find(n => n.id === id) +note.important = !note.important + +axios.put(url, note).then(response => { + // ... +``` + +Esto no es recomendable porque la variable note es una referencia a un elemento en el array notes en el estado del componente, y como recordamos, nunca debemos mutar el estado directamente en React. + +También vale la pena señalar que el nuevo objeto _changedNote_ es solo una [copia superficial](https://en.wikipedia.org/wiki/Object_copying#Shallow_copy), lo que significa que los valores del nuevo objeto son los mismos que los valores del objeto antiguo. Si los valores del objeto antiguo fueran objetos en sí mismos, los valores copiados en el nuevo objeto harían referencia a los mismos objetos que estaban en el objeto antiguo. + +Luego, la nueva nota se envía con una solicitud PUT al backend donde reemplazará el objeto anterior. + +La función callback establece el estado del componente notes en una nueva matriz que contiene todos los elementos de la matriz notes anterior, excepto la nota anterior que se reemplaza por la versión actualizada devuelta por el servidor: + +```js +axios.put(url, changedNote).then(response => { + setNotes(notes.map(note => note.id !== id ? note : response.data)) +}) +``` + +Esto se logra con el método map: + +```js +notes.map(note => note.id !== id ? note : response.data) +``` + +El método map crea una nueva matriz al mapear cada elemento de la matriz anterior a un elemento de la nueva matriz. En nuestro ejemplo, la nueva matriz se crea de forma condicional de modo que si note.id !== id es verdadero, simplemente copiamos el elemento de la matriz anterior en la nueva matriz. Si la condición es falsa, el objeto de nota devuelto por el servidor se agrega a la matriz. + +Este truco de map puede parecer un poco extraño al principio, pero vale la pena dedicar un tiempo a comprenderlo. Usaremos este método muchas veces a lo largo del curso. + +### Extraer la comunicación con el backend en un módulo separado + +El componente App se ha hinchado un poco después de agregar el código para comunicarse con el servidor backend. En el espíritu del [principio de responsabilidad única](https://es.wikipedia.org/wiki/Principio_de_responsabilidad_%C3%BAnica), consideramos prudente extraer esta comunicación en su propio [módulo](/es/part2/renderizando_una_coleccion_modulos#refactorizando-modulos). + +Creemos un directorio src/services y agreguemos allí un archivo llamado notes.js: + +```js +import axios from 'axios' +const baseUrl = 'http://localhost:3001/notes' + +const getAll = () => { + return axios.get(baseUrl) +} + +const create = newObject => { + return axios.post(baseUrl, newObject) +} + +const update = (id, newObject) => { + return axios.put(`${baseUrl}/${id}`, newObject) +} + +export default { + getAll: getAll, + create: create, + update: update +} +``` + +El módulo devuelve un objeto que tiene tres funciones (getAll, create y update) como propiedades que se ocupan de las notas. Las funciones devuelven directamente las promesas devueltas por los métodos axios. + +El componente App usa import para obtener acceso al módulo: + +```js +import noteService from './services/notes' // highlight-line + +const App = () => { +``` + +Las funciones del módulo se pueden usar directamente con la variable importada _noteService_ de la siguiente manera: + +```js +const App = () => { + // ... + + useEffect(() => { + // highlight-start + noteService + .getAll() + .then(response => { + setNotes(response.data) + }) + // highlight-end + }, []) + + const toggleImportanceOf = id => { + const note = notes.find(n => n.id === id) + const changedNote = { ...note, important: !note.important } + + // highlight-start + noteService + .update(id, changedNote) + .then(response => { + setNotes(notes.map(note => note.id !== id ? note : response.data)) + }) + // highlight-end + } + + const addNote = (event) => { + event.preventDefault() + const noteObject = { + content: newNote, + important: Math.random() > 0.5 + } + +// highlight-start + noteService + .create(noteObject) + .then(response => { + setNotes(notes.concat(response.data)) + setNewNote('') + }) +// highlight-end + } + + // ... +} + +export default App +``` + +Podríamos llevar nuestra implementación un paso más allá. Cuando el componente App usa las funciones, recibe un objeto que contiene la respuesta completa para la solicitud HTTP: + +```js +noteService + .getAll() + .then(response => { + setNotes(response.data) + }) +``` + +El componente App solo usa la propiedad response.data del objeto de respuesta. + +El módulo sería mucho más agradable de usar si, en lugar de la respuesta HTTP completa, solo obtuviéramos los datos de respuesta. El uso del módulo se vería así: + +```js +noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) + }) +``` + +Podemos lograr esto cambiando el código en el módulo de la siguiente manera (el código actual contiene algo de copiar y pegar, pero lo toleraremos por ahora): + +```js +import axios from 'axios' +const baseUrl = 'http://localhost:3001/notes' + +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} + +const create = newObject => { + const request = axios.post(baseUrl, newObject) + return request.then(response => response.data) +} + +const update = (id, newObject) => { + const request = axios.put(`${baseUrl}/${id}`, newObject) + return request.then(response => response.data) +} + +export default { + getAll: getAll, + create: create, + update: update +} +``` + +Ya no devolvemos la promesa devuelta por axios directamente. En su lugar, asignamos la promesa a la variable request y llamamos a su método then: + +```js +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} +``` + +La última fila en nuestra función es simplemente una expresión más compacta del mismo código que se muestra a continuación: + +```js +const getAll = () => { + const request = axios.get(baseUrl) + // highlight-start + return request.then(response => { + return response.data + }) + // highlight-end +} +``` + +La función getAll modificada todavía devuelve una promesa, como el método then de una promesa también [devuelve una promesa](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Promise/then). + +Después de definir el parámetro del método then para devolver directamente response.data, hemos conseguido que la función getAll funcione como queríamos. Cuando la solicitud HTTP es exitosa, la promesa devuelve los datos enviados en la respuesta del backend. + +Tenemos que actualizar el componente App para que funcione con los cambios realizados en nuestro módulo. Tenemos que arreglar las funciones callback dadas como parámetros a los métodos del objeto noteService, de modo que utilicen los datos de respuesta devueltos directamente: + +```js +const App = () => { + // ... + + useEffect(() => { + noteService + .getAll() + // highlight-start + .then(initialNotes => { + setNotes(initialNotes) + // highlight-end + }) + }, []) + + const toggleImportanceOf = id => { + const note = notes.find(n => n.id === id) + const changedNote = { ...note, important: !note.important } + + noteService + .update(id, changedNote) + // highlight-start + .then(returnedNote => { + setNotes(notes.map(note => note.id !== id ? note : returnedNote)) + // highlight-end + }) + } + + const addNote = (event) => { + event.preventDefault() + const noteObject = { + content: newNote, + important: Math.random() > 0.5 + } + + noteService + .create(noteObject) + // highlight-start + .then(returnedNote => { + setNotes(notes.concat(returnedNote)) + // highlight-end + setNewNote('') + }) + } + + // ... +} +``` + +Todo esto es bastante complicado e intentar explicarlo puede dificultar la comprensión. Internet está lleno de material que discute el tema, como [este](https://es.javascript.info/promise-chaining). + +El libro "Async and performance" de la serie de libros [You don't know JS](https://github.com/getify/You-Dont-Know-JS/tree/1st-ed) explica el tema [bien](https://github.com/getify/You-Dont-Know-JS/blob/1st-ed/async%20%26%20performance/ch3.md), pero la explicación tiene muchas páginas. + +Las promesas son fundamentales para el desarrollo moderno de JavaScript y se recomienda encarecidamente invertir una cantidad de tiempo razonable en comprenderlas. + +### Sintaxis más limpia para definir objetos literales + +El módulo que define servicios relacionados con notas actualmente exporta un objeto con las propiedades getAll, create y update que son asignado a funciones para el manejo de notas. + +La definición del módulo fue: + +```js +import axios from 'axios' +const baseUrl = 'http://localhost:3001/notes' + +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} + +const create = newObject => { + const request = axios.post(baseUrl, newObject) + return request.then(response => response.data) +} + +const update = (id, newObject) => { + const request = axios.put(`${baseUrl}/${id}`, newObject) + return request.then(response => response.data) +} + +export default { + getAll: getAll, + create: create, + update: update +} +``` + +El módulo exporta el siguiente objeto, de aspecto bastante peculiar: + +```js +{ + getAll: getAll, + create: create, + update: update +} +``` + +Las etiquetas a la izquierda de los dos puntos en la definición del objeto son las claves del objeto, mientras que las que están a la derecha de este son variables que se definen dentro del módulo. + +Dado que los nombres de las claves y las variables asignadas son los mismos, podemos escribir la definición del objeto con una sintaxis más compacta: + +```js +{ + getAll, + create, + update +} +``` + +Como resultado, la definición del módulo se simplifica de la siguiente forma: + +```js +import axios from 'axios' +const baseUrl = 'http://localhost:3001/notes' + +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} + +const create = newObject => { + const request = axios.post(baseUrl, newObject) + return request.then(response => response.data) +} + +const update = (id, newObject) => { + const request = axios.put(`${baseUrl}/${id}`, newObject) + return request.then(response => response.data) +} + +export default { getAll, create, update } // highlight-line +``` + +Al definir el objeto con esta notación más corta, utilizamos una [nueva característica](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer#Property_definitions) que se introdujo a JavaScript a través de ES6, lo que permite una forma un poco más compacta de definir objetos mediante variables. + +Para demostrar esta característica, consideremos una situación en la que tenemos los siguientes valores asignados a las variables: + +```js +const name = 'Leevi' +const age = 0 +``` + +En versiones anteriores de JavaScript, teníamos que definir un objeto como este: + +```js +const person = { + name: name, + age: age +} +``` + +Sin embargo, dado que tanto los campos de propiedad como los nombres de las variables en el objeto son iguales, basta con escribir lo siguiente en ES6 JavaScript: + +```js +const person = { name, age } +``` + +El resultado es idéntico para ambas expresiones. Ambos crean un objeto con una propiedad name con el valor Leevi y una propiedad age con el valor 0. + +### Promesas y errores + +Si nuestra aplicación permitiera a los usuarios eliminar notas, podríamos terminar en una situación en la que un usuario intenta cambiar la importancia de una nota que ya ha sido eliminada del sistema. + +Simulemos esta situación haciendo que la función getAll del servicio de notas devuelva una nota "codificada" que en realidad no existe en el servidor backend: + +```js +const getAll = () => { + const request = axios.get(baseUrl) + const nonExisting = { + id: 10000, + content: 'This note is not saved to server', + important: true, + } + return request.then(response => response.data.concat(nonExisting)) +} +``` + +Cuando intentamos cambiar la importancia de la nota codificada, vemos el siguiente mensaje de error en la consola. El error dice que el servidor backend respondió a nuestra solicitud HTTP PUT con un código de estado 404 no encontrado (not found). + +![error 404 not found en herramientas de desarrollo](../../images/2/23e.png) + +La aplicación debería poder manejar este tipo de situaciones de error con elegancia. Los usuarios no podrán saber que se ha producido un error a menos que tengan la consola abierta. La única forma en que se puede ver el error en la aplicación es que hacer clic en el botón no afecta la importancia de la nota. + +[Anteriormente](/es/part2/obteniendo_datos_del_servidor#axios-y-promesas) mencionamos que una promesa puede estar en uno de tres estados diferentes. Cuando falla una solicitud HTTP, la promesa asociada se rechaza. Nuestro código actual no maneja este rechazo de ninguna manera. + +El rechazo de una promesa se [maneja](https://developer.mozilla.org/es/docs/Web/JavaScript/Guide/Using_promises) proporcionando el método then con una segunda función callback, que se llama en la situación en la que se rechaza la promesa. + +La forma más común de agregar un controlador para las promesas rechazadas es usar el método [catch](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch). + +En la práctica, el controlador de errores para las promesas rechazadas se define así: + +```js +axios + .get('http://example.com/probably_will_fail') + .then(response => { + console.log('success!') + }) + .catch(error => { + console.log('fail') + }) +``` + +Si la solicitud falla, se llama al controlador de eventos registrado con el método catch. + +El método catch se utiliza a menudo colocándolo más profundamente en la cadena de promesas. + +Cuando nuestra aplicación realiza una solicitud HTTP, de hecho estamos creando una [cadena de promesa](https://es.javascript.info/promise-chaining): + +```js +axios + .put(`${baseUrl}/${id}`, newObject) + .then(response => response.data) + .then(changedNote => { + // ... + }) +``` + +El método catch se puede utilizar para definir una función de controlador en el final de una cadena de promesa, que se llama una vez que cualquier promesa en la cadena arroja un error y la promesa es rechazada. + +```js +axios + .put(`${baseUrl}/${id}`, newObject) + .then(response => response.data) + .then(changedNote => { + // ... + }) + .catch(error => { + console.log('fail') + }) +``` + +Usemos esta característica y registremos un controlador de errores en el componente App: + +```js +const toggleImportanceOf = id => { + const note = notes.find(n => n.id === id) + const changedNote = { ...note, important: !note.important } + + noteService + .update(id, changedNote).then(returnedNote => { + setNotes(notes.map(note => note.id !== id ? note : returnedNote)) + }) + // highlight-start + .catch(error => { + alert( + `the note '${note.content}' was already deleted from server` + ) + setNotes(notes.filter(n => n.id !== id)) + }) + // highlight-end +} +``` + +El mensaje de error es mostrado al usuario con la vieja y confiable [alerta](https://developer.mozilla.org/es/docs/Web/API/Window/alert), un cuadro de diálogo emergente, y la nota eliminada se filtra del estado. + +La eliminación de una nota ya eliminada del estado de la aplicación se realiza con el método de array [filter](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array/filter), que devuelve una nueva matriz que comprende solo los elementos de la lista para los cuales la función que se pasó como parámetro devuelve verdadero: + +```js +notes.filter(n => n.id !== id) +``` + +Probablemente no sea una buena idea usar alert en aplicaciones React más serias. Pronto aprenderemos una forma más avanzada de mostrar mensajes y notificaciones a los usuarios. Sin embargo, hay situaciones en las que un método simple y probado en batalla como alert puede funcionar como punto de partida. Siempre se podría agregar un método más avanzado más adelante, dado que hay tiempo y energía para ello. + +El código para el estado actual de nuestra aplicación se puede encontrar en la rama part2-6 en [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-6). + +### Juramento del desarrollador Full Stack + +Nuevamente es hora de los ejercicios. La complejidad de nuestra aplicación está aumentando, ya que además de ocuparnos de los componentes de React en el frontend, también tenemos un backend que persiste los datos de la aplicación. + +Para hacer frente a la creciente complejidad, debemos extender el juramento del desarrollador web a un juramento del desarrollador Full Stack, que nos recuerda asegurarnos de que la comunicación entre el frontend y el backend ocurra como se espera. + +Entonces, aquí está el juramento actualizado: + +El desarrollo Full Stack es extremadamente difícil, por eso usaré todos los medios posibles para facilitarlo. + +- Mantendré abierta la consola de desarrolladores del navegador todo el tiempo. +- Usaré la pestaña de red de las herramientas de desarrollo del navegador para asegurarme de que el frontend y el backend estén comunicándose como espero. +- Mantendré constantemente un ojo en el estado del servidor para asegurarme de que los datos enviados por el frontend se guarden allí como espero. +- Progresaré con pequeños pasos. +- Escribiré muchos mensajes de _console.log_ para asegurarme de entender cómo se comporta el código y ayudar a identificar problemas. +- Si mi código no funciona, no escribiré más código. En cambio, empezaré a eliminar el código hasta que funcione o simplemente volveré a un estado en el que todo seguía funcionando. +- Cuando pida ayuda en el canal de Discord del curso o en otro lugar, formularé mis preguntas adecuadamente, consulta [aquí](/es/part0/informacion_general#como-obtener-ayuda-en-discord) cómo pedir ayuda. + +
    + +
    + +

    Ejercicios 2.12-2.15

    + +

    2.12: La Agenda Telefónica paso 7

    + +Volvamos a nuestra aplicación de agenda telefónica. + +Actualmente, los números que se agregan a la agenda telefónica no se guardan en un servidor backend. Soluciona esta situación. + +

    2.13: La Agenda Telefónica paso 8

    + +Extrae el código que maneja la comunicación con el backend en su propio módulo siguiendo el ejemplo mostrado anteriormente en esta parte del material del curso. + +

    2.14: La Agenda Telefónica paso 9

    + +Permite a los usuarios eliminar entradas de la agenda telefónica. La eliminación se puede hacer a través de un botón dedicado para cada persona en la lista de la agenda telefónica. Puedes confirmar la acción del usuario utilizando el método [window.confirm](https://developer.mozilla.org/es/docs/Web/API/Window/confirm): + +![2.17 captura de pantalla de la función de confirmación de ventana](../../images/2/24e.png) + +El recurso asociado a una persona en el backend se puede eliminar haciendo una solicitud HTTP DELETE a la URL del recurso. Si estamos eliminando, por ejemplo, a una persona que tiene el id 2, tendríamos que hacer una solicitud HTTP DELETE a la URL localhost:3001/persons/2. No se envía ningún dato con la solicitud. + +Puedes hacer una solicitud HTTP DELETE con la librería [axios](https://github.com/axios/axios) de la misma manera que hacemos todas las demás solicitudes. + +**NB:** No puedes usar el nombre delete para una variable porque es una palabra reservada en JavaScript. Por ejemplo, lo siguiente no es posible: + +```js +// use some other name for variable! +const delete = (id) => { + // ... +} +``` + +

    2.15*: La Agenda Telefónica paso 10

    + +¿Por qué hay un asterisco en el ejercicio? Consulta [aquí](/es/part0/informacion_general#tomando-el-curso) para obtener la explicación. + +Cambia la funcionalidad para que si se agrega un número a un usuario que ya existe, el nuevo número reemplace al antiguo. Se recomienda usar el método HTTP PUT para actualizar el número de teléfono. + +Si la información de la persona ya está en la agenda telefónica, la aplicación puede pedirle al usuario que confirme la acción: + +![2.18 captura de pantalla de la confirmación de alerta](../../images/teht/16e.png) + +
    diff --git a/src/content/2/es/part2e.md b/src/content/2/es/part2e.md new file mode 100644 index 00000000000..c24526d2369 --- /dev/null +++ b/src/content/2/es/part2e.md @@ -0,0 +1,629 @@ +--- +mainImage: ../../../images/part-2.svg +part: 2 +letter: e +lang: es +--- + +
    + +La apariencia de nuestra aplicación actual es bastante modesta. En el [ejercicio 0.2](/es/part0/fundamentos_de_las_aplicaciones_web#ejercicios-0-1-0-6), la tarea era pasar por el [tutorial CSS](https://developer.mozilla.org/es/docs/Learn/Getting_started_with_the_web/CSS_basics) de Mozilla. + +Antes de pasar a la siguiente parte, echemos un vistazo a cómo podemos agregar estilos a una aplicación React. Hay varias formas diferentes de hacer esto y veremos los otros métodos más adelante. Al principio, agregaremos CSS a nuestra aplicación a la vieja usanza; en un solo archivo sin usar un [preprocesador CSS](https://developer.mozilla.org/es/docs/Glossary/CSS_preprocessor) (aunque esto no es del todo cierto como veremos más adelante). + +Agreguemos un nuevo archivo index.css bajo el directorio src y luego agrégalo a la aplicación importándolo en el archivo main.jsx: + +```js +import './index.css' +``` + +Agreguemos la siguiente regla CSS al archivo index.css: + +```css +h1 { + color: green; +} +``` + +Las reglas CSS se componen de selectores y declaraciones. El selector define a qué elementos se debe aplicar la regla. El selector de arriba es h1, que coincidirá con todas las etiquetas de encabezado h1 en nuestra aplicación. + +La declaración establece el valor green para la propiedad _color_. + +Una regla CSS puede contener un número arbitrario de propiedades. Modifiquemos la regla anterior para convertir el texto en cursiva, definiendo el estilo de fuente como italic: + +```css +h1 { + color: green; + font-style: italic; // highlight-line +} +``` + +Hay muchas formas de hacer coincidir elementos usando [diferentes tipos de selectores CSS](https://developer.mozilla.org/es/docs/Web/CSS/CSS_Selectors). + +Si quisiéramos apuntar, digamos, a cada una de las notas con nuestros estilos, podríamos usar el selector li, ya que todas las notas están envueltas dentro de las etiquetas li: + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' + : 'make important'; + + return ( +
  • + {note.content} + +
  • + ) +} +``` + +Agreguemos la siguiente regla a nuestra hoja de estilo (ya que mi conocimiento de diseño web elegante es cercano a cero, los estilos no tienen mucho sentido): + +```css +li { + color: grey; + padding-top: 3px; + font-size: 15px; +} +``` + +El uso de tipos de elementos para definir reglas CSS es un poco problemático. Si nuestra aplicación contuviera otras etiquetas li, también se les aplicaría la misma regla de estilo. + +Si queremos aplicar nuestro estilo específicamente a las notas, entonces es mejor usar [selectores de clase](https://developer.mozilla.org/es/docs/Web/CSS/Class_selectors). + +En HTML normal, las clases se definen como el valor del atributo class: + +```html +
  • some text...
  • +``` + +En React tenemos para usar el atributo [className](https://es.react.dev/learn#adding-styles) en lugar del atributo class. Con esto en mente, hagamos los siguientes cambios en nuestro componente Note: + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' + : 'make important'; + + return ( +
  • // highlight-line + {note.content} + +
  • + ) +} +``` + +Los selectores de clase se definen con la sintaxis _.classname_: + +```css +.note { + color: grey; + padding-top: 5px; + font-size: 15px; +} +``` + +Si ahora agrega otros elementos li a la aplicación, no se verán afectados por la regla de estilo anterior. + +### Mensaje de error mejorado + +Anteriormente implementamos el mensaje de error que se mostraba cuando el usuario intentaba cambiar la importancia de una nota eliminada con el método alert. Implementemos el mensaje de error como su propio componente React. + +El componente es bastante simple: + +```js +const Notification = ({ message }) => { + if (message === null) { + return null + } + + return ( +
    + {message} +
    + ) +} +``` + +Si el valor del prop message es null, entonces no se muestra nada en la pantalla y, en otros casos, el mensaje se representa dentro de un elemento div. + +Agreguemos un nuevo estado llamado errorMessage al componente App. Inicialicemos con algún mensaje de error para que podamos probar inmediatamente nuestro componente: + +```js +const App = () => { + const [notes, setNotes] = useState([]) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + const [errorMessage, setErrorMessage] = useState('some error happened...') // highlight-line + + // ... + + return ( +
    +

    Notes

    + // highlight-line +
    + +
    + // ... +
    + ) +} +``` + +Entonces agreguemos una regla de estilo que se adapte a un mensaje de error: + +```css +.error { + color: red; + background: lightgrey; + font-size: 20px; + border-style: solid; + border-radius: 5px; + padding: 10px; + margin-bottom: 10px; +} +``` + +Ahora estamos listos para agregar la lógica para mostrar el mensaje de error. Cambiemos la función toggleImportanceOf de la siguiente manera: + +```js + const toggleImportanceOf = id => { + const note = notes.find(n => n.id === id) + const changedNote = { ...note, important: !note.important } + + noteService + .update(changedNote).then(returnedNote => { + setNotes(notes.map(note => note.id !== id ? note : returnedNote)) + }) + .catch(error => { + // highlight-start + setErrorMessage( + `Note '${note.content}' was already removed from server` + ) + setTimeout(() => { + setErrorMessage(null) + }, 5000) + // highlight-end + setNotes(notes.filter(n => n.id !== id)) + }) + } +``` + +Cuando ocurre el error, agregamos un mensaje de error descriptivo al estado errorMessage. Al mismo tiempo, iniciamos un temporizador que establecerá el estado de errorMessage en null después de cinco segundos. + +El resultado se ve así: + +![error eliminando nota en la aplicación](../../images/2/26e.png) + +El código para el estado actual de nuestra aplicación se puede encontrar en la rama part2-7 en [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-7). + +### Estilos en línea + +React también hace posible escribir estilos directamente en el código como los llamados [estilos en línea](https://react-cn.github.io/react/tips/inline-styles.html). + +La idea detrás de la definición de estilos en línea es extremadamente simple. Cualquier componente o elemento de React puede recibir un conjunto de propiedades CSS como un objeto JavaScript a través del atributo [style](https://es.react.dev/reference/react-dom/components/common#applying-css-styles). + +Las reglas de CSS se definen de forma ligeramente diferente en JavaScript que en los archivos CSS normales. Digamos que queremos darle a algún elemento el color verde y la fuente en cursiva que tiene un tamaño de 16 píxeles. En CSS, se vería así: + +```css +{ + color: green; + font-style: italic; + font-size: 16px; +} +``` + +Pero como un objeto de estilo en línea de React se vería así: + +```js + { + color: 'green', + fontStyle: 'italic', + fontSize: 16 +} +``` + +Cada propiedad CSS se define como una propiedad separada del objeto JavaScript. Los valores numéricos de los píxeles se pueden definir simplemente como números enteros. Una de las principales diferencias en comparación con el CSS normal es que las propiedades CSS con guiones (kebab case) están escritas en camelCase. + +A continuación, podríamos agregar un "bloque inferior" a nuestra aplicación creando un componente Footer y definir los siguientes estilos en línea para él: + +```js +// highlight-start +const Footer = () => { + const footerStyle = { + color: 'green', + fontStyle: 'italic', + fontSize: 16 + } + + return ( +
    +
    + Note app, Department of Computer Science, University of Helsinki 2024 +
    + ) +} +// highlight-end + +const App = () => { + // ... + + return ( +
    +

    Notes

    + + + + // ... + +
    // highlight-line +
    + ) +} +``` + +Los estilos en línea tienen ciertas limitaciones. Por ejemplo, las llamadas [pseudoclases](https://developer.mozilla.org/es/docs/Web/CSS/Pseudo-classes) no se pueden usar directamente. + +Los estilos en línea y algunas de las otras formas de agregar estilos a los componentes de React van completamente en contra de las viejas convenciones. Tradicionalmente, se ha considerado la mejor práctica para separar completamente CSS del contenido (HTML) y la funcionalidad (JavaScript). Según esta vieja escuela de pensamiento, el objetivo era escribir CSS, HTML y JavaScript en sus archivos separados. + +La filosofía de React es, de hecho, el polo opuesto a esto. Dado que la separación de CSS, HTML y JavaScript en archivos separados no pareció escalar bien en aplicaciones más grandes, React basa la división de la aplicación en las líneas de sus entidades funcionales lógicas. + +Las unidades estructurales que componen las entidades funcionales de la aplicación son componentes de React. Un componente de React define el HTML para estructurar el contenido, las funciones de JavaScript para determinar la funcionalidad y también el estilo del componente; todo en un lugar. Esto es para crear componentes individuales que sean lo más independientes y reutilizables como sea posible. + +El código de la versión final de nuestra aplicación se puede encontrar en la rama part2-8 en [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-8). + +
    + +
    + +

    Ejercicios 2.16-2.17

    + +

    2.16: Agenda Telefónica paso 11

    + +Usa el ejemplo de [mensaje de error mejorado](/es/part2/agregar_estilos_a_la_aplicacion_react#mensaje-de-error-mejorado) de la parte 2 como guía para mostrar una notificación que dure unos segundos después de que se ejecute una operación exitosa (se agrega una persona o se cambia un número): + +![captura de pantalla de éxito verde agregada](../../images/2/27e.png) + +

    2.17*: Agenda Telefónica paso 12

    + +Abre tu aplicación en dos navegadores. **Si eliminas a una persona en el navegador 1** poco antes de intentar cambiar el número de teléfono de la persona en el navegador 2, obtendrás los siguientes mensajes de error: + +![mensaje de error 404 no encontrado al cambiar en varios navegadores](../../images/2/29b.png) + +Soluciona el problema según el ejemplo mostrado en [promesas y errores](/es/part2/alterando_datos_en_el_servidor#promesas-y-errores) en la parte 2. Modifica el ejemplo para que se muestre un mensaje cuando la operación no tiene éxito. Los mensajes mostrados para eventos exitosos y no exitosos deben lucir diferentes: + +![mensaje de error mostrado en pantalla en lugar de en la consola característica adicional](../../images/2/28e.png) + +**Nota** que incluso si manejas la excepción, el primer mensaje de error "404" todavía se imprime en la consola. Pero no deberías ver "Uncaught (in promise) Error". + +
    + +
    + +### Algunas observaciones importantes + +Al final de esta parte, hay algunos ejercicios más desafiantes. En este momento, puedes saltarte los ejercicios si son demasiado difíciles; volveremos a los mismos temas más adelante. De todas formas, vale la pena leer el material. + +Hemos hecho algo en nuestra aplicación que enmascara una fuente muy típica de errores. + +Establecimos el estado _notes_ con un valor inicial de un array vacío: + +```js +const App = () => { + const [notes, setNotes] = useState([]) + + // ... +} +``` + +Este es un valor inicial bastante natural ya que las notas son un conjunto, es decir, hay muchas notas que el estado almacenará. + +Si el estado solo estuviera guardando "una cosa", un valor inicial más adecuado sería _null_, indicando que no hay nada en el estado al principio. Veamos qué sucede si usamos este valor inicial: + +```js +const App = () => { + const [notes, setNotes] = useState(null) // highlight-line + + // ... +} +``` + +La aplicación se rompe: + +![consola TypeError no se pueden leer propiedades de null a través de map desde App](../../images/2/31a.png) + +El mensaje de error proporciona la razón y la ubicación del error. El código que causó el problema es el siguiente: + +```js + // notesToShow obtiene el valor de notes + const notesToShow = showAll + ? notes + : notes.filter(note => note.important) + + // ... + + {notesToShow.map(note => // highlight-line + + )} +``` + +El mensaje de error es + +```bash +Cannot read properties of null (reading 'map') +``` + +La variable _notesToShow_ se asigna primero con el valor del estado _notes_ y luego el código intenta llamar al método _map_ en un objeto que no existe, es decir, en _null_. + +¿Cuál es la razón de eso? + +El hook de efecto utiliza la función _setNotes_ para establecer que _notes_ tenga las notas que el backend está devolviendo: + +```js + useEffect(() => { + noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) // highlight-line + }) + }, []) +``` + +Sin embargo, el problema es que el efecto se ejecuta solo después de la primera renderización. +Y debido a que _notes_ tiene el valor inicial de null: + +```js +const App = () => { + const [notes, setNotes] = useState(null) // highlight-line + + // ... +``` + +en la primera renderización se ejecuta el siguiente código: + +```js +notesToShow = notes + +// ... + +notesToShow.map(note => ...) +``` + +y esto hace que la aplicación explote porque no podemos llamar al método _map_ del valor _null_. + +Cuando establecemos _notes_ para que sea inicialmente un array vacío, no hay error, ya que se permite llamar a _map_ en un array vacío. + +Así que, la inicialización del estado "enmascaró" el problema causado por el hecho de que los datos aún no se han obtenido del backend. + +Otra forma de evitar el problema es utilizar el renderizado condicional y devolver null si el estado del componente no está correctamente inicializado: + +```js +const App = () => { + const [notes, setNotes] = useState(null) // highlight-line + // ... + + useEffect(() => { + noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) + }) + }, []) + + // no renderizar nada si notes aún es null + // highlight-start + if (!notes) { + return null + } + // highlight-end + + // ... +} +``` + +Entonces, en la primera renderización, no se renderiza nada. Cuando las notas llegan desde el backend, el efecto utiliza la función _setNotes_ para establecer el valor del estado _notes_. Esto provoca que el componente se vuelva a renderizar y, en la segunda renderización, las notas se muestran en la pantalla. + +El método basado en el renderizado condicional es adecuado en casos en los que es imposible definir el estado para que la renderización inicial sea posible. + +La otra cosa que aún necesitamos analizar más de cerca es el segundo parámetro del useEffect: + +```js + useEffect(() => { + noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) + }) + }, []) // highlight-line +``` + +El segundo parámetro de useEffect se utiliza para [especificar con qué frecuencia se ejecuta el efecto](https://es.react.dev/reference/react/useEffect#parameters). El principio es que el efecto siempre se ejecuta después de la primera renderización del componente y cuando cambia el valor del segundo parámetro. + +Si el segundo parámetro es un array vacío [], su contenido nunca cambia y el efecto solo se ejecuta después de la primera renderización del componente. Esto es exactamente lo que queremos cuando estamos inicializando el estado de la aplicación desde el servidor. + +Sin embargo, hay situaciones en las que queremos realizar el efecto en otros momentos, por ejemplo, cuando el estado del componente cambia de una manera particular. + +Considera la siguiente aplicación simple para consultar tasas de cambio de divisas desde la [API de tasas de cambio](https://www.exchangerate-api.com/): + +```js +import { useState, useEffect } from 'react' +import axios from 'axios' + +const App = () => { + const [value, setValue] = useState('') + const [rates, setRates] = useState({}) + const [currency, setCurrency] = useState(null) + + useEffect(() => { + console.log('effect run, currency is now', currency) + + // omitir si la moneda no está definida + if (currency) { + console.log('fetching exchange rates...') + axios + .get(`https://open.er-api.com/v6/latest/${currency}`) + .then(response => { + setRates(response.data.rates) + }) + } + }, [currency]) + + const handleChange = (event) => { + setValue(event.target.value) + } + + const onSearch = (event) => { + event.preventDefault() + setCurrency(value) + } + + return ( +
    +
    + currency: + +
    +
    +        {JSON.stringify(rates, null, 2)}
    +      
    +
    + ) +} + +export default App +``` + +La interfaz de usuario de la aplicación tiene un formulario, en el input del cual se escribe el nombre de la moneda deseada. Si la moneda existe, la aplicación renderiza las tasas de cambio de la moneda a otras monedas: + +![navegador mostrando tasas de cambio de divisas con eur escrito y consola que dice fetching exchange rates](../../images/2/32new.png) + +La aplicación establece el nombre de la moneda ingresado en el formulario al estado _currency_ en el momento en que se presiona el botón. + +Cuando _currency_ obtiene un nuevo valor, la aplicación obtiene sus tasas de cambio desde la API en la función del efecto: + +```js +const App = () => { + // ... + const [currency, setCurrency] = useState(null) + + useEffect(() => { + console.log('effect run, currency is now', currency) + + // omitir si la moneda no está definida + if (currency) { + console.log('fetching exchange rates...') + axios + .get(`https://open.er-api.com/v6/latest/${currency}`) + .then(response => { + setRates(response.data.rates) + }) + } + }, [currency]) // highlight-line + // ... +} +``` + +El hook useEffect ahora tiene _[currency]_ como segundo parámetro. Por lo tanto, la función del efecto se ejecuta después de la primera renderización y siempre después de la tabla, ya que su segundo parámetro _[currency]_ cambia. Es decir, cuando el estado _currency_ obtiene un nuevo valor, el contenido de la tabla cambia y se ejecuta la función del efecto. + +El efecto tiene la siguiente condición: + +```js +if (currency) { + // se obtienen las tasas de cambio +} +``` + +lo que evita solicitar las tasas de cambio justo después de la primera renderización cuando la variable _currency_ aún tiene el valor inicial, es decir, un valor null. + +Entonces, si el usuario escribe, por ejemplo, eur en el campo de búsqueda, la aplicación utiliza Axios para realizar una solicitud HTTP GET a la dirección y almacena la respuesta en el estado _rates_. + +Luego, cuando el usuario ingresa otro valor en el campo de búsqueda, por ejemplo, usd, la función del efecto se ejecuta nuevamente y se solicitan las tasas de cambio de la nueva moneda desde la API. + +La forma presentada aquí para realizar solicitudes a la API podría parecer un poco incómoda. +Esta aplicación en particular podría haberse hecho completamente sin usar useEffect, realizando las solicitudes a la API directamente en la función del controlador de envío del formulario: + +```js + const onSearch = (event) => { + event.preventDefault() + axios + .get(`https://open.er-api.com/v6/latest/${value}`) + .then(response => { + setRates(response.data.rates) + }) + } +``` + +Sin embargo, hay situaciones donde esa técnica no funcionaría. Por ejemplo, podrías encontrarte con una de esas situaciones en el ejercicio 2.20 donde el uso de useEffect podría ofrecer una solución. Ten en cuenta que esto depende en gran medida del enfoque que hayas seleccionado; por ejemplo, la solución modelo no utiliza este truco. + +
    + +
    + +### Ejercicios 2.18.-2.20. + +#### 2.18* Datos de países, paso 1 + +En [https://studies.cs.helsinki.fi/restcountries/](https://studies.cs.helsinki.fi/restcountries/) puedes encontrar un servicio que ofrece mucha información sobre diferentes países en un formato legible por máquinas a través de la API REST. Crea una aplicación que te permita ver información de diferentes países. + +La interfaz de usuario es muy simple. El país que se mostrará se encuentra escribiendo una consulta de búsqueda en el campo de búsqueda. + +Si hay demasiados países (más de 10) que coinciden con la consulta, se le pide al usuario que haga su consulta más específica: + +![captura de pantalla de demasiadas coincidencias](../../images/2/19b1.png) + +Si hay diez o menos países, pero más de uno, se muestran todos los países que coinciden con la consulta: + +![captura de pantalla de países coincidentes en una lista](../../images/2/19b2.png) + +Cuando solo hay un país que coincide con la consulta, se muestran los datos básicos del país (por ejemplo, capital y área), su bandera y los idiomas hablados: + +![captura de pantalla de bandera y atributos adicionales](../../images/2/19c3.png) + +**NB**: Es suficiente que tu aplicación funcione para la mayoría de los países. Algunos países, como Sudán, pueden ser difíciles de admitir ya que el nombre del país es parte del nombre de otro país, Sudán del Sur. No es necesario que te preocupes por estos casos especiales. + +#### 2.19*: Datos de países, paso 2 + +**Todavía hay mucho por hacer en esta parte, ¡así que no te atasques en este ejercicio!** + +Mejora la aplicación del ejercicio anterior, de modo que cuando se muestren los nombres de varios países en la página, haya un botón junto al nombre del país que, al presionarlo, muestra la vista de ese país: + +![adjuntar botones de muestra para cada característica del país](../../images/2/19b4.png) + +En este ejercicio, también es suficiente que tu aplicación funcione para la mayoría de los países. Se pueden ignorar los países cuyo nombre aparece en el nombre de otro país, como Sudán. + +#### 2.20*: Datos de países, paso 3 + +Agrega a la vista que muestra los datos de un solo país el informe meteorológico para la capital de ese país. Hay docenas de proveedores de datos meteorológicos. Una API sugerida es [https://openweathermap.org](https://openweathermap.org). Ten en cuenta que puede pasar algunos minutos hasta que una clave API generada sea válida. + +![captura de pantalla de la función de informe meteorológico agregada](../../images/2/19x.png) + +Si usas OpenWeatherMap, [aquí](https://openweathermap.org/weather-conditions#Icon-list) tienes la descripción de cómo obtener iconos meteorológicos. + +**NB**: En algunos navegadores (como Firefox), la API elegida puede enviar una respuesta de error, lo que indica que no se admite el cifrado HTTPS, aunque la URL de la solicitud comience con _http://_. Este problema se puede solucionar completando el ejercicio con Chrome. + +**NB**: Necesitas una clave API para usar casi todos los servicios meteorológicos. ¡No guardes la clave API en GitHub! Ni codifiques la clave API en tu código fuente. En su lugar, utiliza una [variable de entorno](https://es.vitejs.dev/guide/env-and-mode.html) para guardar la clave. + +Suponiendo que la clave API es 54l41n3n4v41m34rv0, cuando la aplicación se inicia de la siguiente manera: + +```bash +export VITE_SOME_KEY=54l41n3n4v41m34rv0 && npm run dev // Para Linux/macOS Bash +($env:VITE_SOME_KEY="54l41n3n4v41m34rv0") -and (npm run dev) // Para Windows PowerShell +set "VITE_SOME_KEY=54l41n3n4v41m34rv0" && npm run dev // Para Windows cmd.exe +``` + +puedes acceder al valor de la clave desde el objeto _import.meta.env_: + +```js +const api_key = import.meta.env.VITE_SOME_KEY +// variable api_key ahora tiene el valor configurado +``` + +Ten en cuenta que deberás reiniciar el servidor para aplicar los cambios. + +Este fue el último ejercicio de esta parte del curso. Es hora de subir tu código a GitHub y marcar todos tus ejercicios terminados en el [sistema de envío de ejercicios](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    diff --git a/src/content/2/fi/osa2.md b/src/content/2/fi/osa2.md index 7db67bde03d..459c65d8315 100644 --- a/src/content/2/fi/osa2.md +++ b/src/content/2/fi/osa2.md @@ -6,6 +6,9 @@ lang: fi
    -Jatkamme Reactiin tutustumista. Ensin käsitellään datakokoelmien, esimerkiksi useamman taulukkoon sijoitetun nimen renderöimistä ruudulle. Tämän jälkeen tarkastellaan miten käyttäjä voi antaa tietoja React-sovellukselle HTML:n lomakkeiden avulla. Sen jälkeen fokus siirtyy siihen miten selaimessa oleva Javascript-koodi käsittelee palvelimelle talletettua dataa. Osan lopussa tarkastelemme nopeasti paria yksinkertaista tapaa CSS-tyylien lisäämisestä React-sovellukseen. +Jatkamme Reactiin tutustumista. Ensin käsitellään datakokoelmien, esimerkiksi useamman taulukkoon sijoitetun nimen renderöimistä ruudulle. Tämän jälkeen tarkastellaan miten käyttäjä voi antaa tietoja React-sovellukselle HTML:n lomakkeiden avulla. Sen jälkeen fokus siirtyy siihen, miten selaimessa oleva JavaScript-koodi käsittelee palvelimelle talletettua dataa. Osan lopussa tarkastelemme nopeasti paria yksinkertaista tapaa CSS-tyylien lisäämiseksi React-sovellukseen. + +Osa päivitetty 16.2.2025 +- Node päivitetty versioon v22.3.0
    diff --git a/src/content/2/fi/osa2a.md b/src/content/2/fi/osa2a.md index d96b617e7b8..99f93606e44 100644 --- a/src/content/2/fi/osa2a.md +++ b/src/content/2/fi/osa2a.md @@ -11,13 +11,13 @@ Ennen kun menemme uuteen asiaan, nostetaan esiin muutama edellisen osan huomiota ### console.log -**Mikä erottaa kokeneen ja kokemattoman Javascript-ohjelmoijan? Kokeneet käyttävät 10-100 kertaa enemmän console.logia**. +_**Mikä erottaa kokeneen ja kokemattoman JavaScript-ohjelmoijan? Kokeneet käyttävät 10-100 kertaa enemmän console.logia**_. Paradoksaalista kyllä tämä näyttää olevan tilanne, vaikka kokematon ohjelmoija oikeastaan tarvitsisi console.logia (tai jotain muita debuggaustapoja) huomattavissa määrin kokenutta enemmän. -Eli kun joku ei toimi, älä arvaile vaan logaa tai käytä jotain muita debuggauskeinoja. +Eli kun joku ei toimi, älä arvaile vaan logaa tai käytä joitain muita debuggauskeinoja. -**HUOM** kun käytät komentoa _console.log_ debuggaukseen, älä yhdistele asioita "javamaisesti" plussalla, eli sen sijaan että kirjoittaisit +**HUOM** kun käytät komentoa _console.log_ debuggaukseen, älä yhdistele asioita javamaisesti plussalla, eli sen sijaan että kirjoittaisit ```js console.log('props value is' + props) @@ -35,26 +35,26 @@ Jos yhdistät merkkijonoon olion, tuloksena on suhteellisen hyödytön tulostusm props value is [Object object] ``` -kun taas pilkulla erotellessa saat tulostettavat asiat developer-konsoliin oliona, jonka sisältöä on mahdollista tarkastella. +kun taas pilkulla erotellessa saat tulostettavat asiat Developer-konsoliin oliona, jonka sisältöä on mahdollista tarkastella. Lue tarvittaessa lisää React-sovellusten debuggaamisesta [täältä](/osa1/monimutkaisempi_tila_reactin_debuggaus#react-sovellusten-debuggaus). ### Tapahtumankäsittely revisited -Viime vuoden kurssin alun kokemusten perusteella tapahtumien käsittely on osoittautunut haastavaksi. +Aiempien vuosien kurssien alun kokemusten perusteella tapahtumien käsittely on osoittautunut haastavaksi. -Edellisen osan lopussa oleva kertaava osa [tapahtumankäsittely revisited](/osa1/monimutkaisempi_tila_reactin_debuggaus#tapahtumankasittely-revisited) kannattaa käydä läpi, jos osaaminen on vielä häilyvällä pohjalla. +Edellisen osan lopussa oleva kertaava osa [tapahtumankäsittely revisited](/osa1/monimutkaisempi_tila_reactin_debuggaus#tapahtumankasittely-revisited) kannattaa käydä läpi jos osaaminen on vielä häilyvällä pohjalla. -Myös tapahtumankäsittelijöiden välittäminen komponentin App alikomponenteille on herättänyt ilmaan kysymyksiä, pieni kertaus aiheeseen [täällä](/osa1/monimutkaisempi_tila_reactin_debuggaus#tapahtumankasittelijan-vieminen-alikomponenttiin). +Myös tapahtumankäsittelijöiden välittäminen komponentin App alikomponenteille on herättänyt kysymyksiä. Pieni kertaus aiheeseen on [täällä](/osa1/monimutkaisempi_tila_reactin_debuggaus#tapahtumankasittelijan-vieminen-alikomponenttiin). ### Protip: Visual Studio Coden snippetit -Visual studio codeen on helppo määritellä "snippettejä", eli Netbeansin "sout":in tapaisia oikoteitä yleisesti käytettyjen koodinpätkien generointiin. Ohje snippetien luomiseen [täällä](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_creating-your-own-snippets). +Visual Studio Codeen on helppo määritellä "snippettejä", eli Netbeansin "sout":in tapaisia oikoteitä yleisesti käytettyjen koodinpätkien generointiin. Ohje snippetien luomiseen [täällä](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_creating-your-own-snippets). -VS Code -plugineina löytyy myös hyödyllisiä valmiiksi määriteltyjä snippettejä, esim. +VS Code ‑plugineina löytyy myös hyödyllisiä valmiiksi määriteltyjä snippettejä, esim. [tämä](https://marketplace.visualstudio.com/items?itemName=xabikos.ReactSnippets). -Tärkein kaikista snippeteistä on komennon console.log() nopeasti ruudulle tekevä snippet, esim. clog, jonka voi määritellä seuraavasti: +Ehkä kätevin kaikista snippeteistä on komennon console.log() nopeasti ruudulle tekevä snippet, esim. clog, jonka voi määritellä seuraavasti: ```js { @@ -68,11 +68,11 @@ Tärkein kaikista snippeteistä on komennon console.log() nopeasti ruud } ``` -### Taulukkojen käyttö Javascriptissä +### Taulukkojen käyttö JavaScriptissä -Tästä osasta lähtien käytämme runsaasti Javascriptin [taulukkojen](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array) funktionaalisia käsittelymetodeja, kuten _find_, _filter_ ja _map_. Periaate niissä on täysin sama kuin Java 8:sta tutuissa streameissa, joita on käytetty jo parin vuoden ajan Tietojenkäsittelytieteen osaston Ohjelmoinnin perusteissa ja jatkokurssilla sekä Ohjelmoinnin MOOC:issa. +Tästä osasta lähtien käytämme runsaasti JavaScriptin [taulukkojen](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array) funktionaalisia käsittelymetodeja kuten _find_, _filter_ ja _map_. Periaate niissä on täysin sama kuin esim. Java 8:sta tutuissa streameissa, joita on käytetty jo vuosien ajan Tietojenkäsittelytieteen osaston Ohjelmoinnin perusteissa ja jatkokurssilla sekä Ohjelmoinnin MOOC:issa. Operaattoreihin tutustutaan myös Ohjelmoinnin jatkokurssin Python-versiossa, [osassa 12](https://ohjelmointi-22.mooc.fi/osa-12/3-funktionaalista-ohjelmointia). -Jos taulukon funktionaalinen käsittely tuntuu vielä vieraalta, kannattaa katsoa Youtubessa olevasta videosarjasta Functional Programming in JavaScript ainakin kolme ensimmäistä osaa +Jos taulukon funktionaalinen käsittely tuntuu vielä vieraalta, kannattaa katsoa YouTubessa olevasta videosarjasta [Functional Programming in JavaScript](https://www.youtube.com/playlist?list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84) ainakin kolme ensimmäistä osaa: - [Higher-order functions](https://www.youtube.com/watch?v=BMUiFMZr7vk&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84) - [Map](https://www.youtube.com/watch?v=bCqtb-Z5YGQ&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84&index=2) @@ -80,71 +80,73 @@ Jos taulukon funktionaalinen käsittely tuntuu vielä vieraalta, kannattaa katso ### Kokoelmien renderöiminen -Tehdään nyt Reactilla [osan 0](/osa0) alussa käytettyä esimerkkisovelluksen [Single page app -versiota](https://fullstack-exampleapp.herokuapp.com/spa) vastaavan sovelluksen 'frontend' eli selainpuolen sovelluslogiikka. +Tehdään nyt Reactilla [osan 0](/osa0) alussa käytettyä esimerkkisovelluksen [Single page app ‑versiota](https://studies.cs.helsinki.fi/exampleapp/spa) vastaavan sovelluksen 'frontend' eli selainpuolen sovelluslogiikka. -Aloitetaan seuraavasta: +Aloitetaan seuraavasta (tiedosto App.jsx): ```js -import React from 'react' -import ReactDOM from 'react-dom' +const App = (props) => { + const { notes } = props + + return ( +
    +

    Notes

    +
      +
    • {notes[0].content}
    • +
    • {notes[1].content}
    • +
    • {notes[2].content}
    • +
    +
    + ) +} + +export default App +``` + +Tiedosto main.jsx on muuten samanlainen kuin se on ollut toistaiseksi kaikissa ohjelmissa, mutta se määrittelee taulukon, jossa on näytettävä data. + +```js +import ReactDOM from 'react-dom/client' +import App from './App' const notes = [ { id: 1, content: 'HTML is easy', - date: '2020-01-10T17:30:31.098Z', important: true }, { id: 2, - content: 'Browser can execute only Javascript', - date: '2020-01-10T18:39:34.091Z', + content: 'Browser can execute only JavaScript', important: false }, { id: 3, content: 'GET and POST are the most important methods of HTTP protocol', - date: '2020-01-10T19:20:14.298Z', important: true } ] -const App = (props) => { - const { notes } = props - - return ( -
    -

    Notes

    -
      -
    • {notes[0].content}
    • -
    • {notes[1].content}
    • -
    • {notes[2].content}
    • -
    -
    - ) -} - -ReactDOM.render( - , - document.getElementById('root') +ReactDOM.createRoot(document.getElementById('root')).render( + ) ``` -Jokaiseen muistiinpanoon on merkitty tekstuaalisen sisällön ja aikaleiman lisäksi myös _boolean_-arvo, joka kertoo onko muistiinpano luokiteltu tärkeäksi, sekä yksikäsitteinen tunniste id. +Jokaiseen muistiinpanoon on merkitty tekstisisällön lisäksi yksikäsitteinen tunniste id ja _boolean_-arvo, joka kertoo onko muistiinpano luokiteltu tärkeäksi. -Koodin toiminta perustuu siihen, että taulukossa on tasan kolme muistiinpanoa, yksittäiset muistiinpanot renderöidään 'kovakoodatusti' viittaamalla suoraan taulukossa oleviin olioihin: +Taulukossa on kolme muistiinpanoa, ja yksittäiset muistiinpanot renderöidään 'kovakoodatusti' viittaamalla taulukon olioihin: ```js -
  • {note[1].content}
  • +
  • {notes[1].content}
  • ``` -Tämä ei tietenkään ole järkevää. Ratkaisu voidaan yleistää generoimalla taulukon perusteella joukko React-elementtejä käyttäen [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map)-funktiota: +Tämä ei tietenkään ole järkevää. Ratkaisu voidaan yleistää generoimalla taulukon perusteella joukko React-elementtejä [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map)-funktiota käyttäen: ```js notes.map(note =>
  • {note.content}
  • ) ``` -nyt tuloksena on taulukko, jonka sisältö on joukko li-elementtejä +Nyt tuloksena on taulukko, jonka sisältö on joukko li-elementtejä ```js [ @@ -173,9 +175,9 @@ const App = (props) => { } ``` -Koska li-tagit generoiva koodi on Javascriptia, tulee se sijoittaa JSX-templatessa aaltosulkujen sisälle kaiken muun Javascript-koodin tapaan. +Koska li-tagit generoiva koodi on JavaScriptia, se tulee sijoittaa JSX-templatessa aaltosulkujen sisälle muun JavaScript-koodin tapaan. -Parannetaan koodin luetteloa vielä jakamalla nuolifunktion määrittely useammalle riville: +Parannetaan koodin luettavuutta vielä jakamalla nuolifunktion määrittely useammalle riville: ```js const App = (props) => { @@ -200,11 +202,11 @@ const App = (props) => { ### Key-attribuutti -Vaikka sovellus näyttää toimivan, tulee konsoliin ikävä varoitus +Vaikka sovellus näyttää toimivan, konsoliin tulee ikävä varoitus: ![](../../images/2/1a.png) -Kuten virheilmoituksen linkittämä [sivu](https://reactjs.org/docs/lists-and-keys.html#keys) kertoo, tulee taulukossa olevilla, eli käytännössä _map_-metodilla muodostetuilla elementeillä olla uniikki avain, eli attribuutti nimeltään key. +Kuten virheilmoituksen linkittämä [sivu](https://react.dev/learn/rendering-lists#keeping-list-items-in-order-with-key) kertoo, tulee taulukossa olevilla, eli käytännössä _map_-metodilla muodostetuilla elementeillä olla uniikki avain, eli attribuutti nimeltään key. Lisätään avaimet: @@ -217,11 +219,9 @@ const App = (props) => {

    Notes

      {notes.map(note => - // highlight-start -
    • +
    • // highlight-line {note.content} -
    • - // highlight-end + )}
    @@ -231,47 +231,44 @@ const App = (props) => { Virheilmoitus katoaa. -React käyttää taulukossa olevien elementtien key-kenttiä päätellessään miten sen tulee päivittää komponentin generoimaa näkymää silloin kun komponentti uudelleenrenderöidään. Lisää aiheesta [täällä](https://reactjs.org/docs/reconciliation.html#recursing-on-children). +React käyttää taulukossa olevien elementtien key-kenttiä päätellessään miten sen tulee päivittää komponentin generoimaa näkymää silloin kun komponentti uudelleenrenderöidään. Lisää aiheesta on [täällä](https://react.dev/learn/preserving-and-resetting-state#option-2-resetting-state-with-a-key). ### Map -Taulukoiden metodin [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) toiminnan sisäistäminen on jatkon kannalta äärimmäisen tärkeää. +Taulukoiden [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map)-metodin toiminnan sisäistäminen on jatkon kannalta äärimmäisen tärkeää. Sillä ei ole mitään tekemistä karttojen kanssa, vaan oikeampi suomenkielinen termi olisi matematiikan kuvaus. -Sovellus siis sisältää taulukon _notes_ +Sovellus siis sisältää taulukon _notes_: ```js const notes = [ { id: 1, content: 'HTML is easy', - date: '2020-01-10T17:30:31.098Z', important: true }, { id: 2, - content: 'Browser can execute only Javascript', - date: '2020-01-10T18:39:34.091Z', + content: 'Browser can execute only JavaScript', important: false }, { id: 3, content: 'GET and POST are the most important methods of HTTP protocol', - date: '2020-01-10T19:20:14.298Z', important: true } ] ``` -Pysähdytään hetkeksi tarkastelemaan miten _map_ toimii. +Tutkitaan miten _map_ toimii. -Jos esim. tiedoston loppuun lisätään seuraava koodi +Lisätään tiedoston loppuun seuraava koodi: ```js const result = notes.map(note => note.id) console.log(result) ``` -tulostuu konsoliin [1, 2, 3] eli _map_ muodostaa uuden taulukon, jonka jokainen alkio on saatu alkuperäisen taulukon _notes_ alkioista mappaamalla komennon parametrina olevan funktion avulla. +Konsoliin tulostuu [1, 2, 3], eli _map_ muodostaa uuden taulukon, jonka jokainen alkio on saatu alkuperäisen taulukon _notes_ alkioista mappaamalla se komennon parametrina olevan funktion avulla. Funktio on @@ -309,23 +306,15 @@ notes.map(note => joka muodostaa jokaista muistiinpano-olioa vastaavan li-tagin, jonka sisään tulee muistiinpanon sisältö. -Koska metodin _map_ parametrina olevan funktion +Koska _map_-metodin parametrina olevan funktion ```js note =>
  • {note.content}
  • ``` -käyttötarkoitus on näkymäelementtien muodostaminen, tulee muuttujan note.content arvo renderöidä aaltosulkeiden sisällä. Kokeile mitä koodi tekee, jos poistat aaltosulkeet. - -Aaltosulkeiden käyttö tulee varmaan aiheuttamaan alussa pientä päänvaivaa, mutta totut niihin pian. Reactin antama visuaalinen feedback on välitön. - -Parempi muotoilu ohjelmamme muistiinpanorivit tuottavalle apufunktiolle saattaakin olla seuraava useille riveille jaoteltu versio: - -``` -(koodi tähän) -``` +käyttötarkoitus on näkymäelementtien muodostaminen, tulee muuttujien note.id ja note.content arvo renderöidä aaltosulkeiden sisällä. Kokeile mitä koodi tekee, jos poistat aaltosulkeet. -Kyse on kuitenkin edelleen yhden komennon sisältävästä nuolifunktiosta, komento vain sattuu olemaan hieman monimutkaisempi. +Aaltosulkeiden käyttö tulee varmaan aiheuttamaan alussa pientä päänvaivaa, mutta totut niihin pian. Reactin antama visuaalinen palaute on välitön. ### Antipattern: taulukon indeksit avaimina @@ -335,9 +324,9 @@ Olisimme saaneet konsolissa olevan varoituksen katoamaan myös käyttämällä a notes.map((note, i) => ...) ``` -näin kutsuttaessa _i_ saa arvokseen sen paikan indeksin taulukossa, missä Note sijaitsee. +Näin kutsuttaessa _i_ saa arvokseen muistiinpanon indeksin taulukossa. -Eli eräs konsoliin tulostuvaa virheilmoitusta aiheuttamaton tapa määritellä rivien generointi olisi +Eräs tapa päästä eroon konsoliin tulostuvasta virheilmoituksesta olisi siis: ```js
      @@ -349,7 +338,7 @@ Eli eräs konsoliin tulostuvaa virheilmoitusta aiheuttamaton tapa määritellä
    ``` -Tämä **ei kuitenkaan ole suositeltavaa** ja voi näennäisestä toimimisestaan aiheuttaa joissakin tilanteissa pahoja ongelmia. Lue lisää esimerkiksi [täältä](https://medium.com/@robinpokorny/index-as-a-key-is-an-anti-pattern-e0349aece318). +Tämä **ei kuitenkaan ole suositeltavaa** ja voi aiheuttaa ongelmia. Lue lisää esimerkiksi [täältä](https://robinpokorny.medium.com/index-as-a-key-is-an-anti-pattern-e0349aece318). ### Refaktorointia - moduulit @@ -361,7 +350,7 @@ const App = ({ notes }) => { //highlight-line

    Notes

      - {notes.map((note, i) => + {notes.map(note =>
    • {note.content}
    • @@ -372,7 +361,7 @@ const App = ({ notes }) => { //highlight-line } ``` -Jos unohdit mitä destrukturointi tarkottaa ja miten se toimii, kertaa [täältä](/osa1/komponentin_tila_ja_tapahtumankasittely#destrukturointi). +Jos unohdit mitä destrukturointi tarkottaa ja miten se toimii, kertaa asia [täältä](/osa1/komponentin_tila_ja_tapahtumankasittely#destrukturointi). Erotetaan yksittäisen muistiinpanon esittäminen oman komponenttinsa Note vastuulle: @@ -391,7 +380,7 @@ const App = ({ notes }) => {

      Notes

        // highlight-start - {notes.map((note, i) => + {notes.map(note => )} // highlight-end @@ -403,52 +392,44 @@ const App = ({ notes }) => { Huomaa, että key-attribuutti täytyy nyt määritellä Note-komponenteille, eikä li-tageille kuten ennen muutosta. -Koko React-sovellus on mahdollista määritellä samassa tiedostossa, mutta se ei luonnollisesti ole järkevää. Usein käytäntönä on määritellä yksittäiset komponentit omassa tiedostossaan ES6-moduuleina. +Koko React-sovellus on mahdollista määritellä samassa tiedostossa, mutta se ei ole kovin järkevää. Usein käytäntönä on määritellä yksittäiset komponentit omassa tiedostossaan ES6-moduuleina. -Koodissamme on käytetty koko ajan moduuleja. Tiedoston ensimmäiset rivit +Koodissamme on käytetty koko ajan moduuleja. Tiedoston main.jsx ensimmäiset rivit ```js -import React from 'react' -import ReactDOM from 'react-dom' +import ReactDOM from "react-dom/client" +import App from "./App" ``` -[importtaavat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) eli ottavat käyttöönsä kaksi moduulia. Moduuli react sijoitetaan muuttujaan _React_ ja react-dom muuttujaan _ReactDOM_. +[importtaavat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) eli ottavat käyttöönsä kaksi moduulia. Moduuli react-dom/client sijoitetaan muuttujaan _ReactDOM_ ja sovelluksen pääkomponentin määrittelevä moduuli muuttujaan _App_. -Siirretään nyt komponentti Note omaan moduuliinsa. +Siirretään nyt Note-komponentti omaan moduuliinsa. Pienissä sovelluksissa komponentit sijoitetaan yleensä src-hakemiston alle sijoitettavaan hakemistoon components. Konventiona on nimetä tiedosto komponentin mukaan. -Tehdään nyt sovellukseen hakemisto components ja sinne tiedosto Note.js jonka sisältö on seuraava: +Tehdään nyt sovellukseen hakemisto components ja sinne tiedosto Note.jsx, jonka sisältö on seuraava: ```js -import React from 'react' - const Note = ({ note }) => { - return ( -
      • {note.content}
      • - ) + return
      • {note.content}
      • } export default Note ``` -Koska kyseessä on React-komponentti, tulee React importata komponentissa. - Moduulin viimeisenä rivinä [eksportataan](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export) määritelty komponentti, eli muuttuja Note. -Nyt komponenttia käyttävä tiedosto index.js voi [importata](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) moduulin: +Nyt komponenttia käyttävä tiedosto App.jsx voi [importata](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) moduulin: ```js -import React from 'react' -import ReactDOM from 'react-dom' import Note from './components/Note' // highlight-line -const App = ({notes}) => { +const App = ({ notes }) => { // ... } ``` -Moduulin eksporttaama komponentti on nyt käytettävissä muuttujassa Note täysin samalla tavalla kuin aiemmin. +Moduulin eksporttaama komponentti on nyt käytettävissä muuttujassa Note kuten aiemminkin. Huomaa, että itse määriteltyä komponenttia importatessa komponentin sijainti tulee ilmaista suhteessa importtaavaan tiedostoon: @@ -456,66 +437,27 @@ Huomaa, että itse määriteltyä komponenttia importatessa komponentin sijainti './components/Note' ``` -Piste alussa viittaa nykyiseen hakemistoon, eli kyseessä on nykyisen hakemiston alihakemisto components ja sen sisällä tiedosto Note.js. Tiedoston päätteen voi jättää pois. - -Koska myös App on komponentti, eristetään sekin omaan moduuliinsa. Koska kyseessä on sovelluksen juurikomponentti, sijoitetaan se suoraan hakemistoon src. Tiedoston sisältö on seuraava: - -```js -import React from 'react' -import Note from './components/Note' - -const App = ({ notes }) => { - return ( -
        -

        Notes

        -
          - {notes.map((note, i) => - - )} -
        -
        - ) -} - -export default App // highlight-line -``` - -Tiedoston index.js sisällöksi jää: - -```js -import React from 'react' -import ReactDOM from 'react-dom' -import App from './App' // highlight-line - -const notes = [ - // ... -] - -ReactDOM.render( - , - document.getElementById('root') -) -``` +Piste alussa viittaa nykyiseen hakemistoon, eli kyseessä on nykyisen hakemiston alihakemisto components ja sen sisällä tiedosto Note.jsx. Tiedoston päätteen voi jättää pois. -Moduuleilla on paljon muutakin käyttöä kuin mahdollistaa komponenttien määritteleminen omissa tiedostoissaan, palaamme moduuleihin tarkemmin myöhemmin kurssilla. +Moduuleilla on paljon muutakin käyttöä kuin mahdollistaa komponenttien määritteleminen omissa tiedostoissaan. Palaamme moduuleihin tarkemmin myöhemmin kurssilla. -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part2-notes/tree/part2-1) +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-1). -Huomaa, että repositorion master-haarassa on myöhemmän vaiheen koodi, tämän hetken koodi on branchissa [part2-1](https://github.com/fullstack-hy2020/part2-notes/tree/part2-1): +Huomaa, että repositorion main-haarassa on myöhemmän vaiheen koodi. Tämän hetken koodi on branchissa [part2-1](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-1): ![](../../images/2/2b.png) -Jos kloonaat projektin itsellesi, suorita komento _npm install_ ennen käynnistämistä eli komentoa _npm start_. +Jos kloonaat projektin itsellesi, suorita komento _npm install_ ennen käynnistämistä eli komentoa _npm run dev_. ### Kun sovellus hajoaa -Kun aloitat ohjelmoijan uraasi (ja allekirjoittaneella edelleen 30 vuoden ohjelmointikokemuksella) käy melko usein niin, että ohjelma hajoaa aivan totaalisesti. Erityisen usein näin käy dynaamisesti tyypitetyillä kielillä, kuten Javascript, missä kääntäjä ei tarkasta minkä tyyppisiä arvoja esim. funktioiden parametreina ja paluuarvoina liikkuu. +Kun aloitat ohjelmoijan uraasi (ja allekirjoittaneella edelleen 30 vuoden ohjelmointikokemuksella) käy melko usein niin, että ohjelma hajoaa aivan totaalisesti. Erityisen usein näin käy dynaamisesti tyypitetyillä kielillä (kuten JavaScript), joissa kääntäjä ei tarkasta minkä tyyppisiä arvoja esim. funktioiden parametreina ja paluuarvoina liikkuu. -Reactissa räjähdys näyttää esim. seuraavalta +Reactissa räjähdys näyttää esim. seuraavalta: -![](../../images/2/3b.png) +![](../../images/2/3-vite.png) -Tilanteista pelastaa yleensä parhaiten console.log. Pala räjähdyksen aiheuttavaa koodia seuraavassa +Tilanteista pelastaa yleensä parhaiten console.log. Pala räjähdyksen aiheuttavaa koodia seuraavassa: ```js const Course = ({ course }) => ( @@ -537,7 +479,7 @@ const App = () => { } ``` -Syy toimimattomuuteen alkaa selvitä lisäilemällä koodiin console.log-komentoja. Koska ensimmäinen renderöitävä asia on komponentti App kannattaa sinne laittaa ensimmäisen tulostus: +Syy toimimattomuuteen alkaa selvitä lisäilemällä koodiin console.log-komentoja. Koska ensimmäinen renderöitävä asia on komponentti App, kannattaa ensimmäinen tulostus laittaa sinne: ```js const App = () => { @@ -545,7 +487,7 @@ const App = () => { // ... } - console.log('App toimii...') // highlight-line + console.log('app works...') // highlight-line return ( // .. @@ -553,11 +495,11 @@ const App = () => { } ``` -Konsoliin tulevan tulostuksen nähdäkseen on skrollattava pitkän punaisen virhematon yläpuolelle +Konsoliin tulevan tulostuksen nähdäkseen on skrollattava pitkän punaisen virhematon yläpuolelle: ![](../../images/2/4b.png) -Kun joku asia havaitaan toimivaksi, on aika logata syvemmältä. Jos komponentti on määritelty yksilausekkeista, eli returnittomana funktiota, on konsoliin tulostus haastavampaa: +Kun joku asia havaitaan toimivaksi, on aika logata syvemmältä. Jos komponentti on määritelty yksilausekkeisena eli returnittomana funktiona, on konsoliin tulostus haastavampaa: ```js const Course = ({ course }) => ( @@ -567,7 +509,7 @@ const Course = ({ course }) => ( ) ``` -komponentti on syytä muuttaa pidemmän kaavan mukaan määritellyksi jotta tulostus päästään lisäämään: +Komponentti on syytä muuttaa pidemmän kaavan mukaan määritellyksi, jotta tulostus päästään lisäämään: ```js const Course = ({ course }) => { @@ -580,7 +522,7 @@ const Course = ({ course }) => { } ``` -Erittäin usein ongelma on siitä että propsien odotetaan olevan eri muodossa tai eri nimisiä, kuin ne todellisuudessa ovat ja destrukturointi epäonnistuu. Ongelma alkaa useimmiten ratketa kun poistetaan destrukturointi ja katsotaan mitä props oikeasti pitää sisällään: +Erittäin usein ongelma aiheutuu siitä, että propsien odotetaan olevan eri muodossa tai eri nimisiä kuin ne todellisuudessa ovat ja destrukturointi epäonnistuu. Ongelma alkaa useimmiten ratketa, kun poistetaan destrukturointi ja katsotaan, mitä props oikeasti pitää sisällään: ```js const Course = (props) => { // highlight-line @@ -594,9 +536,21 @@ const Course = (props) => { // highlight-line } ``` -Ja jos ongelma ei vieläkään selviä, ei auta kuin jatkaa vianjäljitystä, eli kirjoittaa lisää console.logeja. +Jos ongelma ei vieläkään ratkea, ei auta kuin jatkaa vianjäljitystä eli kirjoittaa lisää console.logeja. + +Lisäsin tämän luvun materiaaliin sen jälkeen, kun seuraavan tehtävän mallivastauksen koodi räjähti ihan totaalisesti (syynä väärässä muodossa ollut propsi), ja jouduin jälleen kerran debuggaamaan console.logaamalla. + +### Websovelluskehittäjän vala -Lisäsin tämän luvun materiaaliin, kun seuraavan tehtävän mallivastauksen koodi räjähti ihan totaalisesti (syynä väärässä muodossa ollut propsi), ja jouduin jälleen kerran debuggaamaan console.logaamalla. +Ennen tehtävien pariin palaamista on hyvä muistaa, mitä lupasimme osan yksi lopussa. + +Ohjelmointi on hankalaa, ja sen takia lupaan hyödyntää kaikkia ohjelmointia helpottavia keinoja: + +- pidän selaimeni konsolin koko ajan auki +- etenen pienin askelin +- käytän koodissani runsaasti _console.log_-komentoja sekä varmistamaan sen, että varmasti ymmärrän jokaisen kirjoittamani koodirivin, että etsiessäni koodistani mahdollisia bugin aiheuttajia +- jos koodini ei toimi, en kirjoita enää yhtään lisää koodia, vaan alan poistaa toiminnan rikkoneita rivejä tai palaan suosiolla tilanteeseen, missä koodini vielä toimi +- kun kysyn apua kurssin Discord-kanavalla, tai muualla internetissä, muotoilen kysymyksen järkevästi, esim. [täällä](/en/part0/general_info#how-to-get-help-in-discord) esiteltyyn tapaan
    @@ -610,15 +564,13 @@ Voit palauttaa kurssin kaikki tehtävät samaan repositorioon, tai käyttää us Tehtävät palautetaan **yksi osa kerrallaan**. Kun olet palauttanut osan tehtävät, et voi enää palauttaa saman osan tekemättä jättämiäsi tehtäviä. -Huomaa, että tässä osassa on muitakin tehtäviä kuin allaolevat, eli älä tee palautusta ennen kun olet tehnyt osan tehtävistä kaikki mitkä haluat palauttaa. - -**VAROITUS** create-react-app tekee projektista automaattisesti git-repositorion, ellei sovellusta luoda jo olemassaolevan repositorion sisälle. Todennäköisesti **et halua** että projektista tulee repositorio, joten suorita projektin juuressa komento _rm -rf .git_. +Huomaa, että tässä osassa on muitakin tehtäviä kuin alla olevat, eli älä tee palautusta ennen kun olet tehnyt osan tehtävistä kaikki mitkä haluat palauttaa.

    2.1: kurssitiedot step6

    Viimeistellään nyt tehtävien 1.1-1.5 kurssin sisältöjä renderöivän ohjelman koodi. Voit ottaa tarvittaessa pohjaksi mallivastauksen koodin. -**Huomaa, että jos kopioit projektin paikasta toiseen, saattaa olla tarpeen ensin tuhota hakemisto node\_modules ja antaa sen jälkeen asentaa riippuvuudet uudelleen, eli komento _npm install_ ennen kuin saat kopioidun projektin käynnistettyä.** Lähtökohtaisesti toki kannattaa olla kokonaan kopioimatta tai laittamatta versionhallintaan hakemistoa node\_modules +**Huomaa, että jos kopioit projektin paikasta toiseen, saattaa olla tarpeen ensin tuhota hakemisto node\_modules ja antaa sen jälkeen asentaa riippuvuudet uudelleen, eli komento _npm install_ ennen kuin saat kopioidun projektin käynnistettyä.** Lähtökohtaisesti toki kannattaa olla kokonaan kopioimatta tai laittamatta versionhallintaan hakemistoa node\_modules. Muutetaan komponenttia App seuraavasti: @@ -652,13 +604,15 @@ const App = () => {
    ) } + +export default App ``` Määrittele sovellukseen yksittäisen kurssin muotoilusta huolehtiva komponentti Course. Sovelluksen komponenttirakenne voi olla esim. seuraava: -
    +```
     App
       Course
         Header
    @@ -666,7 +620,7 @@ App
           Part
           Part
           ...
    -
    +``` Eli komponentti Course sisältää edellisessä osassa määritellyt komponentit, joiden vastuulle tulee kurssin nimen ja osien renderöinti. @@ -676,9 +630,9 @@ Renderöityvä sivu voi näyttää esim. seuraavalta: Tässä vaiheessa siis tehtävien yhteenlaskettua lukumäärää ei vielä tarvita. -Sovelluksen täytyy luonnollisesti toimia riippumatta kurssissa olevien osien määrästä, eli varmista että sovellus toimii jos lisäät tai poistat kurssin osia. +Sovelluksen täytyy nyt toimia riippumatta kurssissa olevien osien määrästä. Eli varmista, että sovellus toimii jos lisäät tai poistat kurssin osia. -Varmista, että konsolissa ei näy mitään virheilmoituksia! +Varmista myös, että konsolissa ei näy mitään virheilmoituksia!

    2.2: kurssitiedot step7

    @@ -688,16 +642,18 @@ Ilmoita myös kurssin yhteenlaskettu tehtävien lukumäärä:

    2.3*: kurssitiedot step8

    +Miksi tehtävä on merkattu tähdellä? Selitys asiaan [täällä](/osa0/yleista#suoritustapa). + Jos et jo niin tehnyt, laske koodissasi tehtävien määrä taulukon metodilla [reduce](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce). -**Pro tip:** Kun koodisi joka näyttää esimerkisi seuraavalta +**Pro tip:** Kun koodisi joka näyttää esimerkiksi seuraavalta ```js const total = parts.reduce( (s, p) => someMagicHere ) ``` -ei toimi, kannattaa taas kerran turvautua komentoon _console.log_, joka jälleen vaatii sen, että nuolifunktio muutetaan pidempään muotoonsa +ei toimi, kannattaa taas kerran turvautua komentoon _console.log_, joka vaatii jälleen sen, että nuolifunktio muutetaan pidempään muotoonsa: ```js const total = parts.reduce( (s, p) => { @@ -706,17 +662,6 @@ const total = parts.reduce( (s, p) => { }) ``` -**Pro tip2:** VS codeen on asennettavissa laajennus, ilmeisesti [tämä](https://marketplace.visualstudio.com/items?itemName=cmstead.jsrefactor), jonka avulla nuolifunktion lyhyen muodon voi muuttaa automaattisesti pidemmäksi muodoksi ja päinvastoin: - -![](../../images/2/5b.png) - -**Pro tip3:** Mikäli console.login haluaa vain pikaisesti ujuttaa koodiin nuolifunktiota muuttamatta, voi sen tehdä näppärästi myös tällä tapaa: - -```js -const total = - parts.reduce( (s, p) => console.log('what is happening', s, p) || someMagicHere ) -``` -

    2.4: kurssitiedot step9

    Laajennetaan sovellusta siten, että kursseja voi olla mielivaltainen määrä: @@ -776,7 +721,7 @@ const App = () => { } ``` -Sovelluksen ulkoasu voi olla esim seuraava: +Sovelluksen ulkoasu voi olla esim. seuraava: ![](../../images/teht/10e.png) diff --git a/src/content/2/fi/osa2b.md b/src/content/2/fi/osa2b.md index 2ef2e7fb1ae..45798cb06c5 100644 --- a/src/content/2/fi/osa2b.md +++ b/src/content/2/fi/osa2b.md @@ -9,10 +9,12 @@ lang: fi Jatketaan sovelluksen laajentamista siten, että se mahdollistaa uusien muistiinpanojen lisäämisen. -Jotta saisimme sivun päivittymään uusien muistiinpanojen lisäyksen yhteydessä, on parasta sijoittaa muistiinpanot komponentin App tilaan. Eli importataan funktio [useState](https://reactjs.org/docs/hooks-state.html) ja määritellään sen avulla komponentille tila, joka saa aluksi arvokseen propsina välitettävän muistiinpanot alustavan taulukon: +### Muistiinpanojen tallettaminen komponentin tilaan + +Jotta saisimme sivun päivittymään uusien muistiinpanojen lisäyksen yhteydessä, on parasta sijoittaa muistiinpanot komponentin App tilaan. Eli importataan funktio [useState](https://react.dev/reference/react/useState) ja määritellään sen avulla komponentille tila, joka saa aluksi arvokseen propsina välitettävän muistiinpanot alustavan taulukon: ```js -import React, { useState } from 'react' // highlight-line +import { useState } from 'react' // highlight-line import Note from './components/Note' const App = (props) => { // highlight-line @@ -22,8 +24,8 @@ const App = (props) => { // highlight-line

    Notes

      - {notes.map((note, i) => - + {notes.map(note => + )}
    @@ -33,7 +35,7 @@ const App = (props) => { // highlight-line export default App ``` -Komponentti siis alustaa funktion useState avulla tilan notes arvoksi propseina välitettävän alustavan muistiinpanojen listan: +Komponentti siis alustaa funktion useState avulla tilan notes arvoksi propseina välitettävän alustavan muistiinpanojen listan: ```js const App = (props) => { @@ -43,6 +45,10 @@ const App = (props) => { } ``` +Voimme vielä havainnollistaa tilanteen React Developer Toolsin avulla: + +![](../../images/2/30.png) + Jos haluaisimme lähteä liikkeelle tyhjästä muistiinpanojen listasta, annettaisiin tilan alkuarvoksi tyhjä taulukko, ja koska komponentti ei käyttäisi ollenkaan propseja, voitaisiin parametri props jättää kokonaan määrittelemättä: ```js @@ -72,8 +78,8 @@ const App = (props) => {

    Notes

      - {notes.map((note, i) => - + {notes.map(note => + )}
    // highlight-start @@ -87,22 +93,22 @@ const App = (props) => { } ``` -Lomakkeelle on lisätty myös tapahtumankäsittelijäksi funktio _addNote_ reagoimaan sen "lähettämiseen", eli napin painamiseen. +Lomakkeelle on lisätty myös tapahtumankäsittelijäksi funktio _addNote_ reagoimaan sen "lähettämiseen" eli napin painamiseen. Tapahtumankäsittelijä on [osasta 1](/osa1/komponentin_tila_ja_tapahtumankasittely#tapahtumankasittely) tuttuun tapaan määritelty seuraavasti: ```js const addNote = (event) => { event.preventDefault() - console.log('button clicked'', event.target) + console.log('button clicked', event.target) } ``` -Parametrin event arvona on metodin kutsun aiheuttama [tapahtuma](https://reactjs.org/docs/handling-events.html). +Parametrin event arvona on metodin kutsun aiheuttama [tapahtuma](https://react.dev/learn/responding-to-events). Tapahtumankäsittelijä kutsuu heti tapahtuman metodia event.preventDefault() jolla se estää lomakkeen lähetyksen oletusarvoisen toiminnan, joka aiheuttaisi mm. sivun uudelleenlatautumisen. -Tapahtuman kohde, eli _event.target_ on tulostettu konsoliin +Tapahtuman kohde eli _event.target_ on tulostettu konsoliin: ![](../../images/2/6e.png) @@ -110,7 +116,9 @@ Kohteena on siis komponentin määrittelemä lomake. Miten pääsemme käsiksi lomakkeen input-komponenttiin syötettyyn dataan? -Tapoja on useampia, tutustumme ensin ns. [kontrolloituina komponentteina](https://reactjs.org/docs/forms.html#controlled-components) toteutettuihin lomakkeisiin. +### Kontrolloitu komponentti + +Tapoja on useampia, joista tutustumme ensin [kontrolloituina komponentteina](https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable) toteutettuihin lomakkeisiin. Lisätään komponentille App tila newNote lomakkeen syötettä varten **ja** määritellään se input-komponentin attribuutin value arvoksi: @@ -132,8 +140,8 @@ const App = (props) => {

    Notes

      - {notes.map((note, i) => - + {notes.map(note => + )}
    @@ -145,11 +153,11 @@ const App = (props) => { } ``` -Tilaan newNote määritelty "placeholder"-teksti uusi muistiinpano... ilmestyy syötekomponenttiin, tekstiä ei kuitenkaan voi muuttaa. Konsoliin tuleekin ikävä varoitus joka kertoo mistä on kyse +Tilaan newNote määritelty "placeholder"-teksti a new note... ilmestyy syötekomponenttiin, mutta tekstiä ei voi muuttaa. Konsoliin tuleekin ikävä varoitus joka kertoo mistä on kyse: ![](../../images/2/7e.png) -Koska määrittelimme syötekomponentille value-attribuutiksi komponentin App tilassa olevan muuttujan, alkaa App [kontrolloimaan](https://reactjs.org/docs/forms.html#controlled-components) syötekomponentin toimintaa. +Koska määrittelimme syötekomponentille value-attribuutiksi komponentin App tilassa olevan muuttujan, alkaa App [kontrolloimaan](https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variables) syötekomponentin toimintaa. Jotta kontrolloidun syötekomponentin editoiminen olisi mahdollista, täytyy sille rekisteröidä tapahtumankäsittelijä, joka synkronoi syötekenttään tehdyt muutokset komponentin App tilaan: @@ -173,8 +181,8 @@ const App = (props) => {

    Notes

      - {notes.map((note, i) => - + {notes.map(note => + )}
    @@ -198,7 +206,7 @@ Lomakkeen input-komponentille on nyt rekisteröity tapahtumankäsittelij /> ``` -Tapahtumankäsittelijää kutsutaan aina kun syötekomponentissa tapahtuu jotain. Tapahtumankäsittelijämetodi saa parametriksi tapahtumaolion event +Tapahtumankäsittelijää kutsutaan aina kun syötekomponentissa tapahtuu jotain. Tapahtumankäsittelijämetodi saa parametriksi tapahtumaolion event. ```js const handleNoteChange = (event) => { @@ -209,13 +217,13 @@ const handleNoteChange = (event) => { Tapahtumaolion kenttä target vastaa nyt kontrolloitua input-kenttää ja event.target.value viittaa inputin syötekentän arvoon. -Huomaa, että toisin kuin lomakkeen lähettämistä vastaavan tapahtuman onSubmit käsittelijässä, nyt oletusarvoisen toiminnan estävää metodikutusua _event.preventDefault()_ ei tarvita, sillä syötekentän muutoksella ei ole oletusarvoista toimintaa toisin kuin lomakkeen lähettämisellä. +Huomaa, että toisin kuin lomakkeen lähettämistä vastaavan tapahtuman onSubmit käsittelijässä, nyt oletusarvoisen toiminnan estävää metodikutsua _event.preventDefault()_ ei tarvita, sillä syötekentän muutoksella ei ole oletusarvoista toimintaa toisin kuin lomakkeen lähettämisellä. Voit seurata konsolista miten tapahtumankäsittelijää kutsutaan: ![](../../images/2/8e.png) -Muistithan jo asentaa [React devtoolsin](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi)? Devtoolsista näet, miten tila muuttuu syötekenttään kirjoitettaessa: +Olethan jo asentanut [React Developer Toolsin](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi)? Developer Toolsista näet, miten tila muuttuu syötekenttään kirjoitettaessa: ![](../../images/2/9ea.png) @@ -226,9 +234,8 @@ const addNote = (event) => { event.preventDefault() const noteObject = { content: newNote, - date: new Date().toISOString(), important: Math.random() > 0.5, - id: notes.length + 1, + id: String(notes.length + 1), } setNotes(notes.concat(noteObject)) @@ -236,29 +243,29 @@ const addNote = (event) => { } ``` -Ensin luodaan uutta muistiinpanoa vastaava olio noteObject, jonka sisältökentän arvo saadaan komponentin tilasta newNote. Yksikäsitteinen tunnus eli id generoidaan kaikkien muistiinpanojen lukumäärän perusteella. Koska muistiinpanoja ei poisteta, menetelmä toimii sovelluksessamme. Komennon Math.random() avulla muistiinpanosta tulee 50% todennäköisyydellä tärkeä. +Ensin luodaan uutta muistiinpanoa vastaava olio noteObject, jonka sisältökentän arvo saadaan komponentin tilasta newNote. Yksikäsitteinen tunnus eli id generoidaan kaikkien muistiinpanojen lukumäärän perusteella. Koska muistiinpanoja ei poisteta, menetelmä toimii sovelluksessamme. Komennon Math.random() avulla muistiinpanosta tulee 50 %:n todennäköisyydellä tärkeä. -Uusi muistiinpano lisätään vanhojen joukkoon oikeaoppisesti käyttämällä [osasta 1](/osa1/javascriptia#taulukot) tuttua taulukon metodia [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat): +Uusi muistiinpano lisätään vanhojen joukkoon oikeaoppisesti käyttämällä [osasta 1](/osa1/java_scriptia#taulukot) tuttua taulukon metodia [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat): ```js setNotes(notes.concat(noteObject)) ``` -Metodi ei muuta alkuperäistä tilaa notes vaan luo uuden taulukon, joka sisältää myös lisättävän alkion. Tämä on tärkeää, sillä Reactin tilaa [ei saa muuttaa suoraan](https://reactjs.org/docs/state-and-lifecycle.html#using-state-correctly)! +Metodi ei muuta alkuperäistä tilaa notes vaan luo uuden taulukon, joka sisältää myös lisättävän alkion. Tämä on tärkeää, sillä Reactin tilaa [ei saa muuttaa suoraan](https://react.dev/learn/updating-objects-in-state#why-is-mutating-state-not-recommended-in-react)! -Tapahtumankäsittelijä tyhjentää myös syötekenttää kontrolloivan tilan newNote sen funktiolla setNewNote +Tapahtumankäsittelijä tyhjentää myös syötekenttää kontrolloivan tilan newNote sen funktiolla setNewNote. ```js setNewNote('') ``` -Sovelluksen tämän hetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part2-notes/tree/part2-2), branchissä part2-2. +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-2), branchissä part2-2. ### Näytettävien elementtien filtteröinti Tehdään sovellukseen toiminto, joka mahdollistaa ainoastaan tärkeiden muistiinpanojen näyttämisen. -Lisätään komponentin App tilaan tieto siitä näytetäänkö muistiinpanoista kaikki vai ainoastaan tärkeät: +Lisätään komponentin App tilaan tieto siitä, näytetäänkö muistiinpanoista kaikki vai ainoastaan tärkeät: ```js const App = (props) => { @@ -270,10 +277,10 @@ const App = (props) => { } ``` -Muutetaan komponenttia siten, että se tallettaa muuttujaan notesToShow näytettävien muistiinpanojen listan riippuen siitä tuleeko näyttää kaikki vai vain tärkeät: +Muutetaan komponenttia siten, että se tallettaa muuttujaan notesToShow näytettävien muistiinpanojen listan riippuen siitä, tuleeko näyttää kaikki vai vain tärkeät: ```js -import React, { useState } from 'react' +import { useState } from 'react' import Note from './components/Note' const App = (props) => { @@ -293,8 +300,8 @@ const App = (props) => {

    Notes

      - {notesToShow.map((note, i) => // highlight-line - + {notesToShow.map(note => // highlight-line + )}
    // ... @@ -303,7 +310,7 @@ const App = (props) => { } ``` -Muuttujan notesToShow määrittely on melko kompakti +Muuttujan notesToShow määrittely on melko kompakti: ```js const notesToShow = showAll @@ -313,7 +320,7 @@ const notesToShow = showAll Käytössä on monissa muissakin kielissä oleva [ehdollinen](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator) operaattori. -Operaattori toimii seuraavasti. Jos meillä on esim: +Lausekkeella ```js const tulos = ehto ? val1 : val2 @@ -321,28 +328,26 @@ const tulos = ehto ? val1 : val2 muuttujan tulos arvoksi asetetaan val1:n arvo jos ehto on tosi. Jos ehto ei ole tosi, muuttujan tulos arvoksi tulee val2:n arvo. -Eli jos tilan arvo showAll on epätosi, muuttuja notesToShow saa arvokseen vaan ne muistiinpanot, joiden important-kentän arvo on tosi. Filtteröinti tapahtuu taulukon metodilla [filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter): +Eli jos tilan arvo showAll on epätosi, muuttuja notesToShow saa arvokseen vain ne muistiinpanot, joiden important-kentän arvo on tosi. Filtteröinti tapahtuu taulukon metodilla [filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter): ```js notes.filter(note => note.important === true) ``` -vertailu-operaatio on oikeastaan turha, koska note.important on arvoltaan joko true tai false, eli riittää kirjoittaa +Vertailuoperaatio on oikeastaan turha. Koska note.important on arvoltaan joko true tai false, riittää kun kirjoitamme: ```js notes.filter(note => note.important) ``` -Tässä käytettiin kuitenkin ensin vertailuoperaattoria, mm. korostamaan erästä tärkeää seikkaa: Javascriptissa arvo1 == arvo2 ei toimi kaikissa tilanteissa loogisesti ja onkin varmempi käyttää aina vertailuissa muotoa arvo1 === arvo2. Enemmän aiheesta [täällä](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness). - -Filtteröinnin toimivuutta voi jo nyt kokeilla vaihtelemalla sitä, miten tilan kentän showAll alkuarvo määritellään konstruktorissa. +Tässä käytettiin kuitenkin ensin vertailuoperaattoria mm. korostamaan erästä tärkeää seikkaa: JavaScriptissa arvo1 == arvo2 ei toimi kaikissa tilanteissa loogisesti ja onkin varmempi käyttää aina vertailuissa muotoa arvo1 === arvo2. Enemmän aiheesta on [täällä](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness). -Lisätään sitten toiminnallisuus, joka mahdollistaa showAll:in tilan muuttamisen sovelluksesta. +Filtteröinnin toimivuutta voi jo nyt kokeilla vaihtelemalla sitä, miten tilan kentän showAll alkuarvo määritellään funktion useState parametrina. -Oleelliset muutokset ovat seuraavassa: +Lisätään sitten toiminnallisuus, joka mahdollistaa showAll:in tilan muuttamisen sovelluksesta: ```js -import React, { useState } from 'react' +import { useState } from 'react' import Note from './components/Note' const App = (props) => { @@ -367,8 +372,8 @@ const App = (props) => {
    // highlight-end
      - {notesToShow.map((note, i) => // highlight-line - + {notesToShow.map(note => + )}
    // ... @@ -377,7 +382,7 @@ const App = (props) => { } ``` -Näkyviä muistiinpanoja (kaikki vai ainoastaan tärkeät) siis kontrolloidaan napin avulla. Napin tapahtumankäsittelijä on niin yksinkertainen että se on kirjotettu suoraan napin attribuutiksi. Tapahtumankäsittelijä muuttaa _showAll_:n arvon truesta falseksi ja päinvastoin: +Näkyviä muistiinpanoja (kaikki vai ainoastaan tärkeät) siis kontrolloidaan napin avulla. Napin tapahtumankäsittelijä on niin yksinkertainen, että se on kirjoitettu suoraan napin attribuutiksi. Tapahtumankäsittelijä muuttaa _showAll_:n arvon truesta falseksi ja päinvastoin: ```js () => setShowAll(!showAll) @@ -389,7 +394,7 @@ Napin teksti riippuu tilan showAll arvosta: show {showAll ? 'important' : 'all' } ``` -Sovelluksen tämän hetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part2-notes/tree/part2-3), branchissa part2-3. +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-3) branchissa part2-3.
    @@ -399,24 +404,22 @@ Sovelluksen tämän hetkinen koodi on kokonaisuudessaan [githubissa](https://git Seuraavassa tehtävässä aloitettavaa ohjelmaa kehitellään eteenpäin muutamassa seuraavassa tehtävässä. Tässä ja kurssin aikana muissakin vastaantulevissa tehtäväsarjoissa ohjelman lopullisen version palauttaminen riittää, voit toki halutessasi tehdä commitin jokaisen tehtävän jälkeisestä tilanteesta, mutta se ei ole välttämätöntä. -**VAROITUS** create-react-app tekee projektista automaattisesti git-repositorion, ellei sovellusta luoda jo olemassaolevan repositorion sisälle. Todennäköisesti **et halua** että projektista tulee repositorio, joten suorita projektin juuressa komento _rm -rf .git_. -

    2.6: puhelinluettelo step1

    -Toteutetaan yksinkertainen puhelinluettelo. **Aluksi luetteloon lisätään vaan nimiä.** +Toteutetaan yksinkertainen puhelinluettelo. **Aluksi luetteloon lisätään vain nimiä.** Toteutetaan tässä tehtävässä henkilön lisäys puhelinluetteloon. Voit ottaa sovelluksesi komponentin App pohjaksi seuraavan: ```js -import React, { useState } from 'react' +import { useState } from 'react' const App = () => { - const [ persons, setPersons] = useState([ + const [persons, setPersons] = useState([ { name: 'Arto Hellas' } ]) - const [ newName, setNewName ] = useState('') + const [newName, setNewName] = useState('') return (
    @@ -441,24 +444,24 @@ export default App Tila newName on tarkoitettu lomakkeen kentän kontrollointiin. -Joskus tilaa tallettavia ja tarvittaessa muitakin muuttujia voi olla hyödyllistä renderöidä debugatessa komponenttiin, eli voi tilapäisesti lisätä komponentin palauttamaan koodiin esim. seuraavan: +Joskus tilaa tallettavia ja tarvittaessa muitakin muuttujia voi olla hyödyllistä renderöidä debugatessa komponenttiin, eli voit tilapäisesti lisätä komponentin palauttamaan koodiin esim. seuraavan: -``` +```js
    debug: {newName}
    ``` -Muista myös osan 1 luku [React-sovellusten debuggaus](/osa1/monimutkaisempi_tila_reactin_debuggaus#react-sovellusten-debuggaus), erityisesti [react developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) on välillä todella kätevä komponentin tilan muutosten seuraamisessa. +Muista myös osan 1 luku [React-sovellusten debuggaus](/osa1/monimutkaisempi_tila_reactin_debuggaus#react-sovellusten-debuggaus), erityisesti [React Developer Tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) on välillä todella kätevä komponentin tilan muutosten seuraamisessa. Sovellus voi näyttää tässä vaiheessa seuraavalta: ![](../../images/2/10ea.png) -Huomaa, React developer toolsin käyttö! +Huomaa React Developer Toolsin käyttö! **Huom:** - voit käyttää kentän key arvona henkilön nimeä -- muista estää lomakkeen lähetyksen oletusarvoinen toiminta! +- muista estää lomakkeen lähetyksen oletusarvoinen toiminta

    2.7: puhelinluettelo step2

    @@ -468,7 +471,7 @@ Anna tilanteessa virheilmoitus komennolla [alert](https://developer.mozilla.org/ ![](../../images/2/11e.png) -**Muistutus edellisestä osasta:** kun muodostat Javascriptissä merkkijonoja muuttujaan perustuen, on tyylikkäin tapa asian hoitamiseen [template string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals): +**Muistutus edellisestä osasta:** kun muodostat JavaScriptissä muuttujaan perustuvan merkkijonon, tyylikkäin tapa asian hoitamiseen on [template string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals): ```js `${newName} is already added to phonebook` @@ -480,14 +483,12 @@ Jos muuttujalla newName on arvona Arto Hellas, on tuloksena merk `Arto Hellas is already added to phonebook` ``` -Sama toki hoituisi javamaisesti merkkijonojen plus-metodilla +Template stringin käyttö antaa ammattimaisen vaikutelman, vaikka sama toki hoituisi javamaisesti myös merkkijonojen plus-metodilla: ```js newName + ' is already added to phonebook' ``` -Template stringin käyttö antaa kuitenkin ammattimaisemman vaikutelman. -

    2.8: puhelinluettelo step3

    Lisää sovellukseen mahdollisuus antaa henkilöille puhelinnumero. Tarvitset siis lomakkeeseen myös toisen input-elementin (ja sille oman muutoksenkäsittelijän): @@ -500,7 +501,7 @@ Lisää sovellukseen mahdollisuus antaa henkilöille puhelinnumero. Tarvitset si ``` -Sovellus voi näyttää tässä vaiheessa seuraavalta. Kuvassa myös [react developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi):in tarjoama näkymä komponentin App tilaan: +Sovellus voi näyttää tässä vaiheessa seuraavalta. Kuvassa myös [React Developer Tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi):in tarjoama näkymä komponentin App tilaan: ![](../../images/2/12ea.png) @@ -510,9 +511,9 @@ Tee lomakkeeseen hakukenttä, jonka avulla näytettävien nimien listaa voidaan ![](../../images/2/13e.png) -Rajausehdon syöttämisen voi hoitaa omana lomakkeeseen kuulumattomana input-elementtinä. Kuvassa rajausehdosta on tehty caseinsensitiivinen eli ehto arto löytää isolla kirjaimella kirjoitetun Arton. +Rajausehdon syöttämisen voi hoitaa omana lomakkeeseen kuulumattomana input-elementtinä. Kuvassa rajausehdosta on tehty case-insensitiivinen eli ehto arto löytää isolla kirjaimella kirjoitetun Arton. -**Huom:** Kun toteutat jotain uutta toiminnallisuutta, on usein hyötyä 'kovakoodata' sovellukseen jotain sisältöä, esim. +**Huom:** Testiaineiston syöttäminen manuaalisesti selainta käyttäen on useimmiten turhaa manuaalista työtä. Yleensä on järkevämpää 'kovakoodata' sovellukseen jotain testidataa: ```js const App = () => { @@ -527,15 +528,13 @@ const App = () => { } ``` -Näin vältytään turhalta manuaaliselta työltä, missä testaaminen edellyttäisi myös testiaineiston syöttämistä käsin sovelluksen lomakkeen kautta. -

    2.10: puhelinluettelo step5

    Jos koko sovelluksesi on tehty yhteen komponenttiin, refaktoroi sitä eriyttämällä sopivia komponentteja. Pidä kuitenkin edelleen kaikki tila- sekä tapahtumankäsittelijäfunktiot juurikomponentissa App. -Riittää että erotat sovelluksesta **kolme** komponenttia. Hyviä kandidaatteja ovat esim. filtteröintilomake, uuden henkilön lisäävä lomake, kaikki henkilöt renderöivä komponentti sekä yksittäisen henkilön renderöivä komponentti. +Riittää että erotat sovelluksesta **kolme** komponenttia. Hyviä kandidaatteja ovat filtteröintilomake, uuden henkilön lisäävä lomake, kaikki henkilöt renderöivä komponentti sekä yksittäisen henkilön renderöivä komponentti. -Sovelluksen juurikomponentti voi näyttää refaktoroinnin jälkeen suunnilleen seuraavalta, eli se ei itse renderöi suoraan oikeastaan mitään muita kuin otsikkoja: +Sovelluksen juurikomponentin ei tarvitse refaktoroinnin jälkeen renderöidä suoraan muuta kuin otsikoita. Komponentti voi näyttää suunnilleen seuraavalta: ```js const App = () => { @@ -561,6 +560,6 @@ const App = () => { } ``` -**HUOM**: saatat törmätä ongelmiin tässä tehtävässä, jos määrittelet komponentteja "väärässä paikassa", nyt kannattaakin ehdottomasti kerrata edellisen osan luku [älä määrittele komponenttia komponentin sisällä](/osa1/monimutkaisempi_tila_reactin_debuggaus#ala-maarittele-komponenttia-komponentin-sisalla). +**HUOM**: Saatat törmätä ongelmiin jos määrittelet komponentteja "väärässä paikassa". Nyt kannattaakin ehdottomasti kerrata edellisen osan luku [älä määrittele komponenttia komponentin sisällä](/osa1/monimutkaisempi_tila_reactin_debuggaus#ala-maarittele-komponenttia-komponentin-sisalla).
    diff --git a/src/content/2/fi/osa2c.md b/src/content/2/fi/osa2c.md index 4447096b66e..ba88e4fcc5c 100644 --- a/src/content/2/fi/osa2c.md +++ b/src/content/2/fi/osa2c.md @@ -7,69 +7,63 @@ lang: fi
    -Olemme nyt viipyneet tovin keskittyen pelkkään "frontendiin", eli selainpuolen toiminnallisuuteen. Rupeamme itse toteuttamaan "backendin", eli palvelinpuolen toiminnallisuutta vasta kurssin kolmannessa osassa, mutta otamme nyt jo askeleen sinne suuntaan tutustumalla siihen, miten selaimessa suoritettava koodi kommunikoi backendin kanssa. +Olemme nyt viipyneet tovin keskittyen pelkkään frontendiin eli selainpuolen toiminnallisuuteen. Rupeamme itse toteuttamaan backendin eli palvelinpuolen toiminnallisuutta vasta kurssin kolmannessa osassa, mutta tutustumme jo nyt siihen, miten selaimessa suoritettava koodi kommunikoi backendin kanssa. Käytetään nyt palvelimena sovelluskehitykseen tarkoitettua [JSON Serveriä](https://github.com/typicode/json-server). -Tehdään projektin juurihakemistoon tiedosto db.json, jolla on seuraava sisältö: +Tehdään projektin juurihakemistoon tiedosto db.json: ```json { "notes": [ { - "id": 1, + "id": "1", "content": "HTML is easy", - "date": "2020-01-10T17:30:31.098Z", "important": true }, { - "id": 2, - "content": "Browser can execute only Javascript", - "date": "2020-01-10T18:39:34.091Z", + "id": "2", + "content": "Browser can execute only JavaScript", "important": false }, { - "id": 3, + "id": "3", "content": "GET and POST are the most important methods of HTTP protocol", - "date": "2020-01-10T19:20:14.298Z", "important": true } ] } ``` -JSON server on mahdollista [asentaa](https://github.com/typicode/json-server#install) koneelle ns. globaalisti komennolla _npm install -g json-server_. Globaali asennus edellyttää kuitenkin pääkäyttäjän oikeuksia, eli se ei ole mahdollista laitoksen koneilla tai uusilla fuksiläppäreillä. +JSON Serverin voi käynnistää ilman erillistä asennusta suorittamalla seuraavan _npx_-komennon sovelluksen juurihakemistossa: -Globaali asennus ei kuitenkaan ole tarpeen, voimme käynnistää json-serverin komennon _npx_ avulla: - -```js -npx json-server --port=3001 --watch db.json +```bash +npx json-server --port 3001 db.json ``` -Oletusarvoisesti json-server käynnistyy porttiin 3000, mutta create-react-app:illa luodut projektit varaavat portin 3000, joten joudumme nyt määrittelemään json-serverille vaihtoehtoisen portin 3001. +Oletusarvoisesti JSON Server käynnistyy porttiin 3000. Käytämme nyt kuitenkin porttia 3001. -Mennään selaimella osoitteeseen . Kuten huomaamme, json-server tarjoaa osoitteessa tiedostoon tallentamamme muistiinpanot JSON-muodossa: +Mennään selaimella osoitteeseen . Kuten huomaamme, JSON Server tarjoaa osoitteessa tiedostoon tallentamamme muistiinpanot JSON-muodossa: -![](../../images/2/14ea.png) +![](../../images/2/14new.png) -Jos selaimesi ei osaa näyttää JSON-muotoista dataa formatoituna, asenna jokin sopiva plugin, esim. [JSONView](https://chrome.google.com/webstore/detail/jsonview/chklaanhfefbnpoihckbnefhakgolnmc) -helpottamaan elämääsi. +Jos selaimesi ei osaa näyttää JSON-muotoista dataa formatoituna, asenna jokin sopiva plugin, esim. [JSONView](https://chromewebstore.google.com/detail/gmegofmjomhknnokphhckolhcffdaihd) helpottamaan elämääsi. -Ideana jatkossa onkin se, että muistiinpanot talletetaan palvelimelle, eli tässä vaiheessa json-serverille. React-koodi hakee muistiinpanot palvelimelta ja renderöi ne ruudulle. Kun sovellukseen lisätään uusi muistiinpano, React-koodi lähettää sen myös palvelimelle, jotta uudet muistiinpanot jäävät pysyvästi "muistiin". +Jatkossa ideana onkin se, että muistiinpanot talletetaan palvelimelle eli tässä vaiheessa JSON Serverille. React-koodi hakee muistiinpanot palvelimelta ja renderöi ne ruudulle. Kun sovellukseen lisätään uusi muistiinpano, React-koodi lähettää sen myös palvelimelle, jotta uudet muistiinpanot jäävät pysyvästi "muistiin". -json-server tallettaa kaiken datan palvelimella sijaitsevaan tiedostoon db.json. Todellisuudessa data tullaan tallentamaan johonkin tietokantaan. json-server on kuitenkin käyttökelpoinen apuväline, joka mahdollistaa palvelinpuolen toiminnallisuuden käyttämisen kehitysvaiheessa ilman tarvetta itse ohjelmoida mitään. +JSON Server tallettaa kaiken datan palvelimella sijaitsevaan tiedostoon db.json. Todellisuudessa data tullaan tallentamaan johonkin tietokantaan. JSON Server on kuitenkin käyttökelpoinen apuväline, joka mahdollistaa palvelinpuolen toiminnallisuuden käyttämisen kehitysvaiheessa ilman tarvetta itse ohjelmoida mitään. Tutustumme palvelinpuolen toteuttamisen periaatteisiin tarkemmin kurssin [osassa 3](/osa3). ### Selain suoritusympäristönä -Ensimmäisenä tehtävänämme on siis hakea React-sovellukseen jo olemassaolevat mustiinpanot osoitteesta . +Ensimmäisenä tehtävänämme on siis hakea React-sovellukseen jo olemassa olevat muistiinpanot osoitteesta . -Osan 0 [esimerkkiprojektissa](/osa0/web_sovelluksen_toimintaperiaatteita#selaimessa-suoritettava-sovelluslogiikka) nähtiin jo eräs tapa hakea Javascript-koodista palvelimella olevaa dataa. Esimerkin koodissa data haettiin [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest)- eli XHR-olion avulla muodostetulla HTTP-pyynnöllä. Kyseessä on vuonna 1999 lanseerattu tekniikka, jota kaikki web-selaimet ovat jo pitkään tukeneet. +Osan 0 [esimerkkiprojektissa](/osa0/web_sovelluksen_toimintaperiaatteita#selaimessa-suoritettava-sovelluslogiikka) nähtiin jo eräs tapa hakea palvelimella olevaa dataa. Esimerkissä data haettiin [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest)- eli XHR-olion avulla muodostetulla HTTP-pyynnöllä. Kyseessä on vuonna 1999 lanseerattu tekniikka, jota kaikki web-selaimet ovat jo pitkään tukeneet. -Nykyään XHR:ää ei kuitenkaan kannata käyttää ja selaimet tukevatkin jo laajasti [fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch)-metodia, joka perustuu XHR:n käyttämän tapahtumapohjaisen mallin sijaan ns. [promiseihin](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise). +Nykyään XHR:ää ei kuitenkaan kannata käyttää, ja selaimet tukevatkin jo laajasti [fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch)-metodia, joka perustuu XHR:n käyttämän tapahtumapohjaisen mallin sijaan ns. [promiseihin](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise). -Muistutuksena edellisestä osasta (oikeastaan tätä tapaa pitää lähinnä muistaa olla käyttämättä ilman painavaa syytä), XHR:llä haettiin dataa seuraavasti +Muistutuksena edellisestä osasta (oikeastaan tätä tapaa pitää lähinnä muistaa olla käyttämättä ilman painavaa syytä), data haettiin XHR:llä seuraavasti: ```js const xhttp = new XMLHttpRequest() @@ -85,16 +79,16 @@ xhttp.open('GET', '/data.json', true) xhttp.send() ``` -Heti alussa HTTP-pyyntöä vastaavalle xhttp-oliolle rekisteröidään tapahtumankäsittelijä, jota Javascript runtime kutsuu kun xhttp-olion tila muuttuu. Jos tilanmuutos tarkoittaa että pyynnön vastaus on saapunut, käsitellään data halutulla tavalla. +Heti alussa HTTP-pyyntöä vastaavalle xhttp-oliolle rekisteröidään tapahtumankäsittelijä, jota JavaScript Runtime kutsuu xhttp-olion tilan muuttuessa. Jos pyynnön vastaus on saapunut, data käsitellään halutulla tavalla. -Huomionarvoista on se, että tapahtumankäsittelijän koodi on määritelty jo ennen kun itse pyyntö lähetetään palvelimelle. Tapahtumankäsittelijäfunktio tullaan kuitenkin suorittamaan vasta jossain myöhäisemmässä vaiheessa. Koodin suoritus ei siis etene synkronisesti "ylhäältä alas", vaan asynkronisesti, Javascript kutsuu sille rekisteröityä tapahtumankäsittelijäfunktiota jossain vaiheessa. +Huomionarvoista on se, että tapahtumankäsittelijän koodi on määritelty jo ennen kuin itse pyyntö lähetetään palvelimelle. Tapahtumankäsittelijäfunktio tullaan kuitenkin suorittamaan vasta jossain myöhäisemmässä vaiheessa. Koodin suoritus ei siis etene synkronisesti "ylhäältä alas", vaan JavaScript kutsuu sille rekisteröityä tapahtumankäsittelijäfunktiota jossain vaiheessa asynkronisesti. Esim. Java-ohjelmoinnista tuttu synkroninen tapa tehdä kyselyjä etenisi seuraavaan tapaan (huomaa että kyse ei ole oikeasti toimivasta Java-koodista): ```java HTTPRequest request = new HTTPRequest(); -String url = "https://fullstack-exampleapp.herokuapp.com/data.json"; +String url = "https://studies.cs.helsinki.fi/exampleapp/data.json"; List notes = request.get(url); notes.forEach(m => { @@ -102,32 +96,32 @@ notes.forEach(m => { }); ``` -Javassa koodi etenee nyt rivi riviltä ja koodi pysähtyy odottamaan HTTP-pyynnön, eli komennon _request.get(...)_ valmistumista. Komennon palauttama data, eli muistiinpanot talletetaan muuttujaan ja dataa aletaan käsittelemään halutulla tavalla. +Javassa koodi etenee nyt rivi riviltä ja koodi pysähtyy odottamaan HTTP-pyynnön eli komennon _request.get(...)_ valmistumista. Komennon palauttama data eli muistiinpanot talletetaan muuttujaan ja dataa aletaan käsittelemään halutulla tavalla. -Javascript-enginet eli suoritusympäristöt kuitenkin noudattavat [asynkronista mallia](https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop), eli periaatteena on se, että kaikki [IO-operaatiot](https://en.wikipedia.org/wiki/Input/output) (poislukien muutama poikkeus) suoritetaan ei-blokkaavana, eli operaatioiden tulosta ei jäädä odottamaan vaan koodin suoritusta jatketaan heti eteenpäin. +JavaScript-enginet eli suoritusympäristöt kuitenkin noudattavat [asynkronista mallia](https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop), eli periaatteena on, että kaikki [IO-operaatiot](https://en.wikipedia.org/wiki/Input/output) (poislukien muutama poikkeus) suoritetaan ei-blokkaavana, eli operaatioiden tulosta ei jäädä odottamaan vaan koodin suoritusta jatketaan heti eteenpäin. -Siinä vaiheessa kun operaatio valmistuu tai tarkemmin sanoen jonain valmistumisen jälkeisenä ajanhetkenä, kutsuu Javascript-engine operaatiolle rekisteröityjä tapahtumankäsittelijöitä. +Siinä vaiheessa kun operaatio valmistuu tai tarkemmin sanoen jonain valmistumisen jälkeisenä ajanhetkenä, JavaScript-engine kutsuu operaatiolle rekisteröityjä tapahtumankäsittelijöitä. -Nykyisellään Javascript-moottorit ovat yksisäikeisiä eli ne eivät voi suorittaa rinnakkaista koodia. Tämän takia on käytännössä pakko käyttää ei-blokkaavaa mallia IO-operaatioiden suorittamiseen, sillä muuten selain 'jäätyisi' siksi aikaa kun esim. palvelimelta haetaan dataa. +Nykyisellään JavaScript-enginet ovat yksisäikeisiä eli ne eivät voi suorittaa rinnakkaista koodia. Tämän takia on käytännössä pakko käyttää ei-blokkaavaa mallia IO-operaatioiden suorittamiseen, sillä muuten selain 'jäätyisi' esim. silloin kun palvelimelta haetaan dataa. -Javascript-moottoreiden yksisäikeisyydellä on myös sellainen seuraus, että jos koodin suoritus kestää erittäin pitkään, menee selain jumiin suorituksen ajaksi. Jos lisätään sovelluksen alkuun seuraava koodi: +JavaScript-engineiden yksisäikeisyydellä on myös sellainen seuraus, että jos koodin suoritus kestää pitkään, selain menee jumiin suorituksen ajaksi. Jos lisätään sovelluksen alkuun seuraava koodi: ```js setTimeout(() => { console.log('loop..') let i = 0 - while (i < 50000000000) { + while (i < 99999999999) { i++ } console.log('end') }, 5000) ``` -Kaikki toimii 5 sekunnin ajan normaalisti. Kun setTimeout:in parametrina määritelty funktio suoritetaan, menee selaimen sivu jumiin pitkän loopin suorituksen ajaksi. Ainakaan Chromessa selaimen tabia ei pysty edes sulkemaan loopin suorituksen aikana. +Kaikki toimii viiden sekunnin ajan normaalisti. Kun setTimeout:in parametrina määritelty funktio suoritetaan, menee selaimen sivu jumiin pitkän loopin suorituksen ajaksi. Ainakaan Chromessa selaimen tabia ei pysty edes sulkemaan loopin suorituksen aikana. -Eli jotta selain säilyy responsiivisena, eli että se reagoi koko ajan riittävän nopeasti käyttäjän haluamiin toimenpiteisiin, koodin logiikan tulee olla sellainen, että yksittäinen laskenta ei saa kestää liian kauaa. +Eli jotta selain säilyy responsiivisena eli että se reagoi koko ajan riittävän nopeasti käyttäjän haluamiin toimenpiteisiin, koodin logiikan tulee olla sellainen, että yksittäinen laskenta ei kestä liian kauan. -Aiheesta löytyy paljon lisämateriaalia internetistä, eräs varsin havainnollinen esitys aiheesta Philip Robertsin esitelmä [What the heck is the event loop anyway?](https://www.youtube.com/watch?v=8aGhZQkoFbQ) +Aiheesta löytyy paljon lisämateriaalia Internetistä. Philip Robertsin esitelmä [What the heck is the event loop anyway?](https://www.youtube.com/watch?v=8aGhZQkoFbQ) on varsin havainnollinen esitys. Nykyään selaimissa on mahdollisuus suorittaa myös rinnakkaista koodia ns. [web workerien](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) avulla. Yksittäisen selainikkunan koodin ns. event loopista huolehtii kuitenkin edelleen [vain yksi säie](https://medium.com/techtrument/multithreading-javascript-46156179cf9a). @@ -139,77 +133,75 @@ Voisimme käyttää datan palvelimelta hakemiseen aiemmin mainittua promiseihin Käytetään selaimen ja palvelimen väliseen kommunikaatioon kuitenkin [axios](https://github.com/axios/axios)-kirjastoa, joka toimii samaan tapaan kuin fetch, mutta on hieman mukavampikäyttöinen. Hyvä syy axios:in käytölle on myös se, että pääsemme tutustumaan siihen miten ulkopuolisia kirjastoja eli npm-paketteja liitetään React-projektiin. -Nykyään lähes kaikki Javascript-projektit määritellään node "pakkausmanagerin" eli [npm](https://docs.npmjs.com/getting-started/what-is-npm):n avulla. Myös create-react-app:in avulla generoidut projektit ovat npm-muotoisia projekteja. Varma tuntomerkki siitä on projektin juuressa oleva tiedosto package.json: +Nykyään lähes kaikki JavaScript-projektit määritellään node "pakkausmanagerin" eli [npm](https://docs.npmjs.com/getting-started/what-is-npm):n avulla. Myös Viten avulla generoidut projektit ovat npm-muotoisia projekteja. Varma tuntomerkki siitä on projektin juuressa oleva tiedosto package.json: ```json { - "name": "notes", - "version": "0.1.0", + "name": "part2-notes-frontend", "private": true, - "dependencies": { - "@testing-library/jest-dom": "^4.2.4", - "@testing-library/react": "^9.4.0", - "@testing-library/user-event": "^7.2.1", - "react": "^16.12.0", - "react-dom": "^16.12.0", - "react-scripts": "3.3.0" - }, + "version": "0.0.0", + "type": "module", "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject" + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" }, - "eslintConfig": { - "extends": "react-app" + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.17.0", + "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.16", + "globals": "^15.14.0", + "vite": "^6.0.5" } } ``` Tässä vaiheessa meitä kiinnostaa osa dependencies, joka määrittelee mitä riippuvuuksia eli ulkoisia kirjastoja projektilla on. -Haluamme nyt käyttöömme axioksen. Voisimme määritellä kirjaston suoraan tiedostoon package.json, mutta on parempi asentaa se komentoriviltä +Voisimme määritellä Axios-kirjaston suoraan tiedostoon package.json, mutta on parempi asentaa se komentoriviltä. **Huomaa, että _npm_-komennot tulee antaa aina projektin juurihakemistossa** eli siinä, jossa tiedosto package.json on. -```js -npm install axios --save +```bash +npm install axios ``` -**Huomaa, että _npm_-komennot tulee antaa aina projektin juurihakemistossa**, eli siinä minkä sisältä tiedosto package.json_ löytyy. - -Nyt axios on mukana riippuvuuksien joukossa: +Nyt Axios on mukana riippuvuuksien joukossa: ```json { + "name": "part2-notes-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, "dependencies": { - "@testing-library/jest-dom": "^4.2.4", - "@testing-library/react": "^9.4.0", - "@testing-library/user-event": "^7.2.1", - "axios": "^0.19.1", // highlight-line - "react": "^16.12.0", - "react-dom": "^16.12.0", - "react-scripts": "3.3.0" + "axios": "^1.7.9", // highlight-line + "react": "^18.3.1", + "react-dom": "^18.3.1" }, // ... } ``` -Sen lisäksi, että komento npm install lisäsi axiosin riippuvuuksien joukkoon, se myös latasi kirjaston koodin. Koodi löytyy muiden riippuvuuksien tapaan projektin juuren hakemistosta node_modules, mikä kuten huomata saattaa sisältääkin runsaasti kaikenlaista. +Sen lisäksi, että komento npm install lisäsi haluamamme kirjaston riippuvuuksien joukkoon, se myös latasi kirjaston koodin. Koodi löytyy muiden riippuvuuksien tapaan projektin juuren hakemistosta node_modules, mikä kuten huomata saattaa sisältääkin runsaasti kaikenlaista. -Tehdään toinenkin pieni lisäys. Asennetaan myös json-server projektin sovelluskehityksen aikaiseksi riippuvuudeksi komennolla +Tehdään toinenkin pieni lisäys. Asennetaan myös JSON Server projektin sovelluskehityksen aikaiseksi riippuvuudeksi komennolla -```js +```bash npm install json-server --save-dev ``` @@ -219,51 +211,53 @@ ja tehdään tiedoston package.json osaan scripts pieni lisäys { // ... "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject", - "server": "json-server -p3001 --watch db.json" // highlight-line + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview", + "server": "json-server -p 3001 db.json" // highlight-line }, } ``` -Nyt voimme käynnistää json-serverin projektin hakemistosta mukavasti ilman tarvetta parametrien määrittelylle komennolla +Nyt voimme käynnistää JSON Serverin projektin hakemistosta mukavasti ilman tarvetta parametrien määrittelylle: -```js +```bash npm run server ``` Tutustumme _npm_-työkaluun tarkemmin kurssin [kolmannessa osassa](/osa3). -Huomaa, että aiemmin käynnistetty json-server tulee olla sammutettuna, muuten seuraa ongelmia +Huomaa, että aiemmin käynnistetty JSON Server tulee olla sammutettuna, muuten seuraa ongelmia: ![](../../images/2/15b.png) -Virheilmoituksen punaisella oleva teksti kertoo mistä on kyse: +Virheilmoituksen punaisella oleva teksti kertoo mistä on kyse: Cannot bind to the port 3001. Please specify another port number either through --port argument or through the json-server.json configuration file -eli sovellus ei onnistu käynnistyessään kytkemään itseään [porttiin](https://en.wikipedia.org/wiki/Port_(computer_networking)), syy tälle on se, että portti 3001 on jo aiemmin käynnistetyn json-serverin varaama. +Sovellus ei onnistu käynnistyessään kytkemään itseään [porttiin](https://en.wikipedia.org/wiki/Port_(computer_networking)) 3001, koska kyseinen portti on jo aiemmin käynnistetyn JSON Serverin varaama. -Käytimme komentoa _npm install_ kahteen kertaan hieman eri tavalla +Käytimme komentoa _npm install_ kahteen kertaan hieman eri tavalla: -```js -npm install axios --save +```bash +npm install axios npm install json-server --save-dev ``` -Parametrissa oli siis hienoinen ero. axios tallennettiin sovelluksen ajonaikaiseksi riippuvuudeksi (_--save_), sillä ohjelman suoritus edellyttää kirjaston olemassaoloa. json-server taas asennettiin sovelluskehityksen aikaiseksi riippuvuudeksi (_--save-dev_), sillä ohjelma itse ei varsinaisesti kirjastoa tarvitse, se on ainoastaan apuna sovelluksehityksen aikana. Erilaisista riippuvuuksista lisää kurssin seuraavassa osassa. +Parametrissa oli siis hienoinen ero. Axios tallennettiin sovelluksen suoritusaikaiseksi riippuvuudeksi, sillä ohjelman suoritus edellyttää kirjaston olemassaoloa. JSON Server taas asennettiin sovelluskehityksen aikaiseksi riippuvuudeksi (_--save-dev_). JSON Server on ainoastaan apuna sovelluskehityksen aikana eikä varsinainen sovelluksemme tarvitse sitä. Erilaisista riippuvuuksista kerrotaan lisää kurssin seuraavassa osassa. ### Axios ja promiset -Olemme nyt valmiina käyttämään axiosia. Jatkossa oletetaan että json-server on käynnissä portissa 3001. Lisäksi varsinainen React-sovellus tulee käynnistää erikseen, erilliseen komentorivi-ikkunaan komennolla: +Axios on nyt valmis käyttöömme. Jatkossa oletetaan, että JSON Server on käynnissä portissa 3001. Lisäksi varsinainen React-sovellus tulee käynnistää erikseen erilliseen komentorivi-ikkunaan: -```npm start``` +```bash +npm run dev +``` -Kirjaston voi ottaa käyttöön samaan tapaan kuin esim. React otetaan käyttöön, eli sopivalla import-lauseella. +Kirjaston voi ottaa käyttöön samaan tapaan kuin muutkin kirjastot eli sopivalla import-lauseella. -Lisätään seuraava tiedostoon index.js +Lisätään seuraava tiedostoon main.jsx: ```js import axios from 'axios' @@ -275,23 +269,25 @@ const promise2 = axios.get('http://localhost:3001/foobar') console.log(promise2) ``` -Konsoliin tulostuu seuraavaa +Konsoliin tulostuu: -![](../../images/2/16b.png) +![](../../images/2/16new.png) Axiosin metodi _get_ palauttaa [promisen](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises). -Mozillan dokumentaatio sanoo promisesta seuraavaa: +Mozillan dokumentaatio kertoo promisesta seuraavaa: > A Promise is an object representing the eventual completion or failure of an asynchronous operation. Promise siis edustaa asynkronista operaatiota. Promise voi olla kolmessa eri tilassa: -- aluksi promise on pending, eli promisea vastaava asynkroninen operaatio ei ole vielä tapahtunut -- jos operaatio päättyy onnistuneesti, menee promise tilaan fulfilled, josta joskus käytetään nimitystä resolved -- kolmas mahdollinen tila on rejected, joka edustaa epäonnistunutta operaatiota +- Aluksi promise on pending, eli promisea vastaava asynkroninen operaatio ei ole vielä tapahtunut. +- Jos operaatio päättyy onnistuneesti, promise menee tilaan fulfilled. +- Kolmas mahdollinen tila on rejected, ja se edustaa epäonnistunutta operaatiota. + +Promiseihin liittyy paljon yksityiskohtia, mutta näiden kolmen tilan ymmärtäminen riittää meille toistaiseksi hyvin. Voit halutessasi lukea promiseista tarkemmin [Mozillan dokumentaatiosta](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise). -Esimerkkimme ensimmäinen promise on fulfilled, eli vastaa onnistunutta axios.get('http://localhost:3001/notes') pyyntöä. Promiseista toinen taas on rejected, syy selviää konsolista, eli yritettiin tehdä HTTP GET -pyyntöä osoitteeseen, jota ei ole olemassa. +Esimerkkimme ensimmäinen promise on fulfilled, eli vastaa onnistunutta axios.get('http://localhost:3001/notes') pyyntöä. Promiseista toinen taas on rejected. Syy selviää konsolista, eli yritimme tehdä HTTP GET ‑pyyntöä osoitteeseen, jota ei ole olemassa. Jos ja kun haluamme tietoon promisea vastaavan operaation tuloksen, tulee promiselle rekisteröidä tapahtumankuuntelija. Tämä tapahtuu metodilla then: @@ -303,13 +299,13 @@ promise.then(response => { }) ``` -Konsoliin tulostuu seuraavaa +Konsoliin tulostuu: -![](../../images/2/17e.png) +![](../../images/2/17new.png) -Javascriptin suoritusympäristö kutsuu then-metodin avulla rekisteröityä takaisinkutsufunktiota antaen sille parametriksi olion result, joka sisältää kaiken oleellisen HTTP GET -pyynnön vastaukseen liittyvän, eli palautetun datan, statuskoodin ja headerit. +JavaScriptin suoritusympäristö kutsuu then-metodin avulla rekisteröityä takaisinkutsufunktiota antaen sille parametriksi olion response, joka sisältää kaiken oleellisen HTTP GET ‑pyynnön vastaukseen liittyvän, eli palautetun datan, statuskoodin ja headerit. -Promise-oliota ei ole yleensä tarvetta tallettaa muuttujaan, ja onkin tapana ketjuttaa metodin then kutsu suoraan axiosin metodin kutsun perään: +Promise-oliota ei ole yleensä tarvetta tallettaa muuttujaan, ja onkin tapana ketjuttaa metodin then kutsu suoraan Axiosin metodin kutsun perään: ```js axios.get('http://localhost:3001/notes').then(response => { @@ -332,56 +328,52 @@ axios ``` Palvelimen palauttama data on pelkkää tekstiä, käytännössä yksi iso merkkijono. -Axios-kirjasto osaa kuitenkin parsia datan Javascript-taulukoksi, sillä palvelin on kertonut headerin content-type avulla että datan muoto on application/json; charset=utf-8 (ks. edellinen kuva). +Axios osaa kuitenkin parsia datan JavaScript-taulukoksi, sillä palvelin on kertonut headerin content-type avulla että datan muoto on application/json; charset=utf-8 (ks. edellinen kuva). Voimme vihdoin siirtyä käyttämään sovelluksessamme palvelimelta haettavaa dataa. -Tehdään se aluksi "huonosti", eli lisätään sovellusta vastaavan komponentin App renderöinti takaisinkutsufunktion sisälle muuttamalla index.js seuraavaan muotoon: +Tehdään se aluksi "huonosti", eli lisätään sovellusta vastaavan komponentin App renderöinti takaisinkutsufunktion sisälle muuttamalla main.jsx seuraavaan muotoon: ```js -import ReactDOM from 'react-dom' -import React from 'react' -import App from './App' - +import ReactDOM from 'react-dom/client' import axios from 'axios' +import App from './App' axios.get('http://localhost:3001/notes').then(response => { const notes = response.data - ReactDOM.render( - , - document.getElementById('root') - ) + ReactDOM.createRoot(document.getElementById('root')).render() }) ``` -Joissain tilanteissa tämäkin tapa voisi olla ok, mutta se on hieman ongelmallinen ja päätetäänkin siirtää datan hakeminen komponenttiin App. +Joissain tilanteissa tämäkin tapa voisi olla ok, mutta se on hieman ongelmallinen ja on parempi siirtää datan hakeminen komponenttiin App. Ei ole kuitenkaan ihan selvää, mihin kohtaan komponentin koodia komento axios.get olisi hyvä sijoittaa. ### Effect-hookit -Olemme jo käyttäneet Reactin version [16.8.0](https://www.npmjs.com/package/react/v/16.8.0) mukanaan tuomia [state hookeja](https://reactjs.org/docs/hooks-state.html) tuomaan funktioina määriteltyihin React-komponentteihin tilan. Versio 16.8.0 tarjoaa kokonaan uutena ominaisuutena myös -[effect hookit](https://reactjs.org/docs/hooks-effect.html), dokumentaation sanoin +Olemme jo käyttäneet Reactin version [16.8.0](https://www.npmjs.com/package/react/v/16.8.0) mukanaan tuomia [state hookeja](https://react.dev/learn/state-a-components-memory) tuomaan funktioina määriteltyihin React-komponentteihin tilan. Versio 16.8.0 tarjoaa kokonaan uutena ominaisuutena myös +[effect-hookit](https://react.dev/reference/react/hooks#effect-hooks), joista dokumentaatio kertoo: > The Effect Hook lets you perform side effects in function components. -> Data fetching, setting up a subscription, and manually changing the DOM in React components are all examples of side effects. +> Data fetching, setting up a subscription, and manually changing the DOM in React components are all examples of side effects. -Eli effect hookit ovat juuri oikea tapa hakea dataa palvelimelta. +Eli effect-hookit ovat juuri oikea tapa hakea dataa palvelimelta. -Poistetaan nyt datan hakeminen tiedostosta index.js. Komponentille App ei ole enää tarvetta välittää dataa propseina. Eli index.js pelkistyy seuraavaan muotoon +Poistetaan nyt datan hakeminen tiedostosta main.jsx. Komponentille App ei ole enää tarvetta välittää dataa propseina. Eli main.jsx pelkistyy seuraavaan muotoon: ```js -ReactDOM.render(, document.getElementById('root')) +ReactDOM.createRoot(document.getElementById('root')).render() ``` + Komponentti App muuttuu seuraavasti: ```js -import React, { useState, useEffect } from 'react' // highlight-line +import { useState, useEffect } from 'react' // highlight-line import axios from 'axios' // highlight-line import Note from './components/Note' const App = () => { - const [notes, setNotes] = useState([]) + const [notes, setNotes] = useState([]) // highlight-line const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) @@ -404,18 +396,18 @@ const App = () => { ``` -Koodiin on myös lisätty muutama aputulostus, jotka auttavat hahmottamaan miten suoritus etenee. +Koodiin on myös lisätty muutamia aputulostuksia, jotka auttavat hahmottamaan miten suoritus etenee. -Konsoliin tulostuu +Konsoliin tulostuu: -
    +```
     render 0 notes
     effect
     promise fulfilled
     render 3 notes
    -
    +``` -Ensin siis suoritetaan komponentin määrittelevan funktion runko ja renderöidään komponentti ensimmäistä kertaa. Tässä vaiheessa tulostuu render 0 notes eli dataa ei ole vielä haettu palvelimelta. +Ensin siis suoritetaan komponentin määrittelevän funktion runko ja renderöidään komponentti ensimmäistä kertaa. Tässä vaiheessa tulostuu render 0 notes eli dataa ei ole vielä haettu palvelimelta. Efekti, eli funktio @@ -431,7 +423,7 @@ Efekti, eli funktio } ``` -suoritetaan heti renderöinnin jälkeen. Funktion suoritus saa aikaan sen, että konsoliin tulostuu effect ja että komento axios.get aloittaa datan hakemisen palvelimelta sekä rekisteröi operaatiolle tapahtumankäsittelijäksi funktion +suoritetaan heti komponentin renderöinnin jälkeen. Funktion suoritus saa aikaan sen, että konsoliin tulostuu effect ja että komento axios.get aloittaa datan hakemisen palvelimelta sekä rekisteröi operaatiolle tapahtumankäsittelijäksi funktion ```js response => { @@ -440,7 +432,7 @@ response => { }) ``` -Siinä vaiheessa kun data saapuu palvelimelta, Javascriptin runtime kutsuu rekisteröityä tapahtumankäsittelijäfunktiota, joka tulostaa konsoliin promise fulfilled sekä tallettaa tilaan palvelimen palauttamat muistiinpanot funktiolla setNotes(response.data). +Siinä vaiheessa kun data saapuu palvelimelta, JavaScript Runtime kutsuu rekisteröityä tapahtumankäsittelijäfunktiota, joka tulostaa konsoliin promise fulfilled sekä tallettaa tilaan palvelimen palauttamat muistiinpanot funktiolla setNotes(response.data). Kuten aina, tilan päivittävän funktion kutsu aiheuttaa komponentin uudelleen renderöitymisen. Tämän seurauksena konsoliin tulostuu render 3 notes ja palvelimelta haetut muistiinpanot renderöityvät ruudulle. @@ -457,7 +449,7 @@ useEffect(() => { }, []) ``` -Kirjotetaan koodi hieman toisella tavalla. +Kirjoitetaan koodi hieman toisella tavalla: ```js const hook = () => { @@ -473,17 +465,17 @@ const hook = () => { useEffect(hook, []) ``` -Nyt huomaamme selvemmin, että funktiolle [useEffect](https://reactjs.org/docs/hooks-reference.html#useeffect) annetaan kaksi parametria. Näistä ensimmäinen on funktio, eli itse efekti. Dokumentaation mukaan +Nyt huomaamme selvemmin, että funktiolle [useEffect](https://react.dev/reference/react/useEffect) annetaan kaksi parametria. Näistä ensimmäinen on funktio eli itse efekti. Dokumentaation mukaan > By default, effects run after every completed render, but you can choose to fire it only when certain values have changed. -Eli oletusarvoisesti efekti suoritetaan aina sen jälkeen, kun komponentti renderöidään. Meidän tapauksessamme emme kuitenkaan halua suorittaa efektin kuin ensimmäisen renderöinnin yhteydessä. +Eli oletusarvoisesti efekti suoritetaan aina sen jälkeen, kun komponentti renderöidään. Meidän tapauksessamme haluamme suorittaa efektin vain ensimmäisen renderöinnin yhteydessä. -Funktion useEffect toista parametria käytetään [tarkentamaan sitä miten usein efekti suoritetaan](https://reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect). Jos toisena parametrina on tyhjä taulukko [], suoritetaan efekti ainoastaan komponentin ensimmäisen renderöinnin jälkeen. +Funktion useEffect toista parametria käytetään [tarkentamaan sitä, miten usein efekti suoritetaan](https://react.dev/reference/react/useEffect#parameters). Jos toisena parametrina on tyhjä taulukko [], suoritetaan efekti ainoastaan komponentin ensimmäisen renderöinnin jälkeen. -Efektihookien avulla on mahdollisuus tehdä paljon muutakin kuin hakea dataa palvelimelta, tämä riittää kuitenkin meille tässä vaiheessa. +Effect hookien avulla on mahdollisuus tehdä paljon muutakin kuin hakea dataa palvelimelta, mutta tämä riittää meille tässä vaiheessa. -Mieti vielä tarkasti äsken läpikäytyä tapahtumasarjaa, eli mitä kaikkea koodista suoritetaan, missä järjetyksessä ja kuinka monta kertaa. Tapahtumien järjestyksen ymmärtäminen on erittäin tärkeää! +Mieti vielä tarkasti äsken läpikäytyä tapahtumasarjaa eli sitä, mitä kaikkea koodista suoritetaan, missä järjetyksessä ja kuinka monta kertaa. Tapahtumien järjestyksen ymmärtäminen on erittäin tärkeää! Huomaa, että olisimme voineet kirjoittaa efektifunktion koodin myös seuraavasti: @@ -501,7 +493,7 @@ useEffect(() => { }, []) ``` -Muuttujaan eventHandler on sijoitettu viite tapahtumankäsittelijäfunktioon. Axiosin metodin get palauttama promise on talletettu muuttujaan promise. Takaisinkutsun rekisteröinti tapahtuu antamalla promisen then-metodin parametrina muuttuja eventHandler, joka viittaa käsittelijäfunktioon. Useimmiten funktioiden ja promisejen sijoittaminen muuttujiin ei ole tarpeen ja ylempänä käyttämämme kompaktimpi esitystapa riittää: +Muuttujaan eventHandler on sijoitettu viite tapahtumankäsittelijäfunktioon. Axiosin metodin get palauttama promise on talletettu muuttujaan promise. Takaisinkutsun rekisteröinti tapahtuu antamalla promisen then-metodin parametrina muuttuja eventHandler, joka viittaa käsittelijäfunktioon. Useimmiten funktioiden ja promisejen sijoittaminen muuttujiin ei ole tarpeen, ja ylempänä käyttämämme kompaktimpi esitystapa riittää: ```js useEffect(() => { @@ -515,27 +507,27 @@ useEffect(() => { }, []) ``` -Sovelluksessa on tällä hetkellä vielä se ongelma, että jos lisäämme uusia muistiinpanoja, ne eivät tallennu palvelimelle asti. Eli kun uudelleenlataamme sovelluksen, kaikki lisäykset katoavat. Korjaus asiaan tulee pian. +Sovelluksessa on tällä hetkellä vielä se ongelma, että jos lisäämme uusia muistiinpanoja, ne eivät tallennu palvelimelle asti. Eli kun lataamme sovelluksen uudelleen, kaikki lisäykset katoavat. Korjaus asiaan tulee pian. -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part2-notes/tree/part2-4), branchissa part2-4. +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-4), branchissa part2-4. ### Sovelluskehityksen suoritusympäristö -Sovelluksemme kokonaisuuden konfiguraatiosta on pikkuhiljaa muodostunut melko monimutkainen. Käydään vielä läpi mitä tapahtuu missäkin. Seuraava diagrammi kuvaa asetelmaa +Sovelluksemme kokonaisuuden konfiguraatiosta on pikkuhiljaa muodostunut melko monimutkainen. Käydään vielä läpi mitä tapahtuu missäkin. Seuraava diagrammi kuvaa asetelmaa: ![](../../images/2/18e.png) -React-sovelluksen muodostavaa Javascript-koodia siis suoritetaan selaimessa. Selain hakee Javascriptin React dev serveriltä, joka on se ohjelma, mikä käynnistyy kun suoritetaan komento npm start. Dev-serveri muokkaa sovelluksen Javascriptin selainta varten sopivaan muotoon, se mm. yhdistelee eri tiedostoissa olevan Javascript-koodin yhdeksi tiedostoksi. Puhumme enemmän dev-serveristä kurssin [osassa 7](/osa7). +React-sovelluksen muodostavaa JavaScript-koodia siis suoritetaan selaimessa. Selain hakee JavaScriptin React Development Serveriltä, joka on se ohjelma, joka käynnistyy kun suoritetaan komento npm run dev. Development Server muokkaa sovelluksen JavaScriptin selainta varten sopivaan muotoon, se mm. yhdistelee eri tiedostoissa olevan JavaScript-koodin yhdeksi tiedostoksi. Puhumme enemmän Development Serveristä kurssin [osassa 7](/osa7). -JSON-muodossa olevan datan selaimessa pyörivä React-sovellus siis hakee koneella portissa 3001 käynnissä olevalta json-serveriltä, joka taas saa JSON-datan tiedostosta db.json. +JSON-muodossa olevan datan selaimessa pyörivä React-sovellus hakee siis koneella portissa 3001 käynnissä olevalta JSON Serveriltä, joka taas saa JSON-datan tiedostosta db.json. -Kaikki sovelluksen osat ovat näin sovelluskehitysvaiheessa ohjelmoijan koneella eli localhostissa. Tilanne muuttuu sitten kun sovellus viedään internettiin. Teemme näin [osassa 3](/osa3). +Kaikki sovelluksen osat ovat sovelluskehitysvaiheessa siis ohjelmoijan koneella eli localhostissa. Tilanne muuttuu, kun sovellus viedään Internetiin. Teemme näin [osassa 3](/osa3).
    -

    Tehtävät 2.11.-2.14.

    +

    Tehtävä 2.11.

    2.11: puhelinluettelo step6

    @@ -547,30 +539,30 @@ Jatketaan puhelinluettelon kehittämistä. Talleta sovelluksen alkutila projekti { "name": "Arto Hellas", "number": "040-123456", - "id": 1 + "id": "1" }, { "name": "Ada Lovelace", "number": "39-44-5323523", - "id": 2 + "id": "2" }, { "name": "Dan Abramov", "number": "12-43-234345", - "id": 3 + "id": "3" }, { "name": "Mary Poppendieck", "number": "39-23-6423122", - "id": 4 + "id": "4" } ] } ``` -Käynnistä json-server porttiin 3001 ja varmista selaimella osoitteesta , että palvelin palauttaa henkilölistan. +Käynnistä JSON Server porttiin 3001 ja varmista selaimella osoitteesta , että palvelin palauttaa henkilölistan. -Jos saat virheilmoituksen: +Jos saat virheilmoituksen ```js events.js:182 @@ -582,68 +574,8 @@ Error: listen EADDRINUSE 0.0.0.0:3001 at _exceptionWithHostPort (util.js:1041:20) ``` -on portti 3001 jo jonkin muun sovelluksen, esim. jo käynnissä olevan json-serverin käytössä. Sulje toinen sovellus tai jos se ei onnistu, vaihda porttia. - -Muuta sovellusta siten, että datan alkutila haetaan axios-kirjaston avulla palvelimelta. Hoida datan hakeminen [Effect hookilla](https://reactjs.org/docs/hooks-effect.html)). - -

    2.12* maiden tiedot, step1

    - -Rajapinta [https://restcountries.eu](https://restcountries.eu) tarjoaa paljon eri maihin liittyvää tietoa koneluettavassa muodossa ns. REST-apina. +on portti 3001 jo jonkin muun sovelluksen, esim. jo käynnissä olevan JSON Serverin käytössä. Sulje toinen sovellus tai jos se ei onnistu, vaihda porttia. -Tee sovellus, jonka avulla voit tarkastella eri maiden tietoja. Sovelluksen kannattaa hakea tiedot endpointista [all](https://restcountries.eu/#api-endpoints-all). - -Sovelluksen käyttöliittymä on yksinkertainen. Näytettävä maa haetaan kirjoittamalla hakuehto etsintäkenttään. - -Jos ehdon täyttäviä maita on liikaa (yli 10), kehoitetaan tarkentamaan hakuehtoa: - -![](../../images/2/19b1.png) - -Jos maita on kymmenen tai alle, mutta yli 1 näytetään hakuehdon täyttävät maat: - -![](../../images/2/19b2.png) - -Kun ehdon täyttäviä maita on enää yksi, näytetään maan perustiedot, lippu sekä siellä puhutut kielet: - -![](../../images/2/19b3.png) - -**Huom1:** riittää että sovelluksesi toimii suurimmalle osalle maista. Jotkut maat kuten Sudan voivat tuottaa ongelmia, sillä maan nimi on toisen maan South Sudan osa. Näistä corner caseista ei tarvitse välittää. - -**Huom2:** saatat törmätä ongelmiin tässä tehtävässä, jos määrittelet komponentteja "väärässä paikassa", nyt kannattaakin ehdottomasti kerrata edellisen osan luku [älä määrittele komponenttia komponentin sisällä](/osa1/monimutkaisempi_tila_reactin_debuggaus#ala-maarittele-komponenttia-komponentin-sisalla). - - -**VAROITUS** create-react-app tekee projektista automaattisesti git-repositorion, ellei sovellusta luoda jo olemassaolevan repositorion sisälle. Todennäköisesti **et halua** että projektista tulee repositorio, joten suorita projektin juuressa komento _rm -rf .git_. - -

    2.13*: maiden tiedot, step2

    - -**Tässä osassa on vielä paljon tekemistä, joten älä juutu tähän tehtävään!** - -Paranna edellisen tehtävän maasovellusta siten, että kun sivulla näkyy useiden maiden nimiä, tulee maan nimen viereen nappi, jota klikkaamalla pääsee suoraan maan näkymään: - -![](../../images/2/19b4.png) - -Tässäkin tehtävässä riittää, että ohjelmasi toimii suurella osalla maita ja maat joiden nimi sisältyy johonkin muuhun maahan, kuten Sudan voit unohtaa. - -

    2.14*: maiden tiedot, step3

    - -**Tässä osassa on vielä paljon tekemistä, joten älä juutu tähän tehtävään!** - -Lisää yksittäisen maan näkymään pääkaupungin säätiedotus. Säätiedotuksen tarjoavia palveluita on kymmeniä. Itse käytin [https://weatherstack.com/](https://weatherstack.com/):ia. - -![](../../images/2/19ba.png) - -**Huom:** tarvitset melkein kaikkia säätietoja tarjoavia palveluja käyttääksesi api-avaimen. Älä talleta avainta versionhallintaan, eli älä kirjoita avainta suoraan koodiin. Avaimen arvo kannattaa määritellä ns. [ympäristömuuttujana](https://create-react-app.dev/docs/adding-custom-environment-variables/). - -Oletetaan että api-avaimen arvo on 54l41n3n4v41m34rv0. Kun ohjelma käynnistetään seuraavasti - -```bash -REACT_APP_API_KEY=54l41n3n4v41m34rv0 npm start -``` - -koodista päästään avaimen arvoon käsiksi olion _process.env_ kautta: - -```js -const api_key = process.env.REACT_APP_API_KEY -// muuttujassa api_key on nyt käynnistyksessä annettu api-avaimen arvo -``` +Muuta sovellusta siten, että alkutila haetaan Axios-kirjaston avulla palvelimelta. Hoida datan hakeminen [Effect hookilla](https://react.dev/reference/react/useEffect).
    diff --git a/src/content/2/fi/osa2d.md b/src/content/2/fi/osa2d.md index 1b50f221d5b..7db54068f6e 100644 --- a/src/content/2/fi/osa2d.md +++ b/src/content/2/fi/osa2d.md @@ -7,34 +7,33 @@ lang: fi
    -Kun sovelluksella luodaan uusia muistiinpanoja, täytyy ne luonnollisesti tallentaa palvelimelle. [json-server](https://github.com/typicode/json-server) mainitsee dokumentaatiossaan olevansa ns. REST- tai RESTful-API +Kun sovelluksella luodaan uusia muistiinpanoja, täytyy ne luonnollisesti tallentaa palvelimelle. [JSON Server](https://github.com/typicode/json-server) mainitsee dokumentaatiossaan olevansa ns. REST- tai RESTful-API > Get a full fake REST API with zero coding in less than 30 seconds (seriously) -Ihan alkuperäisen [määritelmän](https://en.wikipedia.org/wiki/Representational_state_transfer) mukainen RESTful API json-server ei ole, mutta ei ole kovin moni muukaan itseään REST:iksi kutsuva rajapinta. +Ihan alkuperäisen [määritelmän](https://en.wikipedia.org/wiki/Representational_state_transfer) mukainen RESTful API JSON Server ei ole, mutta ei ole kovin moni muukaan itseään REST:iksi kutsuva rajapinta. -Tutustumme REST:iin tarkemmin kurssin [seuraavassa osassa](/osa3), mutta jo nyt on tärkeä ymmärtää minkälaista [konventiota](https://en.wikipedia.org/wiki/Representational_state_transfer#Applied_to_Web_services) json-server ja yleisemminkin REST API:t käyttävät [reittien](https://github.com/typicode/json-server#routes), eli URL:ien ja käytettävien HTTP-pyyntöjen tyyppien suhteen. +Tutustumme REST:iin tarkemmin kurssin [seuraavassa osassa](/osa3), mutta jo nyt on tärkeä ymmärtää minkälaista [konventiota](https://en.wikipedia.org/wiki/Representational_state_transfer#Applied_to_web_services) JSON Server ja yleisemminkin REST API:t käyttävät [reittien](https://github.com/typicode/json-server#routes) eli URL:ien ja käytettävien HTTP-pyyntöjen tyyppien suhteen. ### REST -REST:issä yksittäisiä asioita esim. meidän tapauksessamme muistiinpanoja kutsutaan resursseiksi. Jokaisella resurssilla on yksilöivä osoite eli URL. json-serverin noudattaman yleisen konvention mukaan yksittäistä muistiinpanoa kuvaavan resurssin URL on muotoa notes/3, missä 3 on resurssin tunniste. Osoite notes taas vastaa kaikkien yksittäisten muistiinpanojen kokoelmaa. +REST:issä yksittäisiä asioita, esim. meidän tapauksessamme muistiinpanoja, kutsutaan resursseiksi. Jokaisella resurssilla on yksilöivä osoite eli URL. JSON Serverin noudattaman yleisen konvention mukaan yksittäistä muistiinpanoa kuvaavan resurssin URL on muotoa notes/3, missä 3 on resurssin tunniste. Osoite notes taas vastaa kaikkien yksittäisten muistiinpanojen kokoelmaa. -Resursseja haetaan palvelimelta HTTP GET -pyynnöillä. Esim. HTTP GET osoitteeseen notes/3 palauttaa muistiinpanon, jonka id-kentän arvo on 3. Kun taas HTTP GET -pyyntö osoitteeseen notes palauttaa kaikki muistiinpanot. +Resursseja haetaan palvelimelta HTTP GET ‑pyynnöillä. Esim. HTTP GET osoitteeseen notes/3 palauttaa muistiinpanon, jonka id-kentän arvo on 3. HTTP GET ‑pyyntö osoitteeseen notes palauttaa kaikki muistiinpanot. -Uuden muistiinpanoa vastaavan resurssin luominen tapahtuu json-serverin noudattamassa REST-konventiossa tekemällä HTTP POST -pyyntö, joka kohdistuu myös samaan osoitteeseen notes. Pyynnön mukana sen runkona eli bodynä lähetetään luotavan muistiinpanon tiedot. +Uuden muistiinpanoa vastaavan resurssin luominen tapahtuu JSON Serverin noudattamassa REST-konventiossa tekemällä HTTP POST ‑pyyntö, joka kohdistuu myös samaan osoitteeseen notes. Pyynnön mukana sen runkona eli bodynä lähetetään luotavan muistiinpanon tiedot. -json-server vaatii, että tiedot lähetetään JSON-muodossa, eli käytännössä sopivasti muotoiltuna merkkijonona ja asettamalla headerille Content-Type arvo application/json. +JSON Server vaatii, että tiedot lähetetään JSON-muodossa eli käytännössä sopivasti muotoiltuna merkkijonona ja asettamalla headerille Content-Type:ksi arvo application/json. ### Datan lähetys palvelimelle -Muutetaan nyt uuden muistiinpanon lisäämisestä huolehtivaa tapahtumankäsittelijää seuraavasti: +Muutetaan nyt uuden muistiinpanon lisäämisestä huolehtivaa tapahtumankäsittelijää seuraavasti ```js const addNote = event => { event.preventDefault() const noteObject = { content: newNote, - date: new Date().toISOString(), important: Math.random() > 0.5, } @@ -48,32 +47,37 @@ const addNote = event => { } ``` -eli luodaan muistiinpanoa vastaava olio, ei kuitenkaan lisätä sille kenttää id, sillä on parempi jättää id:n generointi palvelimen vastuulle! +eli luodaan muistiinpanoa vastaava olio. Ei kuitenkaan lisätä sille kenttää id, sillä on parempi jättää id:n generointi palvelimen vastuulle! -Olio lähetetään palvelimelle käyttämällä axiosin metodia post. Rekisteröity tapahtumankäsittelijä tulostaa konsoliin palvelimen vastauksen. +Olio lähetetään palvelimelle käyttämällä Axiosin metodia post. Rekisteröity tapahtumankäsittelijä tulostaa konsoliin palvelimen vastauksen. Kun nyt kokeillaan luoda uusi muistiinpano, konsoliin tulostus näyttää seuraavalta: -![](../../images/2/20e.png) +![](../../images/2/20new.png) -Uusi muistiinpano on siis _response_-olion kentän data arvona. Palvelin on lisännyt muistiinpanolle tunnisteen, eli id-kentän. +Uusi muistiinpano on siis _response_-olion kentän data arvona. Palvelin on lisännyt muistiinpanolle tunnisteen eli id-kentän. -Joskus on hyödyllistä tarkastella HTTP-pyyntöjä [osan 0 alussa](/osa0/web_sovelluksen_toimintaperiaatteita#http-get) paljon käytetyn konsolin Network-välilehden kautta: +Usein on hyödyllistä tarkastella HTTP-pyyntöjä [osan 0 alussa](/osa0/web_sovelluksen_toimintaperiaatteita#http-get) paljon käytetyn konsolin Network-välilehden kautta. Välilehti header kertoo pyynnön perustiedot ja näyttää pyynnön ja vastauksen headereiden arvot: -![](../../images/2/21e.png) +![](../../images/2/21new1.png) -Voimme esim. tarkastaa onko POST-pyynnön mukana menevä data juuri se mitä oletimme, onko headerit asetettu oikein ym. +Koska POST-pyynnössä lähettämämme data oli JavaScript-olio, osasi Axios automaattisesti asettaa pyynnön Content-type-headerille oikean arvon eli application/json. -Koska POST-pyynnössä lähettämämme data oli Javascript-olio, osasi axios automaattisesti asettaa pyynnön Content-type headerille oikean arvon eli application/json. +Välilehdeltä payload näemme missä muodossa data lähti: + +![](../../images/2/21new2.png) + +Välilehti response on myös hyödyllinen, se kertoo mitä palvelin palautti: + +![](../../images/2/21new3.png) Uusi muistiinpano ei vielä renderöidy ruudulle, sillä emme aseta komponentille App uutta tilaa muistiinpanon luomisen yhteydessä. Viimeistellään sovellus vielä tältä osin: ```js -addNote = event => { +const addNote = event => { event.preventDefault() const noteObject = { content: newNote, - date: new Date().toISOString(), important: Math.random() > 0.5, } @@ -90,25 +94,23 @@ addNote = event => { Palvelimen palauttama uusi muistiinpano siis lisätään tuttuun tapaan funktiolla setNotes tilassa olevien muiden muistiinpanojen joukkoon (kannattaa [muistaa tärkeä detalji](/osa1/monimutkaisempi_tila_reactin_debuggaus#taulukon-kasittelya) siitä, että metodi concat ei muuta komponentin alkuperäistä tilaa, vaan luo uuden taulukon) ja tyhjennetään lomakkeen teksti. -Kun palvelimella oleva data alkaa vaikuttaa web-sovelluksen toimintalogiikkaan, tulee sovelluskehitykseen heti iso joukko uusia haasteita, joita tuo mukanaan mm. kommunikoinnin asynkronisuus. Debuggaamiseenkin tarvitaan uusia strategiota, debug-printtaukset ym. muuttuvat vain tärkeämmäksi, myös Javascriptin runtimen periaatteita ja React-komponenttien toimintaa on pakko tuntea riittävällä tasolla, arvaileminen ei riitä. +Kun palvelimella oleva data alkaa vaikuttaa web-sovelluksen toimintalogiikkaan, tulee sovelluskehitykseen heti iso joukko uusia haasteita, joita tuo mukanaan mm. kommunikoinnin asynkronisuus. Debuggaamiseenkin tarvitaan uusia strategiota, debug-printtaukset ym. muuttuvat vain tärkeämmiksi, myös JavaScriptin runtimen periaatteita ja React-komponenttien toimintaa on pakko tuntea riittävällä tasolla, arvaileminen ei riitä. -Palvelimen tilaa kannattaa tarkastella myös suoraan, esim. selaimella: +Palvelimen tilaa kannattaa tarkastella myös suoraan esim. selaimella: -![](../../images/2/22e.png) +![](../../images/2/22.png) -näin on mahdollista varmistua, mm. siirtyykö kaikki oletettu data palvelimelle. +Näin on mahdollista varmistua mm. siitä, siirtyykö kaikki oletettu data palvelimelle. -Kurssin seuraavassa osassa alamme toteuttaa itse myös palvelimella olevan sovelluslogiikan, tutustumme silloin tarkemmin palvelimen debuggausta auttaviin työkaluihin, mm. [postmaniin](https://chrome.google.com/webstore/detail/postman/fhbjgbiflinjbdggehcddcbncdddomop). Tässä vaiheessa json-server-palvelimen tilan tarkkailuun riittänee selain. +Kurssin seuraavassa osassa alamme toteuttaa itse myös palvelimella olevan sovelluslogiikan. Tutustumme silloin tarkemmin palvelimen debuggausta auttaviin työkaluihin kuten [Postmaniin](https://www.postman.com/). Tässä vaiheessa JSON Server ‑palvelimen tilan tarkkailuun riittänee selain. -> **HUOM:** sovelluksen nykyisessä versiossa selain lisää uudelle muistiinpanolle sen luomishetkeä kuvaavan kentän. Koska koneen oma kello voi näyttää periaatteessa mitä sattuu, on aikaleimojen generointi todellisuudessa viisaampaa hoitaa palvelimella ja tulemmekin tekemään tämän muutoksen kurssin seuraavassa osassa. - -Sovelluksen tämän hetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part2-notes/tree/part2-5), branchissa part2-5. +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-5), branchissa part2-5. ### Muistiinpanon tärkeyden muutos -Lisätään muistiinpanojen yhteyteen painike, millä niiden tärkeyttä voi muuttaa. +Lisätään muistiinpanojen yhteyteen painikkeet, joilla muistiinpanojen tärkeyttä voi muuttaa. -Muistiinpanon määrittelevän komponentin muutos on seuraavat: +Muistiinpanon määrittelevän komponentin muutos on seuraava: ```js const Note = ({ note, toggleImportance }) => { @@ -153,9 +155,9 @@ const App = () => {
      - {notesToShow.map((note, i) => + {notesToShow.map(note => toggleImportanceOf(note.id)} // highlight-line /> @@ -181,19 +183,19 @@ Pieni muistutus tähän väliin. Tapahtumankäsittelijän koodin tulostuksessa m console.log('importance of ' + id + ' needs to be toggled') ``` -ES6:n [template string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals) -ominaisuuden ansiosta Javascriptissa vastaavat merkkijonot voidaan kirjottaa hieman mukavammin: +ES6:n [template string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals) ‑ominaisuuden ansiosta JavaScriptissa vastaavat merkkijonot voidaan kirjoittaa hieman mukavammin: ```js console.log(`importance of ${id} needs to be toggled`) ``` -Merkkijonon sisälle voi nyt määritellä "dollari-aaltosulku"-syntaksilla kohtia, minkä sisälle evaluoidaan javascript-lausekkeita, esim. muuttujan arvo. Huomaa, että template stringien hipsutyyppi poikkeaa Javascriptin normaaleista merkkijonojen käyttämistä hipsuista. +Merkkijonon sisälle voi nyt määritellä "dollari-aaltosulku"-syntaksilla kohtia, joiden sisälle evaluoidaan JavaScript-lausekkeita, esim. muuttujan arvo. Huomaa, että template stringien hipsutyyppi poikkeaa JavaScriptin normaalien merkkijonojen käyttämistä hipsuista. -Yksittäistä json-serverillä olevaa muistiinpanoa voi muuttaa kahdella tavalla, joko korvaamalla sen tekemällä HTTP PUT -pyyntö muistiinpanon yksilöivään osoitteeseen tai muuttamalla ainoastaan joidenkin muistiinpanon kenttien arvoja HTTP PATCH -pyynnöllä. +Yksittäistä JSON Serverillä olevaa muistiinpanoa voi muuttaa kahdella tavalla: joko korvaamalla sen tekemällä HTTP PUT ‑pyynnön muistiinpanon yksilöivään osoitteeseen tai muuttamalla ainoastaan joidenkin muistiinpanon kenttien arvoja HTTP PATCH ‑pyynnöllä. -Korvaamme nyt muistiinpanon kokonaan, sillä samalla tulee esille muutama tärkeä React:iin ja Javascriptiin liittyvä seikka. +Korvaamme nyt muistiinpanon kokonaan, sillä samalla tulee esille muutama tärkeä Reactiin ja JavaScriptiin liittyvä seikka. -Tapahtumankäsittelijäfunktion lopullinen muoto on seuraavassa: +Tapahtumankäsittelijäfunktion lopullinen muoto on seuraava: ```js const toggleImportanceOf = id => { @@ -211,17 +213,17 @@ Melkein jokaiselle riville sisältyy tärkeitä yksityiskohtia. Ensimmäinen riv Taulukon metodilla [find](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find) etsitään muutettava muistiinpano ja talletetaan muuttujaan _note_ viite siihen. -Sen jälkeen luodaan uusi olio, jonka sisältö on sama kuin vanhan olion sisältö poislukien kenttä important. +Sen jälkeen luodaan uusi olio, jonka sisältö on sama kuin vanhan olion sisältö pois lukien kenttä important jonka arvo vaihtuu päinvastaiseksi. -Niin sanottua [object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) -syntaksia hyödyntävä uuden olion luominen näyttää hieman erikoiselta: +Niin sanottua [object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) ‑syntaksia hyödyntävä uuden olion luominen näyttää hieman erikoiselta: ```js const changedNote = { ...note, important: !note.important } ``` -Käytännössä { ... note} luo olion, jolla on kenttinään kopiot olion _note_ kenttien arvoista. Kun aaltosulkeisiin lisätään asioita, esim. { ...note, important: true }, tulee uuden olion kenttä _important_ saamaan arvon _true_. Eli esimerkissämme important saa uudessa oliossa vanhan arvonsa käänteisarvon. +Käytännössä { ... note} luo olion, jolla on kenttinään kopiot olion _note_ kenttien arvoista. Kun aaltosulkeiden sisään lisätään asioita, esim. { ...note, important: true }, tulee uuden olion kenttä _important_ saamaan arvon _true_. Eli esimerkissämme important saa uudessa oliossa vanhan arvonsa käänteisarvon. -Pari huomioita. Miksi teimme muutettavasta oliosta kopion vaikka myös seuraava koodi näyttää toimivan: +Miksi teimme muutettavasta oliosta kopion vaikka myös seuraava koodi näyttää toimivan: ```js const note = notes.find(n => n.id === id) @@ -231,13 +233,13 @@ axios.put(url, note).then(response => { // ... ``` -Näin ei ole suositetavaa tehdä, sillä muuttuja note on viite komponentin tilassa, eli notes-taulukossa olevaan olioon, ja kuten muistamme tilaa ei Reactissa saa muuttaa suoraan! +Näin ei ole suositeltavaa tehdä, sillä muuttuja note on viite komponentin tilassa, eli notes-taulukossa olevaan olioon, ja kuten muistamme, Reactissa tilaa [ei saa muuttaa suoraan!](https://react.dev/learn/updating-objects-in-state#why-is-mutating-state-not-recommended-in-react) -Kannattaa myös huomata, että uusi olio _changedNote_ on ainoastaan ns. [shallow copy](https://en.wikipedia.org/wiki/Object_copying#Shallow_copy), eli uuden olion kenttien arvoina on vanhan olion kenttien arvot. Jos vanhan olion kentät olisivat itsessään olioita, viittaisivat uuden olion kentät samoihin olioihin. +Kannattaa huomata myös, että uusi olio _changedNote_ on ainoastaan ns. [shallow copy](https://en.wikipedia.org/wiki/Object_copying#Shallow_copy), eli uuden olion kenttien arvoina on vanhan olion kenttien arvot. Jos vanhan olion kentät olisivat itsessään olioita, viittaisivat uuden olion kentät samoihin olioihin. Uusi muistiinpano lähetetään sitten PUT-pyynnön mukana palvelimelle, jossa se korvaa aiemman muistiinpanon. -Takaisinkutsufunktiossa asetetaan komponentin App tilaan notes kaikki vanhat muistiinpanot paitsi muuttuneen, josta tilaan asetetaan palvelimen palauttama versio: +Takaisinkutsufunktiossa asetetaan komponentin App tilaan notes kaikki vanhat muistiinpanot paitsi muuttunut, josta tilaan asetetaan palvelimen palauttama versio: ```js axios.put(url, changedNote).then(response => { @@ -251,13 +253,13 @@ Tämä saadaan aikaan metodilla map: notes.map(note => note.id !== id ? note : response.data) ``` -Operaatio siis luo uuden taulukon vanhan taulukon perusteella. Jokainen uuden taulukon alkio luodaan ehdollisesti siten, että jos ehto note.id !== id on tosi, otetaan uuteen taulukkoon suoraan vanhan taulukon kyseinen alkio. Jos ehto on epätosi, eli kyseessä on muutettu muistiinpano, otetaan uuteen taulukkoon palvelimen palauttama olio. +Operaatio siis luo uuden taulukon vanhan taulukon perusteella. Jokainen uuden taulukon alkio luodaan ehdollisesti siten, että jos ehto note.id !== id on tosi, otetaan uuteen taulukkoon suoraan vanhan taulukon kyseinen alkio. Jos ehto on epätosi eli kyseessä on muutettu muistiinpano, otetaan uuteen taulukkoon palvelimen palauttama olio. Käytetty map-kikka saattaa olla aluksi hieman hämmentävä. Asiaa kannattaakin miettiä tovi. Tapaa tullaan käyttämään kurssilla vielä kymmeniä kertoja. ### Palvelimen kanssa tapahtuvan kommunikoinnin eristäminen omaan moduuliin -App-komponentti alkaa kasvaa uhkaavasti kun myös palvelimen kanssa kommunikointi tapahtuu komponentissa. [Single responsibility](https://en.wikipedia.org/wiki/Single_responsibility_principle) -periaatteen hengessä kommunikointi onkin viisainta eristää omaan [moduuliinsa](/osa2/kokoelmien_renderointi_ja_moduulit#refaktorointia-moduulit). +App-komponentti alkaa kasvaa uhkaavasti kun myös palvelimen kanssa kommunikointi tapahtuu komponentissa. [Single responsibility](https://en.wikipedia.org/wiki/Single_responsibility_principle) ‑periaatteen hengessä kommunikointi onkin viisainta eristää omaan [moduuliinsa](/osa2/kokoelmien_renderointi_ja_moduulit#refaktorointia-moduulit). Luodaan hakemisto src/services ja sinne tiedosto notes.js: @@ -284,9 +286,9 @@ export default { } ``` -Moduuli palauttaa nyt olion, jonka kenttinä (getAll, create ja update) on kolme muistiinpanojen käsittelyä hoitavaa funktiota. Funktiot palauttavat suoraan axiosin metodien palauttaman promisen. +Moduuli palauttaa nyt olion, jonka kenttinä (getAll, create ja update) on kolme muistiinpanojen käsittelyä hoitavaa funktiota. Funktiot palauttavat suoraan Axiosin metodien palauttaman promisen. -Komponentti App saa moduulin käyttöön import-lauseella +Komponentti App saa moduulin käyttöön import-lauseella: ```js import noteService from './services/notes' // highlight-line @@ -294,7 +296,7 @@ import noteService from './services/notes' // highlight-line const App = () => { ``` -moduulin funktioita käytetään importatun muuttujan _noteService_ kautta seuraavasti: +Moduulin funktioita käytetään importatun muuttujan _noteService_ kautta seuraavasti: ```js const App = () => { @@ -327,7 +329,6 @@ const App = () => { event.preventDefault() const noteObject = { content: newNote, - date: new Date().toISOString(), important: Math.random() > 0.5 } @@ -357,9 +358,9 @@ noteService }) ``` -Eli asia mistä App on kiinnostunut on parametrin kentässä response.data. +Eli kiinnostava asia on parametrin kentässä response.data. -Moduulia olisi miellyttävämpi käyttää, jos se HTTP-pyynnön vastauksen sijaan palauttaisi suoraan muistiinpanot sisältävän taulukon. Tällöin moduulin käyttö näyttäisi seuraavalta +Moduulia olisi miellyttävämpi käyttää, jos se HTTP-pyynnön vastauksen sijaan palauttaisi suoraan muistiinpanot sisältävän taulukon. Tällöin moduulin käyttö näyttäisi seuraavalta: ```js noteService @@ -368,6 +369,7 @@ noteService setNotes(initialNotes) }) ``` + Tämä onnistuu muuttamalla moduulin koodia seuraavasti (koodiin jää ikävästi copy-pastea, emme kuitenkaan nyt välitä siitä): ```js @@ -396,7 +398,7 @@ export default { } ``` -eli enää ei palautetakaan suoraan axiosin palauttamaa promisea, vaan otetaan promise ensin muuttujaan request ja kutsutaan sille metodia then: +Enää ei palautetakaan suoraan Axiosin palauttamaa promisea, vaan otetaan promise ensin muuttujaan request ja kutsutaan sille metodia then: ```js const getAll = () => { @@ -418,7 +420,7 @@ const getAll = () => { } ``` -Myös nyt funktio getAll palauttaa promisen, sillä promisen metodi then [palauttaa promisen](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then). +Myös nyt funktio getAll palauttaa promisen, sillä promisen metodi then [palauttaa promisen](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then). Koska then:in parametri palauttaa suoraan arvon response.data, on funktion getAll palauttama promise sellainen, että jos HTTP-kutsu onnistuu, antaa promise takaisinkutsulleen HTTP-pyynnön mukana olleen datan, eli se toimii juuri niin kuin haluamme. @@ -455,7 +457,6 @@ const App = () => { event.preventDefault() const noteObject = { content: newNote, - date: new Date().toISOString(), important: Math.random() > 0.5 } @@ -473,11 +474,11 @@ const App = () => { } ``` -Tämä kaikki on hieman monimutkaista ja asian selittäminen varmaan vain vaikeuttaa sen ymmärtämistä. Internetistä löytyy paljon vaihtelevatasoista materiaalia aiheesta, esim. [tämä](https://javascript.info/promise-chaining). +Tämä kaikki on hieman monimutkaista, ja asian selittäminen varmaan vain vaikeuttaa sen ymmärtämistä. Internetistä löytyy paljon vaihtelevatasoista materiaalia aiheesta, esim. [tämä](https://javascript.info/promise-chaining). -[You do not know JS](https://github.com/getify/You-Dont-Know-JS) sarjan kirja "Async and performance" selittää asian [hyvin](https://github.com/getify/You-Dont-Know-JS/blob/1st-ed/async%20%26%20performance/ch3.md) mutta tarvitsee selitykseen kohtuullisen määrän sivuja. +[You do not know JS](https://github.com/getify/You-Dont-Know-JS) sarjan kirja "Async and performance" selittää asian [hyvin](https://github.com/getify/You-Dont-Know-JS/blob/1st-ed/async%20%26%20performance/ch3.md), mutta tarvitsee selitykseen kohtuullisen määrän sivuja. -Promisejen ymmärtäminen on erittäin keskeistä modernissa Javascript-sovelluskehityksessä, joten asiaan kannattaa uhrata kohtuullisessa määrin aikaa. +Promisejen ymmärtäminen on erittäin keskeistä modernissa JavaScript-sovelluskehityksessä, joten asiaan kannattaa uhrata aikaa. ### Kehittyneempi tapa olioliteraalien määrittelyyn @@ -511,7 +512,7 @@ export default { } ``` -Exportattava asia on siis seuraava, hieman erikoiselta näyttävä olio: +Eksportattava asia on siis seuraava, hieman erikoiselta näyttävä olio: ```js { @@ -523,7 +524,7 @@ Exportattava asia on siis seuraava, hieman erikoiselta näyttävä olio: Olion määrittelyssä vasemmalla puolella kaksoispistettä olevat nimet tarkoittavat eksportoitavan olion kenttiä, kun taas oikealla puolella olevat nimet ovat moduulin sisällä määriteltyjä muuttujia. -Koska olion kenttien nimet ovat samat kuin niiden arvon määrittelevien muuttujien nimet, voidaan olion määrittely kirjoittaa tiivimmässä muodossa: +Koska olion kenttien nimet ovat samat kuin niiden arvon määrittelevien muuttujien nimet, voidaan olion määrittely kirjoittaa tiiviimmässä muodossa: ```js { @@ -557,16 +558,16 @@ const update = (id, newObject) => { export default { getAll, create, update } // highlight-line ``` -Tässä tiiviimmässä olioiden määrittelytavassa hyödynnetään ES6:n myötä Javascriptiin tullutta [uutta ominaisuutta](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer#Property_definitions), joka mahdollistaa hieman tiiviimmän tavan muuttujien avulla tapahtuvaan olioiden määrittelyyn. +Tässä tiiviimmässä olioiden määrittelytavassa hyödynnetään ES6:n myötä JavaScriptiin tullutta [uutta ominaisuutta](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer#Property_definitions), joka mahdollistaa hieman tiiviimmän tavan muuttujien avulla tapahtuvaan olioiden määrittelyyn. -Havainnollistaaksemme asiaa tarkastellaan tilannetta, jossa meillä on muuttujissa arvoja +Havainnollistaaksemme asiaa tarkastellaan tilannetta, jossa meillä on muuttujissa arvoja: ```js const name = 'Leevi' const age = 0 ``` -Vanhassa Javascriptissä olio täytyi määritellä seuraavaan tyyliin +Vanhassa JavaScriptissä olio täytyi määritellä seuraavaan tyyliin: ```js const person = { @@ -575,19 +576,19 @@ const person = { } ``` -koska muuttujien ja luotavan olion kenttien nimi nyt on sama, riittää ES6:ssa kirjoittaa: +Koska muuttujien ja luotavan olion kenttien nimi nyt on sama, riittää ES6:ssa kirjoittaa: ```js const person = { name, age } ``` -lopputulos molemmissa on täsmälleen sama, eli ne luovat olion jonka kentän name arvo on Leevi ja kentän age arvo 0. +Lopputulos molemmissa on täsmälleen sama, eli ne luovat olion, jonka kentän name arvo on Leevi ja kentän age arvo 0. ### Promise ja virheet -Jos sovelluksemme mahdollistaisi muistiinpanojen poistamisen, voisi syntyä tilanne, missä käyttäjä yrittää muuttaa sellaisen muistiinpanon tärkeyttä, joka on jo poistettu järjestelmästä. +Jos sovelluksemme mahdollistaisi muistiinpanojen poistamisen, voisi syntyä tilanne, jossa käyttäjä yrittää muuttaa sellaisen muistiinpanon tärkeyttä, joka on jo poistettu järjestelmästä. -Simuloidaan tälläistä tilannetta "kovakoodaamalla" noteServiceen funktioon getAll muistiinpano, jota ei ole todellisuudessa (eli palvelimella) olemassa: +Simuloidaan tällaista tilannetta "kovakoodaamalla" noteServiceen funktioon getAll muistiinpano, jota ei ole todellisuudessa (eli palvelimella) olemassa: ```js const getAll = () => { @@ -595,26 +596,25 @@ const getAll = () => { const nonExisting = { id: 10000, content: 'This note is not saved to server', - date: '2019-05-30T17:30:31.098Z', important: true, } return request.then(response => response.data.concat(nonExisting)) } ``` -Kun valemuistiinpanon tärkeyttä yritetään muuttaa, konsoliin tulee virheilmoitus, joka kertoo palvelimen vastanneen urliin /notes/10000 tehtyyn HTTP PUT -pyyntöön statuskoodilla 404 not found: +Kun valemuistiinpanon tärkeyttä yritetään muuttaa, konsoliin tulee virheilmoitus, joka kertoo palvelimen vastanneen urliin /notes/10000 tehtyyn HTTP PUT ‑pyyntöön statuskoodilla 404 not found: ![](../../images/2/23e.png) Sovelluksen tulisi pystyä käsittelemään tilanne hallitusti. Jos konsoli ei ole auki, ei käyttäjä huomaa mitään muuta kuin sen, että muistiinpanon tärkeys ei vaihdu napin painelusta huolimatta. -Jo [aiemmin](/osa2/palvelimella_olevan_datan_hakeminen#axios-ja-promiset) mainittiin, että promisella voi olla kolme tilaa. Kun HTTP-pyyntö epäonnistuu, menee pyyntöä vastaava promise tilaan rejected. Emme tällä hetkellä käsittele koodissamme promisen epäonnistumista mitenkään. +Jo [aiemmin](/osa2/palvelimella_olevan_datan_hakeminen#axios-ja-promiset) mainittiin, että promisella voi olla kolme tilaa. Kun axioksella tehty HTTP-pyyntö epäonnistuu, menee pyyntöä vastaava promise tilaan rejected. Emme tällä hetkellä käsittele koodissamme promisen epäonnistumista mitenkään. -Promisen epäonnistuminen [käsitellään](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises) antamalla then-metodille parametriksi myös toinen takaisinkutsufunktio, jota kutsutaan siinä tapauksessa jos promise epäonnistuu. +Promisen epäonnistuminen [käsitellään](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises) antamalla then-metodille parametriksi myös toinen takaisinkutsufunktio, jota kutsutaan promisen epäonnistuessa. Ehkä yleisempi tapa kuin kahden tapahtumankäsittelijän käyttö on liittää promiseen epäonnistumistilanteen käsittelijä kutsumalla metodia [catch](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch). -Käytännössä virhetilanteen käsittelijän rekisteröiminen tapahtuisi seuraavasti +Käytännössä virhetilanteen käsittelijän rekisteröiminen tapahtuisi seuraavasti: ```js axios @@ -631,24 +631,24 @@ Jos pyyntö epäonnistuu, kutsutaan catch-metodin avulla rekisteröity Metodia catch hyödynnetään usein siten, että se sijoitetaan syvemmälle promiseketjuun. -Kun sovelluksemme tekee HTTP-operaation syntyy oleellisesti ottaen [promiseketju](https://javascript.info/promise-chaining): +Kun useita _.then_-metodeja ketjutetaan yhteen, syntyy oleellisesti ottaen [promiseketju](https://javascript.info/promise-chaining): ```js axios - .put(`${baseUrl}/${id}`, newObject) + .get('http://...') .then(response => response.data) - .then(changedNote => { + .then(data => { // ... }) ``` -Metodilla catch voidaan määritellä ketjun lopussa käsittelijäfunktio, jota kutsutaan siinä vaiheessa jos mikä tahansa ketjun promisesta epäonnistuu, eli menee tilaan rejected: +Metodilla catch voidaan määritellä ketjun lopussa käsittelijäfunktio, jota kutsutaan, jos mikä tahansa ketjun promiseista epäonnistuu eli menee tilaan rejected: ```js axios - .put(`${baseUrl}/${id}`, newObject) + .get('http://...') .then(response => response.data) - .then(changedNote => { + .then(data => { // ... }) .catch(error => { @@ -656,7 +656,7 @@ axios }) ``` -Hyödynnetään tätä ominaisuutta, ja sijoitetaan virheenkäsittelijä komponenttiin App: +Hyödynnetään tätä ominaisuutta. Sijoitetaan sovelluksemme virheenkäsittelijä komponenttiin App: ```js const toggleImportanceOf = id => { @@ -680,43 +680,60 @@ const toggleImportanceOf = id => { Virheilmoitus annetaan vanhan kunnon [alert](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert)-dialogin avulla ja palvelimelta poistettu muistiinpano poistetaan tilasta. -Olemattoman muistiinpanon poistaminen siis tapahtuu metodilla [filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter), joka muodostaa uuden taulukon, jonka sisällöksi tulee alkuperäisen taulukon sisällöstä ne alkiot, joille parametrina oleva funktio palauttaa arvon true: +Olemattoman muistiinpanon poistaminen tapahtuu siis metodilla [filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter), joka muodostaa uuden taulukon, jonka sisällöksi tulee alkuperäisen taulukon sisällöstä ne alkiot, joille parametrina oleva funktio palauttaa arvon true: ```js notes.filter(n => n.id !== id) ``` -Alertia tuskin kannattaa käyttää todellisissa React-sovelluksissa. Opimme kohta kehittyneemmän menetelmän käyttäjille tarkoitettujen tiedotteiden antamiseen. Toisaalta on tilanteita, joissa simppeli battle tested -menetelmä kuten alert riittää aluksi aivan hyvin. Hienomman tavan voi sitten tehdä myöhemmin jos aikaa ja intoa riittää. +Alertia tuskin kannattaa käyttää todellisissa React-sovelluksissa. Opimme kohta kehittyneemmän menetelmän käyttäjille tarkoitettujen tiedotteiden antamiseen. Toisaalta on tilanteita, joissa simppeli battle tested ‑menetelmä kuten alert riittää aluksi aivan hyvin. Hienomman tavan voi sitten tehdä myöhemmin jos aikaa ja intoa riittää. + +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-6), branchissa part2-6. + +### Full stack ‑sovelluskehittäjän vala + +On taas tehtävien aika. Tehtävien haastavuus alkaa nousta, sillä koodin toimivuuteen vaikuttaa myös se, kommunikoiko React-koodi oikein JSON Serverin kanssa. + +Meidän onkin syytä päivittää websovelluskehittäjän vala Full stack ‑sovelluskehittäjän valaksi, eli muistuttaa itseämme siitä, että frontendin koodin lisäksi seuraamme koko ajan sitä, miten frontend ja backend kommunikoivat. + +Full stack ‑ohjelmointi on todella hankalaa, ja sen takia lupaan hyödyntää kaikkia ohjelmointia helpottavia keinoja: + +- pidän selaimen konsolin koko ajan auki +- tarkkailen säännöllisesti selaimen network-välilehdeltä, että frontendin ja backendin välinen kommunikaatio tapahtuu oletusteni mukaan +- tarkkailen säännöllisesti palvelimella olevan datan tilaa, ja varmistan että frontendin lähettämä data siirtyy sinne kuten oletin +- etenen pienin askelin +- käytän koodissa runsaasti _console.log_-komentoja varmistamaan sen, että varmasti ymmärrän jokaisen kirjoittamani koodirivin, sekä etsiessäni koodista mahdollisia bugin aiheuttajia +- jos koodini ei toimi, en kirjoita enää yhtään lisää koodia, vaan alan poistamaan toiminnan rikkoneita rivejä tai palaan suosiolla tilanteeseen, missä koodi vielä toimi +- kun kysyn apua kurssin Discord-kanavalla, tai muualla internetissä, muotoilen kysymyksen järkevästi, esim. [täällä](/en/part0/general_info#how-to-get-help-in-discord) esiteltyyn tapaan -Sovelluksen tämän hetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part2-notes/tree/part2-6), branchissa part2-6.
    -

    Tehtävät 2.15.-2.18.

    +

    Tehtävät 2.12.-2.15.

    -

    2.15: puhelinluettelo step7

    +

    2.12: puhelinluettelo step7

    Palataan jälleen puhelinluettelon pariin. Tällä hetkellä luetteloon lisättäviä uusia numeroita ei synkronoida palvelimelle. Korjaa tilanne. -

    2.16: puhelinluettelo step8

    +

    2.13: puhelinluettelo step8

    Siirrä palvelimen kanssa kommunikoinnista vastaava toiminnallisuus omaan moduuliin tämän osan materiaalissa olevan esimerkin tapaan. -

    2.17: puhelinluettelo step9

    +

    2.14: puhelinluettelo step9

    Tee ohjelmaan mahdollisuus yhteystietojen poistamiseen. Poistaminen voi tapahtua esim. nimen yhteyteen liitetyllä napilla. Poiston suorittaminen voidaan varmistaa käyttäjältä [window.confirm](https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm)-metodilla: ![](../../images/2/24e.png) -Palvelimelta tiettyä henkilöä vastaava resurssi tuhotaan tekemällä HTTP DELETE -pyyntö resurssia vastaavaan URL:iin, eli jos poistaisimme esim. käyttäjän, jonka id on 2, tulisi tapauksessamme tehdä HTTP DELETE osoitteeseen localhost:3001/persons/2. Pyynnön mukana ei lähetetä mitään dataa. +Tiettyä henkilöä vastaava resurssi tuhotaan palvelimelta tekemällä HTTP DELETE ‑pyyntö resurssia vastaavaan URL:iin. Eli jos poistaisimme esim. käyttäjän, jonka id on 2, tulisi tapauksessamme tehdä HTTP DELETE osoitteeseen localhost:3001/persons/2. Pyynnön mukana ei lähetetä mitään dataa. -[Axios](https://github.com/axios/axios)-kirjaston avulla HTTP DELETE -pyyntö tehdään samaan tapaan kuin muutkin pyynnöt. +[Axios](https://github.com/axios/axios)-kirjaston avulla HTTP DELETE ‑pyyntö tehdään samaan tapaan kuin muutkin pyynnöt. -**Huom:** et voi käyttää Javascriptissa muuttujan nimeä delete sillä kyseessä on kielen varattu sana, eli seuraava ei onnistu: +**Huom:** et voi käyttää JavaScriptissa muuttujan nimeä delete, sillä kyseessä on kielen varattu sana. Eli seuraava ei onnistu: ```js // käytä jotain muuta muuttujan nimeä @@ -725,11 +742,13 @@ const delete = (id) => { } ``` -

    2.18*: puhelinluettelo step10

    +

    2.15*: puhelinluettelo step10

    + +Miksi tehtävä on merkattu tähdellä? Selitys asiaan [täällä](/osa0/yleista#suoritustapa). -Muuta toiminnallisuutta siten, että jos jo olemassaolevalle henkilölle lisätään numero, korvaa lisätty numero aiemman numeron. Korvaaminen kannattaa tehdä HTTP PUT -pyynnöllä. +Muuta toiminnallisuutta siten, että jos jo olemassa olevalle henkilölle lisätään numero, korvaa lisätty numero aiemman numeron. Korvaaminen kannattaa tehdä HTTP PUT ‑pyynnöllä. -Jos henkilön tiedot löytyvät jo luettelosta, voi ohjelma kysyä käyttäjältä varmistuksen korvataanko numero: +Jos henkilön tiedot löytyvät jo luettelosta, voi ohjelma kysyä käyttäjältä varmistuksen: ![](../../images/teht/16e.png) diff --git a/src/content/2/fi/osa2e.md b/src/content/2/fi/osa2e.md index 45c42958f75..251c0976923 100644 --- a/src/content/2/fi/osa2e.md +++ b/src/content/2/fi/osa2e.md @@ -7,11 +7,11 @@ lang: fi
    -Sovelluksemme ulkoasu on tällä hetkellä hyvin vaatimaton. Osaan 0 liittyvässä [tehtävässä 0.2](/osa0/web_sovelluksen_toimintaperiaatteita#tehtavia) oli tarkoitus tutustua Mozillan [CSS-tutoriaaliin](https://developer.mozilla.org/en-US/docs/Learn/Getting_started_with_the_web/CSS_basics). +Sovelluksemme ulkoasu on tällä hetkellä hyvin vaatimaton. Osaan 0 liittyvässä [tehtävässä 0.2](/osa0/web_sovelluksen_toimintaperiaatteita#tehtavia) tutustuttiin Mozillan [CSS-tutoriaaliin](https://developer.mozilla.org/en-US/docs/Learn/Getting_started_with_the_web/CSS_basics). -Katsotaan vielä tämän osan lopussa nopeasti kahta tapaa liittää tyylejä React-sovellukseen. Tapoja on useita ja tulemme tarkastelemaan muita myöhemmin. Liitämme ensin CSS:n sovellukseemme vanhan kansan tapaan yksittäisenä, käsin eli ilman [esiprosessorien](https://developer.mozilla.org/en-US/docs/Glossary/CSS_preprocessor) apua kirjoitettuna tiedostona (tämä ei itseasiassa ole täysin totta, kuten myöhemmin tulemme huomaamaan). +Katsotaan vielä tämän osan lopussa nopeasti kahta tapaa liittää tyylejä React-sovellukseen. Tapoja on useita, ja tulemme tarkastelemaan muita myöhemmin. Ensimmäisenä liitämme CSS:n sovellukseemme vanhan kansan tapaan yksittäisenä tiedostona, joka on kirjoitettu käsin ilman [esiprosessorien](https://developer.mozilla.org/en-US/docs/Glossary/CSS_preprocessor) apua (tulemme myöhemmin huomaamaan, että tämä ei ole täysin totta). -Tehdään sovelluksen hakemistoon src tiedosto index.css ja liitetään se sovellukseen lisäämällä tiedostoon index.js seuraava import: +Tehdään sovelluksen hakemistoon src tiedosto index.css ja liitetään se sovellukseen lisäämällä tiedostoon main.jsx seuraava import: ```js import './index.css' @@ -25,11 +25,11 @@ h1 { } ``` -CSS-säännöt koostuvat valitsimesta, eli selektorista ja määrittelystä eli deklaraatiosta. Valitsin määrittelee, mihin elementteihin sääntö kohdistuu. Valitsimena on nyt h1, eli kaikki sovelluksessa käytetyt h1-otsikkotägit. +CSS-säännöt koostuvat valitsimesta eli selektorista ja määrittelystä eli deklaraatiosta. Valitsin määrittelee, mihin elementteihin sääntö kohdistuu. Valitsimena on nyt h1 eli kaikki sovelluksessa käytetyt h1-otsikkotägit. -Määrittelyosa asettaa ominaisuuden _color_, eli fontin värin arvoksi vihreän, eli green. +Määrittelyosa asettaa ominaisuuden _color_ eli fontin värin arvoksi vihreän (green). -Sääntö voi sisältää mielivaltaisen määrän määrittelyjä. Muutetaan edellistä siten, että tekstistä tulee kursivoitua, eli fontin tyyliksi asetetaan italics: +Sääntö voi sisältää mielivaltaisen määrän määrittelyjä. Muutetaan edellistä siten, että tekstistä tulee kursivoitua eli fontin tyyliksi asetetaan italic: ```css h1 { @@ -40,13 +40,13 @@ h1 { Erilaisia selektoreja eli tapoja valita tyylien kohde on [lukuisia](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors). -Jos haluamme kohdistaa tyylejä esim. jokaiseen muistiinpanoon, voisimme nyt käyttää selektoria li, sillä muistiinpanot ovat li-tagien sisällä: +Jos haluamme kohdistaa tyylejä esim. jokaiseen muistiinpanoon, voisimme käyttää selektoria li, sillä muistiinpanot ovat li-tagien sisällä: ```js const Note = ({ note, toggleImportance }) => { const label = note.important ? 'make not important' - : 'make important'; + : 'make important' return (
  • @@ -57,7 +57,7 @@ const Note = ({ note, toggleImportance }) => { } ``` -lisätään tyylitiedostoon seuraava (koska osaamiseni tyylikkäiden web-sivujen tekemiseen on lähellä nollaa, nyt käytettävissä tyyleissä ei ole sinänsä mitään järkeä): +Lisätään tyylitiedostoon seuraava (koska osaamiseni tyylikkäiden web-sivujen tekemiseen on lähellä nollaa, nyt käytettävissä tyyleissä ei ole sinänsä mitään järkeä): ```css li { @@ -67,23 +67,23 @@ li { } ``` -Tyylien kohdistaminen elementtityypin sijaan on kuitenkin hieman ongelmallista, jos sovelluksessa olisi myös muita li-tageja, kaikki saisivat samat tyylit. +Tyylien kohdistaminen elementtityyppeihin on kuitenkin ongelmallista. Jos sovelluksessa on myös muita li-tageja, kaikki saavat samat tyylit. Jos haluamme kohdistaa tyylit nimenomaan muistiinpanoihin, on parempi käyttää [class selectoreja](https://developer.mozilla.org/en-US/docs/Web/CSS/Class_selectors). -Normaalissa HTML:ssä luokat määritellään elementtien attribuutin class arvona: +Normaalissa HTML:ssä luokat määritellään elementtien attribuutin class arvona: ```html
  • tekstiä
  • ``` -Reactissa tulee kuitenkin classin sijaan käyttää attribuuttia [className](https://reactjs.org/docs/dom-elements.html#classname), eli muutetaan komponenttia Note seuraavasti: +Reactissa tulee kuitenkin classin sijaan käyttää attribuuttia [className](https://react.dev/learn#adding-styles), joten muutetaan komponenttia Note seuraavasti: ```js const Note = ({ note, toggleImportance }) => { const label = note.important ? 'make not important' - : 'make important'; + : 'make important' return (
  • // highlight-line @@ -94,7 +94,7 @@ const Note = ({ note, toggleImportance }) => { } ``` -Luokkaselektori määritellään syntaksilla _.classname_, eli: +Luokkaselektori määritellään syntaksilla _.classname_ eli: ```css .note { @@ -108,7 +108,7 @@ Jos nyt lisäät sovellukseen muita li-elementtejä, ne eivät saa muistiinpanoi ### Parempi virheilmoitus -Toteutimme äsken olemassaolemattoman muistiinpanon tärkeyden muutokseen liittyvän virheilmoituksen alert-metodilla. Toteutetaan se nyt Reactilla omana komponenttinaan. +Aiemmin toteutimme olemassa olemattoman muistiinpanon tärkeyden muutokseen liittyvän virheilmoituksen alert-metodilla. Toteutetaan se nyt Reactilla omana komponenttinaan. Komponentti on yksinkertainen: @@ -126,9 +126,9 @@ const Notification = ({ message }) => { } ``` -Jos propsin message arvo on null ei renderöidä mitään, muussa tapauksessa renderöidään viesti div-elementtiin. Elementille on liitetty tyylien lisäämistä varten luokka error. +Jos propsin message arvo on null, ei renderöidä mitään. Muussa tapauksessa renderöidään viesti div-elementtiin. Elementille on liitetty tyylien lisäämistä varten luokka error. -Lisätään komponentin App tilaan kenttä error virheviestiä varten, laitetaan kentälle heti jotain sisältöä, jotta pääsemme heti testaamaan komponenttia: +Lisätään komponentin App tilaan kenttä errorMessage virheviestiä varten. Laitetaan kentälle heti jotain sisältöä, jotta pääsemme testaamaan komponenttia: ```js const App = () => { @@ -176,7 +176,7 @@ Nyt olemme valmiina lisäämään virheviestin logiikan. Muutetaan metodia t const changedNote = { ...note, important: !note.important } noteService - .update(changedNote).then(returnedNote => { + .update(id, changedNote).then(returnedNote => { setNotes(notes.map(note => note.id !== id ? note : returnedNote)) }) .catch(error => { @@ -193,60 +193,69 @@ Nyt olemme valmiina lisäämään virheviestin logiikan. Muutetaan metodia t } ``` -Eli virheen yhteydessä asetetaan tilaan errorMessage sopiva virheviesti. Samalla käynnistetään ajastin, joka asettaa 5 sekunnin kuluttua tilan errorMessage-kentän arvoksi null. +Virheen yhteydessä asetetaan tilaan errorMessage sopiva virheviesti. Samalla käynnistetään ajastin, joka asettaa viiden sekunnin kuluttua tilan errorMessage-kentän arvoksi null. -Lopputulos näyttää seuraavalta +Lopputulos näyttää seuraavalta: ![](../../images/2/26e.png) -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa]https://github.com/fullstack-hy2020/part2-notes/tree/part2-7), branchissa part2-7. +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-7), branchissa part2-7. ### Inline-tyylit -React mahdollistaa myös tyylien kirjoittamisen suoraan komponenttien koodin joukkoon niin sanoittuina [inline-tyyleinä](https://react-cn.github.io/react/tips/inline-styles.html). +React mahdollistaa tyylien kirjoittamisen myös suoraan komponenttien koodin joukkoon niin sanoittuina [inline-tyyleinä](https://react-cn.github.io/react/tips/inline-styles.html). -Periaate inline-tyylien määrittelyssä on erittäin yksinkertainen. Mihin tahansa React-komponenttiin tai elementtiin voi liittää attribuutin [style](https://reactjs.org/docs/dom-elements.html#style), jolle annetaan arvoksi Javascript-oliona määritelty joukko CSS-sääntöjä. +Periaate inline-tyylien määrittelyssä on erittäin yksinkertainen. Mihin tahansa React-komponenttiin tai elementtiin voi liittää attribuutin [style](https://react.dev/reference/react-dom/components/common#applying-css-styles), jolle annetaan arvoksi JavaScript-oliona määritelty joukko CSS-sääntöjä. -CSS-säännöt määritellään Javascriptin avulla hieman eri tavalla kuin normaaleissa CSS-tiedostoissa. Jos haluamme esimerkiksi asettaa jollekin elementille vihreän, kursivoidun ja 16 pikselin korkuisen fontin, eli CSS-syntaksilla ilmaistuna +CSS-säännöt määritellään JavaScriptin avulla hieman eri tavalla kuin normaaleissa CSS-tiedostoissa. Jos haluamme asettaa jollekin elementille esimerkiksi vihreän ja kursivoidun fontin, määrittely ilmaistaan CSS-syntaksilla seuraavasti: ```css { color: green; font-style: italic; - font-size: 16px; } ``` -tulee tämä muotoilla Reactin inline-tyylin määrittelevänä oliona seuraavasti +Vastaava tyyli kirjoitetaan Reactin inline-tyylin määrittelevänä oliona seuraavasti: ```js - { +{ color: 'green', - fontStyle: 'italic', - fontSize: 16 + fontStyle: 'italic' } ``` -Jokainen CSS-sääntö on olion kenttä, joten ne erotetaan Javascript-syntaksin mukaan pilkuilla. Pikseleinä ilmaistut numeroarvot voidaan määritellä kokonaislukuina. Merkittävin ero normaaliin CSS:ään on väliviivan sisältämien CSS-ominaisuuksien kirjoittaminen camelCase-muodossa. +Jokainen CSS-sääntö on olion kenttä, joten ne erotetaan JavaScript-syntaksin mukaan pilkuilla. Pikseleinä ilmaistut numeroarvot voidaan määritellä kokonaislukuina. Merkittävin ero normaaliin CSS:ään on väliviivan sisältämien CSS-ominaisuuksien kirjoittaminen camelCase-muodossa. -Voisimme nyt lisätä sovelluksemme "alapalkin", muodostavan komponentin Footer, ja määritellä sille inline-tyylit seuraavasti: +Lisätään sovellukseemme alapalkin muodostava komponentti Footer ja määritellään sille inline-tyylit. Määritellään komponentti tiedostossa _components/Footer.jsx_ otetaan se käyttöön tiedostossa _App.jsx_ seuraavasti: ```js const Footer = () => { const footerStyle = { color: 'green', - fontStyle: 'italic', - fontSize: 16 + fontStyle: 'italic' } return (

    - Note app, Department of Computer Science, University of Helsinki 2020 +

    + Note app, Department of Computer Science, University of Helsinki 2025 +

    ) } +export default Footer +``` + +```js +import { useState, useEffect } from 'react' +import Footer from './components/Footer' // highlight-line +import Note from './components/Note' +import Notification from './components/Notification' +import noteService from './services/notes' + const App = () => { // ... @@ -266,39 +275,365 @@ const App = () => { Inline-tyyleillä on tiettyjä rajoituksia, esim. ns. pseudo-selektoreja ei ole mahdollisuutta käyttää (ainakaan helposti). -Inline-tyylit ja muutamat myöhemmin kurssilla katsomamme tavat lisätä tyylejä Reactiin ovat periaatteessa täysin vastoin vanhoja hyviä periaatteita, joiden mukaan Web-sovellusten ulkoasujen määrittely eli CSS tulee erottaa sisällön (HTML) ja toiminnallisuuden (Javascript) määrittelystä. Vanha koulukunta pyrkiikin siihen että sovelluksen CSS, HTML ja Javascript on kaikki kirjoitettu omiin tiedostoihinsa. +Inline-tyylit ja muutamat myöhemmin kurssilla katsomamme tavat lisätä tyylejä Reactiin ovat periaatteessa täysin vastoin vanhoja hyviä periaatteita, joiden mukaan web-sovellusten ulkoasujen määrittely eli CSS tulee erottaa sisällön (HTML) ja toiminnallisuuden (JavaScript) määrittelystä. Vanha koulukunta pyrkiikin siihen, että sovelluksen CSS, HTML ja JavaScript kirjoitetaan omiin tiedostoihinsa. -Itseasiassa Reactin filosofia on täysin päinvastainen. Koska CSS:n, HTML:n ja Javascriptin erottelu eri tiedostoihin ei ole kuitenkaan osoittautunut erityisen skaalautuvaksi ratkaisuksi suurissa ohjelmistoissa, on Reactissa periaatteena tehdä erottelu (eli jakaa sovelluksen koodi eri tiedostoihin) noudattaen sovelluksen loogisia toiminnallisia kokonaisuuksia. +CSS:n, HTML:n ja JavaScriptin erottelu omiin tiedostoihinsa ei kuitenkaan ole välttämättä erityisen skaalautuva ratkaisu suurissa ohjelmistoissa. Reactissa onkin periaatteena jakaa sovelluksen koodi eri tiedostoihin noudattaen sovelluksen loogisia toiminnallisia kokonaisuuksia. -Toiminnallisen kokonaisuuden strukturointiyksikkö on React-komponentti, joka määrittelee niin sisällön rakenteen kuvaavan HTML:n, toiminnan määrittelevät Javascript-funktiot kuin komponentin tyylinkin yhdessä paikassa, siten että komponenteista tulee mahdollisimman riippumattomia ja yleiskäyttöisiä. +Toiminnallisen kokonaisuuden strukturointiyksikkö on React-komponentti, joka määrittelee niin sisällön rakenteen kuvaavan HTML:n, toiminnan määrittelevät JavaScript-funktiot kuin komponentin tyylinkin yhdessä paikassa siten, että komponenteista tulee mahdollisimman riippumattomia ja yleiskäyttöisiä. -Sovelluksen lopullinen koodi on kokonaisuudessaan [githubissa]https://github.com/fullstack-hy2020/part2-notes/tree/part2-8), branchissa part2-8. +Sovelluksen lopullinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-8), branchissa part2-8.
  • -

    Tehtävät 2.19.-2.20.

    +

    Tehtävät 2.16.-2.17.

    -

    2.19: puhelinluettelo step11

    +

    2.16: puhelinluettelo step11

    -Toteuta osan 2 esimerkin [parempi virheilmoitus](/osa2/tyylien_lisaaminen_react_sovellukseen#parempi-virheilmoitus) tyyliin ruudulla muutaman sekunnin näkyvä ilmoitus, joka kertoo onnistuneista operaatioista (henkilön lisäys ja poisto, sekä numeron muutos): +Toteuta osan 2 esimerkin [parempi virheilmoitus](/osa2/tyylien_lisaaminen_react_sovellukseen#parempi-virheilmoitus) tyyliin ruudulla muutaman sekunnin näkyvä ilmoitus, joka kertoo onnistuneista operaatioista (henkilön lisäys ja poisto sekä numeron muutos): ![](../../images/2/27e.png) -

    2.20*: puhelinluettelo step12

    +

    2.17*: puhelinluettelo step12

    -Avaa sovelluksesi kahteen selaimeen. **Jos poistat jonkun henkilön selaimesta 1** hieman ennen kun yrität muuttaa henkilön numeroa selaimesta 2, tapahtuu virhetilanne: +Avaa sovelluksesi kahteen selaimeen. **Jos poistat jonkun henkilön selaimella 1** hieman ennen kuin yrität muuttaa henkilön numeroa selaimella 2, tapahtuu virhetilanne: ![](../../images/2/29b.png) -Korjaa ongelma osan 2 esimerkin [promise ja virheet](/osa2/palvelimella_olevan_datan_muokkaaminen#promise-ja-virheet) hengessä, mutta siten että - käyttäjälle ilmoitetaan operaation epäonnistumisesta. Onnistuneen ja epäonnistuneen operaation ilmoitusten tulee erota toisistaan: +Korjaa ongelma osan 2 esimerkin [promise ja virheet](/osa2/palvelimella_olevan_datan_muokkaaminen#promise-ja-virheet) hengessä ja siten, että käyttäjälle ilmoitetaan operaation epäonnistumisesta. Onnistuneen ja epäonnistuneen operaation ilmoitusten tulee erota toisistaan: ![](../../images/2/28e.png) -**HUOM** vaikka käsittelet poikkeuksen koodissa, virheilmoitus tulostuu silti konsoliin. +**HUOM**: Vaikka käsittelet poikkeuksen koodissa, virheilmoitus tulostuu silti konsoliin. + +
    + +
    + +### Muutama tärkeä huomio + +Osan lopussa on vielä muutama hieman haastavampi tehtävä. Voit tässä vaiheessa jättää tehtävät tekemättä jos ne tuottavat liian paljon päänvaivaa, palaamme samoihin teemoihin uudelleen myöhemmin. Materiaali kannattanee jokatapauksessa lukea läpi. + +Eräs sovelluksessa tekemämme ratkaisu piilottaa yhden hyvin tyypillisen virhetilanteen, mihin tulet varmasti törmäämään monta kertaa. + +Alustimme muistiinpanot muistavan tilan alkuarvoksi tyhjän taulukon: + +```js +const App = () => { + const [notes, setNotes] = useState([]) + + // ... +} +``` + +Tämä onkin luonnollinen tapa alustaa tila, muistiinpanot muodostavat joukon, joten tyhjä taulukko on luonteva alkuarvo muuttujalle. + +Niissä tilanteissa, missä tilaan talletetaan "yksi asia" tilan luonteva alkuarvo on usein _null_, joka kertoo että tilassa ei ole vielä mitään. Kokeillaan miten käy jos alustamme nyt tilan nulliksi: + +```js +const App = () => { + const [notes, setNotes] = useState(null) // highlight-line + + // ... +} +``` + +Sovellus hajoaa: + +![](../../images/2/31a.png) + +Virheilmoitus kertoo vian syyn ja sijainnin. Ongelmallinen kohta on seuraava: + +```js + // notesToShow gets the value of notes + const notesToShow = showAll + ? notes + : notes.filter(note => note.important) + + // ... + + {notesToShow.map(note => // highlight-line + + )} +``` + +Virheilmoitus siis on + +``` +Cannot read properties of null (reading 'map') +``` + +Muuttuja _notesToShow_ saa arvokseen tilan _notes_ arvon ja koodi yrittää kutsua olemattomalle oliolle (jonka arvo on null) metodia map. + +Mistä tämä johtuu? + +Effect-hook asettaa tilaan _notes_ palvelimen palauttamat muistiinpanot funktiolla _setNotes_: + +```js + useEffect(() => { + noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) // highlight-line + }) + }, []) +``` + +Ongelma on kuitenkin siinä, että efekti suoritetaan vasta ensimmäisen renderöinnin jälkeen. Koska tilalle _notes_ on asetettu alkuarvo null: + +```js +const App = () => { + const [notes, setNotes] = useState(null) // highlight-line + + // ... +``` + +ensimmäisen renderöinnin tapahtuessa tullaan suorittamaan + +```js +notesToShow = notes + +// ... + +notesToShow.map(note => ...) +``` + +ja tämä aiheuttaa ongelman, sillä arvolle _null_ ei voida kutsua metodia _map_. + +Kun annoimme tilalle _notes_ alkuarvoksi tyhjän taulukon, ei samaa ongelmaa esiinny, tyhjälle taulukolle on luvallista kutsua metodia _map_. + +Sopiva tilan alustaminen siis "peitti" ongelman, joka johtuu siitä että muistiinpanoja ei ole vielä alustettu palvelimelta haettavalla datalla. + +Toinen tapa kiertää ongelma on tehdä ehdollinen renderöinti, ja palauttaa ainoastaan _null_ jos komponentin tila ei ole vielä alustettu: + +```js +const App = () => { + const [notes, setNotes] = useState(null) // highlight-line + // ... + + useEffect(() => { + noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) + }) + }, []) + + // do not render anything if notes is still null + // highlight-start + if (!notes) { + return null + } + // highlight-end + + // ... +} +``` + +Nyt ensimmäisellä renderöinnillä ei renderöidä mitään. Kun muistiinpanot saapuvat palvelimelta, asetetaan ne tilaan _notes_ kutsumalla funktiota _setNotes_. Tämä saa aikaan uuden renderöinnin ja muistiinpanot piirtyvät ruudulle. + +Tämä tapa sopii erityisesti niihin tilanteisiin, joissa tilaa ei voi alustaa muuten komponentille sopivaan, renderöinnin mahdollistavaan alkuarvoon kuten tyhjäksi taulukoksi. + +Toinen huomiomme liittyy useEffectin toiseen parametriin: + +```js + useEffect(() => { + noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) + }) + }, []) // highlight-line +``` + +Funktion useEffect toista parametria käytetään [tarkentamaan sitä, miten usein efekti suoritetaan](https://react.dev/reference/react/useEffect#parameters). Periaate on se, että efekti suoritetaan aina ensimmäisen renderöinnin yhteydessä ja silloin kuin toisena parametrina olevan taulukon sisältö muuttuu. + +Kun toisena parametrina on tyhjä taulukko [], sen sisältö ei koskaan muutu ja efekti suoritetaan ainoastaan komponentin ensimmäisen renderöinnin jälkeen. Tämä on juuri se mitä haluamme kun alustamme sovelluksen tilan. + +On kuitenkin tilanteita, missä efekti halutaan suorittaa muulloinkin, esim. komponentin tilan muuttuessa sopivalla tavalla. + +Tarkastellaan seuraavaa yksinkertaista sovellusta, jonka avulla voidaan kysellä valuuttojen vaihtokursseja [Exchange rate API](https://www.exchangerate-api.com/) ‑palvelusta: + +```js +import { useState, useEffect } from 'react' +import axios from 'axios' + +const App = () => { + const [value, setValue] = useState('') + const [rates, setRates] = useState({}) + const [currency, setCurrency] = useState(null) + + useEffect(() => { + console.log('effect run, currency is now', currency) + + // skip if currency is not defined + if (currency) { + console.log('fetching exchange rates...') + axios + .get(`https://open.er-api.com/v6/latest/${currency}`) + .then(response => { + setRates(response.data.rates) + }) + } + }, [currency]) + + const handleChange = (event) => { + setValue(event.target.value) + } + + const onSearch = (event) => { + event.preventDefault() + setCurrency(value) + } + + return ( +
    +
    + currency: + +
    +
    +        {JSON.stringify(rates, null, 2)}
    +      
    +
    + ) +} +``` + +Sovelluksen käyttöliittymässä on lomake, jonka syötekenttään halutun valuutan nimi kirjoitetaan. Jos valuutta on olemassa, renderöi sovellus valuutan vaihtokurssit muihin valuuttoihin: + +![](../../images/2/32new.png) + +Sovellus asettaa käyttäjän hakulomakkeelle kirjoittaman valuutan nimen tilaan _currency_ sillä hetkellä kun nappia painetaan. + +Kun _currency_ saa uuden arvon, sovellus tekee useEffectin määrittelemässä funktiossa haun valuuttakurssit kertovaan rajapintaan: + +```js +const App = () => { + // ... + const [currency, setCurrency] = useState(null) + + useEffect(() => { + console.log('effect run, currency is now', currency) + + // skip if currency is not defined + if (currency) { + console.log('fetching exchange rates...') + axios + .get(`https://open.er-api.com/v6/latest/${currency}`) + .then(response => { + setRates(response.data.rates) + }) + } + }, [currency]) // highlight-line + // ... +} +``` + +Efektifunktio siis suoritetaan ensimmäisen renderöinnin jälkeen, ja aina sen jälkeen kun sen toisena parametrina oleva taulukko eli esimerkin tapauksessa _[currency]_ muuttuu. Eli kun tila _currency_ saa uuden arvon, muuttuu taulukon sisältö ja efektifunktio suoritetaan. + +On luontevaa valita muuttujan _currency_ alkuarvoksi _null_, koska _currency_ kuvaa yksittäistä asiaa. Alkuarvo _null_ kertoo, että tilassa ei ole vielä mitään, ja tällöin on myös helppo tarkistaa yksinkertaisella if-lauseella, onko muuttujalle asetettu arvoa. Efektiin on tehty ehto + +```js +if (currency) { + // haetaan valuuttakurssit +} +``` + +joka estää valuuttakurssien hakemisen ensimmäisen renderöinnin yhteydessä, eli siinä vaiheessa kun muuttujalla _currency_ on vasta alkuarvo _null_. + +Jos käyttäjä siis kirjoittaa hakukenttään esim. eur, suorittaa sovellus Axiosin avulla HTTP GET ‑pyynnön osoitteeseen https://open.er-api.com/v6/latest/eur ja tallentaa vastauksen tilaan _rates_. + +Kun käyttäjä tämän jälkeen kirjoittaa hakukenttään jonkin toisen arvon, esim. usd suoritetaan efekti jälleen ja uuden valuutan kurssit haetaan. + +Tässä esitelty tapa API-kyselyjen tekemiseen saattaa tuntua hieman hankalalta. +Tämä kyseinen sovellus olisikin voitu tehdä kokonaan ilman useEffectin käyttöä, ja tehdä API-kyselyt suoraan lomakkeen napin painamisen hoitavassa käsittelijäfunktiossa: + +```js + const onSearch = (event) => { + event.preventDefault() + axios + .get(`https://open.er-api.com/v6/latest/${value}`) + .then(response => { + setRates(response.data.rates) + }) + } +``` + +On kuitenkin tilanteita, missä vastaava tekniikka ei onnistu. Esim. eräs tapa tehtävässä 2.20 tarvittavien kaupungin säätietojen hakemiseen on nimenomaan useEffectin hyödyntäminen. Tehtävässä selviää myös hyvin ilman kyseistä kikkaa, esim. malliratkaisu ei sitä tarvitse. + +
    + +
    + +

    Tehtävät 2.18.-2.20.

    + +

    2.18* maiden tiedot, step1

    + +Siirrytään osan lopuksi hieman toisenlaiseen teemaan. + +Osoitteesta [https://studies.cs.helsinki.fi/restcountries/](https://studies.cs.helsinki.fi/restcountries/) löytyy palvelu, joka tarjoaa paljon eri maihin liittyvää tietoa koneluettavassa muodossa ns. REST API:n välityksellä. Tee sovellus, jonka avulla voit tarkastella eri maiden tietoja. + +Sovelluksen käyttöliittymä on yksinkertainen. Näytettävä maa haetaan kirjoittamalla hakuehto hakukenttään. + +Jos ehdon täyttäviä maita on liikaa (yli kymmenen), kehotetaan tarkentamaan hakuehtoa: + +![](../../images/2/19b1.png) + +Jos maita on kymmenen tai alle mutta enemmän kuin yksi, näytetään hakuehdon täyttävät maat: + +![](../../images/2/19b2.png) + +Kun ehdon täyttäviä maita on enää yksi, näytetään maan perustiedot, lippu sekä maassa puhutut kielet: + +![](../../images/2/19c3.png) + +**Huom1:** Riittää, että sovelluksesi toimii suurimmalle osalle maista. Jotkut maat kuten Sudan voivat tuottaa ongelmia, sillä maan nimi on toisen maan (South Sudan) osa. Näistä corner caseista ei tarvitse välittää. + +**Huom2:** Saatat törmätä ongelmiin tässä tehtävässä, jos määrittelet komponentteja "väärässä paikassa". Nyt kannattaakin ehdottomasti kerrata edellisen osan luku [älä määrittele komponenttia komponentin sisällä](/osa1/monimutkaisempi_tila_reactin_debuggaus#ala-maarittele-komponenttia-komponentin-sisalla). + +

    2.19*: maiden tiedot, step2

    + +**Tässä osassa on vielä paljon tekemistä, joten älä juutu tähän tehtävään!** + +Paranna edellisen tehtävän maasovellusta siten, että kun sivulla näkyy useiden maiden nimiä, tulee maan nimen viereen nappi, jota klikkaamalla pääsee suoraan maan näkymään: + +![](../../images/2/19b4.png) + +Tässäkin tehtävässä riittää, että ohjelmasi toimii suurella osalla maita ja maat, joiden nimi sisältyy johonkin muuhun maahan (kuten Sudan) voit unohtaa. + +

    2.20*: maiden tiedot, step3

    + + + +Lisää yksittäisen maan näkymään pääkaupungin säätiedotus. Säätiedotuksen tarjoavia palveluita on kymmeniä. Itse käytin [https://openweathermap.org/](https://openweathermap.org/):ia. Huomaa että api-avaimen luomisen jälkeen saattaa kulua hetki ennen kuin avain alkaa toimia. + +![](../../images/2/19x.png) + +Jos käytät Open weather mapia, [täällä](https://openweathermap.org/weather-conditions#Icon-list) on ohje sääikonien generointiin. + +**Huom:** Tarvitset melkein kaikkia säätietoja tarjoavia palveluja käyttääksesi API-avaimen. Älä talleta avainta versionhallintaan eli älä kirjoita avainta suoraan koodiin. Avaimen arvo kannattaa tässä tehtävässä määritellä ns. [ympäristömuuttujana](https://vitejs.dev/guide/env-and-mode.html). Todellisissa sovelluksissa avaimien lähettäminen suoraan selaimesta ei kuitenkaan ole suositeltavaa, koska tällöin kuka tahansa sovelluksen käyttäjä voi saada API-avaimen tietoonsa. Käsittelemme erillisen backendin toteuttamista kurssin seuraavassa osassa. + +Oletetaan että API-avaimen arvo on 54l41n3n4v41m34rv0. Kun ohjelma käynnistetään seuraavasti + +```bash +export VITE_SOME_KEY=54l41n3n4v41m34rv0 && npm run dev // Linux/macOS Bash +($env:VITE_SOME_KEY="54l41n3n4v41m34rv0") -and (npm run dev) // Windows PowerShell +set "VITE_SOME_KEY=54l41n3n4v41m34rv0" && npm run dev // Windows cmd.exe +``` + +koodista päästään avaimen arvoon käsiksi olion _import.meta.env_ kautta: + +```js +const api_key = import.meta.env.VITE_SOME_KEY +// muuttujassa api_key on nyt käynnistyksessä annettu API-avaimen arvo +``` + +Huomaa, että ympäristömuuttujan nimen täytyy alkaa merkkijonolla VITE_. + +Muista myös, että jos teet muutoksia ympäristömuuttujiin, sinun on käynnistettävä kehityspalvelin uudelleen, jotta muutokset tulevat voimaan. -Tämä oli osan viimeinen tehtävä ja on aika pushata koodi githubiin merkata tehdyt tehtävät [palautussovellukseen](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). +Tämä oli osan viimeinen tehtävä ja on aika sekä puskea koodi GitHubiin että merkitä tehdyt tehtävät [palautussovellukseen](https://studies.cs.helsinki.fi/stats/courses/fullstackopen).
    diff --git a/src/content/2/fr/part2.md b/src/content/2/fr/part2.md new file mode 100644 index 00000000000..01a248e7e40 --- /dev/null +++ b/src/content/2/fr/part2.md @@ -0,0 +1,10 @@ +--- +mainImage: ../../../images/part-2.svg +part: 2 +lang: fr +--- + +
    + +Continuons notre introduction à React. Tout d'abord, nous verrons comment faire le rendu d'une collection de données, comme une liste de noms, à l'écran. Après cela, nous inspecterons comment un utilisateur peut soumettre des données à une application React à l'aide de formulaires HTML. Ensuite, nous nous concentrons sur la façon dont le code JavaScript dans le navigateur peut récupérer et gérer les données stockées dans un serveur principal distant. Enfin, nous examinerons rapidement quelques façons simples d'ajouter du style CSS à nos applications React. +
    \ No newline at end of file diff --git a/src/content/2/fr/part2a.md b/src/content/2/fr/part2a.md new file mode 100644 index 00000000000..2fee76fb7b4 --- /dev/null +++ b/src/content/2/fr/part2a.md @@ -0,0 +1,748 @@ +--- +mainImage: ../../../images/part-2.svg +part: 2 +letter: a +lang: fr +--- + +
    + +Avant de commencer une nouvelle partie, récapitulons quelques-uns des sujets qui se sont avérés difficiles l'année dernière. + +### console.log + +***Quelle est la différence entre un programmeur JavaScript expérimenté et un débutant ? L'expérimenté utilise console.log 10 à 100 fois plus.*** + +Paradoxalement, cela semble être vrai même si un programmeur débutant aurait besoin de console.log (ou de toute méthode de débogage) plus qu'un programmeur expérimenté. + +Lorsque quelque chose ne fonctionne pas, ne vous contentez pas de deviner ce qui ne va pas. Au lieu de cela, logger ou utilisez un autre moyen de débogage. + +**NB** Comme expliqué dans la partie 1, lorsque vous utilisez la commande _console.log_ pour le débogage, ne concaténez pas les choses "à la manière Java" avec un plus. Au lieu d'écrire : + +```js +console.log('props value is ' + props) +``` + +séparez les éléments à afficher par une virgule : + +```js +console.log('props value is', props) +``` + +Si vous concaténez un objet avec une chaîne et que vous le loggez dans la console (comme dans notre premier exemple), le résultat sera plutôt inutile : + +```js +props value is [Object object] +``` + +Au contraire, lorsque vous transmettez des objets en tant qu'arguments distincts séparés par des virgules à _console.log_, comme dans notre deuxième exemple ci-dessus, le contenu de l'objet est affiché sur la console du développeur sous forme de chaînes perspicaces. +Si nécessaire, en savoir plus sur [le débogage des applications React](/fr/part1/plongez_dans_le_debogage_dapplications_react#debogage-des-applications-react). + +### Conseil de pro : snippets de code Visual Studio + +Avec Visual Studio Code, il est facile de créer des "snippets", c'est-à-dire des raccourcis pour générer rapidement des portions de code couramment réutilisées, un peu comme le fonctionnement de "sout" dans Netbeans. + +Les instructions pour créer des snippets sont disponibles [ici](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_creating-your-own-snippets). + +Des extraits de code utiles et prêts à l'emploi peuvent également être trouvés sous forme de plugins VS Code, sur le [marketplace](https://marketplace.visualstudio.com/items?itemName=dsznajder.es7-react-js-snippets). + +Le snippet le plus important est celui de la commande console.log(), par exemple, clog. Cela peut être créé comme ceci: + +```js +{ + "console.log": { + "prefix": "clog", + "body": [ + "console.log('$1')", + ], + "description": "Log output to console" + } +} +``` + +Le débogage du code à l'aide de _console.log()_ est si courant que Visual Studio Code intègre ce snippet. Pour l'utiliser, tapez _log_ et appuyez sur l'onglet pour compléter automatiquement. Des extensions d'extraits de code _console.log()_ plus complètes sont disponibles sur le [marketplace](https://marketplace.visualstudio.com/search?term=console.log&target=VSCode&category=All%20categories&sortBy=Relevance). + +### Tableaux JavaScript + +À partir de maintenant, nous utiliserons les méthodes de programmation fonctionnelle des [tableaux](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array) JavaScript , comme _find_ , _filter_ et _map_ - tout le temps. Ils fonctionnent sur les mêmes principes généraux que les flux de Java 8, qui ont été utilisés ces dernières années dans les cours 'Ohjelmoinnin perusteet' et 'Ohjelmoinnin jatkokurssi' du département d'informatique de l'université, ainsi que dans le MOOC de programmation. + +Si la programmation fonctionnelle avec des tableaux vous semble étrangère, cela vaut la peine de regarder au moins les trois premières parties de la série de vidéos YouTube [Programmation fonctionnelle en JavaScript](https://www.youtube.com/playlist?list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84) : + +- [Higher-order functions](https://www.youtube.com/watch?v=BMUiFMZr7vk&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84) +- [Map](https://www.youtube.com/watch?v=bCqtb-Z5YGQ&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84&index=2) +- [Reduce basics](https://www.youtube.com/watch?v=Wl98eZpkp-c&t=31s) + +### Gestionnaires d'événements revisités + +Sur la base du cours de l'année dernière, la gestion des événements s'est avérée difficile. + +Cela vaut la peine de lire le chapitre de révision à la fin de la partie précédente [gestion d'événements revisitée](/fr/part1/plongez_dans_le_debogage_dapplications_react#gestion-des-evenements-revisitee), si vous pensez que vos propres connaissances sur le sujet ont besoin d'être affinées. +Passer des gestionnaires d'événements aux composants enfants du composant App a soulevé quelques questions. Une petite révision sur le sujet peut être trouvée [ici](/fr/part1/plongez_dans_le_debogage_dapplications_react#passer-vos-events-handlers-aux-composants-enfants). + +### Rendu de Collections + +Nous allons maintenant faire le "frontend", ou la logique d'application côté navigateur, dans React pour une application similaire à l'exemple d'application de la [partie 0](/fr/part0) + +Commençons par ce qui suit (le fichier App.js) : + +```js +const App = (props) => { + const { notes } = props + + return ( +
    +

    Notes

    +
      +
    • {notes[0].content}
    • +
    • {notes[1].content}
    • +
    • {notes[2].content}
    • +
    +
    + ) +} + +export default App +``` + +Le fichier index.js ressemble à : + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' + +import App from './App' + +const notes = [ + { + id: 1, + content: 'HTML is easy', + date: '2019-05-30T17:30:31.098Z', + important: true + }, + { + id: 2, + content: 'Browser can execute only JavaScript', + date: '2019-05-30T18:39:34.091Z', + important: false + }, + { + id: 3, + content: 'GET and POST are the most important methods of HTTP protocol', + date: '2019-05-30T19:20:14.298Z', + important: true + } +] + +ReactDOM.createRoot(document.getElementById('root')).render( + +) +``` + +Chaque note contient son contenu textuel et un horodatage, ainsi qu'une valeur _booléenne_ pour marquer si la note a été classée comme importante ou non, ainsi qu'un id unique. + +L'exemple ci-dessus fonctionne car il y a exactement trois notes dans le tableau. + +Une seule note est rendue en accédant aux objets du tableau en se référant à un numéro d'index codé en dur : + +```js +
  • {notes[1].content}
  • +``` + +Ce n'est bien sûr pas pratique. Nous pouvons améliorer cela en générant des éléments React à partir des objets du tableau à l'aide de la fonction [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map). + +```js +notes.map(note =>
  • {note.content}
  • ) +``` + +Le résultat est un tableau d'éléments li. + +```js +[ +
  • HTML is easy
  • , +
  • Browser can execute only JavaScript
  • , +
  • GET and POST are the most important methods of HTTP protocol
  • , +] +``` + + +Qui peut ensuite être placé à l'intérieur de balises ul : + +```js +const App = (props) => { + const { notes } = props + + return ( +
    +

    Notes

    +// highlight-start +
      + {notes.map(note =>
    • {note.content}
    • )} +
    +// highlight-end +
    + ) +} +``` + +Étant donné que le code générant les balises li est JavaScript, il doit être entouré d'accolades dans un modèle JSX, comme tout autre code JavaScript. + +Nous rendrons également le code plus lisible en séparant la déclaration de la fonction fléchée sur plusieurs lignes : + +```js +const App = (props) => { + const { notes } = props + + return ( +
    +

    Notes

    +
      + {notes.map(note => + // highlight-start +
    • + {note.content} +
    • + // highlight-end + )} +
    +
    + ) +} +``` + +### Attribut clé + +Même si l'application semble fonctionner, il y a un méchant avertissement dans la console : + +![](../../images/2/1a.png) + +Comme le suggère la [page React](https://reactjs.org/docs/lists-and-keys.html#keys) dans le message d'erreur; les éléments de la liste, c'est-à-dire les éléments générés par la méthode _map_, doivent chacun avoir une valeur clé unique : un attribut appelé clé. + +Ajoutons les clés : + +```js +const App = (props) => { + const { notes } = props + + return ( +
    +

    Notes

    +
      + {notes.map(note => +
    • // highlight-line + {note.content} +
    • + )} +
    +
    + ) +} +``` + +Et le message d'erreur disparaît. + +React utilise les attributs clés des objets dans un tableau pour déterminer comment mettre à jour la vue générée par un composant lorsque le composant est rendu à nouveau. Plus d'informations à ce sujet dans la [documentation React](https://reactjs.org/docs/reconciliation.html#recursing-on-children). + +### Map + +Comprendre comment la méthode [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) fonctionne est crucial pour le reste du cours. + +L'application contient un tableau appelé _notes_ : + +```js +const notes = [ + { + id: 1, + content: 'HTML is easy', + date: '2019-05-30T17:30:31.098Z', + important: true + }, + { + id: 2, + content: 'Browser can execute only JavaScript', + date: '2019-05-30T18:39:34.091Z', + important: false + }, + { + id: 3, + content: 'GET and POST are the most important methods of HTTP protocol', + date: '2019-05-30T19:20:14.298Z', + important: true + } +] +``` + +Arrêtons-nous un instant et examinons comment fonctionne _map_. + + +Si le code suivant est ajouté, disons, à la fin du fichier : + +```js +const result = notes.map(note => note.id) +console.log(result) +``` + +[1, 2, 3] sera imprimé sur la console. + _map_ crée toujours un nouveau tableau dont les éléments ont été créés à partir des éléments du tableau d'origine par mappage : en utilisant la fonction donnée en paramètre à la méthode _map_. + + +La fonction est + +```js +note => note.id +``` + +Qui est une fonction fléchée écrite sous forme compacte. La forme complete serait : + +```js +(note) => { + return note.id +} +``` + +La fonction a comme paramètre un objet note et renvoie la valeur de son champ id. + +Changer la commande en : + +```js +const result = notes.map(note => note.content) +``` + +donne un tableau contenant le contenu des notes. + +C'est déjà assez proche du code React que nous avons utilisé : + +```js +notes.map(note => +
  • {note.content}
  • +) +``` + +qui génère une balise li contenant le contenu de la note de chaque objet note. + +Parce que le paramètre de fonction passé à la méthode _map_ - + +```js +note =>
  • {note.content}
  • +``` + + - est utilisé pour créer des éléments de vue, la valeur de la variable doit être rendue entre accolades. Essayez de voir ce qui se passe si les accolades sont retirées. + +L'utilisation d'accolades vous causera quelques douleurs au début, mais vous vous y habituerez assez tôt. Le retour visuel de React est immédiat. + +### Anti-pattern: index de tableau en tant que clés + +Nous aurions pu faire disparaître le message d'erreur sur notre console en utilisant les index du tableau comme clés. Les index peuvent être récupérés en passant un second paramètre à la fonction callback de la _map_ method: + +```js +notes.map((note, i) => ...) +``` + +Lorsqu'il est appelé ainsi, _i_ reçoit la valeur de l'index de la position dans le tableau où réside la note. + +En tant que tel, une façon de définir la génération de lignes sans obtenir d'erreurs est : + +```js +
      + {notes.map((note, i) => +
    • + {note.content} +
    • + )} +
    +``` + +Ceci est cependant, **non recommandé** et peut créer des problèmes indésirables même s'il semble fonctionner correctement. + +En savoir plus à ce sujet dans [cet article](https://robinpokorny.medium.com/index-as-a-key-is-an-anti-pattern-e0349aece318). + +### Refactoring de Modules + +Changeons un peu le code. Nous ne sommes intéressés que par le champ _notes_ des props, alors récupérons cela directement en utilisant la [déstructuration](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) : + +```js +const App = ({ notes }) => { //highlight-line + return ( +
    +

    Notes

    +
      + {notes.map(note => +
    • + {note.content} +
    • + )} +
    +
    + ) +} +``` + +Si vous avez oublié ce que signifie la déstructuration et comment cela fonctionne, veuillez consulter la [section associée](/fr/part1/etat_des_composants_gestionnaires_devenements#destructuration). + +Nous séparerons l'affichage d'une seule note dans son propre composant Note : + +```js +// highlight-start +const Note = ({ note }) => { + return ( +
  • {note.content}
  • + ) +} +// highlight-end + +const App = ({ notes }) => { + return ( +
    +

    Notes

    +
      + // highlight-start + {notes.map(note => + + )} + // highlight-end +
    +
    + ) +} +``` + +Notez que l'attribut key doit maintenant être défini pour les composants Note, et non pour les balises li comme auparavant. + +Une application React entière peut être écrite dans un seul fichier. Même si ce n'est bien sûr pas très pratique. La pratique courante consiste à déclarer chaque composant dans son propre fichier en tant que module ES6. + +Nous avons utilisé des modules tout le temps. Les premières lignes du fichier index.js : + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' + +import App from './App' +``` + +[importent](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) trois modules, nous permettant de les utiliser dans ce fichier. Le module React est placé dans la variable _React_, le module react-dom dans la variable _ReactDOM_, et le module qui définit le composant principal de l'application est placé dans la variable _Application_ + +Déplaçons notre composant Note dans son propre module. + +Dans les petites applications, les composants sont généralement placés dans un répertoire appelé components, qui est à son tour placé dans le répertoire src. La convention est de nommer le fichier d'après le composant. + +Maintenant, nous allons créer un répertoire appelé components pour notre application et y placer un fichier nommé Note.js. +Le contenu du fichier Note.js est le suivant : + +```js +import React from 'react' + +const Note = ({ note }) => { + return ( +
  • {note.content}
  • + ) +} + +export default Note +``` + +La dernière ligne du module [exporte](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export) le module déclaré, la variable Note . + +Maintenant, le fichier qui utilise le composant - App.js - peut [importer](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import ) le module : + +```js +import Note from './components/Note' // highlight-line + +const App = ({ notes }) => { + // ... +} +``` + +Le composant exporté par le module est maintenant disponible pour être utilisé dans la variable Note, comme il l'était auparavant. + +Notez que lors de l'importation de nos propres composants, leur emplacement doit être indiqué. + +```js +'./components/Note' +``` + +Le point - . - au début fait référence au répertoire courant, donc l'emplacement du module est un fichier appelé Note.js dans le sous répertoire components du répertoire courant. L'extension du fichier _.js_ peut être omise. + +Les modules ont de nombreuses autres utilisations que de permettre aux déclarations de composants d'être séparées dans leurs propres fichiers. Nous y reviendrons plus tard dans ce cours. + +Le code actuel de l'application peut être trouvé sur [GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part2-1). + +Notez que la branche main du référentiel contient le code d'une version ultérieure de l'application. Le code actuel se trouve sur la branche [part2-1](https://github.com/fullstack-hy2020/part2-notes/tree/part2-1) : + +![](../../images/2/2e.png) + +Si vous clonez le projet, exécutez la commande _npm install_ avant de démarrer l'application avec _npm run dev_. + +### Lorsque l'application crashe + +Au début de votre carrière de programmeur (et même après 30 ans de codage comme le vôtre vraiment), ce qui arrive souvent, c'est que l'application tombe complètement en panne. C'est encore plus le cas avec les langages à typage dynamique, comme JavaScript, où le compilateur ne vérifie pas le type de données. Par exemple, des variables de fonction ou des valeurs de retour. + +Une "explosion React" peut, par exemple, ressembler à ceci : + +![](../../images/2/3b.png) + +Dans ces situations, votre meilleure solution reste la méthode console.log. + +Le morceau de code à l'origine de l'explosion est celui-ci : + +```js +const Course = ({ course }) => ( +
    +
    +
    +) + +const App = () => { + const course = { + // ... + } + + return ( +
    + +
    + ) +} +``` + +Nous allons rechercher la raison de la panne en ajoutant des commandes console.log au code. Étant donné que la première chose à rendre est le composant App, cela vaut la peine d'y mettre le premier console.log : + +```js +const App = () => { + const course = { + // ... + } + + console.log('App works...') // highlight-line + + return ( + // .. + ) +} +``` + +Pour voir l'impression dans la console, il faut faire défiler vers le haut le long fil rouge des erreurs. + +![](../../images/2/4b.png) + +Lorsqu'une chose fonctionne, il faut aller chercher le problème en profondeur. Si le composant a été déclaré en tant qu'instruction unique ou en tant que fonction sans retour, cela rend l'impression sur la console plus difficile. + +```js +const Course = ({ course }) => ( +
    +
    +
    +) +``` + +Le composant doit être remplacé par sa forme plus longue afin que nous puissions ajouter le retour sur console: + +```js +const Course = ({ course }) => { + console.log(course) // highlight-line + return ( +
    +
    +
    + ) +} +``` + +Très souvent, la racine du problème est que les props sont censés être d'un type différent, ou appelés avec un nom différent de ce qu'ils sont réellement, et la déstructuration échoue en conséquences. Le problème commence souvent à se résoudre de lui-même lorsque la déstructuration est supprimée et que nous voyons ce que les props contiennent réellement. + +```js +const Course = (props) => { // highlight-line + console.log(props) // highlight-line + const { course } = props + return ( +
    +
    +
    + ) +} +``` + +Si le problème n'a toujours pas été résolu, il n'y a vraiment pas grand-chose à faire à part continuer à chasser les bogues en saupoudrant plus d'instructions _console.log_ autour de votre code. + +J'ai ajouté ce chapitre au cours après que le modèle de réponse à la question suivante ait complètement explosé (car les props étaient du mauvais type), et j'ai dû le déboguer en utilisant console.log. + +
    + +
    + +

    Exercices 2.1.-2.5.

    + +Les exercices sont soumis via GitHub, et en marquant les exercices comme effectués dans le [système de soumission](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +Vous pouvez soumettre tous les exercices dans le même référentiel ou utiliser plusieurs référentiels différents. Si vous soumettez des exercices de différentes parties dans le même référentiel, nommez bien vos répertoires. + +Les exercices sont soumis **Une partie à la fois**. Lorsque vous avez soumis les exercices d'une partie, vous ne pouvez plus soumettre d'exercices manqués pour cette partie. + +Notez que cette partie contient plus d'exercices que les précédentes, donc ne soumettez pas avant d'avoir fait tous les exercices de cette partie que vous souhaitez soumettre. + +**ATTENTION** create-react-app transforme automatiquement le projet en un référentiel git, si le projet n'est pas créé à l'intérieur d'un référentiel déjà existant. Vous **ne voulez probablement pas** que le projet devienne un référentiel, alors exécutez la commande _rm -rf .git_ depuis sa racine. + +

    2.1: courseinfo étape6

    + +Terminons le code pour le rendu du contenu du cours des exercices 1.1 à 1.5. Vous pouvez commencer le code avec le modèle de réponse. Ces modèles pour la partie 1 peuvent être trouvés en allant sur le [système de soumission](https://studies.cs.helsinki.fi/stats/courses/fullstackopen), cliquez sur mes soumissions en haut, et dans la ligne correspondant à la partie 1 sous la colonne solutions cliquez sur afficher. Pour voir la solution de l'exercice informations sur le cours, cliquez sur _index.js_ sous kurssitiedot ("kurssitiedot" signifie "informations sur le cours"). + +**Notez que si vous copiez un projet d'un endroit à un autre, vous devrez peut-être supprimer le répertoire node\_modules et réinstaller les dépendances avec la commande _npm install_ avant de pouvoir démarrer l'application.** +Généralement, il n'est pas recommandé de copier tout le contenu d'un projet et/ou d'ajouter le répertoire node\_modules au système de contrôle de version. + +Modifions le composant App comme suit : + +```js +const App = () => { + const course = { + id: 1, + name: 'Half Stack application development', + parts: [ + { + name: 'Fundamentals of React', + exercises: 10, + id: 1 + }, + { + name: 'Using props to pass data', + exercises: 7, + id: 2 + }, + { + name: 'State of a component', + exercises: 14, + id: 3 + } + ] + } + + return +} + +export default App +``` + +Définissez un composant responsable de la mise en forme d'un seul cours appelé Course. + +La structure des composants de l'application peut être, par exemple, la suivante : + +``` +App + Course + Header + Content + Part + Part + ... +``` + +Par conséquent, le composant Course contient les composants définis dans la partie précédente, qui sont responsables du rendu du nom du cours et de ses parties. + +La page rendue peut, par exemple, ressembler à ceci : + +![](../../images/teht/8e.png) + +Vous n'avez pas encore besoin de la somme des exercices. + +L'application doit fonctionner quel que soit le nombre de parties d'un cours, alors assurez-vous que l'application fonctionne si vous ajoutez ou supprimez des parties d'un cours. + +Assurez-vous que la console n'affiche aucune erreur ! + +

    2.2: courseinfo étape7

    + +Afficher aussi la somme des exercices du cours. + +![](../../images/teht/9e.png) + +

    2.3*: courseinfo étape8

    + +Si vous ne l'avez pas déjà fait, calculez la somme des exercices avec la méthode array [reduce](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce). + +**Conseil de pro :** lorsque votre code ressemble à ceci : + +```js +const total = + parts.reduce((s, p) => someMagicHere) +``` + +et ne fonctionne pas, cela vaut la peine d'utiliser console.log, qui nécessite que la fonction fléchée soit écrite dans sa forme plus longue : + +```js +const total = parts.reduce((s, p) => { + console.log('what is happening', s, p) + return someMagicHere +}) +``` + +**Ca ne fonctionne pas? :** Utilisez votre moteur de recherche pour rechercher comment reduce est utilisé dans un **Array**. + +**Conseil de pro 2 :** Il existe un [plugin pour VS Code](https://marketplace.visualstudio.com/items?itemName=cmstead.js-codeformer) qui transforme automatiquement les fonctions fléchée de forme courte en leur forme plus longue, et vice versa. + +![](../../images/2/5b.png) + +

    2.4: courseinfo étape9

    + + +Étendons notre application pour permettre un nombre arbitraire de cours : + +```js +const App = () => { + const courses = [ + { + name: 'Half Stack application development', + id: 1, + parts: [ + { + name: 'Fundamentals of React', + exercises: 10, + id: 1 + }, + { + name: 'Using props to pass data', + exercises: 7, + id: 2 + }, + { + name: 'State of a component', + exercises: 14, + id: 3 + }, + { + name: 'Redux', + exercises: 11, + id: 4 + } + ] + }, + { + name: 'Node.js', + id: 2, + parts: [ + { + name: 'Routing', + exercises: 3, + id: 1 + }, + { + name: 'Middlewares', + exercises: 7, + id: 2 + } + ] + } + ] + + return ( +
    + // ... +
    + ) +} +``` + +L'application peut, par exemple, ressembler à ceci : + +![](../../images/teht/10e.png) + +

    2.5: separation des modules

    + +Déclarez le composant Course en tant que module séparé, qui est importé par le composant App. Vous pouvez inclure tous les sous-composants du cours dans le même module. + +
    diff --git a/src/content/2/fr/part2b.md b/src/content/2/fr/part2b.md new file mode 100644 index 00000000000..177497e638d --- /dev/null +++ b/src/content/2/fr/part2b.md @@ -0,0 +1,566 @@ +--- +mainImage: ../../../images/part-2.svg +part: 2 +letter: b +lang: fr +--- + +
    + +Continuons à développer notre application en permettant aux utilisateurs d'ajouter de nouvelles notes. Vous pouvez trouver le code de l'application actuelle [ici](https://github.com/fullstack-hy2020/part2-notes/tree/part2-1). + +Afin que notre page soit mise à jour lorsque de nouvelles notes sont ajoutées, il est préférable de stocker les notes dans l'état du composant App. Importons la fonction [useState](https://reactjs.org/docs/hooks-state.html) et utilisons-la pour définir un élément d'état qui est initialisé avec le tableau de notes initial transmis dans les accessoires. + +```js +import { useState } from 'react' // highlight-line +import Note from './components/Note' + +const App = (props) => { // highlight-line + const [notes, setNotes] = useState(props.notes) // highlight-line + + return ( +
    +

    Notes

    +
      + {notes.map(note => + + )} +
    +
    + ) +} + +export default App +``` + +Le composant utilise la fonction useState pour initialiser l'élément d'état stocké dans notes avec le tableau de notes passé dans les accessoires : + +```js +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + + // ... +} +``` + +Si nous voulions commencer avec une liste vide de notes, nous définirions la valeur initiale comme un tableau vide, et puisque les accessoires ne seraient pas utilisés, nous pourrions omettre le paramètre props de la définition de la fonction : + +```js +const App = () => { + const [notes, setNotes] = useState([]) + + // ... +} +``` + +Restons avec la valeur initiale transmise dans les props pour le moment. + +Ensuite, ajoutons un [formulaire](https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms) HTML au composant qui sera utilisé pour ajouter de nouvelles notes. + +```js +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + +// highlight-start + const addNote = (event) => { + event.preventDefault() + console.log('button clicked', event.target) + } + // highlight-end + + return ( +
    +

    Notes

    +
      + {notes.map(note => + + )} +
    + // highlight-start +
    + + +
    + // highlight-end +
    + ) +} +``` + +Nous avons ajouté la fonction _addNote_ en tant que event handler à l'élément du formulaire qui sera appelé lors de la soumission du formulaire, le click sur le bouton Soumettre. + +Nous utilisons la méthode décrite dans la [partie 1](/fr/part1/etat_des_composants_gestionnaires_devenements#gestion-des-evenements) pour définir notre gestionnaire d'événements : + +```js +const addNote = (event) => { + event.preventDefault() + console.log('button clicked', event.target) +} +``` + +Le paramètre event est l'[évènement](https://reactjs.org/docs/handling-events.html) qui déclenche l'appel de la fonction de gestion d'événements : + + +Le gestionnaire d'événements appelle immédiatement la méthode event.preventDefault(), qui empêche l'action par défaut de soumettre un formulaire. L'action par défaut entraînerait [entre autres](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit_event) la page à recharger. + + +La cible de l'événement stocké dans event.target est consignée dans la console : + +![](../../images/2/6e.png) + + +La cible dans ce cas est le formulaire que nous avons défini dans notre composant. + +Comment accède-t-on aux données contenues dans l'élément input du formulaire ? + +### Composant contrôlé + +Il y a plusieurs façons d'accomplir ceci; la première méthode que nous allons examiner consiste à utiliser ce que l'on appelle des [composants contrôlés](https://reactjs.org/docs/forms.html#controlled-components). + +Ajoutons un nouvel élément d'état appelé newNote pour stocker l'entrée soumise par l'utilisateur **et** définissons-le comme la valeur de l'élément input attribut: + +```js +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + // highlight-start + const [newNote, setNewNote] = useState( + 'a new note...' + ) + // highlight-end + + const addNote = (event) => { + event.preventDefault() + console.log('button clicked', event.target) + } + + return ( +
    +

    Notes

    +
      + {notes.map(note => + + )} +
    +
    + //highlight-line + +
    +
    + ) +} +``` + +Le texte d'espace réservé stocké comme valeur initiale de l'état newNote apparaît dans l'élément input, mais le texte d'entrée ne peut pas être modifié. La console affiche un avertissement qui nous donne une idée de ce qui ne va pas : + +![](../../images/2/7e.png) + +Étant donné que nous avons attribué une partie de l'état du composant App en tant qu'attribut value de l'élément d'entrée, le composant App [contrôle](https://reactjs.org/docs/forms.html#controlled-components) maintenant le comportement de l'élément d'entrée. + +Afin de permettre l'édition de l'élément d'entrée, nous devons enregistrer un gestionnaire d'événements qui synchronise les modifications apportées à l'entrée avec l'état du composant : + +```js +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + const [newNote, setNewNote] = useState( + 'a new note...' + ) + + // ... + +// highlight-start + const handleNoteChange = (event) => { + console.log(event.target.value) + setNewNote(event.target.value) + } +// highlight-end + + return ( +
    +

    Notes

    +
      + {notes.map(note => + + )} +
    +
    + + +
    +
    + ) +} +``` + +Nous avons maintenant enregistré un gestionnaire d'événements pour l'attribut onChange de l'élément input du formulaire : + +```js + +``` + +Le gestionnaire d'événements est appelé chaque fois qu'un changement se produit dans l'élément d'entrée. La fonction de gestionnaire d'événements reçoit l'objet événement comme paramètre: + +```js +const handleNoteChange = (event) => { + console.log(event.target.value) + setNewNote(event.target.value) +} +``` + +La propriété target de l'objet événement correspond maintenant à l'élément input contrôlé, et event.target.value fait référence à la valeur d'entrée de cet élément . + +Notez que nous n'avons pas eu besoin d'appeler la méthode _event.preventDefault()_ comme nous l'avons fait dans le gestionnaire d'événements onSubmit. En effet, aucune action par défaut ne se produit lors d'un changement d'entrée, contrairement à une soumission de formulaire. + +Vous pouvez suivre dans la console pour voir comment le gestionnaire d'événements est appelé : + +![](../../images/2/8e.png) + +Vous avez pensé à installer [React devtools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi), n'est-ce pas ? Bien. Vous pouvez voir directement comment l'état change depuis l'onglet React Devtools : + +![](../../images/2/9ea.png) + +Maintenant, l'état newNote du composant App reflète la valeur actuelle de l'entrée, ce qui signifie que nous pouvons compléter la fonction addNote pour créer de nouvelles notes : + +```js +const addNote = (event) => { + event.preventDefault() + const noteObject = { + content: newNote, + date: new Date().toISOString(), + important: Math.random() < 0.5, + id: notes.length + 1, + } + + setNotes(notes.concat(noteObject)) + setNewNote('') +} +``` + +Tout d'abord, nous créons un nouvel objet pour la note appelée noteObject qui recevra son contenu de l'état newNote du composant. L'identifiant unique id est généré en fonction du nombre total de notes. Cette méthode fonctionne pour notre application puisque les notes ne sont jamais supprimées. Avec l'aide de la fonction Math.random(), notre note a 50 % de chances d'être marquée comme importante. + +La nouvelle note est ajoutée à la liste des notes à l'aide de la méthode [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat), introduite dans la [partie 1](/fr/part1/java_script#tableaux): + +```js +setNotes(notes.concat(noteObject)) +``` + +La méthode ne modifie pas le tableau notes d'origine, mais crée plutôt une nouvelle copie du tableau avec le nouvel élément ajouté à la fin. Ceci est important car nous ne devons [jamais muter l'état directement](https://reactjs.org/docs/state-and-lifecycle.html#using-state-correctly) dans React ! + +Le gestionnaire d'événements réinitialise également la valeur de l'élément d'entrée contrôlé en appelant la fonction setNewNote de l'état newNote : + +```js +setNewNote('') +``` + +Vous pouvez trouver le code de notre application actuelle dans son intégralité sur la branche part2-2 de [ce référentiel GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part2-2). + +### Filtrage des éléments affichés + +Ajoutons quelques nouvelles fonctionnalités à notre application qui nous permettrons de visualiser uniquement les notes importantes. + +Ajoutons un élément d'état au composant App qui garde une trace des notes à afficher : + +```js +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) // highlight-line + + // ... +} +``` + +Modifions le composant afin qu'il stocke une liste de toutes les notes à afficher dans la variable notesToShow. Les éléments de la liste dépendent de l'état du composant : + +```js +import { useState } from 'react' +import Note from './components/Note' + +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + + // ... + +// highlight-start + const notesToShow = showAll + ? notes + : notes.filter(note => note.important === true) +// highlight-end + + return ( +
    +

    Notes

    +
      + {notesToShow.map(note => // highlight-line + + )} +
    + // ... +
    + ) +} +``` + +La définition de la variable notesToShow est plutôt compacte : + +```js +const notesToShow = showAll + ? notes + : notes.filter(note => note.important === true) +``` + +La définition utilise l'opérateur [conditionnel](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator) également présent dans de nombreux autres langages de programmation. + +L'opérateur fonctionne comme suit. Si nous avons: + +```js +const result = condition ? val1 : val2 +``` + +la variable résultat sera définie sur la valeur de val1 si la condition est vraie. Si condition est fausse, la variable result sera définie sur la valeur de val2. + +Si la valeur de showAll est false, la variable notesToShow sera affectée à une liste qui ne contient que des notes dont la propriété important est définie sur true . Le filtrage est effectué à l'aide de la méthode array [filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter) : + +```js +notes.filter(note => note.important === true) +``` + +L'opérateur de comparaison est en fait redondant, puisque la valeur de note.important est soit true soit false, ce qui signifie qu'on peut simplement écrire : + +```js +notes.filter(note => note.important) +``` + +La raison pour laquelle nous avons d'abord montré l'opérateur de comparaison était de souligner un détail important : en JavaScript, val1 == val2 ne fonctionne pas comme prévu dans toutes les situations et il est plus sûr d'utiliser val1 === val2 exclusivement dans les comparaisons. Vous pouvez en savoir plus sur le sujet [ici](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness). + +Vous pouvez tester la fonctionnalité de filtrage en modifiant la valeur initiale de l'état showAll. + +Ensuite, ajoutons une fonctionnalité qui permet aux utilisateurs de basculer l'état showAll de l'application à partir de l'interface utilisateur. + +Les changements pertinents sont indiqués ci-dessous : + +```js +import { useState } from 'react' +import Note from './components/Note' + +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + + // ... + + return ( +
    +

    Notes

    +// highlight-start +
    + +
    +// highlight-end +
      + {notesToShow.map(note => + + )} +
    + // ... +
    + ) +} +``` + + +Les notes affichées (toutes versus importantes) sont contrôlées par un bouton. Le gestionnaire d'événements pour le bouton est si simple qu'il a été défini directement dans l'attribut de l'élément bouton. Le gestionnaire d'événements fait passer la valeur de _showAll_ de true à false et vice versa : + +```js +() => setShowAll(!showAll) +``` + +Le texte du bouton dépend de la valeur de l'état showAll : + +```js +show {showAll ? 'important' : 'all'} +``` + +Vous pouvez trouver le code de l'application actuelle dans son intégralité sur la branche part2-3 de [ce référentiel GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part2-3). +
    + +
    + +

    Exercices 2.6.-2.10.

    + +Nous commencerons à partir du premier exercice à travailler sur une application qui sera développée plus en détail dans les exercices suivants. Dans les ensembles d'exercices connexes, il suffit de renvoyer la version finale de votre application. Vous pouvez également faire un commit séparé après avoir terminé chaque partie de l'ensemble d'exercices, mais cela n'est pas obligatoire. + +**ATTENTION** create-react-app transformera automatiquement votre projet en un référentiel git à moins que vous ne créiez votre application dans un référentiel git existant. Il est probable que vous **ne vouliez pas** que votre projet soit un référentiel, alors exécutez simplement la commande _rm -rf .git_ à la racine de votre application. + +

    2.6 : phonebook, étape1

    + +Créons un répertoire téléphonique simple. **Dans cette partie, nous n'ajouterons que des noms au répertoire.** + +Commençons par implémenter l'ajout d'une personne au répertoire. + +Vous pouvez utiliser le code ci-dessous comme point de départ pour le composant App de votre application : + +```js +import { useState } from 'react' + +const App = () => { + const [persons, setPersons] = useState([ + { name: 'Arto Hellas' } + ]) + const [newName, setNewName] = useState('') + + return ( +
    +

    Phonebook

    +
    +
    + name: +
    +
    + +
    +
    +

    Numbers

    + ... +
    + ) +} + +export default App +``` + +L'état newName est destiné à contrôler l'élément d'entrée du formulaire. + +Parfois, il peut être utile de rendre l'état et d'autres variables sous forme de texte à des fins de débogage. Vous pouvez temporairement ajouter l'élément suivant au composant rendu : + +``` +
    debug: {newName}
    +``` + +Il est également important de mettre à profit ce que nous avons appris dans le chapitre [debugging React applications](/en/part1/a_more_complex_state_debugging_react_apps) de la première partie. L'extension [React developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) est particulièrement utile pour suivre les changements qui se produisent dans l'état de l'application. + +Après avoir terminé cet exercice, votre application devrait ressembler à ceci : + +![](../../images/2/10e.png) + +Notez l'utilisation de l'extension des outils de développement React dans l'image ci-dessus ! + +**NB:** + +- vous pouvez utiliser le nom de la personne comme valeur de la propriété key +- n'oubliez pas d'empêcher l'action par défaut lors de la soumission des formulaires HTML ! + +

    2.7: phonebook, étape2

    + +Empêcher l'utilisateur d'ajouter des noms qui existent déjà dans le répertoire. Les tableaux JavaScript ont de nombreuses [méthodes](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array) appropriées pour accomplir cette tâche. Gardez à l'esprit [comment fonctionne l'égalité des objets](https://www.joshbritz.co/posts/why-its-so-hard-to-check-object-equality/) en Javascript. + +Émettez un avertissement avec la commande [alert](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert) lorsqu'une telle action est tentée : + +![](../../images/2/11e.png) + +**Astuce :** lorsque vous formez des chaînes contenant des valeurs à partir de variables, il est recommandé d'utiliser une [chaîne de modèle](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals): + +```js +`${newName} is already added to phonebook` +``` + +Si la variable newName contient la valeur Arto Hellas, l'expression de chaîne de modèle renvoie la chaîne + +```js +`Arto Hellas is already added to phonebook` +``` + +La même chose pourrait être faite d'une manière plus semblable à Java en utilisant l'opérateur plus : + +```js +newName + ' is already added to phonebook' +``` + +L'utilisation de chaînes de modèle est l'option la plus idiomatique et le signe d'un vrai professionnel de JavaScript. + +

    2.8: phonebook, étape3

    + +Développez votre application en permettant aux utilisateurs d'ajouter des numéros de téléphone au répertoire téléphonique. Vous devrez ajouter un deuxième élément input au formulaire (avec son propre gestionnaire d'événements) : + +```js +
    +
    name:
    +
    number:
    +
    +
    +``` + +À ce stade, l'application pourrait ressembler à ceci. L'image affiche également l'état de l'application à l'aide des [outils de développement React](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) : + +![](../../images/2/12e.png) + +

    2.9*: phonebook, étape4

    + +Implémentez un champ de recherche qui peut être utilisé pour filtrer la liste des personnes par nom : + +![](../../images/2/13e.png) + +Vous pouvez implémenter le champ de recherche en tant qu'élément input placé en dehors du formulaire HTML. La logique de filtrage affichée dans l'image est insensible à la casse, ce qui signifie que le terme de recherche arto renvoie également des résultats contenant Arto avec un A majuscule. + + +**NB :** Lorsque vous travaillez sur de nouvelles fonctionnalités, il est souvent utile de "coder en dur" certaines données factices dans votre application, par ex. + +```js +const App = () => { + const [persons, setPersons] = useState([ + { name: 'Arto Hellas', number: '040-123456', id: 1 }, + { name: 'Ada Lovelace', number: '39-44-5323523', id: 2 }, + { name: 'Dan Abramov', number: '12-43-234345', id: 3 }, + { name: 'Mary Poppendieck', number: '39-23-6423122', id: 4 } + ]) + + // ... +} +``` + +Cela vous évite d'avoir à entrer manuellement des données dans votre application pour tester votre nouvelle fonctionnalité. + +

    2.10: phonebook étape5

    + +Si vous avez implémenté votre application dans un seul composant, refactorisez-le en extrayant les parties appropriées dans de nouveaux composants. Conservez l'état de l'application et tous les gestionnaires d'événements dans le composant racine App. + +Il suffit d'extraire **trois** composants de l'application. De bons candidats pour des composants séparés sont, par exemple, le filtre de recherche, le formulaire d'ajout de nouvelles personnes dans l'annuaire, un composant qui affiche toutes les personnes de l'annuaire et un composant qui affiche les détails d'une seule personne. + +Le composant racine de l'application pourrait ressembler à ceci après la refactorisation. Le composant racine refactorisé ci-dessous n'affiche que les titres et laisse les composants extraits s'occuper du reste. + +```js +const App = () => { + // ... + + return ( +
    +

    Phonebook

    + + + +

    Add a new

    + + + +

    Numbers

    + + +
    + ) +} +``` + +**NB** : Vous risquez de rencontrer des problèmes dans cet exercice si vous définissez vos composants "au mauvais endroit". Ce serait le bon moment pour revoir +le chapitre de la dernière partie, [ne pas définir de composants dans des composants](/fr/part1/plongez_dans_le_debogage_dapplications_react#ne-pas-definir-de-composants-dans-les-composants). + +
    diff --git a/src/content/2/fr/part2c.md b/src/content/2/fr/part2c.md new file mode 100644 index 00000000000..ac5b830f252 --- /dev/null +++ b/src/content/2/fr/part2c.md @@ -0,0 +1,584 @@ +--- +mainImage: ../../../images/part-2.svg +part: 2 +letter: c +lang: fr +--- + +
    + +Depuis un certain temps, nous ne travaillons que sur le "frontend", c'est-à-dire la fonctionnalité côté client (navigateur). Nous commencerons à travailler sur le "backend", c'est-à-dire les fonctionnalités côté serveur dans la [troisième partie](/fr/part3) de ce cours. Néanmoins, nous allons maintenant faire un pas dans cette direction en nous familiarisant avec la façon dont le code s'exécutant dans le navigateur communique avec le backend. + +Utilisons un outil destiné à être utilisé lors du développement logiciel appelé [JSON Server](https://github.com/typicode/json-server) pour agir en tant que notre serveur. + +Créez un fichier nommé db.json dans le répertoire racine du projet de notes précédent avec le contenu suivant : + +```json +{ + "notes": [ + { + "id": 1, + "content": "HTML is easy", + "date": "2022-1-17T17:30:31.098Z", + "important": true + }, + { + "id": 2, + "content": "Browser can execute only JavaScript", + "date": "2022-1-17T18:39:34.091Z", + "important": false + }, + { + "id": 3, + "content": "GET and POST are the most important methods of HTTP protocol", + "date": "2022-1-17T19:20:14.298Z", + "important": true + } + ] +} +``` + +Vous pouvez [installer](https://github.com/typicode/json-server#getting-started) le serveur JSON globalement sur votre ordinateur à l'aide de la commande _npm install -g json-server_. Une installation globale nécessite des privilèges administratifs, ce qui signifie qu'elle n'est pas possible sur les ordinateurs des professeurs ou les ordinateurs portables des étudiants de première année. + +Cependant, une installation globale n'est pas nécessaire. Depuis le répertoire racine de votre application, nous pouvons exécuter le json-server en utilisant la commande _npx_ : + +```js +npx json-server --port 3001 --watch db.json +``` + +Le json-server commence à s'exécuter sur le port 3000 par défaut ; mais comme les projets créés à l'aide de create-react-app réservent le port 3000, nous devons définir un port alternatif, tel que le port 3001, pour le serveur json. + +Naviguons jusqu'à l'adresse dans le navigateur. Nous pouvons voir que json-server retourne les notes que nous avons précédemment écrites dans le fichier au format JSON : + +![](../../images/2/14e.png) + +Si votre navigateur ne permet pas de formater l'affichage des données JSON, installez un plugin approprié, par ex. [JSONView](https://chrome.google.com/webstore/detail/jsonview/chklaanhfefbnpoihckbnefhakgolnmc) pour vous faciliter la vie. + +À l'avenir, l'idée sera de sauvegarder les notes sur le serveur, ce qui signifie dans ce cas de les sauvegarder sur le serveur json. Le code React récupère les notes du serveur et les affiche à l'écran. Chaque fois qu'une nouvelle note est ajoutée à l'application, le code React l'envoie également au serveur pour que la nouvelle note persiste en "mémoire". + +json-server stocke toutes les données dans le fichier db.json, qui réside sur le serveur. Dans le monde réel, les données seraient stockées dans une sorte de base de données. Cependant, json-server est un outil pratique qui permet d'utiliser les fonctionnalités côté serveur dans la phase de développement sans avoir besoin de programmer quoi que ce soit. + +Nous nous familiariserons plus en détail avec les principes d'implémentation des fonctionnalités côté serveur dans la [partie 3](/fr/part3) de ce cours. + +### Le navigateur comme environnement d'exécution + +Notre première tâche consiste à récupérer les notes déjà existantes dans notre application React à partir de l'adresse . + +Dans l'[exemple de projet](/fr/part0/introduction_aux_applications_web#execution-de-la-logique-dapplication-dans-le-navigateur) de la partie 0, nous avons appris un moyen de récupérer des données à partir d'un serveur à l'aide de JavaScript. Le code de l'exemple récupérait les données à l'aide de [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest), également connu sous le nom de requête HTTP effectuée à l'aide d'un objet XHR. Il s'agit d'une technique introduite en 1999, que tous les navigateurs supportent depuis un bon moment maintenant. + +L'utilisation de XHR n'est plus recommandée, et les navigateurs supportent déjà largement la méthode [fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch), qui est basée sur les [promises](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise), au lieu du modèle événementiel utilisé par XHR. + +Pour rappel de la partie 0 (qu'il faut en fait se souvenir de ne pas utiliser sans raison impérieuse), les données ont été récupérées en utilisant XHR de la manière suivante : + + +```js +const xhttp = new XMLHttpRequest() + +xhttp.onreadystatechange = function() { + if (this.readyState == 4 && this.status == 200) { + const data = JSON.parse(this.responseText) + // gérer la réponse qui est enregistrée dans la variable data + } +} + +xhttp.open('GET', '/data.json', true) +xhttp.send() +``` + +Dès le début, nous enregistrons un gestionnaire d'événements sur l'objet xhttp représentant la requête HTTP, qui sera appelée par le runtime JavaScript chaque fois que l'état de xhttp changements d'objet. Si le changement d'état signifie que la réponse à la demande est arrivée, alors les données sont traitées en conséquence. + +Il convient de noter que le code du gestionnaire d'événements est défini avant que la requête ne soit envoyée au serveur. Malgré cela, le code du gestionnaire d'événements sera exécuté ultérieurement. Par conséquent, le code ne s'exécute pas de manière synchrone "de haut en bas", mais le fait de manière asynchrone. JavaScript appelle le gestionnaire d'événements qui a été enregistré pour la demande à un moment donné. + +Une manière synchrone de faire des requêtes qui est courante dans la programmation Java, par exemple, se déroulerait comme suit (NB, ce n'est pas du code Java fonctionnel): + +```java +HTTPRequest request = new HTTPRequest(); + +String url = "https://studies.cs.helsinki.fi/exampleapp/data.json"; +List notes = request.get(url); + +notes.forEach(m => { + System.out.println(m.content); +}); +``` + +En Java, le code s'exécute ligne par ligne et s'arrête pour attendre la requête HTTP, c'est-à-dire attendre la fin de la commande _request.get(...)_. Les données renvoyées par la commande, dans ce cas les notes, sont ensuite stockées dans une variable, et nous commençons à manipuler les données de la manière souhaitée. + +D'autre part, les moteurs JavaScript ou les environnements d'exécution suivent le [modèle asynchrone](https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop). En principe, cela nécessite que toutes les [opérations IO](https://en.wikipedia.org/wiki/Input/output) (à quelques exceptions près) soient exécutées de manière non bloquante. Cela signifie que l'exécution du code se poursuit immédiatement après l'appel d'une fonction IO, sans attendre son retour. + +Lorsqu'une opération asynchrone est terminée, ou, plus précisément, à un moment donné après son achèvement, le moteur JavaScript appelle les gestionnaires d'événements enregistrés à l'opération. + +Actuellement, les moteurs JavaScript sont à thread unique, ce qui signifie qu'ils ne peuvent pas exécuter de code en parallèle. Par conséquent, il est nécessaire en pratique d'utiliser un modèle non bloquant pour l'exécution des opérations IO. Sinon, le navigateur "se bloquerait" pendant, par exemple, la récupération de données à partir d'un serveur. + +Une autre conséquence de cette nature à thread unique des moteurs JavaScript est que si l'exécution de certains codes prend beaucoup de temps, le navigateur restera bloqué pendant toute la durée de l'exécution. Si nous ajoutions le code suivant en haut de notre application : + +```js +setTimeout(() => { + console.log('loop..') + let i = 0 + while (i < 50000000000) { + i++ + } + console.log('end') +}, 5000) +``` + +tout fonctionnerait normalement pendant 5 secondes. Cependant, lorsque la fonction définie comme paramètre pour setTimeout est exécutée, le navigateur sera bloqué pendant toute la durée d'exécution de la longue boucle. Même l'onglet du navigateur ne peut pas être fermé pendant l'exécution de la boucle, du moins pas dans Chrome. + +Pour que le navigateur reste réactif, c'est-à-dire qu'il puisse réagir en permanence aux opérations de l'utilisateur avec une vitesse suffisante, la logique du code doit être telle qu'aucun calcul ne peut prendre trop de temps. + +Il existe une foule de documents supplémentaires sur le sujet à trouver sur Internet. Une présentation particulièrement claire du sujet est le discours d'ouverture de Philip Roberts intitulé [Qu'est-ce que c'est que la boucle d'événement de toute façon ?](https://www.youtube.com/watch?v=8aGhZQkoFbQ) + +Dans les navigateurs d'aujourd'hui, il est possible d'exécuter du code parallélisé à l'aide de ce qu'on appelle des [web workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers). La boucle d'événements d'une fenêtre de navigateur individuelle n'est cependant toujours gérée que par un [fil unique](https://medium.com/techtrument/multithreading-javascript-46156179cf9a). + +### npm + +Revenons au sujet de la récupération des données du serveur. + +Nous pourrions utiliser la fonction basée sur la promise mentionnée précédemment [fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) pour extraire les données du serveur. Fetch est un excellent outil. Il est standardisé et supporté par tous les navigateurs modernes (hors IE). + +Cela étant dit, nous utiliserons plutôt la bibliothèque [axios](https://github.com/axios/axios) pour la communication entre le navigateur et le serveur. Axios fonctionne comme fetch, mais est un peu plus agréable à utiliser. Une autre bonne raison d'utiliser axios est que nous nous familiarisons avec l'ajout de bibliothèques externes, appelées packages npm, aux projets React. + +De nos jours, pratiquement tous les projets JavaScript sont définis à l'aide du gestionnaire de packages de noeuds, alias [npm](https://docs.npmjs.com/getting-started/what-is-npm). Les projets créés à l'aide de create-react-app suivent également le format npm. Un indicateur clair qu'un projet utilise npm est le fichier package.json situé à la racine du projet : + +```json +{ + "name": "part2-notes-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.17.0", + "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.16", + "globals": "^15.14.0", + "vite": "^6.0.5" + } +} +``` + +À ce stade, la partie dépendances nous intéresse le plus car elle définit les dépendances, ou bibliothèques externes, du projet. + +Nous voulons maintenant utiliser axios. Théoriquement, on pourrait définir la librairie directement dans le fichier package.json, mais il vaut mieux l'installer depuis la ligne de commande. + +```js +npm install axios +``` + + +**NB Les commandes _npm_ doivent toujours être exécutées dans le répertoire racine du projet**, où se trouve le fichier package.json. + +Axios est désormais inclus parmi les autres dépendances : + +```json +{ + "name": "part2-notes-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.9", // highlight-line + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + // ... +} +``` + +En plus d'ajouter axios aux dépendances, la commande npm install a également téléchargé le code de la bibliothèque. Comme pour les autres dépendances, le code se trouve dans le répertoire node\_modules situé à la racine. Comme on a pu le remarquer, node\_modules contient une bonne quantité de choses intéressantes. + +Faisons un autre ajout. Installez json-server en tant que dépendance de développement (utilisée uniquement pendant le développement) en exécutant la commande : + +```js +npm install json-server --save-dev +``` + +et faire un petit ajout à la partie scripts du fichier package.json : + +```json +{ + // ... + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview", + "server": "json-server -p 3001 db.json" // highlight-line + }, +} +``` + +Nous pouvons maintenant, sans définitions de paramètres, démarrer le json-server à partir du répertoire racine du projet avec la commande : + +```js +npm run server +``` + +Nous nous familiariserons davantage avec l'outil _npm_ dans la [troisième partie du cours](/fr/part3). + +**NB** Le serveur json précédemment démarré doit être terminé avant d'en démarrer un nouveau ; sinon il y aura des problèmes : + +![](../../images/2/15b.png) + +Le rouge dans le message d'erreur nous informe du problème : + +Cannot bind to port 3001. Please specify another port number either through --port argument or through the json-server.json configuration file + +Comme nous pouvons le voir, l'application n'est pas capable de se lier au [port](https://en.wikipedia.org/wiki/Port_(computer_networking)). La raison étant que le port 3001 est déjà occupé par le serveur json précédemment démarré. + +Nous avons utilisé la commande _npm install_ deux fois, mais avec de légères différences : + +```js +npm install axios +npm install json-server --save-dev +``` + +Il y a une fine différence dans les paramètres. axios est installé en tant que dépendance d'exécution de l'application, car l'exécution du programme nécessite l'existence de la bibliothèque. D'autre part, json-server a été installé en tant que dépendance de développement (_--save-dev_), puisque le programme lui-même n'en a pas besoin. Il est utilisé pour l'assistance lors du développement de logiciels. Il y aura plus sur les différentes dépendances dans la prochaine partie du cours. + +### Axios et promises + +Nous sommes maintenant prêts à utiliser axios. À l'avenir, json-server est supposé s'exécuter sur le port 3001. + +NB : Pour exécuter simultanément json-server et votre application React, vous devrez peut-être utiliser deux fenêtres de terminal. L'une pour maintenir json-server en cours d'exécution et l'autre pour exécuter react-app. + +La bibliothèque peut être mise en service de la même manière que d'autres bibliothèques, par ex. React, c'est-à-dire en utilisant une instruction import appropriée. + +Ajoutez ce qui suit au fichier index.js : + +```js +import axios from 'axios' + +const promise = axios.get('http://localhost:3001/notes') +console.log(promise) + +const promise2 = axios.get('http://localhost:3001/foobar') +console.log(promise2) +``` + +Si vous ouvrez dans le navigateur, cela devrait être imprimé sur la console + +![](../../images/2/16b.png) + +**Remarque :** lorsque le contenu du fichier index.js change, React ne le remarque pas toujours automatiquement, vous devrez donc peut-être actualiser le navigateur pour voir vos modifications ! Une solution de contournement simple pour que React remarque automatiquement le changement consiste à créer un fichier nommé .env dans le répertoire racine du projet et à ajouter cette ligne FAST_REFRESH=false. Redémarrez l'application pour que les modifications appliquées prennent effet. + +La méthode _get_ d'Axios renvoie une [promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises). + +La documentation sur le site de Mozilla indique ce qui suit à propos des promises: + +> Une Promise est un objet représentant l'achèvement ou l'échec éventuel d'une opération asynchrone. + +En d'autres termes, une promise est un objet qui représente une opération asynchrone. Une promise peut avoir trois états distincts : + +1. La promise est en attente : cela signifie que la valeur finale (l'une des deux suivantes) n'est pas encore disponible. +2. La promise est tenue : cela signifie que l'opération est terminée et que la valeur finale est disponible, ce qui est généralement une opération réussie. Cet état est parfois aussi appelé résolu. +3. La promise est rejetée : cela signifie qu'une erreur a empêché la détermination de la valeur finale, ce qui représente généralement une opération échouée. + +La première promise dans notre exemple est fulfilled, représentant une requête _axios.get('http://localhost:3001/notes')_ réussie. La seconde, cependant, est rejetée, et la console nous en donne la raison. Il semble que nous essayions de faire une requête HTTP GET à une adresse inexistante. + +Si, et quand, nous voulons accéder au résultat de l'opération représentée par la promise, nous devons associer un gestionnaire d'événements à la promise. Ceci est réalisé en utilisant la méthode then : + +```js +const promise = axios.get('http://localhost:3001/notes') + +promise.then(response => { + console.log(response) +}) +``` +Ce qui suit est renvoyé sur la console : + +![](../../images/2/17new.png) + +L'environnement d'exécution JavaScript appelle la fonction de rappel enregistrée par la méthode then en lui fournissant un objet response en tant que paramètre. L'objet response contient toutes les données essentielles liées à la réponse d'une requête HTTP GET, qui inclurait les données renvoyées, le code d'état et en-têtes. + +Stocker l'objet promise dans une variable est généralement inutile, et il est plutôt courant d'enchaîner l'appel de méthode then à l'appel de méthode axios, de sorte qu'il le suive directement : + +```js +axios.get('http://localhost:3001/notes').then(response => { + const notes = response.data + console.log(notes) +}) +``` + +La fonction callback prend maintenant les données contenues dans la réponse, les stocke dans une variable et affiche les notes sur la console. + +Une façon plus lisible de formater les appels de méthode chaînés consiste à placer chaque appel sur sa propre ligne : + +```js +axios + .get('http://localhost:3001/notes') + .then(response => { + const notes = response.data + console.log(notes) + }) +``` + +Les données renvoyées par le serveur sont du texte brut, essentiellement une seule longue chaîne. La bibliothèque axios est toujours capable d'analyser les données dans un tableau JavaScript, puisque le serveur a spécifié que le format de données est application/json ; charset=utf-8 (voir image précédente) en utilisant l'en-tête content-type. + +Nous pouvons enfin commencer à utiliser les données récupérées sur le serveur. + +Essayons de demander les notes à notre serveur local et de les rendre, initialement en tant que composant App. Veuillez noter que cette approche présente de nombreux problèmes, car nous n'affichons l'intégralité du composant App que lorsque nous récupérons avec succès une réponse : + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' +import axios from 'axios' // highlight-line + +import App from './App' + +axios.get('http://localhost:3001/notes').then(response => { + const notes = response.data + ReactDOM.createRoot(document.getElementById('root')).render() +}) +``` + +Cette méthode pourrait être acceptable dans certaines circonstances, mais elle est quelque peu problématique. Déplaçons plutôt la récupération des données dans le composant App. + +Ce qui n'est pas immédiatement évident, cependant, c'est où la commande axios.get doit être placée dans le composant. + +### Hooks d'effet + +Nous avons déjà utilisé les [state hooks](https://reactjs.org/docs/hooks-state.html) qui ont été introduits avec la version React [16.8.0](https://www.npmjs.com/package/react/v/16.8.0), qui fournissent un état aux composants React définis comme des fonctions - les soi-disant composants fonctionnels. La version 16.8.0 introduit également les [Hooks d'effet](https://reactjs.org/docs/hooks-effect.html) en tant que nouvelle fonctionnalité. Selon la doc officielle : + +> Le hook d'effet vous permet d'effectuer des effets secondaires sur les composants fonctionnels. +> La récupération de données, la configuration d'un abonnement et la modification manuelle du DOM dans les composants React sont tous des exemples d'effets secondaires. + +En tant que tels, les hooks d'effet sont précisément le bon outil à utiliser lors de la récupération de données à partir d'un serveur. + +Supprimons la récupération des données de index.js. Puisque nous allons récupérer les notes du serveur, il n'est plus nécessaire de transmettre des données en tant qu'accessoires au composant App. Donc index.js peut être simplifié en : + +```js +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +Le composant App change comme suit : + +```js +import { useState, useEffect } from 'react' // highlight-line +import axios from 'axios' // highlight-line +import Note from './components/Note' + +const App = () => { // highlight-line + const [notes, setNotes] = useState([]) // highlight-line + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + +// highlight-start + useEffect(() => { + console.log('effect') + axios + .get('http://localhost:3001/notes') + .then(response => { + console.log('promise fulfilled') + setNotes(response.data) + }) + }, []) + + console.log('render', notes.length, 'notes') +// highlight-end + + // ... +} +``` + +Nous avons également ajouté quelques impressions utiles, qui clarifient la progression de l'exécution. + +Ceci est affiché sur la console : + +``` +render 0 notes +effect +promise fulfilled +render 3 notes +``` + +Tout d'abord, le corps de la fonction définissant le composant est exécuté et le composant est rendu pour la première fois. À ce stade, render 0 notes est imprimé, ce qui signifie que les données n'ont pas encore été extraites du serveur. + +La fonction ou l'effet suivant dans le jargon React : + +```js +() => { + console.log('effect') + axios + .get('http://localhost:3001/notes') + .then(response => { + console.log('promise fulfilled') + setNotes(response.data) + }) +} +``` + +est exécuté immédiatement après le rendu. L'exécution de la fonction entraîne l'impression de l'effet sur la console, et la commande axios.get lance la récupération des données du serveur et enregistre la fonction suivante en tant que un gestionnaire d'événements pour l'opération : + +```js +response => { + console.log('promise fulfilled') + setNotes(response.data) +} +``` + +Lorsque les données arrivent du serveur, le runtime JavaScript appelle la fonction enregistrée en tant que gestionnaire d'événements, qui affiche promise fulfilled sur la console et stocke les notes reçues du serveur dans l'état à l'aide de la fonction setNotes(response.data). + +Comme toujours, un appel à une fonction de mise à jour d'état déclenche le nouveau rendu du composant. Par conséquent, render 3 notes est affiché sur la console et les notes récupérées sur le serveur sont rendues à l'écran. + +Enfin, regardons la définition du hook d'effet dans son ensemble : + +```js +useEffect(() => { + console.log('effect') + axios + .get('http://localhost:3001/notes').then(response => { + console.log('promise fulfilled') + setNotes(response.data) + }) +}, []) +``` + +Réécrivons le code un peu différemment. + +```js +const hook = () => { + console.log('effect') + axios + .get('http://localhost:3001/notes') + .then(response => { + console.log('promise fulfilled') + setNotes(response.data) + }) +} + +useEffect(hook, []) +``` + +Maintenant, nous pouvons voir plus clairement que la fonction [useEffect](https://reactjs.org/docs/hooks-reference.html#useeffect) prend en fait deux paramètres. Le premier est une fonction, l'effet lui-même. Selon la documentation : + +> Par défaut, les effets s'exécutent après chaque rendu terminé, mais vous pouvez choisir de ne les déclencher que lorsque certaines valeurs ont changé. + +Ainsi, par défaut, l'effet est toujours exécuté après le rendu du composant. Dans notre cas, cependant, nous ne voulons exécuter l'effet qu'avec le premier rendu. + +Le deuxième paramètre de useEffect est utilisé pour [spécifier la fréquence d'exécution de l'effet](https://reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect). Si le deuxième paramètre est un tableau vide [], alors l'effet n'est exécuté qu'avec le premier rendu du composant. + +Il existe de nombreux cas d'utilisation possibles pour un hook d'effet autres que la récupération de données à partir du serveur. Cependant, cette utilisation nous suffit, pour l'instant. + +Repensez à la séquence d'événements dont nous venons de parler. Quelles parties du code sont exécutées ? Dans quel ordre? À quelle fréquence? Comprendre l'ordre des événements est essentiel ! + +Notez que nous aurions également pu écrire le code de la fonction d'effet de cette façon : + +```js +useEffect(() => { + console.log('effect') + + const eventHandler = response => { + console.log('promise fulfilled') + setNotes(response.data) + } + + const promise = axios.get('http://localhost:3001/notes') + promise.then(eventHandler) +}, []) +``` + +Une référence à une fonction de gestion d'événements est affectée à la variable eventHandler. La promise renvoyée par la méthode get d'Axios est stockée dans la variable promise. L'enregistrement du rappel se produit en donnant la variable eventHandler, faisant référence à la fonction de gestion d'événements, en tant que paramètre de la méthode then de la promise. Il n'est généralement pas nécessaire d'assigner des fonctions et des promises aux variables, et une manière plus compacte de représenter les choses, comme vu plus haut, est suffisante. + +```js +useEffect(() => { + console.log('effect') + axios + .get('http://localhost:3001/notes') + .then(response => { + console.log('promise fulfilled') + setNotes(response.data) + }) +}, []) +``` + +Nous avons toujours un problème dans notre application. Lors de l'ajout de nouvelles notes, elles ne sont pas stockées sur le serveur. + +Le code de l'application, tel que décrit jusqu'à présent, peut être trouvé dans son intégralité sur [github](https://github.com/fullstack-hy2020/part2-notes/tree/part2-4), sur la branche part2-4. + +### Environnement d'exécution de développement + +La configuration de l'ensemble de l'application est devenue de plus en plus complexe. Passons en revue ce qui se passe et où. L'image suivante décrit la composition de l'application + +![](../../images/2/18e.png) + +Le code JavaScript composant notre application React est exécuté dans le navigateur. Le navigateur obtient le JavaScript du serveur de développement React, qui est l'application qui s'exécute après l'exécution de la commande npm start. Le dev-server transforme le JavaScript dans un format compris par le navigateur. Entre autres choses, il assemble le JavaScript de différents fichiers en un seul fichier. Nous aborderons le dev-server plus en détail dans la partie 7 du cours. + +L'application React s'exécutant dans le navigateur récupère les données au format JSON à partir de json-server s'exécutant sur le port 3001 de la machine. Le serveur à partir duquel nous interrogeons les données - json-server - obtient ses données à partir du fichier db.json. + +À ce stade du développement, toutes les parties de l'application résident sur la machine du développeur, également appelée localhost. La situation change lorsque l'application est déployée sur Internet. Nous le ferons dans la partie 3. + +
    + +
    + +

    Exercices 2.11.

    + +

    2.11: phonebook, étape6

    + +Nous continuons à développer le répertoire. Stockez l'état initial de l'application dans le fichier db.json, qui doit être placé à la racine du projet. + +```json +{ + "persons":[ + { + "name": "Arto Hellas", + "number": "040-123456", + "id": 1 + }, + { + "name": "Ada Lovelace", + "number": "39-44-5323523", + "id": 2 + }, + { + "name": "Dan Abramov", + "number": "12-43-234345", + "id": 3 + }, + { + "name": "Mary Poppendieck", + "number": "39-23-6423122", + "id": 4 + } + ] +} +``` + +Démarrez json-server sur le port 3001 et assurez-vous que le serveur renvoie la liste des personnes en allant à l'adresse dans le navigateur. + +Si vous recevez le message d'erreur suivant : + +```js +events.js:182 + throw er; // Unhandled 'error' event + ^ + +Error: listen EADDRINUSE 0.0.0.0:3001 + at Object._errnoException (util.js:1019:11) + at _exceptionWithHostPort (util.js:1041:20) +``` + +cela signifie que le port 3001 est déjà utilisé par une autre application, par ex. en cours d'utilisation par un json-server déjà en cours d'exécution. Fermez l'autre application ou modifiez le port au cas où cela ne fonctionnerait pas. + +Modifiez l'application de sorte que l'état initial des données soit extrait du serveur à l'aide de la bibliothèque axios. Terminez la récupération avec un [Hook d'effet](https://reactjs.org/docs/hooks-effect.html). diff --git a/src/content/2/fr/part2d.md b/src/content/2/fr/part2d.md new file mode 100644 index 00000000000..3bcee5af788 --- /dev/null +++ b/src/content/2/fr/part2d.md @@ -0,0 +1,740 @@ +--- +mainImage: ../../../images/part-2.svg +part: 2 +letter: d +lang: fr +--- + +
    + + +Lors de la création de notes dans notre application, nous voudrions naturellement les stocker dans un serveur principal. Le package [json-server](https://github.com/typicode/json-server) prétend être une API dite REST ou RESTful dans sa documentation : + +> Obtenez une fausse API REST complète sans codage en moins de 30 secondes (sérieusement) + +Le serveur json ne correspond pas exactement à la description fournie par le manuel [definition](https://en.wikipedia.org/wiki/Representational_state_transfer) d'une API REST, mais la plupart des autres API prétendant être RESTful non plus. + +Nous examinerons de plus près REST dans la [prochaine partie](/fr/part3) du cours. Mais il est important de nous familiariser à ce stade avec certaines des [conventions](https://en.wikipedia.org/wiki/Representational_state_transfer#Applied_to_web_services) utilisées par json-server et les API REST en général. En particulier, nous examinerons l'utilisation conventionnelle des [routes](https://github.com/typicode/json-server#routes), c'est-à-dire des URL et des types de requêtes HTTP, dans REST. + +### REST + +Dans la terminologie REST, nous nous référons à des objets de données individuels, tels que les notes de notre application, en tant que ressources. Chaque ressource est associée à une adresse unique - son URL. Selon une convention générale utilisée par json-server, nous pourrions localiser une note individuelle à l'URL de la ressource notes/3, où 3 est l'identifiant de la ressource. L'url notes, d'autre part, pointerait vers une collection de ressources contenant toutes les notes. + +Les ressources sont extraites du serveur avec des requêtes HTTP GET. Par exemple, une requête HTTP GET à l'URL notes/3 renverra la note qui a le numéro d'identification 3. Une requête HTTP GET à l'URL notes renverra une liste de toutes les notes. + +La création d'une nouvelle ressource pour stocker une note se fait en faisant une requête HTTP POST à ​​l'URL notes selon la convention REST à laquelle adhère le serveur json. Les données de la nouvelle ressource de note sont envoyées dans le body de la requête. + +json-server nécessite que toutes les données soient envoyées au format JSON. Cela signifie en pratique que les données doivent être une chaîne correctement formatée et que la requête doit contenir l'en-tête de requête Content-Type avec la valeur application/json. + +### Envoi de données au serveur + +Apportons les modifications suivantes au gestionnaire d'événements responsable de la création d'une nouvelle note : + +```js +addNote = event => { + event.preventDefault() + const noteObject = { + content: newNote, + date: new Date(), + important: Math.random() < 0.5, + } + +// highlight-start + axios + .post('http://localhost:3001/notes', noteObject) + .then(response => { + console.log(response) + }) +// highlight-end +} +``` + +Nous créons un nouvel objet pour la note mais omettons la propriété id, car il est préférable de laisser le serveur générer des identifiants pour nos ressources ! + +L'objet est envoyé au serveur à l'aide de la méthode axios post. Le gestionnaire d'événements enregistré consigne la réponse qui est renvoyée du serveur à la console. + +Lorsque nous essayons de créer une nouvelle note, la sortie suivante apparaît dans la console : + +![](../../images/2/20e.png) + +La ressource de note nouvellement créée est stockée dans la valeur de la propriété data de l'objet _response_. + +Parfois, il peut être utile d'inspecter les requêtes HTTP dans l'onglet Réseau des outils de développement Chrome, qui a été largement utilisé au début de la [partie 0](/fr/part0/introduction_aux_applications_web#http-get) : + +![](../../images/2/21e.png) + +Nous pouvons utiliser l'inspecteur pour vérifier que les en-têtes envoyés dans la requête POST correspondent à ce que nous attendions d'eux et que leurs valeurs sont correctes. + +Étant donné que les données que nous avons envoyées dans la requête POST étaient un objet JavaScript, axios a automatiquement su définir la valeur application/json appropriée pour l'en-tête Content-Type. + +La nouvelle note n'est pas encore rendue à l'écran. En effet, nous n'avons pas mis à jour l'état du composant App lors de la création de la nouvelle note. Réparons ça : + +```js +addNote = event => { + event.preventDefault() + const noteObject = { + content: newNote, + date: new Date(), + important: Math.random() > 0.5, + } + + axios + .post('http://localhost:3001/notes', noteObject) + .then(response => { + // highlight-start + setNotes(notes.concat(response.data)) + setNewNote('') + // highlight-end + }) +} +``` + +La nouvelle note renvoyée par le serveur principal est ajoutée à la liste des notes dans l'état de notre application de la manière habituelle en utilisant la fonction setNotes puis en réinitialisant le formulaire de création de note. Un [détail important](/fr/part1/plongez_dans_le_debogage_dapplications_react#gestion-des-tableaux) à retenir est que la méthode concat ne modifie pas l'état d'origine du composant, mais crée à la place une nouvelle copie de la liste. + +Une fois que les données renvoyées par le serveur commencent à avoir un effet sur le comportement de nos applications Web, nous sommes immédiatement confrontés à un tout nouvel ensemble de défis découlant, par exemple, de l'asynchronicité de la communication. Cela nécessite de nouvelles stratégies de débogage, la journalisation de la console et d'autres moyens de débogage deviennent de plus en plus importants. Nous devons également développer une compréhension suffisante des principes des composants d'exécution JavaScript et React. Deviner ne suffira pas. + +Il est avantageux d'inspecter l'état du serveur principal, par ex. via le navigateur : + +![](../../images/2/22e.png) + +Cela permet de vérifier que toutes les données que nous avions l'intention d'envoyer ont bien été reçues par le serveur. + +Dans la prochaine partie du cours, nous apprendrons à implémenter notre propre logique dans le backend. Nous examinerons ensuite de plus près des outils tels que [Postman](https://www.postman.com/downloads/) qui nous aident à déboguer nos applications serveur. Cependant, inspecter l'état du serveur json via le navigateur est suffisant pour nos besoins actuels. + +> **NB :** Dans la version actuelle de notre application, le navigateur ajoute la propriété date de création à la note. Étant donné que l'horloge de la machine exécutant le navigateur peut être mal configurée, il est beaucoup plus sage de laisser le serveur principal générer cet horodatage pour nous. C'est d'ailleurs ce que nous ferons dans la suite du cours. + + +Le code de l'état actuel de notre application se trouve sur la branche part2-5 sur [GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part2-5). + +### Modification de l'importance des notes + +Ajoutons un bouton à chaque note qui peut être utilisé pour changer son importance. + +Nous apportons les modifications suivantes au composant Note : + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' : 'make important' + + return ( +
  • + {note.content} + +
  • + ) +} +``` + +Nous ajoutons un bouton au composant et affectons son gestionnaire d'événements en tant que fonction toggleImportance transmise dans les props du composant. + +Le composant App définit une version initiale de la fonction de gestionnaire d'événements toggleImportanceOf et la transmet à chaque composant Note : + +```js +const App = () => { + const [notes, setNotes] = useState([]) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + + // ... + + // highlight-start + const toggleImportanceOf = (id) => { + console.log('importance of ' + id + ' needs to be toggled') + } + // highlight-end + + // ... + + return ( +
    +

    Notes

    +
    + +
    +
      + {notesToShow.map(note => + toggleImportanceOf(note.id)} // highlight-line + /> + )} +
    + // ... +
    + ) +} +``` + +Remarquez comment chaque note reçoit sa propre fonction de gestion d'événements unique, puisque l'id de chaque note est unique. + +Par exemple, si note.id vaut 3, la fonction de gestion d'événements renvoyée par _toggleImportance(note.id)_ sera : + +```js +() => { console.log('importance of 3 needs to be toggled') } +``` + +Petit rappel ici. La chaîne retournée par le gestionnaire d'événements est définie à la manière de Java en concatenant les chaînes aux moyens de l'opérateur + : + +```js +console.log('importance of ' + id + ' needs to be toggled') +``` + +La syntaxe [template string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals) ajoutée dans ES6 peut être utilisée pour écrire des chaînes similaires de manière beaucoup plus agréable : + +```js +console.log(`importance of ${id} needs to be toggled`) +``` + +Nous pouvons maintenant utiliser la syntaxe "dollar-bracket" pour ajouter des parties à la chaîne qui évalueront les expressions JavaScript, par ex. la valeur d'une variable. Notez que les guillemets utilisés dans les chaînes de modèle diffèrent des guillemets utilisés dans les chaînes JavaScript normales. + +Les notes individuelles stockées dans le backend json-server peuvent être modifiées de deux manières différentes en envoyant des requêtes HTTP à l'URL unique de la note. Nous pouvons soit remplacer l'intégralité de la note par une requête HTTP PUT, soit modifier uniquement certaines propriétés de la note avec une requête HTTP PATCH. + +La forme finale de la fonction de gestion d'événements est la suivante : + +```js +const toggleImportanceOf = id => { + const url = `http://localhost:3001/notes/${id}` + const note = notes.find(n => n.id === id) + const changedNote = { ...note, important: !note.important } + + axios.put(url, changedNote).then(response => { + setNotes(notes.map(n => n.id !== id ? n : response.data)) + }) +} +``` + +Presque chaque ligne de code dans le corps de la fonction contient des détails importants. La première ligne définit l'URL unique pour chaque ressource de note en fonction de son identifiant. + +La méthode [find](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find) est utilisée pour trouver la note que nous voulons modifier, que nous attribuons ensuite à la variable _note_. + +Après cela, nous créons un nouvel objet qui est une copie exacte de l'ancienne note, à l'exception de la propriété importante. + +Le code de création du nouvel objet qui utilise la syntaxe de [déstructuration](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) peut sembler un peu étrange au premier abord : + +```js +const changedNote = { ...note, important: !note.important } +``` + +En pratique, { ...note } crée un nouvel objet avec des copies de toutes les propriétés de l'objet _note_. Lorsque nous ajoutons des propriétés à l'intérieur des accolades après l'objet propagé, par ex. { ...note, important : true }, alors la valeur de la propriété _important_ du nouvel objet sera _true_. Dans notre exemple, la propriété important obtient la négation de sa valeur précédente dans l'objet d'origine. + +Il y a quelques points à souligner. Pourquoi avons-nous fait une copie de l'objet note que nous voulions modifier, alors que le code suivant semble également fonctionner ? + +```js +const note = notes.find(n => n.id === id) +note.important = !note.important + +axios.put(url, note).then(response => { + // ... +}) +``` + +Ceci n'est pas recommandé car la variable note est une référence à un élément du tableau notes dans l'état du composant, et comme nous le rappelons, nous ne devons jamais muter l'état directement dans React. + +Il convient également de noter que le nouvel objet _changedNote_ n'est qu'une soi-disant [copie superficielle](https://en.wikipedia.org/wiki/Object_copying#Shallow_copy), ce qui signifie que les valeurs du nouvel objet sont les mêmes que celles du valeurs de l'ancien objet. Si les valeurs de l'ancien objet étaient elles-mêmes des objets, les valeurs copiées dans le nouvel objet feraient référence aux mêmes objets qui se trouvaient dans l'ancien objet. + +La nouvelle note est ensuite envoyée avec une requête PUT au backend où elle remplacera l'ancien objet. + +La fonction de callback définit l'état notes du composant sur un nouveau tableau contenant tous les éléments du tableau notes précédent, à l'exception de l'ancienne note qui est remplacée par sa version mise à jour renvoyée par le serveur : + +```js +axios.put(url, changedNote).then(response => { + setNotes(notes.map(note => note.id !== id ? note : response.data)) +}) +``` + +Ceci est accompli avec la méthode map : + +```js +notes.map(note => note.id !== id ? note : response.data) +``` + +La méthode map crée un nouveau tableau en mappant chaque élément de l'ancien tableau dans un élément du nouveau tableau. Dans notre exemple, le nouveau tableau est créé conditionnellement de sorte que si note.id !== id est vrai ; nous copions simplement l'élément de l'ancien tableau dans le nouveau tableau. Si la condition est fausse, l'objet note renvoyé par le serveur est ajouté au tableau à la place. + +Cette astuce de map peut sembler un peu étrange au début, mais cela vaut la peine de passer un peu de temps à comprendre. Nous utiliserons cette méthode plusieurs fois tout au long du cours. + +### Extraction de la communication avec le backend dans un module séparé + + +Le composant App est devenu quelque peu gonflé après l'ajout du code pour communiquer avec le serveur principal. Dans l'esprit du [principe de responsabilité unique](https://en.wikipedia.org/wiki/Single_responsibility_principle), nous jugeons judicieux d'extraire cette communication dans son propre [module](/en/part2/rendering_a_collection_modules#refactoring-modules). + +Créons un répertoire src/services et ajoutons-y un fichier appelé notes.js : + +```js +import axios from 'axios' +const baseUrl = 'http://localhost:3001/notes' + +const getAll = () => { + return axios.get(baseUrl) +} + +const create = newObject => { + return axios.post(baseUrl, newObject) +} + +const update = (id, newObject) => { + return axios.put(`${baseUrl}/${id}`, newObject) +} + +export default { + getAll: getAll, + create: create, + update: update +} +``` + +Le module renvoie un objet qui a trois fonctions (getAll, create et update) comme propriétés traitant des notes. Les fonctions retournent directement les promises retournées par les méthodes axios. + +Le composant App utilise import pour accéder au module : + +```js +import noteService from './services/notes' // highlight-line + +const App = () => { +``` + +Les fonctions du module peuvent être utilisées directement avec la variable importée _noteService_ comme suit : + +```js +const App = () => { + // ... + + useEffect(() => { + // highlight-start + noteService + .getAll() + .then(response => { + setNotes(response.data) + }) + // highlight-end + }, []) + + const toggleImportanceOf = id => { + const note = notes.find(n => n.id === id) + const changedNote = { ...note, important: !note.important } + + // highlight-start + noteService + .update(id, changedNote) + .then(response => { + setNotes(notes.map(note => note.id !== id ? note : response.data)) + }) + // highlight-end + } + + const addNote = (event) => { + event.preventDefault() + const noteObject = { + content: newNote, + date: new Date().toISOString(), + important: Math.random() > 0.5 + } + +// highlight-start + noteService + .create(noteObject) + .then(response => { + setNotes(notes.concat(response.data)) + setNewNote('') + }) +// highlight-end + } + + // ... +} + +export default App +``` + +Nous pourrions aller plus loin dans notre mise en oeuvre. Lorsque le composant App utilise les fonctions, il reçoit un objet contenant la réponse complète à la requête HTTP : + +```js +noteService + .getAll() + .then(response => { + setNotes(response.data) + }) +``` + +Le composant App utilise uniquement la propriété response.data de l'objet de réponse. + +Le module serait beaucoup plus agréable à utiliser si, au lieu de la réponse HTTP entière, nous n'obtenions que les données de réponse. L'utilisation du module ressemblerait alors à ceci : + +```js +noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) + }) +``` + +Nous pouvons y parvenir en modifiant le code dans le module comme suit (le code actuel contient du copier-coller, mais nous le tolérerons pour l'instant) : + +```js +import axios from 'axios' +const baseUrl = 'http://localhost:3001/notes' + +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} + +const create = newObject => { + const request = axios.post(baseUrl, newObject) + return request.then(response => response.data) +} + +const update = (id, newObject) => { + const request = axios.put(`${baseUrl}/${id}`, newObject) + return request.then(response => response.data) +} + +export default { + getAll: getAll, + create: create, + update: update +} +``` + + +Nous ne renvoyons plus directement la promise renvoyée par axios. Au lieu de cela, nous attribuons la promise à la variable request et appelons sa méthode then : + +```js +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} +``` + +La dernière ligne de notre fonction est simplement une expression plus compacte du même code, comme indiqué ci-dessous : + +```js +const getAll = () => { + const request = axios.get(baseUrl) + // highlight-start + return request.then(response => { + return response.data + }) + // highlight-end +} +``` + +La fonction getAll modifiée renvoie toujours une promise, car la méthode then d'une promise [renvoie également une promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then). + +Après avoir défini le paramètre de la méthode then pour retourner directement response.data, nous avons fait en sorte que la fonction getAll fonctionne comme nous le voulions. Lorsque la requête HTTP aboutit, la promise renvoie les données renvoyées dans la réponse du backend. + +Nous devons mettre à jour le composant App pour fonctionner avec les modifications apportées à notre module. Nous devons corriger les fonctions de callback données en paramètres aux méthodes de l'objet noteService, afin qu'elles utilisent les données de réponse directement renvoyées : + +```js +const App = () => { + // ... + + useEffect(() => { + noteService + .getAll() + // highlight-start + .then(initialNotes => { + setNotes(initialNotes) + // highlight-end + }) + }, []) + + const toggleImportanceOf = id => { + const note = notes.find(n => n.id === id) + const changedNote = { ...note, important: !note.important } + + noteService + .update(id, changedNote) + // highlight-start + .then(returnedNote => { + setNotes(notes.map(note => note.id !== id ? note : returnedNote)) + // highlight-end + }) + } + + const addNote = (event) => { + event.preventDefault() + const noteObject = { + content: newNote, + date: new Date().toISOString(), + important: Math.random() > 0.5 + } + + noteService + .create(noteObject) + // highlight-start + .then(returnedNote => { + setNotes(notes.concat(returnedNote)) + // highlight-end + setNewNote('') + }) + } + + // ... +} +``` + +Tout cela est assez compliqué et tenter de l'expliquer peut simplement rendre la compréhension plus difficile. Internet regorge de documents traitant du sujet, comme [ceci](https://javascript.info/promise-chaining). + +Le livre "Async et performance" de la série de livres [Vous ne connaissez pas JS](https://github.com/getify/You-Dont-Know-JS/tree/1st-ed) [explique bien le sujet](https://github.com/getify/You-Dont-Know-JS/blob/1st-ed/async%20%26%20performance/ch3.md), mais l'explication fait plusieurs pages. + +Les promises sont au coeur du développement JavaScript moderne et il est fortement recommandé d'investir un temps raisonnable pour les comprendre. + +### Syntaxe plus propre pour la définition des Object Literals + +Le module définissant les services liés aux notes exporte actuellement un objet avec les propriétés getAll, create et update qui sont affectées aux fonctions de gestion des notes. + +La définition du module était : + +```js +import axios from 'axios' +const baseUrl = 'http://localhost:3001/notes' + +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} + +const create = newObject => { + const request = axios.post(baseUrl, newObject) + return request.then(response => response.data) +} + +const update = (id, newObject) => { + const request = axios.put(`${baseUrl}/${id}`, newObject) + return request.then(response => response.data) +} + +export default { + getAll: getAll, + create: create, + update: update +} +``` + +Le module exporte l'objet suivant, plutôt particulier : + +```js +{ + getAll: getAll, + create: create, + update: update +} +``` + +Les étiquettes à gauche des deux-points dans la définition de l'objet sont les clés de l'objet, tandis que celles à droite sont des variables qui sont définies à l'intérieur du module . + +Puisque les noms des clés et des variables assignées sont les mêmes, nous pouvons écrire la définition de l'objet avec une syntaxe plus compacte : + +```js +{ + getAll, + create, + update +} +``` + +En conséquence, la définition du module est simplifiée sous la forme suivante : + +```js +import axios from 'axios' +const baseUrl = 'http://localhost:3001/notes' + +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} + +const create = newObject => { + const request = axios.post(baseUrl, newObject) + return request.then(response => response.data) +} + +const update = (id, newObject) => { + const request = axios.put(`${baseUrl}/${id}`, newObject) + return request.then(response => response.data) +} + +export default { getAll, create, update } // highlight-line +``` + +En définissant l'objet à l'aide de cette notation plus courte, nous utilisons une [nouvelle fonctionnalité](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer#Property_definitions) qui a été introduite à JavaScript via ES6, permettant une manière légèrement plus compacte de définir des objets à l'aide de variables. + +Pour illustrer cette fonctionnalité, considérons une situation dans laquelle les valeurs suivantes sont attribuées aux variables : + +```js +const name = 'Leevi' +const age = 0 +``` + +Dans les anciennes versions de JavaScript, nous devions définir un objet comme celui-ci : + +```js +const person = { + name: name, + age: age +} +``` + +Cependant, étant donné que les champs de propriété et les noms de variable dans l'objet sont les mêmes, il suffit d'écrire simplement ce qui suit en JavaScript ES6 : + +```js +const person = { name, age } +``` + +Le résultat est identique pour les deux expressions. Ils créent tous les deux un objet avec une propriété name avec la valeur Leevi et une propriété age avec la valeur 0. + +### Promises et Erreurs + +Si notre application permettait aux utilisateurs de supprimer des notes, nous pourrions nous retrouver dans une situation où un utilisateur essaie de modifier l'importance d'une note qui a déjà été supprimée du système. + +Simulons cette situation en faisant en sorte que la fonction getAll du service note renvoie une note "codée en dur" qui n'existe pas réellement sur le serveur principal : + +```js +const getAll = () => { + const request = axios.get(baseUrl) + const nonExisting = { + id: 10000, + content: 'This note is not saved to server', + date: '2019-05-30T17:30:31.098Z', + important: true, + } + return request.then(response => response.data.concat(nonExisting)) +} +``` + +Lorsque nous essayons de modifier l'importance de la note codée en dur, nous voyons le message d'erreur suivant dans la console. L'erreur indique que le serveur principal a répondu à notre requête HTTP PUT avec un code d'état 404 not found. + +![](../../images/2/23e.png) + +L'application doit être capable de gérer ces types de situations d'erreur avec élégance. Les utilisateurs ne pourront pas dire qu'une erreur s'est réellement produite à moins qu'ils n'aient leur console ouverte. La seule façon de voir l'erreur dans l'application est que le fait de cliquer sur le bouton n'a aucun effet sur l'importance de la note. + +Nous avions [précédemment](/en/part2/getting_data_from_server#axios-and-promises) mentionné qu'une promesse peut être dans l'un des trois états différents. Lorsqu'une requête HTTP échoue, la promesse associée est rejetée. Notre code actuel ne gère en aucun cas ce rejet. + +Le rejet d'une promise est [géré](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises) en fournissant la méthode then avec un deuxième rappel fonction, qui est appelée dans la situation où la promesse est rejetée. + +La manière la plus courante d'ajouter un gestionnaire pour les promesses rejetées consiste à utiliser la méthode [catch](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch). + +En pratique, le gestionnaire d'erreurs pour les promesses rejetées est défini comme ceci : + +```js +axios + .get('http://example.com/probably_will_fail') + .then(response => { + console.log('success!') + }) + .catch(error => { + console.log('fail') + }) +``` + +Si la requête échoue, le gestionnaire d'événements enregistré avec la méthode catch est appelé. + +La méthode catch est souvent utilisée en la plaçant plus profondément dans la chaîne de promises. + +Lorsque notre application effectue une requête HTTP, nous créons en fait une [chaîne de promises](https://javascript.info/promise-chaining) : + +```js +axios + .put(`${baseUrl}/${id}`, newObject) + .then(response => response.data) + .then(changedNote => { + // ... + }) +``` + +La méthode catch peut être utilisée pour définir une fonction de gestion à la fin d'une chaîne de promises, qui est appelée une fois qu'une promise de la chaîne génère une erreur et que la promise devient rejetée . + +```js +axios + .put(`${baseUrl}/${id}`, newObject) + .then(response => response.data) + .then(changedNote => { + // ... + }) + .catch(error => { + console.log('fail') + }) +``` + +Utilisons cette fonctionnalité et ajoutons un gestionnaire d'erreurs dans le composant App : + +```js +const toggleImportanceOf = id => { + const note = notes.find(n => n.id === id) + const changedNote = { ...note, important: !note.important } + + noteService + .update(id, changedNote).then(returnedNote => { + setNotes(notes.map(note => note.id !== id ? note : returnedNote)) + }) + // highlight-start + .catch(error => { + alert( + `the note '${note.content}' was already deleted from server` + ) + setNotes(notes.filter(n => n.id !== id)) + }) + // highlight-end +} +``` + +Le message d'erreur s'affiche pour l'utilisateur avec l'ancienne boîte de dialogue fidèle [alert](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert) et la note supprimée est filtrée de l'état. + +La suppression d'une note déjà supprimée de l'état de l'application se fait avec la méthode array [filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter), qui renvoie un nouveau tableau comprenant uniquement les éléments de la liste pour lesquels la fonction passée en paramètre renvoie vrai pour : + +```js +notes.filter(n => n.id !== id) +``` + +Ce n'est probablement pas une bonne idée d'utiliser alert dans des applications React plus sérieuses. Nous apprendrons bientôt une manière plus avancée d'afficher des messages et des notifications aux utilisateurs. Il existe cependant des situations où une méthode simple et éprouvée comme alert peut fonctionner comme point de départ. Une méthode plus avancée pourrait toujours être ajoutée plus tard, étant donné qu'il y a du temps et de l'énergie pour cela. + +Le code de l'état actuel de notre application se trouve sur la branche part2-6 sur [GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part2-6). + +
    + +
    + +

    Exercices 2.12.-2.15.

    + +

    2.12: phonebook, étape7

    + +Revenons à notre application de répertoire. + +Actuellement, les numéros ajoutés au répertoire ne sont pas enregistrés sur un serveur principal. Corrigez cette situation. + +

    2.13: phonebook, étape8

    + +Extrayez le code qui gère la communication avec le backend dans son propre module en suivant l'exemple présenté précédemment dans cette partie du support de cours. + +

    2.14: phonebook étape9

    + +Permettre aux utilisateurs de supprimer des entrées du répertoire. La suppression peut être effectuée via un bouton dédié pour chaque personne dans la liste du répertoire. Vous pouvez confirmer l'action de l'utilisateur en utilisant la méthode [window.confirm](https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm) : + +![](../../images/2/24e.png) + +La ressource associée à une personne dans le backend peut être supprimée en envoyant une requête HTTP DELETE à l'URL de la ressource. Si nous supprimons par ex. une personne qui a l'id 2, il faudrait faire une requête HTTP DELETE à l'URL localhost:3001/persons/2. Aucune donnée n'est envoyée avec la demande. + +Vous pouvez effectuer une requête HTTP DELETE avec la bibliothèque [axios](https://github.com/axios/axios) de la même manière que nous effectuons toutes les autres requêtes. + +**NB :** Vous ne pouvez pas utiliser le nom delete pour une variable car il s'agit d'un mot réservé en JavaScript. Par exemple. ce qui suit n'est pas possible : + +```js +// utilisez un autre nom pour la variable ! +const delete = (id) => { + // ... +} +``` + +

    2.15*: phonebook, étape10

    + +Modifiez le code de sorte que si un numéro est ajouté à un utilisateur déjà existant, le nouveau numéro remplacera l'ancien numéro. Il est recommandé d'utiliser la méthode HTTP PUT pour mettre à jour le numéro de téléphone. + +Si les informations de la personne sont déjà dans le répertoire, l'application peut confirmer l'action de l'utilisateur : + +![](../../images/teht/16e.png) + +
    diff --git a/src/content/2/fr/part2e.md b/src/content/2/fr/part2e.md new file mode 100644 index 00000000000..b79addb8103 --- /dev/null +++ b/src/content/2/fr/part2e.md @@ -0,0 +1,637 @@ +--- +mainImage: ../../../images/part-2.svg +part: 2 +letter: e +lang: fr +--- + +
    + + +L'apparence de notre application actuelle est assez modeste. Dans l'[exercice 0.2](/fr/part0/introduction_aux_applications_web#exercices-0-1-0-6), le travail consistait à suivre le [tutoriel CSS](https://developer.mozilla.org/en-US/docs/Learn/Getting_started_with_the_web/CSS_basics) de Mozilla. + +Avant de passer à la partie suivante, voyons comment ajouter des styles à une application React. Il existe plusieurs façons de procéder et nous verrons les autres méthodes plus tard. Tout d'abord, nous allons ajouter CSS à notre application à l'ancienne ; dans un seul fichier sans utiliser de [préprocesseur CSS](https://developer.mozilla.org/en-US/docs/Glossary/CSS_preprocessor) (bien que ce ne soit pas tout à fait vrai comme nous le verrons plus tard). + +Ajoutons un nouveau fichier index.css sous le répertoire src puis ajoutons-le à l'application en l'important dans le fichier index.js : + +```js +import './index.css' +``` + +Ajoutons la règle CSS suivante au fichier index.css : + +```css +h1 { + color: green; +} +``` + +**Remarque :** lorsque le contenu du fichier index.css change, React peut ne pas le remarquer automatiquement, vous devrez donc peut-être actualiser le navigateur pour voir vos modifications ! + +Les règles CSS comprennent des sélecteurs et des déclarations. Le sélecteur définit les éléments auxquels la règle doit être appliquée. Le sélecteur ci-dessus est h1, qui correspondra à toutes les balises d'en-tête h1 de notre application. + +La déclaration définit la propriété _color_ sur la valeur green. + +Une règle CSS peut contenir un nombre arbitraire de propriétés. Modifions la règle précédente pour rendre le texte cursif, en définissant le style de police en italique : + +```css +h1 { + color: green; + font-style: italic; // highlight-line +} +``` + +Il existe de nombreuses façons de faire correspondre des éléments en utilisant [différents types de sélecteurs CSS](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors). + +Si nous voulions cibler, disons, chacune des notes avec nos styles, nous pourrions utiliser le sélecteur li, car toutes les notes sont enveloppées dans des balises li : + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' + : 'make important'; + + return ( +
  • + {note.content} + +
  • + ) +} +``` +Ajoutons la règle suivante à notre feuille de style (puisque ma connaissance de la conception Web élégante est proche de zéro, les styles n'ont pas beaucoup de sens) : + +```css +li { + color: grey; + padding-top: 3px; + font-size: 15px; +} +``` + +L'utilisation de types d'éléments pour définir des règles CSS est légèrement problématique. Si notre application contenait d'autres balises li, la même règle de style leur serait également appliquée. + +Si nous voulons appliquer notre style spécifiquement aux notes, il est préférable d'utiliser [class selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/Class_selectors). + +En HTML normal, les classes sont définies comme la valeur de l'attribut class : + +```html +
  • some text...
  • +``` +Dans React, nous devons utiliser l'attribut [className](https://reactjs.org/docs/dom-elements.html#classname) au lieu de l'attribut class. Gardant cela à l'esprit, apportons les modifications suivantes à notre composant Note : + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' + : 'make important'; + + return ( +
  • // highlight-line + {note.content} + +
  • + ) +} +``` + +Les sélecteurs de classe sont définis avec la syntaxe _.classname_ : + +```css +.note { + color: grey; + padding-top: 5px; + font-size: 15px; +} +``` + +Si vous ajoutez maintenant d'autres éléments li à l'application, ils ne seront pas affectés par la règle de style ci-dessus. + + +### Message d'erreur amélioré + +Nous avons précédemment implémenté le message d'erreur qui s'affichait lorsque l'utilisateur tentait de modifier l'importance d'une note supprimée avec la méthode alert. Implémentons le message d'erreur comme son propre composant React. + +Le composant est assez simple : + +```js +const Notification = ({ message }) => { + if (message === null) { + return null + } + + return ( +
    + {message} +
    + ) +} +``` + +Si la valeur de la prop message est null, alors rien n'est rendu à l'écran, et dans d'autres cas, le message est rendu à l'intérieur d'un élément div. + +Ajoutons un nouvel élément d'état appelé errorMessage au composant App. Initialisons-le avec un message d'erreur afin que nous puissions immédiatement tester notre composant : + +```js +const App = () => { + const [notes, setNotes] = useState([]) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + const [errorMessage, setErrorMessage] = useState('some error happened...') // highlight-line + + // ... + + return ( +
    +

    Notes

    + // highlight-line +
    + +
    + // ... +
    + ) +} +``` + +Ajoutons ensuite une règle de style qui convient à un message d'erreur : + +```css +.error { + color: red; + background: lightgrey; + font-size: 20px; + border-style: solid; + border-radius: 5px; + padding: 10px; + margin-bottom: 10px; +} +``` +Nous sommes maintenant prêts à ajouter la logique pour afficher le message d'erreur. Modifions la fonction toggleImportanceOf de la manière suivante : + +```js + const toggleImportanceOf = id => { + const note = notes.find(n => n.id === id) + const changedNote = { ...note, important: !note.important } + + noteService + .update(changedNote).then(returnedNote => { + setNotes(notes.map(note => note.id !== id ? note : returnedNote)) + }) + .catch(error => { + // highlight-start + setErrorMessage( + `Note '${note.content}' was already removed from server` + ) + setTimeout(() => { + setErrorMessage(null) + }, 5000) + // highlight-end + setNotes(notes.filter(n => n.id !== id)) + }) + } +``` + +Lorsque l'erreur se produit, nous ajoutons un message d'erreur descriptif à l'état errorMessage. En même temps, nous démarrons une minuterie, qui définira l'état errorMessage sur null après cinq secondes. + +Le résultat ressemble à ceci : + +![](../../images/2/26e.png) + + +Le code de l'état actuel de notre application se trouve sur la branche part2-7 sur [GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part2-sept). + +### Styles en ligne + +React permet également d'écrire des styles directement dans le code en tant que [inline-styles](https://react-cn.github.io/react/tips/inline-styles.html). + +L'idée derrière la définition des styles en ligne est extrêmement simple. Tout composant ou élément React peut être fourni avec un ensemble de propriétés CSS en tant qu'objet JavaScript via l'attribut [style](https://reactjs.org/docs/dom-elements.html#style). + +Les règles CSS sont définies légèrement différemment dans JavaScript que dans les fichiers CSS normaux. Disons que nous voulions donner à un élément la couleur verte et une police en italique d'une taille de 16 pixels. En CSS, cela ressemblerait à ceci : + +```css +{ + color: green; + font-style: italic; + font-size: 16px; +} +``` + +Mais en tant qu'objet de style en ligne React, cela ressemblerait à ceci : + +```js +{ + color: 'green', + fontStyle: 'italic', + fontSize: 16 +} +``` + +Chaque propriété CSS est définie comme une propriété distincte de l'objet JavaScript. Les valeurs numériques des pixels peuvent être simplement définies comme des nombres entiers. L'une des principales différences par rapport au CSS standard est que les propriétés CSS avec trait d'union (kebab case) sont écrites en camelCase. + +Ensuite, nous pourrions ajouter un "bloc inférieur" à notre application en créant un composant Footer et en définissant les styles en ligne suivants pour celui-ci: + +```js +// highlight-start +const Footer = () => { + const footerStyle = { + color: 'green', + fontStyle: 'italic', + fontSize: 16 + } + + return ( +
    +
    + Note app, Department of Computer Science, University of Helsinki 2022 +
    + ) +} +// highlight-end + +const App = () => { + // ... + + return ( +
    +

    Notes

    + + + + // ... + +
    // highlight-line +
    + ) +} +``` + +Les styles en ligne comportent certaines limitations. Par exemple, les soi-disant [pseudo-classes](https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes) ne peuvent pas être utilisées directement. + +Les styles en ligne et certaines des autres façons d'ajouter des styles aux composants React vont complètement à l'encontre des anciennes conventions. Traditionnellement, il a été considéré comme une bonne pratique de séparer entièrement CSS du contenu (HTML) et de la fonctionnalité (JavaScript). Selon cette ancienne école de pensée, l'objectif était d'écrire CSS, HTML et JavaScript dans leurs fichiers séparés. + +La philosophie de React est, en fait, à l'opposé de cela. Étant donné que la séparation de CSS, HTML et JavaScript dans des fichiers séparés ne semblait pas bien évoluer dans les applications plus grandes, React fonde la division de l'application sur les lignes de ses entités fonctionnelles logiques. + +Les unités structurelles qui composent les entités fonctionnelles de l'application sont des composants React. Un composant React définit le HTML pour structurer le contenu, les fonctions JavaScript pour déterminer les fonctionnalités, ainsi que le style du composant ; tout en un seul endroit. Il s'agit de créer des composants individuels aussi indépendants et réutilisables que possible. + +Le code de la version finale de notre application se trouve sur la branche part2-8 sur [GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part2-8). + +
    + +
    + +

    Exercices 2.16.-2.17.

    + +

    2.16: phonebook, étape11

    + +Utilisez l'exemple de [message d'erreur amélioré](/en/part2/adding_styles_to_react_app#improved-error-message) de la partie 2 comme guide pour afficher une notification qui dure quelques secondes après l'exécution d'une opération réussie (une personne est ajoutée ou un nombre est modifié) : + +![](../../images/2/27e.png) + +

    2.17*: phonebook, étape12

    + +Ouvrez votre application dans deux navigateurs. **Si vous supprimez une personne dans le navigateur 1**, essayer de modifier le numéro de téléphone de la personne dans le navigateur 2, vous obtiendrez le message d'erreur suivant : + +![](../../images/2/29b.png) + +Corrigez le problème selon l'exemple présenté dans [promises et erreurs](/en/part2/altering_data_in_server#promises-and-errors) dans la partie 2. Modifiez l'exemple afin qu'un message s'affiche lorsque l'opération échoue. Les messages affichés pour les événements réussis et non réussis doivent être différents : + +![](../../images/2/28e.png) + +**Note** Même si vous gérez l'exception, le message d'erreur est affiché sur la console. + +
    + +
    + +### Quelques remarques importantes + +À la fin de cette partie, vous trouverez quelques exercices plus difficiles. À ce stade, vous pouvez les ignorer s'ils vous semblent trop complexes. Nous y reviendrons plus tard. Quoi qu'il en soit, ce document mérite d'être lu. + +Nous avons fait, dans notre application, quelque chose qui masque une source d'erreur très courante. + +Nous avons initialisé l'etat de _notes_ à partir d'un tableau vide: + +```js +const App = () => { + const [notes, setNotes] = useState([]) + + // ... +} +``` + +C'est une valeur initiale tout à fait naturelle, puisque notes constitue un ensemble, c'est-à-dire qu'il y a de nombreuses notes que l'état va stocker. + +Si l'état ne devait sauvegarder qu' " une seule chose ", une valeur initiale plus appropriée serait null, ce qui indique qu'il n'y a rien dans l'état au départ. Voyons ce qui se passe si nous utilisons cette valeur initiale : + +```js +const App = () => { + const [notes, setNotes] = useState(null) // highlight-line + + // ... +} +``` + +L'application plante: + +![console typerror cannot read properties of null via map from App](../../images/2/31a.png) + +Le message d'erreur indique la raison et l'emplacement de l'erreur. Le code qui a provoqué le problème est le suivant : + +```js + // notesToShow gets the value of notes + const notesToShow = showAll + ? notes + : notes.filter(note => note.important) + + // ... + + {notesToShow.map(note => // highlight-line + + )} +``` + +Le message d'erreur est: + +```bash +Cannot read properties of null (reading 'map') +``` + +La variable _notesToShow_ est d'abord assigné à la valeur de l'état _notes_ puis le code tente d'appeler la méthode _map_ sur un object inexistant, c'est à dire sur _null_. + +Pourquoi ? + +Le hook d'effet(useEffect) utilise la fonction _setNotes_ pour affecter à _notes_ les données renvoyées par le back-end + +```js + useEffect(() => { + noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) // highlight-line + }) + }, []) +``` + +Cependant, le problème est que le hook d'effet n'est exécuté qu'après le premier rendu. +Et puisque _notes_ a pour valeur initiale null: + +```js +const App = () => { + const [notes, setNotes] = useState(null) // highlight-line + + // ... +``` + +Lors du premier rendu, le code suivant est exécuté : + +```js +notesToShow = notes + +// ... + +notesToShow.map(note => ...) +``` + +et cela fait planter l'application, car on ne peut pas appeler la méthode _map_ sur une valeur _null_. + +En initialisant _notes_ avec un tableau vide, il n'y a pas d'erreur puisqu'il est permis d'utiliser _map_ sur un tableau vide + +Ainsi, l'initialisation de l'état a "masqué" le problème lié au fait que les données ne sont pas encore récupérées depuis le backend + +une autre manière de contourner le problème aurait été d'utiliser un rendu conditionnel et de retourner null tant que l'état du composant n'est pas correctement initialisé : + +```js +const App = () => { + const [notes, setNotes] = useState(null) // highlight-line + // ... + + useEffect(() => { + noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) + }) + }, []) + + // do not render anything if notes is still null + // highlight-start + if (!notes) { + return null + } + // highlight-end + + // ... +} +``` + +Donc, lors du premier rendu, rien n'est affiché. Lorsque les notes arrivent du backend, l'effet utilise la fonction _setNotes_ our mettre à jour la valeur de l'état _notes_. Cela provoque un nouveau rendu du composant, et lors de ce second rendu, les notes sont affichées à l'écran. + +Cette méthode basée sur le rendu conditionnel convient dans les cas où il est impossible de définir l'état de façon à permettre un rendu initial. + +L'autre point auquel il nous faut encore jeter un œil est le second paramètre de useEffect: + +```js + useEffect(() => { + noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) + }) + }, []) // highlight-line +``` + +Le second paramètre de useEffect sert à [spécifier la fréquence d'exécution de l'effet](https://react.dev/reference/react/useEffect#parameters). Le principe est que l'effet s'exécute systématiquement après le premier rendu du composant et à chaque fois que la valeur de ce second paramètre change. + +Si ce second paramètre est un tableau vide [], son contenu ne change jamais et l'effet n'est exécuté qu'après le premier rendu du composant. C'est exactement ce que l'on souhaite lorsqu'on initialise l'état de l'application depuis le serveur. + +Cependant, il existe des situations où l'on veut exécuter cet effet à d'autres moments, par exemple lorsque l'état du composant change de manière particulière. + +Considérons l'exemple d'une application simple pour interroger les taux de change via [l'Api de taux de change](https://www.exchangerate-api.com/): + +```js +import { useState, useEffect } from 'react' +import axios from 'axios' + +const App = () => { + const [value, setValue] = useState('') + const [rates, setRates] = useState({}) + const [currency, setCurrency] = useState(null) + + useEffect(() => { + console.log('effect run, currency is now', currency) + + // skip if currency is not defined + if (currency) { + console.log('fetching exchange rates...') + axios + .get(`https://open.er-api.com/v6/latest/${currency}`) + .then(response => { + setRates(response.data.rates) + }) + } + }, [currency]) + + const handleChange = (event) => { + setValue(event.target.value) + } + + const onSearch = (event) => { + event.preventDefault() + setCurrency(value) + } + + return ( +
    +
    + currency: + +
    +
    +        {JSON.stringify(rates, null, 2)}
    +      
    +
    + ) +} + +export default App +``` + +L'interface utilisateur de l'application comporte un formulaire dont le champ de saisie reçoit le code de la devise désirée. Si la devise existe, l'application affiche ses taux de change par rapport aux autres monnaies : + +![navigateur affichant les taux de change avec eur saisi et console indiquant fetching exchange rates](../../images/2/32new.png) + +Lorsque l’on appuie sur le bouton, l’application stocke la devise saisie dans l’état _currency_. + +Dès que la valeur de _currency_ change, l’application récupère ses taux de change depuis l’API dans la fonction d’effet : + +```js +const App = () => { + // ... + const [currency, setCurrency] = useState(null) + + useEffect(() => { + console.log('effect run, currency is now', currency) + + // skip if currency is not defined + if (currency) { + console.log('fetching exchange rates...') + axios + .get(`https://open.er-api.com/v6/latest/${currency}`) + .then(response => { + setRates(response.data.rates) + }) + } + }, [currency]) // highlight-line + // ... +} +``` + +Le hook useEffect prend désormais _[currency]_ comme second paramètre. La fonction d’effet s’exécute donc après le premier rendu et à chaque fois que la valeur de ce second paramètre _[currency]_ change. Autrement dit, lorsque l’état _currency_ reçoit une nouvelle valeur, le contenu du tableau est mis à jour et la fonction d’effet est relancée. + +Il est normal de choisir _null_ comme valeur initiae pour la variable _currency_, puisque _currency_ ne stocke qu'un seul élément. la valeur _null_ indique qu’il n’y a encore rien dans l’état, et il est très simple, à l’aide d’un if, de vérifier si la variable a reçu une valeur. L’effet comporte donc la condition suivante : + +```js +if (currency) { + // exchange rates are fetched +} +``` + +ce qui empêche de requêter les taux de change juste après le premier rendu lorsque la variable _currency_ a encore sa valeur initiale, c’est-à-dire _null_. + +Ainsi, si l’utilisateur saisit par exemple eur dans le champ de recherche, l’application utilise Axios pour effectuer une requête HTTP GET vers l’adresse et stocke la réponse dans l’état _rates_. + +Lorsque l’utilisateur saisit ensuite une autre valeur dans le champ de recherche, par exemple usd, la fonction d’effet est de nouveau exécutée et les taux de change de la nouvelle devise sont récupérés depuis l’API. + +La méthode présentée ici pour effectuer les requêtes API peut sembler un peu lourde. Cette application particulière aurait pu être réalisée entièrement sans utiliser useEffect en effectuant les requêtes directement dans le gestionnaire de soumission du formulaire : + +```js +const onSearch = (event) => { + event.preventDefault() + axios + .get(`https://open.er-api.com/v6/latest/${value}`) + .then(response => { + setRates(response.data.rates) + }) +} +``` + +Cependant, il existe des situations où cette technique ne fonctionnerait pas. Par exemple, vous pourriez rencontrer un tel cas dans l’exercice 2.20 où l’utilisation de _useEffect_ pourrait apporter une solution. Notez que cela dépend beaucoup de l’approche choisie ; par exemple, la solution modèle n’utilise pas toujours cette astuce. + +
    + +
    + +

    Exercices 2.18.-2.20.

    + +

    2.18* countries, étape1

    + +L'API [https://restcountries.com](https://restcountries.com) fournit des données pour différents pays dans un format lisible par machine, appelé API REST. + +Créer une application, dans laquelle on peut consulter les données de différents pays. L'application devrait probablement obtenir les données du end point [all](https://restcountries.com/v3.1/all). + +L'interface utilisateur est très simple. Le pays à afficher est trouvé en tapant une requête de recherche dans le champ de recherche. + +S'il y a trop de pays (plus de 10) qui correspondent à la requête, l'utilisateur est invité à préciser sa requête : + +![](../../images/2/19b1.png) + +S'il y a dix pays ou moins, mais plus d'un, tous les pays correspondant à la requête sont affichés : + +![](../../images/2/19b2.png) + +Lorsqu'il n'y a qu'un seul pays correspondant à la requête, les données de base du pays (par exemple, sa capitale et sa superficie), son drapeau et les langues qui y sont parlées sont affichés : + +![](../../images/2/19c3.png) + +**NB** : Il suffit que votre application fonctionne pour la plupart des pays. Certains pays, comme le Soudan, peuvent être difficiles à soutenir, car le nom du pays fait partie du nom d'un autre pays, le Soudan du Sud. Vous n'avez pas à vous soucier de ces cas extrêmes. + +

    2.19*: countries, étape2

    + +**Il y a encore beaucoup à faire dans cette partie, alors ne restez pas bloqué sur cet exercice !** + +Améliorez l'application de l'exercice précédent, de sorte que lorsque les noms de plusieurs pays sont affichés sur la page, il y a un bouton à côté du nom du pays, qui, lorsqu'il est pressé, affiche la vue pour ce pays : + +![](../../images/2/19b4.png) + +Dans cet exercice, il suffit également que votre application fonctionne pour la plupart des pays. Les pays dont le nom apparaît dans le nom d'un autre pays, comme le Soudan, peuvent être ignorés. + +

    2.20*: countries, étape3

    + +Ajoutez à la vue montrant les données d'un seul pays, le bulletin météo de la capitale de ce pays. Il existe des dizaines de fournisseurs de données météorologiques. Une API suggérée est [https://openweathermap.org](https://openweathermap.org). Notez que cela peut prendre quelques minutes avant qu'une clé API générée soit valide. + +![](../../images/2/19x.png) + +Si vous utilisez Open weather map, trouvez [ici](https://openweathermap.org/weather-conditions#Icon-list) une description de comment obtenir des icônes météo. + +**NB :** Dans certains navigateurs (tels que Firefox), l'API choisie peut envoyer une réponse d'erreur, ce qui indique que le cryptage HTTPS n'est pas pris en charge, bien que l'URL de la requête commence par _http://_. Ce problème peut être résolu en effectuant l'exercice à l'aide de Chrome. + +**NB :** Vous avez besoin d'une clé API pour utiliser presque tous les services météorologiques. N'enregistrez pas la clé API dans le contrôle de code source ! Ni coder en dur la clé API dans votre code source. Utilisez plutôt une [variable d'environnement](https://vitejs.dev/guide/env-and-mode.html) pour enregistrer la clé. +Dans les applications réelles, il est considéré comme peu sûr d’envoyer ces clés directement depuis le navigateur, car toute personne pouvant ouvrir la console développeur pourrait intercepter vos clés ! Nous verrons comment implémenter un backend séparé dans la prochaine partie du cours. + +En supposant que la clé API est 4l41n3n4v41m34rv0, lorsque l'application est démarrée comme suit : + +```bash +export VITE_SOME_KEY=54l41n3n4v41m34rv0 && npm run dev # Linux/macOS Bash +($env:VITE_SOME_KEY="54l41n3n4v41m34rv0") -and (npm run dev) # Windows PowerShell +set "VITE_SOME_KEY=54l41n3n4v41m34rv0" && npm run dev # Windows cmd.exe +``` + +vous pouvez accéder à la valeur de la clé via l’objet import.meta.env : + +```js +const api_key = import.meta.env.VITE_SOME_KEY +// la variable api_key a maintenant la valeur définie au démarrage +``` + +**NB :** Pour éviter de divulguer accidentellement des variables d’environnement au client, seules celles préfixées par VITE_ sont exposées à Vite. + +N’oubliez pas que si vous modifiez des variables d’environnement, vous devez redémarrer le serveur de développement pour que les changements soient pris en compte. + +C'était le dernier exercice de cette partie du cours. Il est temps de transmettre votre code à GitHub et de marquer tous vos exercices terminés dans le [système de soumission d'exercices](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    + + diff --git a/src/content/2/ptbr/part2.md b/src/content/2/ptbr/part2.md new file mode 100644 index 00000000000..9061a1e9325 --- /dev/null +++ b/src/content/2/ptbr/part2.md @@ -0,0 +1,14 @@ +--- +mainImage: ../../../images/part-2.svg +part: 2 +lang: ptbr +--- + +
    + +Vamos continuar com nossa introdução à biblioteca React. Primeiramente, aprenderemos a como renderizar uma coleção de dados, como uma lista de nomes, por exemplo. Depois, examinaremos como um usuário pode enviar dados a uma aplicação React usando formulários HTML. Em seguida, nosso foco muda para a forma como o código JavaScript no navegador busca e manipula dados armazenados em um servidor back-end remoto. Por fim, daremos uma rápida olhada em algumas formas simples de adição de estilos CSS às nossas aplicações React. + +Parte atualizada em 18 de janeiro de 2023 +- A ordem dos exercícios foi alterada: os exercícios 2.11 a 2.13 foram movidos ao final desta parte + +
    \ No newline at end of file diff --git a/src/content/2/ptbr/part2a.md b/src/content/2/ptbr/part2a.md new file mode 100644 index 00000000000..86ec3f34543 --- /dev/null +++ b/src/content/2/ptbr/part2a.md @@ -0,0 +1,755 @@ +--- +mainImage: ../../../images/part-2.svg +part: 2 +letter: a +lang: ptbr +--- + +
    + +Antes de começar esta nova parte, vamos revisar alguns dos tópicos que, ano passado, se provaram difíceis para alguns estudantes. + +### console.log + +*** Qual é a diferença entre um programador de JavaScript experiente e um iniciante? O experiente usa o console.log 10, 100 vezes mais. *** + +Paradoxalmente, isso parece ser verdade, mesmo que um programador iniciante precise do console.log (ou de qualquer outro método de depuração) mais do que um experiente. + +Quando algo não funciona, não tente adivinhar o que está errado. Em vez disso, faça o log ou use outra forma de depuração. + +**Obs.:** Como explicado na Parte 1, ao usar o comando _console.log_ para depuração, não concatene coisas "do jeito Java" com o sinal de adição (+). Em vez de escrever + +```js +console.log('valor de props é ' + props) +``` + +separe os valores a serem impressos com uma vírgula: + +```js +console.log('valor de props é', props) +``` + +Se você concatenar um objeto com uma string e fazer o registro (log) no console (como demonstrado em nosso primeiro exemplo), o resultado será bem inútil: + +```js +valor de props é [object Object] +``` + +Pelo contrário, quando você passa objetos como argumentos distintos separados por vírgulas para o _console.log_, como no nosso segundo exemplo acima, o conteúdo do objeto é impresso no console do desenvolvedor como strings que são informativas. +Se necessário, leia mais sobre [depuração de aplicações React](/ptbr/part1/um_estado_mais_complexo_e_depuracao_de_aplicacoes_react#depuracao-de-aplicacoes-react). + +### Dica: Atalhos (Snippets) do Visual Studio Code + +Com o Visual Studio Code, é fácil criar "snippets", ou seja, "atalhos" para gerar rapidamente porções de código que são reutilizadas diversas vezes, assim como o "sout" no Netbeans. + +As instruções para criar atalhos podem ser encontradas [aqui](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_creating-your-own-snippets). + +Atalhos úteis pré-prontos também podem ser encontrados como plugins do VS Code, no [marketplace](https://marketplace.visualstudio.com/items?itemName=dsznajder.es7-react-js-snippets). + +O atalho mais importante é o do comando console.log(), por exemplo, clog. Ele pode ser criado assim: + +```js +{ + "console.log": { + "prefix": "clog", + "body": [ + "console.log('$1')", + ], + "description": "Registra saída no console" + } +} +``` + +Depurar seu código usando _console.log()_ é algo tão trivial que o Visual Studio Code já tem esse atalho embutido. Para usá-lo, digite _log_ e aperte Tab para autocompletar. Extensões do atalho _console.log()_ mais completas podem ser encontradas no [marketplace](https://marketplace.visualstudio.com/search?term=console.log&target=VSCode&category=All%20categories&sortBy=Relevance). + +### Arrays em JavaScript + +A partir daqui, usaremos os operadores de programação funcional do [array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array) (matriz) JavaScript, como _find_, _filter_ e _map_ o tempo todo. + +Se operar arrays com operadores funcionais parecer estranho para você, vale a pena assistir pelo menos os primeiros três vídeos da série [Programação Funcional em JavaScript](https://www.youtube.com/playlist?list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84) no YouTube: + +- [Funções de ordem superior](https://www.youtube.com/watch?v=BMUiFMZr7vk&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84) +- [Map](https://www.youtube.com/watch?v=bCqtb-Z5YGQ&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84&index=2) +- [Básico do método Reduce](https://www.youtube.com/watch?v=Wl98eZpkp-c&t=31s) + +### Revisão sobre Gerenciadores de Evento + +Baseado no curso do ano passado, o gerenciamento de eventos provou ser algo difícil. + +Vale a pena ler o capítulo de revisão no final da parte anterior — [Revisão sobre Gerenciamento de Eventos](/ptbr/part1/um_estado_mais_complexo_e_depuracao_de_aplicacoes_react#revisao-sobre-gerenciamento-de-eventos) — caso ainda ache que precise estudar mais sobre o assunto. + +A passagem de gerenciadores de eventos para os componentes-filho do componente App levantou algumas questões. Uma pequena revisão sobre o tópico pode ser encontrada [aqui](/ptbr/part1/um_estado_mais_complexo_e_depuracao_de_aplicacoes_react#passando-gerenciadores-de-evento-para-componentes-filho). + +### Renderização de Coleções + +**Nota dos tradutores:** A partir deste momento, os códigos utilizados como exemplo permanecerão no idioma original (inglês), visto que é disponibilizado ao final de cada sessão o repositório onde o código-exemplo pode ser encontrado na íntegra. É muito provável que o estudante se confunda caso os nomes de variáveis, funções, componentes, etc estejam em português, dado que estaria diferente do código disponibilizado no repositório do GitHub, que está em inglês. + +Faremos neste momento a lógica da aplicação do lado do cliente (navegador), ou o "front-end", em React, para uma aplicação semelhante à aplicação de exemplo da [Parte 0](/ptbr/part0). + +Comecemos com o seguinte (arquivo App.js): + +```js +const App = (props) => { + const { notes } = props + + return ( +
    +

    Notes

    +
      +
    • {notes[0].content}
    • +
    • {notes[1].content}
    • +
    • {notes[2].content}
    • +
    +
    + ) +} + +export default App +``` + +O arquivo index.js fica assim: + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' + +import App from './App' + +const notes = [ + + + { + id: 1, + content: 'HTML é fácil', + important: true + }, + { + id: 2, + content: 'O navegador só pode executar JavaScript', + important: false + }, + { + id: 3, + content: 'GET e POST são os métodos mais importantes do protocolo HTTP', + important: true + } +] + +ReactDOM.createRoot(document.getElementById('root')).render( + +) +``` + +Cada nota contém seu conteúdo textual, um valor _booleano_ para marcar se a nota foi categorizada como importante ou não e também um id (identificador) único. + +O exemplo acima funciona devido ao fato de haver exatamente três notas no array. + +Uma única nota é renderizada acessando os objetos no array, referindo-se a um número de índice no "código de teste": + +```js +
  • {notes[1].content}
  • +``` + +Isso, obviamente, não é algo prático. Podemos melhorar nosso código gerando elementos React a partir dos objetos do array usando a função [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) (mapear). + +```js +notes.map(note =>
  • {note.content}
  • ) +``` + +O resultado é um array de elementos li... + +```js +[ +
  • HTML é fácil
  • , +
  • O navegador só pode executar JavaScript
  • , +
  • GET e POST são os métodos mais importantes do protocolo HTTP
  • , +] +``` + +... que então podem ser colocados dentro de tags ul: + +```js +const App = (props) => { + const { notes } = props + + return ( +
    +

    Notes

    +// highlight-start +
      + {notes.map(note =>
    • {note.content}
    • )} +
    +// highlight-end +
    + ) +} +``` + +Como o código que gera as tags li é JavaScript, ele deve ser envolto em chaves no modelo JSX, assim como todo código JavaScript. + +Também faremos com que o código fique mais legível separando a declaração da função de seta em várias linhas: + +```js +const App = (props) => { + const { notes } = props + + return ( +
    +

    Notes

    +
      + {notes.map(note => + // highlight-start +
    • + {note.content} +
    • + // highlight-end + )} +
    +
    + ) +} +``` + +### O atributo "key" (chave) + +Mesmo que a aplicação pareça estar funcionando, há um aviso no console: + +![erro da propriedade de chave única no console](../../images/2/1a.png) + +Como sugere a [página React](https://reactjs.org/docs/lists-and-keys.html#keys) vinculada na mensagem de erro, os itens da lista, ou seja, os elementos gerados pelo método _map_, devem ter cada qual um valor único que os permite serem identificados: um atributo chamado key (chave). + +Vamos adicionar as keys: + +```js +const App = (props) => { + const { notes } = props + + return ( +
    +

    Notes

    +
      + {notes.map(note => +
    • // highlight-line + {note.content} +
    • + )} +
    +
    + ) +} +``` + +E então, a mensagem de erro desaparece. + +React usa os atributos "key" (ou atributos-chave) dos objetos em um array para determinar como atualizar a visualização gerada por um componente quando o componente é re-renderizado. Leia mais sobre esse assunto na [documentação React](https://reactjs.org/docs/reconciliation.html#recursing-on-children). + +### Map + +Entender como funciona o método de array [`map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) é crucial para fazer o restante do curso. + +A aplicação contém um array chamado _notes_: + +```js +const notes = [ + { + id: 1, + content: 'HTML is easy', + important: true + }, + { + id: 2, + content: 'Browser can execute only JavaScript', + important: false + }, + { + id: 3, + content: 'GET and POST are the most important methods of HTTP protocol', + important: true + } +] +``` + +Vamos parar por um momento e examinar como o _map_ funciona. + +Se o código a seguir for adicionado, digamos, ao final do arquivo: + +```js +const result = notes.map(note => note.id) +console.log(result) +``` + +[1, 2, 3] será impresso no console. +O método _map_ sempre cria um array novo, cujos elementos foram criados a partir dos elementos do array original por meio do mapping (mapeamento): usa-se a função fornecida como um parâmetro para o método _map_. + +A função é esta: + +```js +note => note.id +``` + +Que, neste caso, é uma _arrow function_ escrita de forma compacta. A forma completa seria: + +```js +(note) => { + return note.id +} +``` + +A função recebe um objeto "note" como parâmetro e retorna o valor de seu campo id. + +Se mudarmos a instrução para: + +```js +const result = notes.map(note => note.content) +``` + +o resultado será um array contendo as notas. + +Essa forma está bem parecida com o código React que usamos: + +```js +notes.map(note => +
  • + {note.content} +
  • +) +``` + +o qual gera uma tag li contendo o conteúdo da nota de cada objeto de nota. + +Por conta do parâmetro da função passado para o método _map_ — + +```js +note =>
  • {note.content}
  • +``` + + — ser usado para criar elementos de visualização, o valor da variável deve ser renderizado dentro de chaves. Tente ver o que acontece se as chaves forem removidas. + +O uso constante de chaves pode gerar algum desconforto no início, mas você se acostumará rapidamente com elas. O feedback visual em React é imediato. + +### Antipadrão: Índices de Array como Keys + +Poderíamos ter feito a mensagem de erro em nosso console desaparecer usando os índices do array como keys. Os índices podem ser recuperados passando um segundo parâmetro para a função de retorno do método _map_: + +```js +notes.map((note, i) => ...) +``` + +Quando chamado desta forma, é atribuído ao _i_ o valor do índice da posição no array onde a nota reside. + +Como tal, uma forma de definir a criação de linhas (_row_) sem gerar erros é esta: + +```js +
      + {notes.map((note, i) => +
    • + {note.content} +
    • + )} +
    +``` + +Entretanto, **isso não é recomendado** visto que pode criar problemas indesejados mesmo se parecer estar funcionando bem. + +Leia mais sobre isso neste [artigo](https://robinpokorny.medium.com/index-as-a-key-is-an-anti-pattern-e0349aece318). + +### Refatorando módulos + +Vamos arrumar um pouco nosso código. Estamos interessados apenas no campo _notes_ das props, então vamos recuperá-lo diretamente usando [desestruturação](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment): + +```js +const App = ({ notes }) => {//highlight-line + return ( +
    +

    Notes

    +
      + {notes.map(note => +
    • + {note.content} +
    • + )} +
    +
    + ) +} +``` + +Se você esqueceu o que significa desestruturação e como essa ferramenta funciona, por favor, revise a [seção sobre desestruturação](/ptbr/part1/estado_de_componente_e_gerenciadores_de_eventos#desestruturacao-destructuring). + +Vamos separar a exibição de uma única nota em seu próprio componente Note: + +```js +// highlight-start +const Note = ({ note }) => { + return ( +
  • {note.content}
  • + ) +} +// highlight-end + +const App = ({ notes }) => { + return ( +
    +

    Notes

    +
      + // highlight-start + {notes.map(note => + + )} + // highlight-end +
    +
    + ) +} +``` + +Note que o atributo key agora deve ser definido para os componentes Note, e não para as tags li como antes. + +Uma aplicação React pode ser escrita inteiramente em um único arquivo, embora fazer isso não seja muito prático. O ideal é declarar cada componente em seu próprio arquivo como um módulo ES6. + +Estamos utilizando módulos o tempo todo. As primeiras linhas do arquivo index.js: + +```js +import ReactDOM from "react-dom/client" + +import App from "./App" +``` + +[importam](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) dois módulos, habilitando-os a serem usados ​​nessa pasta. É importado o módulo react-dom/client para a variável _ReactDOM_ e o módulo que define o componente principal da aplicação é atribuído à variável _App_. + +Vamos separar nosso componente Note em um módulo próprio. + +Em aplicações menores, os componentes geralmente são colocados em uma pasta chamada components, que por sua vez é colocada dentro da pasta src. A convenção é nomear o arquivo com o nome do componente. + +Agora, criaremos uma pasta chamada components para nossa aplicação e criaremos dentro dela um arquivo chamado Note.js. +O conteúdo do arquivo Note.js é o seguinte: + +```js +const Note = ({ note }) => { + return ( +
  • {note.content}
  • + ) +} + +export default Note +``` + +A última linha do código [exporta](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export) o módulo declarado, a variável Note. + +Agora, o arquivo que está usando o componente — App.js — pode [importar](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) o módulo: + +```js +import Note from './components/Note' // highlight-line + +const App = ({ notes }) => { + // ... +} +``` + +O componente exportado pelo módulo passa a ficar disponível para uso na variável Note, assim como antes. + +Observe que ao importar nossos próprios componentes, sua localização deve ser dada em relação ao arquivo importador: + +```js +'./components/Note' +``` + +O ponto — . — no começo se refere ao diretório atual, então a localização do módulo é um arquivo chamado Note.js no subdiretório de componentes do diretório atual. A extensão de arquivo _.js_ pode ser omitida. + +Módulos têm muitas outras utilidades além de permitir que as declarações de componentes sejam separadas em suas próprias instâncias. Voltaremos a falar sobre eles mais tarde neste curso. + +O código atual da aplicação pode ser encontrado [neste repositório GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part2-1). + +Note que a branch main do repositório contém o código para uma versão posterior da aplicação. O código atual está na branch [part2-1](https://github.com/fullstack-hy2020/part2-notes/tree/part2-1): + +![captura de tela da branch do GitHub](../../images/2/2e.png) + +Caso deseje clonar o projeto, execute o comando _npm install_ antes de iniciar a aplicação com _npm run dev_. + +### Quando a Aplicação Quebra + +Logo cedo em sua carreira em programação (e até mesmo após 30 anos de programação como esta pessoa que vos escreve), o que acontece com frequência é que a aplicação simplesmente quebra, completamente. Isso é ainda mais verídico quando se trata de linguagens dinamicamente tipadas, como JavaScript, onde o compilador não verifica o tipo do dado declarado. Por exemplo, variáveis de função ou valores de retorno. + +Uma "explosão React" pode parecer assim, por exemplo: + +![exemplo de erro em React](../../images/2/3b.png) + +Nessas situações, sua melhor saída é utilizar o método console.log. + +O pedaço de código que está causando a explosão é este: + +```js +const Course = ({ course }) => ( + // "Course" traduz-se como "Curso" + // "Header" traduz-se como "Cabeçalho" +
    +
    +
    +) + +const App = () => { + const course = { + // ... + } + + return ( +
    + +
    + ) +} +``` + +Vamos nos aprofundar investigando a razão do problema adicionando algumas linhas de console.log ao código. Por conta do componente App ser a primeira entidade a ser renderizada, vale a pena colocar o primeiro console.log lá: + +```js +const App = () => { + const course = { + // ... + } + + console.log('App works...') // highlight-line + + return ( + // .. + ) +} +``` + +Para ver a impressão no console, devemos rolar por toda a longa parede vermelha de erros até chegar lá em cima. + +![impressão inicial do console](../../images/2/4b.png) + +Quando se encontra alguma parte do código que está funcionando, é o momento exato para se aprofundar na impressão. Fica mais difícil de se imprimir no console se o componente foi declarado como uma única instrução ou uma função que não retorna nada. + +```js +const Course = ({ course }) => ( +
    +
    +
    +) +``` + +O componente deve ser alterado para sua forma mais extensa, no qual poderemos adicionar a impressão desejada: + +```js +const Course = ({ course }) => { + console.log(course) // highlight-line + return ( +
    +
    +
    + ) +} +``` + +Muitas vezes, a raiz do problema é que espera-se que as propriedades sejam de um tipo diferente, ou que sejam chamadas com um nome diferente do que realmente são, e a desestruturação falha como resultado. O problema começa a revelar-se quando a desestruturação é removida e vemos o que as props armazenam. + +```js +const Course = (props) => { // highlight-line + console.log(props) // highlight-line + const { course } = props + return ( +
    +
    +
    + ) +} +``` + +Se o problema ainda não foi resolvido, infelizmente não há muito o que fazer, a não ser continuar a busca por erros adicionando mais comandos _console.log_ ao seu código. + +Adicionei este capítulo ao material após a resposta do modelo da próxima pergunta explodir completamente (devido as props estarem armazenando o tipo errado de dados), e precisei depurá-lo usando console.log. + +### Juramento do Programador Web + +Antes de fazer os exercícios, deixe-me lembrá-lo do que havia jurado no final da parte anterior. + +Programar é difícil, e é por isso que eu usarei todos os meios possíveis para ser mais fácil: + +- Eu manterei meu Console do navegador aberto o tempo todo; +- Eu vou progredir aos poucos, passo a passo; +- Eu escreverei muitas instruções _console.log_ para ter certeza de que estou entendendo como o código se comporta e para me ajudar a identificar os erros; +- Se meu código não funcionar, não escreverei mais nenhuma linha no código. Em vez disso, começarei a excluir o código até que funcione ou retornarei ao estado em que tudo ainda estava funcionando; e +- Quando eu pedir ajuda no canal do Discord do curso ou em outro lugar, formularei minhas perguntas de forma adequada. Veja [aqui](/ptbr/part0/informacoes_gerais#como-pedir-ajuda-no-discord) como pedir ajuda. + +
    + +
    + +

    Exercícios 2.1 a 2.5

    + +Envie suas soluções aos exercícios dando "push" para seu repositório no GitHub e, em seguida, marque os exercícios concluídos na guia "my submissions" no [sistema de envio de exercícios](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +Lembre-se: envie **todos** os exercícios de uma parte **de uma única vez**; isto é, envie todas as suas soluções de uma vez para seu repositório. Uma vez que você tenha enviado suas soluções para uma parte, **não é mais possível enviar mais exercícios para essa parte**. + + Alguns dos exercícios funcionam na mesma aplicação. Nestes casos, é suficiente enviar apenas a versão final da aplicação. Se desejar, você pode fazer um "commit" após cada exercício concluído, mas isso não é obrigatório. + +**AVISO**: "create-react-app" transformará automaticamente seu projeto em um repositório git, a menos que você crie sua aplicação dentro de um repositório git já existente. **Você muito provavelmente não quer que cada um de seus projetos seja um repositório separado**, então basta executar o comando _rm -rf .git_ na raiz de sua aplicação para aplicar as modificações. + +**Obs.:** o conteúdo dos exercícios foram deixados no idioma original da tradução (inglês) por questões de conveniência, visto a revisão que os mantenedores do curso devem fazer no código enviado ao sistema de avaliação da Universidade de Helsinque. Desta forma, escreva suas aplicações utilizando os mesmos termos usados nas variáveis, componentes, etc que estão em inglês. + +

    2.1: Course information — 6º passo

    + +Vamos finalizar o código para que possamos renderizar os conteúdos do curso dos exercícios 1.1 a 1.5. Você pode começar com o código das respostas-modelo. As respostas-modelo da Parte 1 podem ser encontradas no [sistema de envio de exercícios](https://studies.cs.helsinki.fi/stats/courses/fullstackopen), clicando em "my submissions" em cima; vá até a linha correspondente à Parte 1 na coluna "solutions" e clique em show. Para ver a solução para o exercício course info, clique em _index.js_ abaixo de kurssitiedot ("kurssitiedot" significa "course info" ou "informações do curso"). + +**Note que se você copiar um projeto de um lugar para outro, é provável que terá de excluir o diretório node\_modules e instalar as dependências novamente com o comando _npm install_ antes de iniciar a aplicação.** Em geral, não é recomendado que você copie todo o conteúdo de um projeto e/ou adicione o diretório node\_modules ao sistema de controle de versão. + +Vamos modificar o componente App desta maneira: + +```js +const App = () => { + const course = { + id: 1, + name: 'Half Stack application development', + parts: [ + { + name: 'Fundamentals of React', + exercises: 10, + id: 1 + }, + { + name: 'Using props to pass data', + exercises: 7, + id: 2 + }, + { + name: 'State of a component', + exercises: 14, + id: 3 + } + ] + } + + return +} + +export default App +``` + +Crie um componente chamado Course que será responsável por formatar/exibir um único curso. + +A estrutura do componente da aplicação pode ser a seguinte, por exemplo: + +``` +App + Course + Header + Content + Part + Part + ... +``` + +Desta forma, o componente Course conterá os componentes definidos na parte anterior, responsáveis por renderizar o nome do curso e suas partes. + +O resultado da página pode ficar assim, por exemplo: + +![captura de tela de um app chamado half stack application](../../images/teht/8e.png) + +Você ainda não precisa da soma do número de exercícios. + +A aplicação deve funcionar independentemente do número de partes de um curso, então certifique-se de que a aplicação funcione se você adicionar ou remover partes de um curso. + +Certifique-se de que o console não esteja mostrando erros! + +

    2.2: Course information — 7º passo

    + +Mostre também a soma (ou total) dos exercícios do curso. + +![recurso de soma de exercícios](../../images/teht/9e.png) + +

    2.3*: Course information — 8º passo

    + +Se você ainda não o fez, calcule a soma dos exercícios com o método de array [reduce](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce) (reduzir). + +**Dica I:** quando seu código fica assim: + +```js +const total = + parts.reduce((s, p) => someMagicHere) +``` + +e ao mesmo tempo que não funciona, vale a pena usar o console.log, o que requer que a função de seta seja escrita em sua forma mais longa: + +```js +const total = parts.reduce((s, p) => { + console.log('what is happening', s, p) + return someMagicHere +}) +``` + +**Não está funcionando? :** Pesquise na internet como `reduce` é usado em um **Array de Objetos**. + +**Dica II:** Existe um [plugin para o VS Code](https://marketplace.visualstudio.com/items?itemName=cmstead.js-codeformer) que altera automaticamente as _arrow functions_ da forma curta para sua forma mais longa e vice-versa. + +![vscode sample suggestion for arrow function](../../images/2/5b.png) + +

    2.4: Course information — 9º passo

    + +Vamos estender nossa aplicação para que permita um número arbitrário de cursos: + +```js +const App = () => { + const courses = [ + { + name: 'Half Stack application development', + id: 1, + parts: [ + { + name: 'Fundamentals of React', + exercises: 10, + id: 1 + }, + { + name: 'Using props to pass data', + exercises: 7, + id: 2 + }, + { + name: 'State of a component', + exercises: 14, + id: 3 + }, + { + name: 'Redux', + exercises: 11, + id: 4 + } + ] + }, + { + name: 'Node.js', + id: 2, + parts: [ + { + name: 'Routing', + exercises: 3, + id: 1 + }, + { + name: 'Middlewares', + exercises: 7, + id: 2 + } + ] + } + ] + + return ( +
    + // ... +
    + ) +} +``` + +A aplicação pode, por exemplo, ficar assim: + +![recurso que mostra o número arbitrário de cursos](../../images/teht/10e.png) + +

    2.5: um módulo separado

    + +Crie o componente Course como um módulo separado, que é importado pelo componente App. Você pode incluir todos os subcomponentes do curso no mesmo módulo (Course). + +
    diff --git a/src/content/2/ptbr/part2b.md b/src/content/2/ptbr/part2b.md new file mode 100644 index 00000000000..1519d88040d --- /dev/null +++ b/src/content/2/ptbr/part2b.md @@ -0,0 +1,567 @@ +--- +mainImage: ../../../images/part-2.svg +part: 2 +letter: b +lang: ptbr +--- + +
    + +Vamos continuar expandindo nossa aplicação permitindo que os usuários adicionem novas notas. Você pode encontrar o código para nossa aplicação atual [aqui](https://github.com/fullstack-hy2020/part2-notes/tree/part2-1). + +Para que a página seja atualizada quando novas notas são adicionadas, armazene as notas no estado do componente App. Vamos importar a função [useState](https://reactjs.org/docs/hooks-state.html) e usá-la para definir um pedaço de estado que é inicializado com o array das notas iniciais passado nas props. + +```js +import { useState } from 'react' // highlight-line +import Note from './components/Note' + +const App = (props) => { // highlight-line + const [notes, setNotes] = useState(props.notes) // highlight-line + + return ( +
    +

    Notes

    +
      + {notes.map(note => + + )} +
    +
    + ) +} + +export default App +``` + +O componente usa a função useState para inicializar o pedaço de estado armazenado em notes com o array de notas passado nas props: + +```js +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + + // ... +} +``` + +Também podemos usar o "React Developer Tools" para ver o que realmente está acontecendo: + +![navegador mostrando a janela ](../../images/2/30.png) + +Se quiséssemos inicializá-la com uma lista vazia de notas, definiríamos o valor inicial como um array vazio e, como as props não seriam utilizadas, poderíamos omitir o parâmetro props da definição da função: + +```js +const App = () => { + const [notes, setNotes] = useState([]) + + // ... +} +``` + +Vamos manter o valor inicial passado nas props por agora. + +Em seguida, vamos adicionar um [formulário HTML](https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms) (form) ao componente que será usado para adicionar novas notas. + +```js +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + +// highlight-start + const addNote = (event) => { + event.preventDefault() + console.log('button clicked', event.target) + } + // highlight-end + + return ( +
    +

    Notes

    +
      + {notes.map(note => + + )} +
    + // highlight-start +
    + + +
    + // highlight-end +
    + ) +} +``` + +Adicionamos a função _addNote_ como um gerenciador de evento ao elemento de formulário que será chamado quando o formulário for enviado, clicando no botão de envio save. + +Usamos o método discutido na [Parte 1](/en/part1/component_state_event_handlers#event-handling) para definir nosso gerenciador de evento: + +```js +const addNote = (event) => { + event.preventDefault() + console.log('button clicked', event.target) +} +``` + +O parâmetro event é o [evento](https://reactjs.org/docs/handling-events.html) que aciona a chamada para a função gerenciadora de evento: + +O gerenciador de evento chama imediatamente o método event.preventDefault(), o que "impede a ação padrão" de enviar um formulário. A ação padrão causaria, [entre outras coisas](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit_event), o recarregamento da página. + +O alvo (target) do evento armazenado em _event.target_ é registrado no console: + +![console mostrando o botão clicado com o objeto formulário](../../images/2/6e.png) + +O target neste caso é o formulário que definimos em nosso componente. + +Como acessamos os dados armazenados no elemento input do formulário? + +### Um componente controlado + +Existem muitas maneiras de fazer isso: o primeiro método que vamos utilizar é através do uso de componentes [controlados](https://reactjs.org/docs/forms.html#controlled-components) (controlled components). + +Vamos adicionar um novo pedaço de estado chamado newNote para armazenar a entrada fornecida pelo usuário **e** vamos defini-lo como o atributo value do elemento input (entrada): + +```js +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + // highlight-start + const [newNote, setNewNote] = useState( + 'a new note...' + ) + // highlight-end + + const addNote = (event) => { + event.preventDefault() + console.log('button clicked', event.target) + } + + return ( +
    +

    Notes

    +
      + {notes.map(note => + + )} +
    +
    + //highlight-line + +
    +
    + ) +} +``` + +O texto do espaço reservado armazenado como o valor inicial do estado newNote aparece no elemento input, mas o texto de entrada (input text) não é editável. O console exibe um aviso que nos dá uma dica do que pode estar errado: + +![console exibindo erro de valor fornecido para prop sem onchange](../../images/2/7e.png) + +A partir do momento em que atribuímos um pedaço do estado do componente App como o atributo value do elemento de entrada (input element), o componente App passou a controlar o comportamento do elemento de entrada. + +Para habilitar a edição do elemento de entrada, precisamos registrar um gerenciador de evento que sincroniza as mudanças feitas na entrada com o estado do componente: + +```js +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + const [newNote, setNewNote] = useState( + 'a new note...' + ) + + // ... + +// highlight-start + const handleNoteChange = (event) => { + console.log(event.target.value) + setNewNote(event.target.value) + } +// highlight-end + + return ( +
    +

    Notes

    +
      + {notes.map(note => + + )} +
    +
    + + +
    +
    + ) +} +``` + +Agora registramos um gerenciador de evento para o atributo onChange do elemento input do formulário: + +```js + +``` + +O gerenciador de evento é chamado toda vez que ocorre uma mudança no elemento de entrada. A função do gerenciador de evento recebe o objeto de evento como seu parâmetro event: + +```js +const handleNoteChange = (event) => { + console.log(event.target.value) + setNewNote(event.target.value) +} +``` + +A propriedade target do objeto "event" agora corresponde ao elemento de entrada controlado, e event.target.value se refere ao valor (value) de entrada desse elemento. + +Observe que não precisamos chamar o método _event.preventDefault()_ como fizemos no gerenciador de evento onSubmit. Isso ocorre porque não há uma ação padrão em uma mudança de entrada, ao contrário do que ocorre em um envio de formulário. + +É possível acompanhar no console como o gerenciador de evento é chamado: + +![múltiplas chamadas no console ao digitar typing text](../../images/2/8e.png) + +Você se lembrou de instalar o [React devtools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) (Ferramentas do Desenvolvedor React), certo? Ótimo. Você pode ver diretamente como o estado muda na guia "Componentes": + +![mudanças de estado no react devtools mostrando a digitação](../../images/2/9ea.png) + +Agora, o estado newNote do componente App reflete o valor atual do elemento de entrada, o que significa que podemos completar a função addNote para criar novas notas: + +```js +const addNote = (event) => { + event.preventDefault() + const noteObject = { + content: newNote, + important: Math.random() < 0.5, + id: notes.length + 1, + } + + setNotes(notes.concat(noteObject)) + setNewNote('') +} +``` + +Primeiramente, criamos um novo objeto para a nota (variável que cria as notas com suas propriedades) chamada noteObject que receberá seu conteúdo do estado newNote do componente. O identificador único id é gerado com base no número total de notas. Este método funciona para a nossa aplicação, já que as nossas notas nunca são excluídas. Com a ajuda da função Math.random(), a nossa nota tem 50% de chance de ser marcada como importante. + +A nova nota é adicionada à lista de notas usando o método de array [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat), apresentado na [Parte 1](/ptbr/part1/java_script#arrays): + +```js +setNotes(notes.concat(noteObject)) +``` + +O método não muda o array original notes, porém, cria uma nova cópia do array com o novo item adicionado ao final. Isso é importante, já que nós [nunca devemos mudar diretamente o estado](https://reactjs.org/docs/state-and-lifecycle.html#using-state-correctly) em React! + +O gerenciador de evento também limpa o valor do elemento de entrada controlado chamando a função setNewNote do estado newNote: + +```js +setNewNote('') +``` + +É possível encontrar o código atual completo da nossa aplicação na branch part2-2 [neste repositório do GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part2-2). + +### Filtrando os elementos exibidos + +Vamos adicionar algumas novas funcionalidades à nossa aplicação que nos permitam visualizar apenas as notas importantes. + +Vamos adicionar um estado ao componente App que vai manter o registro das notas que devem ser exibidas: + +```js +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) // highlight-line + + // ... +} +``` + +Modifiquemos o componente para que ele armazene uma lista de todas as notas a serem exibidas na variável notesToShow. Os itens na lista dependem do estado do componente: + +```js +import { useState } from 'react' +import Note from './components/Note' + +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + + // ... + +// highlight-start + const notesToShow = showAll + ? notes + : notes.filter(note => note.important === true) +// highlight-end + + return ( +
    +

    Notes

    +
      + {notesToShow.map(note => // highlight-line + + )} +
    + // ... +
    + ) +} +``` + +A definição da variável notesToShow é bastante compacta: + +```js +const notesToShow = showAll + ? notes + : notes.filter(note => note.important === true) +``` + +A definição utiliza o operador [condicional](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator) (ou operador ternário) encontrado também em muitas outras linguagens de programação. + +O operador funciona da seguinte maneira. Se tivermos + +```js +const result = condition ? val1 : val2 +``` + +a variável result será definida com o valor de val1 se condition for verdadeiro. Se condition for falso, a variável result será definida com o valor de val2. + +Se o valor de showAll for falso, a variável notesToShow será atribuída a uma lista que contém somente as notas que possuem a propriedade important definida como verdadeira. A filtragem é feita com a ajuda do método de array [filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter) (filtrar): + +```js +notes.filter(note => note.important === true) +``` + +O operador ternário é redundante aqui, já que o valor de note.important é verdadeiro ou falso, o que significa que podemos simplesmente escrever: + +```js +notes.filter(note => note.important) +``` + +A razão de mostrarmos o operador de comparação primeiro foi para enfatizar um detalhe importante: em JavaScript, val1 == val2 não funciona como esperado em todas as situações e é mais seguro usar val1 === val2 exclusivamente em comparações. Leia mais sobre o assunto [aqui](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness). + +É possível testar a funcionalidade de filtragem mudando o valor inicial do estado showAll. + +Em seguida, vamos adicionar a funcionalidade que permite aos usuários alternar o estado showAll da aplicação a partir da interface de usuário (GUI). + +As alterações relevantes são mostradas abaixo: + +```js +import { useState } from 'react' +import Note from './components/Note' + +const App = (props) => { + const [notes, setNotes] = useState(props.notes) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + + // ... + + return ( +
    +

    Notes

    +// highlight-start +
    + +
    +// highlight-end +
      + {notesToShow.map(note => + + )} +
    + // ... +
    + ) +} +``` + +As notas exibidas ("all" versus "important") são controladas com um botão. O gerenciador de evento do botão é tão simples que foi definido diretamente no atributo do elemento do botão. O gerenciador de evento alterna o valor de _showAll_ de verdadeiro para falso e vice-versa: + +```js +() => setShowAll(!showAll) +``` + +O texto do botão depende do valor do estado showAll: + +```js +show {showAll ? 'important' : 'all'} +``` + +Você pode encontrar o código da nossa aplicação atual na íntegra na branch part2-3 [neste repositório GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part2-3). +
    + +
    + +

    Exercícios 2.6 a 2.10

    + +Em nosso primeiro exercício, vamos começar a trabalhar em uma aplicação que será desenvolvida mais tarde em exercícios subsequentes. Em exercícios que fazem parte de conjuntos relacionados, é mais que suficiente retornar a versão final da sua aplicação. Você também pode fazer um commit separado depois de ter terminado cada parte do conjunto de exercícios, mas isso não é obrigatório. + +**AVISO**: "create-react-app" transformará automaticamente seu projeto em um repositório git, a menos que você crie sua aplicação dentro de um repositório git já existente.**Você muito provavelmente não quer que cada um de seus projetos seja um repositório separado**, então basta executar o comando _rm -rf .git_ na raiz de sua aplicação para aplicar as modificações. + +

    2.6: The Phonebook — 1º passo

    + +Vamos criar uma lista telefônica bem simples. **Nesta parte, vamos adicionar apenas nomes à lista telefônica.** + +Vamos começar implementando a funcionalidade que adiciona uma pessoa à lista telefônica. + +Você pode usar o código abaixo como ponto de partida para o componente App da sua aplicação: + +```js +import { useState } from 'react' + +const App = () => { + const [persons, setPersons] = useState([ + { name: 'Arto Hellas' } + ]) + const [newName, setNewName] = useState('') + + return ( +
    +

    Phonebook

    +
    +
    + name: +
    +
    + +
    +
    +

    Numbers

    + ... +
    + ) +} + +export default App +``` + +O estado newName é destinado a controlar o elemento de entrada do formulário. + +Pode ser útil renderizar o estado e outras variáveis, como texto, para fins de depuração. Você pode adicionar temporariamente o seguinte elemento ao componente renderizado: + +``` +
    debug: {newName}
    +``` + +Também é importante colocar em prática o que aprendemos no capítulo sobre [depuração de aplicações React](/ptbr/part1/um_estado_mais_complexo_e_depuracao_de_aplicacoes_react) da primeira parte. A extensão [React developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) é incrivelmente útil para rastrear as alterações que ocorrem no estado da aplicação. + +Depois de concluir este exercício, sua aplicação deve ficar mais ou menos parecida com isto: + +![captura de tela do exercício 2.6 finalizado](../../images/2/10e.png) + +Atente-se ao uso da extensão "React developer tools" na imagem acima! + +**Obs.:** + +- Você pode usar o nome da pessoa como um valor da propriedade key; e +- Lembre-se de impedir a ação padrão de envio de formulários HTML (preventDefault)! + +

    2.7: The Phonebook — 2º passo

    + +Impeça que o usuário adicione nomes que já existam na lista telefônica. JavaScript têm inúmeros [métodos](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array) adequados para realizar esta tarefa. Tenha em mente [como funciona a igualdade de objetos](https://www.joshbritz.co/posts/why-its-so-hard-to-check-object-equality/) em JavaScript. + +Emita um aviso com o comando [alert](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert) (alerta) quando o usuário tentar fazer isso: + +![captura de tela mostrando o exemplo do exercício 2.7](../../images/2/11e.png) +Tradução do alerta em tela: "Arto Hellas já foi adicionado à lista telefônica" + +**Dica:** ao formar strings que contêm valores de variáveis, recomenda-se usar [template strings](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals): + +```js +`${newName} is already added to phonebook` +``` + +Se a variável newName contiver o valor Arto Hellas, a expressão template string retornará a string: + +```js +`Arto Hellas is already added to phonebook` +``` + +O mesmo pode ser feito de um "jeito mais Java" usando o operador de soma (+): + +```js +newName + ' is already added to phonebook' +``` + +Template strings é a opção mais idiomática, além de que seu uso é o indício de um verdadeiro profissional JavaScript. + +

    2.8: The Phonebook — 3º passo

    + +Expanda sua aplicação permitindo que os usuários adicionem números de telefone à lista telefônica. Você precisará adicionar um segundo elemento de entrada (input) ao formulário (junto com seu próprio gerenciador de evento): + +```js +
    +
    name:
    +
    number:
    +
    +
    +``` + +Neste ponto, a aplicação pode ficar mais ou menos assim. A imagem também exibe o estado da aplicação com a ajuda da ferramenta [React developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi): + +![captura de tela do exercício 2.8](../../images/2/12e.png) + +

    2.9*: The Phonebook — 4º passo

    + +Implemente um campo de pesquisa que possa ser usado para filtrar a lista de pessoas por nome: + +![captura de tela do exercício 2.9](../../images/2/13e.png) + +Você pode implementar o campo de pesquisa como um elemento input que é colocado fora do formulário HTML. A lógica de filtragem mostrada na imagem é case insensitive, o que significa que se você pesquisar por arto, também há o retorno de resultados que contêm "Arto" com o A maiúsculo. + +**Obs.:** Quando se está trabalhando em uma nova funcionalidade, é útil inserir em sua aplicação um "código de teste" — como alguns dados fantasiosos de pessoas — desta forma: + +```js +const App = () => { + const [persons, setPersons] = useState([ + { name: 'Arto Hellas', number: '040-123456', id: 1 }, + { name: 'Ada Lovelace', number: '39-44-5323523', id: 2 }, + { name: 'Dan Abramov', number: '12-43-234345', id: 3 }, + { name: 'Mary Poppendieck', number: '39-23-6423122', id: 4 } + ]) + + // ... +} +``` + +Isso lhe economiza o trabalho de ter que ficar inserindo dados manualmente na sua aplicação para testar a nova funcionalidade. + +

    2.10: The Phonebook — 5º passo

    + +Se você implementou sua aplicação em um único componente, refatore-o extraindo e transformando as partes corretas do código em novos componentes. Mantenha o estado da aplicação e todos os gerenciadores de eventos no componente raiz App. + +Já é suficiente extrair **três** componentes da aplicação. São boas opções para tornar em componentes separados, por exemplo, o filtro de pesquisa, o formulário para adicionar novas pessoas à lista telefônica, um componente que renderiza todas as pessoas da lista telefônica e um componente que renderiza os detalhes de uma única pessoa. + +O componente raiz da aplicação após a refatoração pode ficar parecido com o do exemplo abaixo. O componente raiz refatorado abaixo renderiza apenas os títulos, enquanto que deixa os componentes extraídos cuidarem do resto. + +```js +const App = () => { + // ... + + return ( +
    +

    Phonebook

    + + + +

    Add a new

    + + + +

    Numbers

    + + +
    + ) +} +``` + +**Obs.:** Você pode ter problemas neste exercício se definir seus componentes "no lugar errado". Agora, é definitivamente uma boa ideia revisar o capítulo da seção anterior: [Não defina Componentes dentro de Componentes](/ptbr/part1/um_estado_mais_complexo_e_depuracao_de_aplicacoes_react#nao-defina-componentes-dentro-de-componentes). + +
    diff --git a/src/content/2/ptbr/part2c.md b/src/content/2/ptbr/part2c.md new file mode 100644 index 00000000000..5bd00393229 --- /dev/null +++ b/src/content/2/ptbr/part2c.md @@ -0,0 +1,574 @@ +--- +mainImage: ../../../images/part-2.svg +part: 2 +letter: c +lang: ptbr +--- + +
    + +Já estamos trabalhando há um tempo apenas no "front-end", ou seja, com as funcionalidades do lado do cliente (navegador). Começaremos a trabalhar no "back-end", ou seja, com as funcionalidades do lado do servidor na [Parte 3](/ptbr/part3) deste curso. Contudo, agora daremos um passo nessa direção, assim familiarizando-nos com a comunicação do código executado no navegador com o back-end. + +Vamos usar uma ferramenta destinada a ser usada durante a fase de desenvolvimento de software chamada [JSON Server](https://github.com/typicode/json-server), que atuará como nosso servidor. + +Crie um arquivo chamado db.json na raiz do diretório do projeto de notas com o seguinte conteúdo: + +```json +{ + "notes": [ + { + "id": 1, + "content": "HTML é fácil", + "important": true + }, + { + "id": 2, + "content": "O navegador só pode executar JavaScript", + "important": false + }, + { + "id": 3, + "content": "GET e POST são os métodos mais importantes do protocolo HTTP", + "important": true + } + ] +} +``` + +É possível [instalar](https://github.com/typicode/json-server#getting-started) globalmente um servidor JSON na sua máquina usando o comando _npm install -g json-server_. Uma instalação global requer privilégios administrativos, o que significa que não é possível fazer isso em computadores de faculdade, etc. + +Após a instalação, execute o seguinte comando para executar o json-server. O json-server é executado na porta 3000 por padrão; porém, como projetos criados usando o "create-react-app" reservam a porta 3000 para si, devemos definir uma porta alternativa — como a porta 3001 — para o json-server. A opção --watch procura automaticamente por quaisquer alterações salvas no arquivo db.json. + +```js +json-server --port 3001 --watch db.json +``` + +Entretanto, não é necessária uma instalação global. A partir da raiz do diretório da sua aplicação, podemos executar o json-server usando o comando _npx_: + +```js +npx json-server --port 3001 --watch db.json +``` + +Vamos acessar o endereço no navegador. Vemos que o json-server serve as notas que escrevemos anteriormente no arquivo em formato JSON: + +![](../../images/2/14new.png) + +Se o seu navegador não tiver um formatador para exibir os dados JSON, instale um plugin como o [JSONVue](https://chrome.google.com/webstore/detail/jsonview/chklaanhfefbnpoihckbnefhakgolnmc) para facilitar sua vida. + +A partir de agora, a ideia será salvar as notas no servidor, que, neste caso, significa salvá-las no json-server. O código React busca as notas do servidor e as renderiza na tela. Sempre que uma nova nota é adicionada à aplicação, o código React também a envia ao servidor para que a nova nota persista (persist — leia mais sobre persistência de dados [aqui](https://www.take.net/blog/tecnologia/persistencia-de-dados/)) na "memória". + +O json-server armazena todos os dados no arquivo db.json, que reside no servidor. No mundo real, os dados seriam armazenados em algum tipo de banco de dados. No entanto, o json-server é uma ferramenta muito útil que permite o uso da funcionalidade de um servidor na fase de desenvolvimento sem a necessidade de programar nenhum desses outros softwares. + +Nos familiarizaremos com os princípios de implementação das funcionalidades de um servidor com mais detalhes na [parte 3](/ptbr/part3) deste curso. + +### O navegador como ambiente de execução + +Nossa primeira tarefa é buscar as notas já existentes em nossa aplicação React a partir do endereço . + +Na [projeto-exemplo](/ptbr/part0/fundamentos_de_aplicacoes_web#executando-a-logica-da-aplicacao-no-navegador) da Parte 0, já aprendemos uma maneira de buscar dados de um servidor usando JavaScript. O código no exemplo estava buscando os dados usando [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest), também conhecido como uma "requisição HTTP" feita usando um objeto XHR. Esta é uma técnica introduzida em 1999, no qual todos os navegadores têm oferecido suporte a ela já faz um bom tempo. + +Já não é mais recomendado o uso do objeto XHR, e a maioria dos navegadores já suportam amplamente o método [fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) ("ir buscar" ou "buscar"), que é baseado em chamadas conhecidas como [promessas](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) (promises), ao invés do modelo de gerenciamento de eventos utilizado pelo XHR. + +Como lembrete da Parte 0 (que deve ser lembrado de não ser usado sem um motivo plausível), os dados foram buscados usando o XHR da seguinte maneira: + +```js +const xhttp = new XMLHttpRequest() + +xhttp.onreadystatechange = function () { + if (this.readyState == 4 && this.status == 200) { + const data = JSON.parse(this.responseText) + // gerencia a resposta que é salva nos dados variáveis + } +} + +xhttp.open('GET', '/data.json', true) +xhttp.send() +``` + +Desde o início, registramos um gerenciador de evento ao objeto xhttp representando a requisição HTTP, que será chamado pelo ambiente de execução JavaScript sempre que o estado do objeto xhttp mudar. Se a mudança no estado significa que a resposta à requisição chegou, então os dados são lidos de acordo com o que foi estabelecido. + +Vale a pena notar que o código no gerenciador de evento é definido antes da requisição ser enviada ao servidor. Mesmo assim, o código dentro do gerenciador de evento será executado em um momento posterior. Portanto, o código não executa sincronicamente "de cima para baixo", mas sim assincronamente (asynchronously). JavaScript chama em algum momento o gerenciador de evento que foi registrado para a requisição. + +Uma forma comum de fazer requisições síncronas em Java, por exemplo, funcionaria da seguinte maneira (N.B. (Nota Bene): este código Java não funciona): + +```java +HTTPRequest request = new HTTPRequest(); + +String url = "https://studies.cs.helsinki.fi/exampleapp/data.json"; +List notes = request.get(url); + +notes.forEach(m => { + System.out.println(m.content); +}); +``` + +Em Java, o código é executado linha a linha e é interrompido para esperar pela requisição HTTP, o que significa esperar até o comando _request.get(...)_ ser concluído. Os dados retornados pelo comando, neste caso as notas, são então armazenados em uma variável na qual podemos manipular os dados da maneira que desejarmos. + +Por outro lado, os ambientes de tempo de execução JavaScript, ou "engines" (motores), seguem o [modelo assíncrono](https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop). Em princípio, isso requer que todas as [operações IO](https://en.wikipedia.org/wiki/Input/output) (com algumas exceções) sejam executadas como não-bloqueantes. Isso significa que a execução do código continua imediatamente após a chamada de uma função IO, sem esperar que ela termine. + +Quando uma operação assíncrona é concluída, ou mais especificamente, algum tempo depois de sua conclusão, o que acontece é que o motor JavaScript chama os gerenciadores de evento registrados na operação. + +Atualmente, os motores JavaScript são single-threaded (linha de execução única), o que significa que não podem executar código em paralelo. Como resultado, na prática, é uma exigência usar um modelo não-bloqueante para a execução de operações IO. Caso contrário, o navegador "congelaria" durante a busca de dados em um servidor, por exemplo. + +Outra consequência da natureza single-threaded dos motores JavaScript é que se alguma execução de código levar muito tempo, o navegador ficará preso durante toda a execução. Se adicionássemos o seguinte código no topo de nossa aplicação... + +```js +setTimeout(() => { + console.log('loop..') + let i = 0 + while (i < 50000000000) { + i++ + } + console.log('fim do loop') +}, 5000) +``` + +... tudo funcionaria normalmente por 5 segundos. No entanto, quando a função definida como o parâmetro para setTimeout é executada, o navegador fica preso durante toda a execução do longo loop. Mesmo a aba do navegador não pode ser fechada durante a execução do loop, pelo menos não no Chrome. + +Para o navegador permanecer responsivo, ou seja, ser capaz de reagir continuamente às operações do usuário com velocidade suficiente, a lógica do código precisa ser tal que nenhuma única computação tenha de levar tanto tempo para se realizar. + +Existe uma série de materiais sobre o tema disponíveis na internet. Uma apresentação particularmente clara do tópico é a palestra de Philip Roberts chamada [What the heck is the event loop anyway?](https://www.youtube.com/watch?v=8aGhZQkoFbQ) (disponível em português). + +É possível executar código paralelizado nos navegadores de hoje em dia com a ajuda dos chamados [web workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers). No entanto, o loop de eventos de uma única janela do navegador ainda é realizado como [single thread](https://medium.com/techtrument/multithreading-javascript-46156179cf9a). + +### npm + +Vamos voltar ao assunto sobre obtenção de dados do servidor. + +Poderíamos usar a função baseada em promessas [fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch), mencionada anteriormente, para puxar (pull) os dados do servidor. Fetch é uma ótima ferramenta. É padronizada e tem suporte em todos os navegadores modernos (exceto o IE [Internet Explorer]). + +Dito isso, usaremos a biblioteca [axios](https://github.com/axios/axios) para fazer essa comunicação entre navegador e servidor. Ela funciona como o fetch, mas é um pouco mais agradável de se usar. Outra boa razão para usar o axios é que nos familiarizaremos com a adição de bibliotecas externas em projetos React, conhecidas como pacotes npm (npm packages). + +Hoje em dia, praticamente todos os projetos JavaScript são definidos usando o gerenciador de pacotes do Node, conhecido como [npm](https://docs.npmjs.com/getting-started/what-is-npm) (abreviação de "Node Package Manager"). Os projetos criados usando "create-react-app" também seguem o formato npm. Um indicador claro de que um projeto usa npm é o arquivo package.json localizado na raiz do projeto: + +```json +{ + "name": "notes-frontend", + "version": "0.1.0", + "private": true, + "dependencies": { + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^13.5.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-scripts": "5.0.1", + "web-vitals": "^2.1.4" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": ["react-app", "react-app/jest"] + }, + "browserslist": { + "production": [">0.2%", "not dead", "not op_mini all"], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} +``` + +Neste ponto, o objeto dependencies, que é uma parte do documento package.json, é o que mais nos interessa agora, pois define quais são as dependências ou bibliotecas externas do projeto. + +Agora queremos usar o axios. Teoricamente, poderíamos definir a biblioteca diretamente no arquivo package.json, mas é melhor instalá-la a partir da linha de comando. + +```js +npm install axios +``` + +**N.B.: os comandos do _npm_ sempre devem ser executados no diretório raiz do projeto**, onde o arquivo package.json pode ser encontrado. + +O axios agora está incluído entre as outras dependências: + +```json +{ + "name": "notes-frontend", + "version": "0.1.0", + "private": true, + "dependencies": { + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^13.5.0", + "axios": "^1.2.2", // highlight-line + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-scripts": "5.0.1", + "web-vitals": "^2.1.4" + } + // ... +} +``` + +Além de adicionar o axios às dependências, o comando npm install também baixou o código da biblioteca. Como outras dependências, o código pode ser encontrado no diretório node_modules localizado na raiz. É possível notar que o diretório node_modules contém uma quantidade significativa de coisas interessantes. + +Vamos fazer mais uma adição. Instale o json-server como uma dependência de desenvolvimento (usado apenas durante o desenvolvimento) executando o comando... + +```js +npm install json-server --save-dev +``` + +... e fazendo uma pequena adição ao objeto scripts do arquivo package.json: + +```json +{ + // ... + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "server": "json-server -p3001 --watch db.json" // highlight-line + } +} +``` + +Agora podemos, convenientemente e sem definições de parâmetros, iniciar o json-server a partir do diretório raiz do projeto com o comando: + +```js +npm run server +``` + +Vamos ficar mais familiarizados com a ferramenta _npm_ na [terceira parte do curso](/ptbr/part3). + +**N.B.:** O json-server que foi iniciado anteriormente deve ser encerrado antes de iniciar um novo; caso contrário, haverá problemas: + +![erro: cannot bind to port 3001](../../images/2/15b.png) + +A mensagem de erro em vermelho nos informa sobre o problema: + +Não é possível vincular-se ao número da porta 3001. Por favor, especifique outro número de porta, seja através do argumento --port ou através do arquivo de configuração json-server.json + +Como podemos ver, a aplicação não é capaz de se vincular à [porta]() (port). O motivo é que a porta 3001 já está ocupada pelo json-server iniciado anteriormente. + +Usamos o comando _npm install_ duas vezes, mas com pequenas modificações: + +```js +npm install axios +npm install json-server --save-dev +``` + +Há uma pequena diferença nos parâmetros. O axios é instalado como uma dependência de tempo de execução da aplicação, pois a execução do programa exige a existência da biblioteca. Por outro lado, o json-server foi instalado como uma dependência de desenvolvimento (_--save-dev_), uma vez que o próprio programa não o requer. Ele é usado para ajudar durante a fase de desenvolvimento do software. Mais há de ser dito sobre diferentes dependências na próxima parte do curso. + +### Axios e promessas (promises) + +Estamos prontos para usar a biblioteca axios. A partir de agora, supõe-se que o json-server esteja rodando na porta 3001. + +**N.B.**: Para executar o json-server e sua aplicação React simultaneamente, é possível que seja necessário usar duas janelas do terminal. Uma para manter o json-server em execução e outra para executar a aplicação React. + +A biblioteca pode ser utilizada da mesma maneira que outras bibliotecas, como React, por exemplo, através de uma declaração import adequada. + +Adicione o seguinte ao arquivo index.js: + +```js +import axios from 'axios' + +const promise = axios.get('http://localhost:3001/notes') +console.log(promise) + +const promise2 = axios.get('http://localhost:3001/foobar') +console.log(promise2) +``` + +Se você acessar o endereço no navegador, deve ser impresso isso no console: + +![promessas impressas no console](../../images/2/16new.png) + +O método _get_ do Axios retorna uma [promessa](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises) (promise). + +A documentação no site da Mozilla afirma o seguinte sobre "promessas": + +> Uma Promise (promessa) é um objeto que representa a eventual conclusão ou falha de uma operação assíncrona. + +Em outras palavras, uma promessa é um objeto que representa uma operação assíncrona. Uma promessa pode ter três estados distintos: + +1. A promessa está pendente (pending): significa que o valor final (uma das operações seguintes) ainda não está disponível. +2. A promessa está realizada (fulfilled): significa que a operação foi concluída e o valor final está disponível, o que geralmente é uma operação bem-sucedida. Este estado às vezes também é chamado de resolvido(a) (resolved). +3. A promessa está rejeitada (rejected): significa que um erro impediu que o valor final fosse determinado, o que geralmente representa uma operação falha. + +A primeira promessa em nosso exemplo foi realizada, representando uma requisição bem-sucedida _axios.get('http://localhost:3001/notes')_. A segunda, no entanto, foi rejeitada e o console nos diz o motivo. Parece que estamos tentando fazer uma requisição HTTP GET a um endereço que não existe. + +Se e quando quisermos acessar o resultado da operação representada pela promessa, devemos registrar um gerenciador de evento para ela. Isso é feito utilizando o método then: + +```js +const promise = axios.get('http://localhost:3001/notes') + +promise.then((response) => { + console.log(response) +}) +``` + +O seguinte é impresso no console: + +![dados de um objeto json impressos no console](../../images/2/17new.png) + +O ambiente de tempo de execução JavaScript chama a função callback (função de retorno de chamada) registrada pelo método then fornecendo-lhe um objeto response como parâmetro. O objeto response contém todos os dados essenciais relacionados à resposta de uma requisição HTTP GET, que incluiria os dados retornados data, o código de status e os cabeçalhos. + +Em geral, armazenar o objeto de promessa em uma variável é desnecessário, e é uma prática comum encadear a chamada de método then à chamada de método axios, de modo que haja um seguimento lógico: + +```js +axios.get('http://localhost:3001/notes').then((response) => { + const notes = response.data + console.log(notes) +}) +``` + +A função callback (função de retorno de chamada) pega os dados contidos dentro da resposta, armazena-os em uma variável e imprime as notas no console. + +Uma maneira mais legível de formatar chamadas de método encadeadas é colocar cada chamada em sua própria linha: + +```js +axios.get('http://localhost:3001/notes').then((response) => { + const notes = response.data + console.log(notes) +}) +``` + +Os dados retornados pelo servidor são texto simples ou texto puro (plain text), que é basicamente apenas uma string longa. A biblioteca axios ainda consegue analisar os dados em um array JavaScript, já que o servidor especificou que o formato de dados é application/json; charset=utf-8 (veja a imagem anterior) usando o cabeçalho content-type. + +Podemos finalmente começar a usar os dados obtidos do servidor. + +Vamos tentar requisitar as notas do nosso servidor local e renderizá-las utilizando inicialmente o componente App. Por favor, note que esta abordagem tem muitos problemas, pois estamos renderizando todo o componente App apenas quando recebemos uma resposta de uma operação bem-sucedida: + +```js +import ReactDOM from 'react-dom/client' +import axios from 'axios' +import App from './App' + +axios.get('http://localhost:3001/notes').then((response) => { + const notes = response.data + ReactDOM.createRoot(document.getElementById('root')).render( + + ) +}) +``` + +Este método poderia ser aceitável em algumas circunstâncias, mas é um tanto problemático. Em vez disso, vamos mover a busca de dados para o componente App. + +Porém, o que não é imediatamente óbvio é onde o comando axios.get deve ser colocado dentro do componente. + +### Effect-hooks + +Já usamos [hooks de estado](https://reactjs.org/docs/hooks-state.html) que foram introduzidos juntamente com a versão do React [16.8.0](https://www.npmjs.com/package/react/v/16.8.0), que fornecem estado aos componentes React definidos como funções — os chamados componentes funcionais. A versão 16.8.0 também introduz [effect hooks](https://reactjs.org/docs/hooks-effect.html) (ou "ganchos de efeito" ou "hooks de efeito") como uma nova funcionalidade. De acordo com a documentação oficial: + +> O Effect Hook (Hook de Efeito) te permite executar efeitos colaterais em componentes funcionais > Buscar dados, configurar uma subscription (assinatura), e mudar o DOM manualmente dentro dos componentes React são exemplos de efeitos colaterais. + +Como tal, os hooks de efeito são precisamente a ferramenta certa a ser usada ao buscar dados de um servidor. + +Vamos remover a busca de dados de index.js. Já que vamos buscar as notas do servidor, não há mais necessidade de passar dados como props para o componente App. Então, index.js pode ser simplificado desta forma: + +```js +ReactDOM.createRoot(document.getElementById('root')).render() +``` + +O componente App muda da seguinte maneira: + +```js +import { useState, useEffect } from 'react' // highlight-line +import axios from 'axios' // highlight-line +import Note from './components/Note' + +const App = () => { + // highlight-line + const [notes, setNotes] = useState([]) // highlight-line + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + + // highlight-start + useEffect(() => { + console.log('effect (efeito)') + axios.get('http://localhost:3001/notes').then((response) => { + console.log('promise fulfilled (promessa resolvida)') + setNotes(response.data) + }) + }, []) + + console.log('render (renderiza)', notes.length, 'notes (notas)') + // highlight-end + + // ... +} +``` + +Também adicionamos algumas impressões úteis no console, que esclarecem a progressão da execução. + +Isto é impresso no console: + +``` +render (renderiza) 0 notes (notas) +effect (efeito) +promise fulfilled (promessa resolvida) +render (renderiza) 3 notes (notas) +``` + +Assim, o corpo da função que define o componente é executado e o componente é renderizado pela primeira vez. Neste ponto, render (renderiza) 0 notes (notas) é impresso, o que significa que os dados ainda não foram buscados no servidor. + +Em seguida, a função — ou efeito, no linguajar React — ... + +```js +() => { + console.log('effect (efeito)') + axios.get('http://localhost:3001/notes').then((response) => { + console.log('promise fulfilled (promessa resolvida)') + setNotes(response.data) + }) +} +``` + +... é executada imediatamente após a renderização. A execução da função resulta na impressão de effect (efeito) no console, e o comando axios.get inicia a busca de dados no servidor, bem como registra a seguinte função como um gerenciador de evento para a operação: + +```js +response => { + console.log('promise fulfilled (promessa resolvida)') + setNotes(response.data) +}) +``` + +Quando os dados chegam do servidor, o ambiente de execução JavaScript chama a função registrada como o gerenciador de evento, o que imprime promise fulfilled (promessa resolvida) no console e armazena as notas recebidas do servidor no estado usando a função setNotes(response.data). + +Como sempre, uma chamada a uma função de atualização de estado gera a re-renderização do componente. Como resultado, render (renderiza) 3 notes (notas) é impresso no console e as notas buscadas do servidor são renderizadas na tela. + +Por fim, vamos dar uma olhada na definição do hook de efeito como um todo: + +```js +useEffect(() => { + console.log('effect (efeito)') + axios.get('http://localhost:3001/notes').then((response) => { + console.log('promise fulfilled (promessa resolvida)') + setNotes(response.data) + }) +}, []) +``` + +Vamos reescrever o código de uma maneira um pouco diferente: + +```js +const hook = () => { + console.log('effect (efeito)') + axios.get('http://localhost:3001/notes').then((response) => { + console.log('promise fulfilled (promessa resolvida)') + setNotes(response.data) + }) +} + +useEffect(hook, []) +``` + +Podemos ver claramente que a função [useEffect](https://reactjs.org/docs/hooks-reference.html#useeffect) ("usarEfeito") leva dois parâmetros. O primeiro é uma função, o próprio effect (efeito). De acordo com a documentação: + +> Por padrão, useEffect roda depois da primeira renderização e depois de toda atualização, mas é possível escolher rodá-lo somente quando determinados valores tenham mudado. + +Portanto, por padrão, o efeito é sempre executado após a renderização do componente. No nosso caso, no entanto, só queremos executar o efeito junto à primeira renderização. + +O segundo parâmetro de useEffect é usado para [especificar com que frequência o efeito é executado](https://reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect). Se o segundo parâmetro é um array vazio [], então o efeito é executado junto com a primeira renderização do componente. + +Existem muitos casos possíveis de uso para um hook de efeito, além de buscar dados do servidor. Contudo, este uso já é suficiente para nós, por enquanto. + +Pense novamente na sequência de eventos que acabamos de discutir. Qual parte do código é executada? Em que ordem? Com qual frequência? Entender a ordem dos eventos é decisivo! + +Observe que também poderíamos ter escrito o código da função de efeito (effect function) desta forma: + +```js +useEffect(() => { + console.log('effect (efeito)') + + const eventHandler = (response) => { + console.log('promise fulfilled (promessa resolvida)') + setNotes(response.data) + } + + const promise = axios.get('http://localhost:3001/notes') + promise.then(eventHandler) +}, []) +``` + +Uma referência à função gerenciadora de evento é atribuída à variável eventHandler. A promessa retornada pelo método get do Axios é armazenada na variável promise. O registro do callback (retorno de chamada) acontece dando à variável eventHandler, que referencia a função gerenciadora de evento, como parâmetro para o método then da promessa. Em geral, não é necessário atribuir funções e promessas a variáveis, e uma forma mais compacta de representação de ações já é suficiente, como a exibida acima, por exemplo. + +```js +useEffect(() => { + console.log('effect (efeito)') + axios.get('http://localhost:3001/notes').then((response) => { + console.log('promise fulfilled (promessa resolvida)') + setNotes(response.data) + }) +}, []) +``` + +Ainda temos um problema com nossa aplicação. Ao adicionar novas anotações, elas não são armazenadas no servidor. + +O código para a aplicação, como descrito até agora, pode ser encontrado na íntegra no [github](https://github.com/fullstack-hy2020/part2-notes/tree/part2-4), na branch part2-4. + +### O Ambiente de Tempo de Execução de Desenvolvimento + +Tornou-se cada vez mais complexa a configuração de toda a aplicação. Vamos revisar o que são e onde acontecem os eventos. A imagem a seguir descreve a composição da aplicação: + +![diagrama da composição da aplicação React](../../images/2/18e.png) + +O código JavaScript que compõe nossa aplicação React é executado no navegador. O navegador obtém o JavaScript do servidor de desenvolvimento React (React dev server), que é a aplicação que é executada após a execução do comando npm start. O servidor de desenvolvimento transforma o JavaScript em um formato compreendido pelo navegador. Entre outras coisas, ele costura e junta o JavaScript de diferentes arquivos em um único arquivo. Discutiremos sobre o servidor de desenvolvimento React em mais detalhes na Parte 7 do curso. + +A aplicação React em execução no navegador busca os dados no formato JSON do json-server, que está sendo executado na porta 3001 na máquina. O servidor a partir do qual requisitamos os dados — json-server — obtém seus dados do arquivo db.json. + +Neste ponto do desenvolvimento, calha que todas as partes da aplicação residem na máquina do desenvolvedor, conhecido como "localhost". A situação muda quando a aplicação é implementada na internet. Faremos isso na Parte 3. + +
    + +
    + +

    Exercício 2.11

    + +

    2.11: The Phonebook — 6º passo

    + +Continuemos com o desenvolvimento da lista telefônica. Armazene o estado inicial da aplicação no arquivo db.json, que deve ser colocado na raiz do projeto. + +```json +{ + "persons": [ + { + "name": "Arto Hellas", + "number": "040-123456", + "id": 1 + }, + { + "name": "Ada Lovelace", + "number": "39-44-5323523", + "id": 2 + }, + { + "name": "Dan Abramov", + "number": "12-43-234345", + "id": 3 + }, + { + "name": "Mary Poppendieck", + "number": "39-23-6423122", + "id": 4 + } + ] +} +``` + +Inicie o json-server na porta 3001 e verifique se o servidor retorna a lista de pessoas acessando o endereço no navegador. + +Se você receber a seguinte mensagem de erro... + +```js +events.js:182 + throw er; // Unhandled 'error' event + ^ //* Evento de 'erro' não gerenciado + +Error: listen EADDRINUSE 0.0.0.0:3001 + at Object._errnoException (util.js:1019:11) + at _exceptionWithHostPort (util.js:1041:20) +``` + +... significa que a porta 3001 já está em uso por outra aplicação; alguma aplicação pode estar usando json-server nesse momento, por exemplo. Feche a outra aplicação ou altere a porta, caso a primeira opção não funcione. + +Modifique a aplicação para que o estado inicial dos dados seja obtido do servidor usando a biblioteca axios. Conclua a busca (fetching) com um [hook de Efeito](https://reactjs.org/docs/hooks-effect.html) (Effect hook). + +
    diff --git a/src/content/2/ptbr/part2d.md b/src/content/2/ptbr/part2d.md new file mode 100644 index 00000000000..bf65854714f --- /dev/null +++ b/src/content/2/ptbr/part2d.md @@ -0,0 +1,756 @@ +--- +mainImage: ../../../images/part-2.svg +part: 2 +letter: d +lang: ptbr +--- + +
    + +Quando criamos notas em nossa aplicação, naturalmente queremos armazená-las em algum servidor back-end. O pacote [json-server](https://github.com/typicode/json-server) afirma ser uma API REST ou RESTful em sua documentação: + +> Use uma API REST falsa completa sem precisar programá-la em menos de 30 segundos (sério!) + +O json-server não corresponde exatamente à descrição fornecida pela [definição](https://en.wikipedia.org/wiki/Representational_state_transfer) do que é uma API REST, e nem mesmo a maioria das outras APIs que afirmam ser RESTful. + +Vamos nos aprofundar mais em REST na [próxima parte](/ptbr/part3) do curso. Porém, é importante já nos familiarizarmos com algumas das [convenções](https://en.wikipedia.org/wiki/Representational_state_transfer#Applied_to_web_services) usadas pelo json-server e APIs REST em geral. Em particular, vamos dar uma olhada no uso convencional de [rotas](https://github.com/typicode/json-server#routes) (routes) — também conhecidas como URLs —, e os tipos de requisição HTTP em REST. + +### REST + +Na terminologia REST, nos referimos a objetos de dados individuais — as notas em nossa aplicação, por exemplo — como recursos. Cada recurso tem um endereço único associado a ele — sua URL. De acordo com uma convenção geral usada pelo json-server, poderíamos localizar uma nota individual na URL do recurso notes/3, onde 3 é o id do recurso. A URL notes, por outro lado, apontaria para uma coleção de recursos contendo todas as notas. + +Os recursos são buscados do servidor com requisições HTTP GET. Por exemplo, uma requisição HTTP GET para a URL notes/3 retornará a nota que tem o número de id 3. Uma requisição HTTP GET para a URL notes retornaria uma lista de todas as notas. + +A criação de um novo recurso para armazenar uma nota é feita fazendo uma requisição HTTP POST para a URL notes de acordo com a convenção REST a qual o json-server adere. Os dados para o novo recurso de nota são enviados no corpo (body) da requisição. + +O json-server exige que todos os dados sejam enviados no formato JSON. O que isso significa na prática é que os dados devem ser formatados como string e a requisição deve conter o cabeçalho de requisição Content-Type (Tipo de Conteúdo) com o valor application/json. + +### Enviando dados ao servidor + +Vamos fazer as seguintes alterações no gerenciador de evento responsável por criar uma nova nota: + +```js +addNote = event => { + event.preventDefault() + const noteObject = { + content: newNote, + important: Math.random() < 0.5, + } + +// highlight-start + axios + .post('http://localhost:3001/notes', noteObject) + .then(response => { + console.log(response) + }) +// highlight-end +} +``` + +Criamos um novo objeto para a nota, mas omitimos a propriedade id já que é melhor deixar o servidor gerar os ids para nossos recursos! + +O objeto é enviado ao servidor usando o método post do axios. O gerenciador de evento assinalado registra (logs) a resposta que é enviada de volta pelo servidor para o console. + +Quando tentamos criar uma nova nota, a seguinte saída aparece no console: + +![saída dos dados json no console](../../images/2/20new.png) + +O recurso de nota recém-criado é armazenado no valor da propriedade data do objeto _response_. + +Por vezes é útil inspecionar as requisições HTTP na guia Rede das Ferramentas do Desenvolvedor do Chrome (ou do navegador que esteja utilizando), recurso esse que foi amplamente usado no início da [Parte 0](/ptbr/part0/fundamentos_de_aplicacoes_web#http-get). + +Podemos usar o inspetor para verificar se os cabeçalhos enviados na requisição POST são os que esperávamos: + +![o header no dev tools mostra '201 created' para localhost:3001/notes](../../images/2/21new1.png) + +Como os dados que enviamos na requisição POST eram um objeto JavaScript, o axios sabia automaticamente definir o valor apropriado de application/json para o cabeçalho Content-Type. + +A guia Visualização (Payload) pode ser usada para verificar os dados da requisição: + +![a guia VIsualização do devtools mostra os campos content e important](../../images/2/21new2.png) + +Também é útil a guia Resposta (Response), pois mostra qual foi os dados que o servidor respondeu: + +![a guia Resposta do devtools mostra o mesmo conteúdo visto na guia Visualização, mas com o campo id incluído](../../images/2/21new3.png) + +A nova nota ainda não é renderizada na tela. Isso se deve ao fato de que não atualizamos o estado do componente App quando criamos a nova nota. Vamos consertar isso: + +```js +addNote = event => { + event.preventDefault() + const noteObject = { + content: newNote, + important: Math.random() > 0.5, + } + + axios + .post('http://localhost:3001/notes', noteObject) + .then(response => { + // highlight-start + setNotes(notes.concat(response.data)) + setNewNote('') + // highlight-end + }) +} +``` + +A nova nota retornada pelo servidor back-end é adicionada à lista de notas no estado da nossa aplicação seguindo a forma habitual do uso da função setNotes e, em seguida, reinicia o formulário de criação de notas. Um [detalhe importante](/ptbr/part1/um_estado_mais_complexo_e_depuracao_de_aplicacoes_react#gerenciando-arrays) a lembrar é que o método concat não muda o estado original do componente, mas cria uma nova cópia da lista. + +Assim que os dados retornados pelo servidor começam a ter efeito no comportamento das nossas aplicações web, somos imediatamente confrontados com um conjunto inteiro de novos desafios decorrentes como, por exemplo, a assincronicidade da comunicação. Isso necessita de novas estratégias de depuração, como o "console.log" e outros meios de depuração que se tornam cada vez mais importantes. Também devemos desenvolver uma boa compreensão dos princípios do ambiente de execução JavaScript e dos componentes React. Só ficar adivinhando não será suficiente. + +Algo benéfico é inspecionar o estado do servidor back-end, por exemplo, através do navegador: + +![saída de dados JSON do back-end](../../images/2/22e.png) + +Isso torna possível verificar se todos os dados que pretendíamos enviar realmente foram recebidos pelo servidor. + +Na próxima parte do curso, aprenderemos a implementar nossa própria lógica no back-end. Em seguida, daremos uma olhada mais atenta em ferramentas como [Postman](https://www.postman.com/downloads/), que nos ajuda a depurar nossas aplicações de servidor. Por ora, inspecionar o estado do json-server através do navegador é suficiente para nossas necessidades atuais. + +O código para o estado atual de nossa aplicação pode ser encontrado na branch part2-5 neste repositório no [GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part2-5). + +### Alterando a importância das notas + +Vamos adicionar um botão ao lado de cada nota para podermos alternar sua importância. + +Façamos as seguintes alterações no componente Note: + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' : 'make important' + + return ( +
  • + {note.content} + +
  • + ) +} +``` + +Adicionamos um botão ao componente e atribuímos o seu gerenciador de evento como a função toggleImportance ("alternarImportancia") passada nas props do componente. + +O componente App define uma versão inicial da função gerenciadora de evento toggleImportanceOf ("alternarImportanciaDe") e passa para cada componente Note: + +```js +const App = () => { + const [notes, setNotes] = useState([]) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + + // ... + + // highlight-start + const toggleImportanceOf = (id) => { + console.log('importance of ' + id + ' needs to be toggled') + } + // highlight-end + + // ... + + return ( +
    +

    Notes

    +
    + +
    +
      + {notesToShow.map(note => + toggleImportanceOf(note.id)} // highlight-line + /> + )} +
    + // ... +
    + ) +} +``` + +Note como cada nota recebe o seu próprio gerenciador de evento único, uma vez que o id de cada nota é único. + +Por exemplo, se o note.id for 3, a função gerenciadora de evento retornada por _toggleImportance (note.id)_ será: + +```js +() => { console.log('importance of 3 needs to be toggled') } +``` + +Um breve lembrete: a string impressa pelo gerenciador de evento é definida de um jeito Java, isto é, adicionando strings: + +```js +console.log('importance of ' + id + ' needs to be toggled') +``` + +A sintaxe das [template strings](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals), funcionalidade essa adicionada com o ES6, pode ser usada para escrever strings similares de uma maneira muito mais agradável: + +```js +console.log(`importance of ${id} needs to be toggled`) +``` + +Agora podemos usar a sintaxe de "dollar-bracket" (cifrão-colchete) para adicionar partes à string que avaliará expressões JavaScript como, por exemplo, o valor de uma variável. Observe que usamos crases em template strings em vez de aspas usadas em strings JavaScript regulares. + +As notas individuais armazenadas no json-server do back-end podem ser modificadas de duas maneiras diferentes fazendo requisições HTTP para a URL única da nota. Podemos substituir a nota inteira com uma requisição HTTP PUT ou apenas alterar algumas das propriedades da nota com uma requisição HTTP PATCH. + +A forma final da função gerenciadora de evento é a seguinte: + +```js +const toggleImportanceOf = id => { + const url = `http://localhost:3001/notes/${id}` + const note = notes.find(n => n.id === id) + const changedNote = { ...note, important: !note.important } + + axios.put(url, changedNote).then(response => { + setNotes(notes.map(n => n.id !== id ? n : response.data)) + }) +} +``` + +Quase todas as linhas de código no corpo da função contêm detalhes importantes. A primeira linha define a URL única para cada recurso de nota com base em seu id. + +O método de array [find](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find) ("achar" ou "encontrar") é usado para encontrar a nota que queremos modificar e, em seguida, atribuí-la à variável _note_. + +Depois disso, criamos um novo objeto que é uma cópia exata da antiga nota, exceto pela propriedade "important" que tem o valor invertido (de verdadeiro para falso ou de falso para verdadeiro). + +O código para criar o novo objeto que usa a sintaxe [object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) (espalhamento de objeto) pode parecer um tanto estranho de primeira vista: + +```js +const changedNote = { ...note, important: !note.important } +``` + +Na prática, { ...note } cria um novo objeto com cópias de todas as propriedades do objeto _note_. Quando adicionamos propriedades dentro das chaves depois do objeto spread, por exemplo, { ...note, important: true }, então o valor da propriedade _important_ do novo objeto será _true_. Em nosso exemplo, a propriedade important obtém a negação de seu valor anterior no objeto original. + +Há algumas coisas a se pontuar. Por que fizemos uma cópia do objeto "note" que queríamos modificar quando o seguinte código também parece funcionar? + +```js +const note = notes.find(n => n.id === id) +note.important = !note.important + +axios.put(url, note).then(response => { + // ... +``` + +Isso não é recomendado porque a variável note é uma referência a um item no array notes no estado do componente e, como sabemos, nunca devemos [mudar diretamente o estado](https://reactjs.org/docs/state-and-lifecycle.html#using-state-correctly) em React. + +Também vale a pena notar que o novo objeto _changedNote_ é apenas uma cópia superficial, o que significa que os valores do novo objeto são os mesmos que os valores do objeto antigo. Se os valores do objeto antigo eram objetos em si, então os valores copiados no novo objeto referenciariam os mesmos objetos que estavam no objeto antigo. + +A nova nota é então enviada com uma requisição PUT ao back-end, onde ela substituirá o objeto antigo. + +A função callback (função de retorno de chamada) define o estado do componente notes como um array novo que contém todos os itens do array notes anterior, exceto pela nota antiga, que é substituída pela versão atualizada dela retornada pelo servidor: + +```js +axios.put(url, changedNote).then(response => { + setNotes(notes.map(note => note.id !== id ? note : response.data)) +}) +``` + +Isto é feito utilizando o método map: + +```js +notes.map(note => note.id !== id ? note : response.data) +``` + +O método map cria um array novo mapeando cada item do array antigo em um item no array novo. Em nosso exemplo, o array novo é criado de forma condicional de modo que se note.id !== id for verdadeiro, simplesmente copiamos o item do array antigo para o array novo. Se a condição for falsa, então o objeto de nota retornado pelo servidor é adicionado ao array. + +Esse truque do método map pode parecer um pouco estranho agora no início, mas vale a pena gastar algum tempo entendendo como ele funciona. Nós usaremos este método muitas vezes ao longo do curso. + +### Separando a Comunicação com o Back-end em um Módulo Único + +O componente App ficou um pouco carregado após adicionar o código para se comunicar com o servidor back-end. No espírito do [princípio da responsabilidade única](https://en.wikipedia.org/wiki/Single_responsibility_principle) (single responsibility principle), achamos sensato extrair esta comunicação em seu próprio [módulo](/ptbr/part2/renderizacao_de_uma_colecao_e_modulos#refatorando-modulos). + +Vamos criar um diretório src/services e adicionar lá um arquivo chamado notes.js: + +```js +import axios from 'axios' +const baseUrl = 'http://localhost:3001/notes' + +const getAll = () => { + return axios.get(baseUrl) +} + +const create = newObject => { + return axios.post(baseUrl, newObject) +} + +const update = (id, newObject) => { + return axios.put(`${baseUrl}/${id}`, newObject) +} + +export default { + getAll: getAll, + create: create, + update: update +} +``` + +O módulo retorna um objeto que tem três funções (getAll, create, e update) como suas propriedades que lidam com as notas. As funções retornam diretamente as promessas retornadas pelos métodos da biblioteca axios. + +O componente App usa a declaração import para ter acesso ao módulo: + +```js +import noteService from './services/notes' // highlight-line + +const App = () => { +``` + +As funções do módulo podem ser usadas diretamente com a variável importada _noteService_, como a seguir: + +```js +const App = () => { + // ... + + useEffect(() => { + // highlight-start + noteService + .getAll() + .then(response => { + setNotes(response.data) + }) + // highlight-end + }, []) + + const toggleImportanceOf = id => { + const note = notes.find(n => n.id === id) + const changedNote = { ...note, important: !note.important } + + // highlight-start + noteService + .update(id, changedNote) + .then(response => { + setNotes(notes.map(note => note.id !== id ? note : response.data)) + }) + // highlight-end + } + + const addNote = (event) => { + event.preventDefault() + const noteObject = { + content: newNote, + important: Math.random() > 0.5 + } + +// highlight-start + noteService + .create(noteObject) + .then(response => { + setNotes(notes.concat(response.data)) + setNewNote('') + }) +// highlight-end + } + + // ... +} + +export default App +``` + +Poderíamos levar nossa implementação um passo adiante. Quando o componente App usa as funções, ele recebe um objeto que contém a resposta inteira para a requisição HTTP: + +```js +noteService + .getAll() + .then(response => { + setNotes(response.data) + }) +``` + +O componente App usa apenas a propriedade response.data do objeto de resposta. + +Seria muito melhor de usar o módulo se, em vez de obter a resposta HTTP inteira, só obtivéssemos os dados da resposta. Então, o uso do módulo ficaria assim: + +```js +noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) + }) +``` + +Podemos fazer o que estamos planejando mudando o código no módulo da seguinte forma (o código atual contém um pouco de "copia e cola", mas vamos tolerar isso por enquanto): + +```js +import axios from 'axios' +const baseUrl = 'http://localhost:3001/notes' + +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} + +const create = newObject => { + const request = axios.post(baseUrl, newObject) + return request.then(response => response.data) +} + +const update = (id, newObject) => { + const request = axios.put(`${baseUrl}/${id}`, newObject) + return request.then(response => response.data) +} + +export default { + getAll: getAll, + create: create, + update: update +} +``` + +Não retornamos mais a promessa entregue diretamente pelo axios. Em vez disso, atribuímos a promessa à variável request (requisição) e chamamos o seu método then: + +```js +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} +``` + +A última linha em nossa função é simplesmente uma expressão mais compacta do mesmo código mostrado abaixo: + +```js +const getAll = () => { + const request = axios.get(baseUrl) + // highlight-start + return request.then(response => { + return response.data + }) + // highlight-end +} +``` + +A função modificada getAll ainda retorna uma promessa, já que o método then de uma promessa também [retorna uma promessa](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then). + +Depois de definir o parâmetro do método then para retornar diretamente response.data, conseguimos fazer com que a função getAll funcionasse da forma que desejávamos. Quando a requisição HTTP é bem-sucedida, a promessa retorna os dados enviados de volta na resposta do back-end. + +Temos que atualizar o componente App para funcionar com as mudanças feitas em nosso módulo. Temos que consertar as funções callback dadas como parâmetros para os métodos do objeto noteService para que elas usem os dados de resposta que foram diretamente retornados: + +```js +const App = () => { + // ... + + useEffect(() => { + noteService + .getAll() + // highlight-start + .then(initialNotes => { + setNotes(initialNotes) + // highlight-end + }) + }, []) + + const toggleImportanceOf = id => { + const note = notes.find(n => n.id === id) + const changedNote = { ...note, important: !note.important } + + noteService + .update(id, changedNote) + // highlight-start + .then(returnedNote => { + setNotes(notes.map(note => note.id !== id ? note : returnedNote)) + // highlight-end + }) + } + + const addNote = (event) => { + event.preventDefault() + const noteObject = { + content: newNote, + important: Math.random() > 0.5 + } + + noteService + .create(noteObject) + // highlight-start + .then(returnedNote => { + setNotes(notes.concat(returnedNote)) + // highlight-end + setNewNote('') + }) + } + + // ... +} +``` + +Tudo isso é bastante complicado, e tentar explicar pode deixar ainda mais difícil de entender. A internet está cheia de material sobre o tópico, como [este](https://javascript.info/promise-chaining). + +O livro "Async and performance" da série de livros [You do not know JS](https://github.com/getify/You-Dont-Know-JS/tree/1st-ed) [explica bem o tópico](https://github.com/getify/You-Dont-Know-JS/blob/1st-ed/async%20%26%20performance/ch3.md), mas é uma explicação de muitas páginas. + +Promessas são vitais para o desenvolvimento em JavaScript moderno, e é extremamente recomendável investir um tempo razoável para entendê-las. + +### Uma sintaxe mais limpa para definir Objetos Literais (Object Literals) + +O módulo que define os serviços relacionados às notas exporta atualmente um objeto com as propriedades getAll, create e update que são atribuídas a funções que gerenciam as notas. + +A definição do módulo era: + +```js +import axios from 'axios' +const baseUrl = 'http://localhost:3001/notes' + +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} + +const create = newObject => { + const request = axios.post(baseUrl, newObject) + return request.then(response => response.data) +} + +const update = (id, newObject) => { + const request = axios.put(`${baseUrl}/${id}`, newObject) + return request.then(response => response.data) +} + +export default { + getAll: getAll, + create: create, + update: update +} +``` + +O módulo exporta o seguinte objeto, mesmo que pareça um tanto peculiar: + +```js +{ + getAll: getAll, + create: create, + update: update +} +``` + +As etiquetas (labels) à esquerda do dois-pontos na definição do objeto são as chaves (keys) do objeto, enquanto as à direita são as variáveis (variables) que são definidas dentro do módulo. + +Como os nomes das chaves e das variáveis atribuídas são os mesmos, podemos escrever a definição do objeto com uma sintaxe mais compacta: + +```js +{ + getAll, + create, + update +} +``` + +Como resultado, a definição do módulo simplifica-se da seguinte forma: + +```js +import axios from 'axios' +const baseUrl = 'http://localhost:3001/notes' + +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} + +const create = newObject => { + const request = axios.post(baseUrl, newObject) + return request.then(response => response.data) +} + +const update = (id, newObject) => { + const request = axios.put(`${baseUrl}/${id}`, newObject) + return request.then(response => response.data) +} + +export default { getAll, create, update } // highlight-line +``` + +Ao definir o objeto usando esta notação mais curta, fazemos uso de uma [nova funcionalidade](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer#Property_definitions) que foi introduzida ao JavaScript por meio do ES6, permitindo uma maneira ligeiramente mais compacta de se definir objetos usando variáveis. + +Para demonstrar essa nova funcionalidade, consideremos uma situação em que temos os seguintes valores atribuídos às variáveis: + +```js +const name = 'Leevi' +const age = 0 +``` + +Em versões mais antigas de JavaScript, tínhamos que definir um objeto assim: + +```js +const person = { + name: name, + age: age, +} +``` + +No entanto, como tanto os campos de propriedades quanto os nomes de variáveis no objeto são os mesmos, basta escrever o seguinte, utilizando o padrão JavaScript ES6: + +```js +const person = { name, age } +``` + +O resultado é idêntico para ambas as expressões. Ambos criam um objeto com uma propriedade name com o valor Leevi e uma propriedade age com o valor 0. + +### Promessas e Erros + +Se a nossa aplicação permitisse que os usuários excluíssem notas, poderíamos acabar em uma situação em que um usuário tenta mudar a importância de uma nota que já foi excluída do sistema. + +Vamos simular essa situação fazendo com que a função getAll do serviço de notas retorne uma "nota de exemplo" ("'hardcoded' note ") que na verdade não existe no servidor back-end: + +```js +const getAll = () => { + const request = axios.get(baseUrl) + const nonExisting = { + id: 10000, + content: 'This note is not saved to server', + important: true, + } + return request.then(response => response.data.concat(nonExisting)) +} +``` + +Quando tentamos mudar a importância da nota, vemos no console a mensagem de erro abaixo, cujo conteúdo revela que o servidor back-end respondeu à nossa requisição HTTP PUT com um código de status 404 not found (não encontrado(a)). + +![erro 404 not found nas ferramentas do desenvolvedor](../../images/2/23e.png) + +A aplicação deve ser capaz de lidar com estes tipos de erro de forma elegante. Os usuários não serão capazes de dizer que ocorreu um erro a menos que estejam com o console aberto. A única maneira de o erro ser percebido na aplicação é a importância da nota não ser alternada quando se clica no botão. + +Mencionamos [anteriormente](/ptbr/part2/obtendo_dados_do_servidor#axios-e-promessas-promises) que uma promessa pode estar em um dos três estados diferentes. Quando uma requisição HTTP falha, a promessa associada é rejeitada. O nosso código atual não gerencia por nenhum meio essa rejeição. + +A rejeição de uma promessa é [gerenciada](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises) fornecendo ao método then uma segunda função callback, que é chamada na situação em que a promessa é rejeitada. + +A forma mais comum de adicionar um gerenciador para promessas rejeitadas é usar o método [catch](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch) (grosso modo, "pegar" ou "capturar"). + +Na prática, o gerenciador de erro para promessas rejeitadas é definido da seguinte forma: + +```js +axios + .get('http://example.com/probably_will_fail') + .then(response => { + console.log('success!') + }) + .catch(error => { + console.log('fail') + }) +``` + +Se a requisição falhar, o gerenciador de evento registrado com o método catch é chamado. + +O método catch é frequentemente utilizado colocando-o mais no final no encadeamento de promessas. + +Quando a nossa aplicação faz uma requisição HTTP, na verdade estamos criando um [encadeamento de promessa(s)](https://javascript.info/promise-chaining) (promise chain): + +```js +axios + .put(`${baseUrl}/${id}`, newObject) + .then(response => response.data) + .then(changedNote => { + // ... + }) +``` + +O método catch pode ser usado para definir uma função gerenciadora no final de um encadeamento de promessas, que é chamada/acionada uma vez que qualquer promessa no encadeamento lance uma exceção e a promessa se torne rejeitada. + +```js +axios + .put(`${baseUrl}/${id}`, newObject) + .then(response => response.data) + .then(changedNote => { + // ... + }) + .catch(error => { + console.log('fail') + }) +``` + +Vamos usar essa funcionalidade e registrar um gerenciador de erro no componente App: + +```js +const toggleImportanceOf = id => { + const note = notes.find(n => n.id === id) + const changedNote = { ...note, important: !note.important } + + noteService + .update(id, changedNote).then(returnedNote => { + setNotes(notes.map(note => note.id !== id ? note : returnedNote)) + }) + // highlight-start + .catch(error => { + alert( + `the note '${note.content}' was already deleted from server` + ) + setNotes(notes.filter(n => n.id !== id)) + }) + // highlight-end +} +``` + +A mensagem de erro é exibida ao usuário com a antiga e confiável caixa de diálogo [alert](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert) (alerta), e a nota excluída é filtrada do estado. + +A remoção de uma nota já excluída do estado da aplicação é feita com o método de array [filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter) (filtrar), que retorna um array novo com apenas os itens da lista para os quais a função passada como parâmetro retorna verdadeiro para: + +```js +notes.filter(n => n.id !== id) +``` + +Não é uma boa ideia usar o "alert" em aplicações React mais sérias. Em breve aprenderemos uma maneira mais avançada de exibir mensagens e notificações aos usuários. No entanto, há situações em que um método simples e testado como o alert pode funcionar como um ponto de partida. Uma maneira mais avançada sempre pode ser adicionada posteriormente, desde que haja tempo e energia disponíveis para isso. + +O código para o estado atual de nossa aplicação pode ser encontrado na branch part2-6 no [GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part2-6). + +### Juramento do Programador Full Stack + +Chegou novamente a hora dos exercícios. A complexidade de nossa aplicação está aumentando, já que além de cuidarmos dos componentes React no front-end, também temos um back-end que persiste os dados da aplicação. + +Para lidar com essa complexidade crescente, devemos estender o Juramento do Programador Web para o Juramento do Programador Full Stack, que nos lembrará de garantir com que a comunicação entre front e back-end aconteça como planejado. + +Então aqui está o juramento atualizado: + +Desenvolvimento Full Stack é algo extremamente difícil, e é por isso que eu usarei todos os meios possíveis para torná-lo mais fácil: + +- Eu manterei meu Console do navegador sempre aberto; +- Eu usarei a guia Rede das Ferramentas do Desenvolvedor do navegador para garantir que o front-end e o back-end estejam se comunicando da forma que eu planejei ; +- Eu ficarei de olho no estado do servidor para garantir que os dados enviados pelo front-end estejam sendo salvos da forma que eu planejei; +- Eu vou progredir aos poucos, passo a passo; +- Eu escreverei muitas instruções _console.log_ para ter certeza de que estou entendendo como o código se comporta e para me ajudar a identificar os erros; +- Se meu código não funcionar, não escreverei mais nenhuma linha no código. Em vez disso, começarei a excluir o código até que funcione ou retornarei ao estado em que tudo ainda estava funcionando; e +- Quando eu pedir ajuda no canal do Discord do curso ou em outro lugar, formularei minhas perguntas de forma adequada. Veja [aqui](/ptbr/part0/informacoes_gerais#como-pedir-ajuda-no-discord) como pedir ajuda. + +
    + +
    + +

    Exercícios 2.12 a 2.15

    + +

    2.12: The Phonebook — 7º passo

    + +Vamos retornar à nossa lista telefônica. + +No momento, os números adicionados à lista telefônica não são salvos em um servidor back-end. Corrija essa situação. + +

    2.13: The Phonebook — 8º passo

    + +Crie um módulo próprio para o código que gerencia a comunicação com o back-end, seguindo o exemplo mostrado anteriormente no conteúdo desta parte do curso. + +

    2.14: The Phonebook — 9º passo

    + +Faça com que os usuários possam excluir entradas de contato da lista telefônica. A exclusão pode ser feita por meio de um botão dedicado a pessoa na lista telefônica. Você pode confirmar a ação do usuário usando o método [window.confirm](https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm): + +![captura de tela do recurso "window.confirm" - 2.17](../../images/2/24e.png) + +O recurso associado a uma pessoa no back-end pode ser excluído fazendo uma requisição HTTP DELETE para a URL do recurso. Se estivermos excluindo, por exemplo, uma pessoa que tenha o id 2, teríamos que fazer uma requisição HTTP DELETE para a URL localhost:3001/persons/2. Nenhum dado é enviado com a requisição. + +Você pode fazer uma requisição HTTP DELETE com a biblioteca [axios](https://github.com/axios/axios) da mesma forma que fazemos todas as outras requisições. + +**Obs.:** Você não pode utilizar o nome delete para declarar uma variável, porque é uma palavra reservada em JavaScript. Por exemplo, não é possível fazer o seguinte: + +```js +// use algum outro nome para sua variável +const delete = (id) => { + // ... +} +``` + +

    2.15*: The Phonebook — 10º passo

    + +Por que há um asterisco na atividade? Entre [aqui](/ptbr/part0/informacoes_gerais#fazendo-o-curso) para saber o motivo. + +Altere a funcionalidade para que, caso um número seja adicionado a um usuário já existente, o novo número substitua o antigo. É recomendável usar o método HTTP PUT para atualizar o número de telefone. + +Se as informações da pessoa já estiverem na lista telefônica, a aplicação pedirá a confirmação do usuário: + +![captura de tela do alerta de confirmação - 2.18](../../images/teht/16e.png) + +
    diff --git a/src/content/2/ptbr/part2e.md b/src/content/2/ptbr/part2e.md new file mode 100644 index 00000000000..d3916069364 --- /dev/null +++ b/src/content/2/ptbr/part2e.md @@ -0,0 +1,645 @@ +--- +mainImage: ../../../images/part-2.svg +part: 2 +letter: e +lang: ptbr +--- + +
    + +A aparência atual da nossa aplicação está bastante modesta. No [exercício 0.2](/ptbr/part0/fundamentos_de_aplicacoes_web#exercicios-0-1-a-0-6), o objetivo era passar pelo tutorial [CSS da Mozilla](https://developer.mozilla.org/en-US/docs/Learn/Getting_started_with_the_web/CSS_basics). + +Vamos dar uma olhada em como podemos adicionar estilos a uma aplicação React. Existem várias maneiras diferentes de fazer isso e veremos os outros métodos mais tarde. Primeiro, adicionaremos o CSS à nossa aplicação da maneira antiga; em um único arquivo sem usar um [pré-processador CSS](https://developer.mozilla.org/en-US/docs/Glossary/CSS_preprocessor) (embora isso não seja inteiramente verdade, como aprenderemos mais tarde). + +Vamos criar um novo arquivo chamado index.css no diretório src e vamos adicioná-lo à aplicação importando-o no arquivo index.js: + +```js +import './index.css' +``` + +Vamos adicionar a seguinte regra CSS ao arquivo index.css: + +```css +h1 { + color: green; +} +``` + +As regras CSS consistem em seletores (selectors) e declarações (declarations). O seletor define a quais elementos a regra deve ser aplicada. O seletor acima é h1, que corresponderá a todas as tags de cabeçalho h1 em nossa aplicação. + +A declaração define a propriedade _color_ com o valor green (verde). + +Uma regra CSS pode conter um número arbitrário de propriedades. Vamos modificar a regra anterior para tornar o texto cursivo, definindo o estilo da fonte como italic (itálico): + +```css +h1 { + color: green; + font-style: italic; // highlight-line +} +``` + +Existem muitas maneiras de corresponder a elementos usando [diferentes tipos de seletores CSS](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors). + +Se quiséssemos direcionar, digamos, cada uma das notas com nossos estilos, poderíamos usar o seletor li, já que todas as notas estão envolvidas em tags li: + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' + : 'make important'; + + return ( +
  • + {note.content} + +
  • + ) +} +``` + +Vamos adicionar a seguinte regra à nossa folha de estilo (já que meu conhecimento em web design moderno elegante é próximo a zero, os estilos aqui adicionados não fazem muito sentido): + +```css +li { + color: grey; + padding-top: 3px; + font-size: 15px; +} +``` + +Usar tipos de elementos para definir regras CSS é um tanto problemático. Se nossa aplicação contiver outras tags li, a mesma regra de estilo também será aplicada a elas. + +Se quisermos aplicar nosso estilo especificamente às notas, a melhor opção é usar [seletores de classe](https://developer.mozilla.org/en-US/docs/Web/CSS/Class_selectors). + +Em HTML comum, as classes são definidas como o valor do atributo class: + +```html +
  • algum texto...
  • +``` + +Em React, temos que usar o atributo [className](https://reactjs.org/docs/dom-elements.html#classname) em vez do atributo class. Com isso em mente, façamos as seguintes alterações em nosso componente Note: + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' + : 'make important'; + + return ( +
  • // highlight-line + {note.content} + +
  • + ) +} +``` + +Os seletores de classe são definidos com a sintaxe _.className_: + +```css +.note { + color: grey; + padding-top: 5px; + font-size: 15px; +} +``` + +Se você adicionar outros elementos li à aplicação agora, eles não serão afetados pela regra de estilo acima. + +### Uma mensagem de erro aprimorada + +Anteriormente, implementamos a mensagem de erro que era exibida quando o usuário tentava alternar a importância de uma nota excluída com o método alert. Vamos implementar a mensagem de erro como seu próprio componente React. + +O componente é bastante simples: + +```js +const Notification = ({ message }) => { + if (message === null) { + return null + } + + return ( +
    + {message} +
    + ) +} +``` + +Se o valor da prop message for null, nada é renderizado na tela e, em outros casos, a mensagem é renderizada dentro de um elemento div. + +Vamos adicionar um novo pedaço de estado chamado errorMessage ao componente App. Vamos inicializá-lo com alguma mensagem de erro para que possamos testar imediatamente nosso componente: + +```js +const App = () => { + const [notes, setNotes] = useState([]) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + const [errorMessage, setErrorMessage] = useState('some error happened...') // highlight-line + + // ... + + return ( +
    +

    Notes

    + // highlight-line +
    + +
    + // ... +
    + ) +} +``` + +Vamos adicionar uma regra de estilo que sirva para uma mensagem de erro: + +```css +.error { + color: red; + background: lightgrey; + font-size: 20px; + border-style: solid; + border-radius: 5px; + padding: 10px; + margin-bottom: 10px; +} +``` + +Agora estamos prontos para adicionar a lógica para exibir a mensagem de erro. Vamos mudar a função toggleImportanceOf da seguinte maneira: + +```js + const toggleImportanceOf = id => { + const note = notes.find(n => n.id === id) + const changedNote = { ...note, important: !note.important } + + noteService + .update(id, changedNote).then(returnedNote => { + setNotes(notes.map(note => note.id !== id ? note : returnedNote)) + }) + .catch(error => { + // highlight-start + setErrorMessage( + `Note '${note.content}' was already removed from server` + ) + setTimeout(() => { + setErrorMessage(null) + }, 5000) + // highlight-end + setNotes(notes.filter(n => n.id !== id)) + }) + } +``` + +Quando o erro acontece, adicionamos uma mensagem de erro descritiva ao estado errorMessage. Ao mesmo tempo, iniciamos um temporizador, que definirá o estado errorMessage como null após 5 (cinco) segundos. + +O resultado fica assim: + +![captura de tela de erro: removido do servidor da aplicação](../../images/2/26e.png) + +O código para o estado atual da nossa aplicação pode ser encontrado na branch part2-7 no [GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part2-7). + +### Estilos inline + +React também possibilita escrever estilos diretamente no código com o chamados [estilos inline](https://reactjs.org/docs/dom-elements.html#style) (ou "estilos em linha"). + +A ideia por trás da definição de estilos inline é extremamente simples. É possível fornecer a qualquer componente ou elemento React um conjunto de propriedades CSS como um objeto JavaScript através do atributo [style](https://reactjs.org/docs/dom-elements.html#style) (estilo). + +As regras CSS são definidas de forma um tanto diferente em JavaScript se comparadas com as de arquivos CSS comuns. Digamos que quiséssemos dar a um elemento a cor verde e uma fonte itálica de 16 pixels de tamanho. Em CSS, ficaria assim: + +```css +{ + color: green; + font-style: italic; + font-size: 16px; +} +``` + +Mas como se trata de um objeto de estilo inline React, ficaria assim: + +```js +{ + color: 'green', + fontStyle: 'italic', + fontSize: 16 +} +``` + +Cada propriedade CSS é definida como uma propriedade separada do objeto JavaScript. Valores numéricos em pixels podem ser definidos com simples números inteiros. Uma das principais diferenças em comparação ao CSS comum é que propriedades CSS com hífen (kebab case) são escritas em camelCase. + +Em seguida, poderíamos adicionar um "bloco inferior" à nossa aplicação criando um componente Footer e definindo-o com os seguintes estilos inline: + +```js +// highlight-start +const Footer = () => { + const footerStyle = { + color: 'green', + fontStyle: 'italic', + fontSize: 16, + } + return ( +
    +
    + Note app, Department of Computer Science, University of Helsinki 2022 +
    + ) +} +// highlight-end + +const App = () => { + // ... + + return ( +
    +

    Notes

    + + + + // ... + +
    // highlight-line +
    + ) +} +``` + +Estilos inline possuem certas limitações. Por exemplo, não é possível usar as chamadas [pseudo-classes](https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes) diretamente neles. + +Estilos inline e algumas outras maneiras de adicionar estilos aos componentes React vão completamente contra a corrente das antigas convenções. Tradicionalmente, tem sido considerada a melhor prática separar completamente o CSS do conteúdo (HTML) e da funcionalidade (JavaScript). De acordo com essa antiga escola de pensamento, o objetivo era escrever o CSS, o HTML e o JavaScript em arquivos separados. + +A filosofia do React é, na verdade, o oposto disso. Como a separação de CSS, HTML e JavaScript em arquivos separados não parecia ter escalabilidade em aplicativos maiores, o React baseia a divisão do aplicativo ao longo das linhas de suas entidades funcionais lógicas. + +As unidades estruturais que compõem as entidades funcionais da aplicação são os componentes React. Um componente React define o HTML para estruturar o conteúdo, as funções JavaScript para determinar a funcionalidade e também o estilo do componente; tudo em um só lugar. Isso é para criar componentes individuais que sejam o mais independentes e reutilizáveis possível. + +O código da versão final da nossa aplicação pode ser encontrado na branch part2-8 no [GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part2-8). + +
    + +
    + +

    Exercícios 2.16 a 2.17

    + +

    2.16: The Phonebook — 11º passo

    + +Use como guia o exemplo da [mensagem de erro aprimorada](/ptbr/part2/adicionando_estilos_a_aplicacao_react#uma-mensagem-de-erro-aprimorada) da Parte 2 para exibir uma notificação que dure alguns segundos depois que uma operação bem-sucedida for executada (uma pessoa é adicionada ou um número é alterado): + +![captura de tela: 'adicionado com sucesso' em verde](../../images/2/27e.png) + +

    2.17*: The Phonebook — 12º passo

    + +Abra sua aplicação em dois navegadores. **Se você excluir uma pessoa no navegador 1** um pouco antes de tentar alterar o número de telefone da pessoa no navegador 2, você receberá a seguinte mensagem de erro: + +![mensagem de erro "404 not found" quando se altera a aplicação em múltiplos navegadores](../../images/2/29b.png) + +Corrija o problema de acordo com o exemplo mostrado em [promessas e erros](/ptbr/part2/alterando_dados_no_servidor#promessas-e-erros) na Parte 2. Modifique o exemplo para que uma mensagem seja mostrada ao usuário quando a operação for mal-sucedida. As mensagens exibidas para eventos bem e mal sucedidos devem ser diferentes: + +![mensagem de erro exibida na tela em vez do console - recurso complementar](../../images/2/28e.png) + +**Observe** que mesmo se você gerenciar (handle) a exceção, a mensagem de erro ainda é impressa no console. + +
    + +
    + +### Algumas observações importantes + +Há alguns exercícios mais desafiadores no final desta parte. Você pode pular os exercícios se eles forem muito complicados, pois nós voltaremos aos mesmos temas mais tarde; porém, vale a pena ler o conteúdo, de qualquer forma. + +Fizemos uma coisa em nossa aplicação que "mascara" uma fonte muito típica de erro. + +Definimos o estado _notes_ com um valor inicial de um array vazio: + +```js +const App = () => { + const [notes, setNotes] = useState([]) + + // ... +} +``` + +Esse é um valor inicial bem lógico, uma vez que as notas são um conjunto, isto é, há muitas notas que o estado irá armazenar. + +Se o estado estivesse salvando apenas "uma coisa", um valor inicial mais adequado seria _null_, indicando que não há nada no início do estado. Vamos ver o que acontece se usarmos esse valor inicial: + +```js +const App = () => { + const [notes, setNotes] = useState(null) // highlight-line + + // ... +} +``` + +A aplicação quebra: + +![console com erro de tipo: typerror cannot read properties of null](../../images/2/31a.png) + +A mensagem de erro fornece a razão e a localização do erro. O código que causou o problema é o seguinte: + +```js + // notesToShow gets the value of notes + const notesToShow = showAll + ? notes + : notes.filter(note => note.important) + + // ... + + {notesToShow.map(note => // highlight-line + + )} +``` + +A mensagem de erro é: + +``` +Cannot read properties of null (reading 'map') +``` + +A variável _notesToShow_ recebe primeiro o valor do estado _notes_ e depois o código tenta chamar o método _map_ em um objeto inexistente, ou seja, _null_. + +Qual é a razão disso? + +O hook de efeito (effect hook) utiliza a função _setNotes_ para definir que _notes_ terá as notas que o back-end está retornando: + +```js + useEffect(() => { + noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) // highlight-line + }) + }, []) +``` + +No entanto, o problema é que o efeito é executado somente após a primeira renderização. +E por conta de _notes_ ter o valor inicial _null_: + +```js +const App = () => { + const [notes, setNotes] = useState(null) // highlight-line + + // ... +``` + +o código a seguir é executado na primeira renderização: + +```js +notesToShow = notes + +// ... + +notesToShow.map(note => ...) +``` + +e isso faz com que a aplicação quebre, já que não podemos chamar o método _map_ no valor _null_. + +Não há erro quando definimos _notes_ para ser inicialmente um array vazio, já que é permitido chamar o método _map_ em um array vazio. + +Assim, a inicialização do estado "mascarou" o problema que é causado pelo fato de que os dados ainda não foram buscados no back-end. + +Outra maneira de contornar o problema é usar a renderização condicional e retornar um valor nulo se o estado do componente não estiver adequadamente inicializado: + +```js +const App = () => { + const [notes, setNotes] = useState(null) // highlight-line + // ... + + useEffect(() => { + noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) + }) + }, []) + + // do not render anything if notes is still null + // highlight-start + if (!notes) { + return null + } + // highlight-end + + // ... +} +``` + +Assim, nada é renderizado na primeira renderização. Quando as notas chegam do servidor, o efeito usa a função _setNotes_ que define o valor do estado _notes_. Isso faz com que o componente seja renderizado novamente e, na segunda renderização, as notas são exibidas na tela. + +O método baseado na renderização condicional é adequado em casos em que é impossível definir o estado para o qual a renderização inicial seja possível. + +Um outro detalhe que ainda precisamos examinar mais de perto é o segundo parâmetro de useEffect: + +```js + useEffect(() => { + noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) + }) + }, []) // highlight-line +``` + +O segundo parâmetro de useEffect é utilizado para [especificar com que frequência o efeito é executado](https://reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect). +O princípio é que o efeito é sempre executado após a primeira renderização do componente e quando o valor do segundo parâmetro muda. + +Se o segundo parâmetro for um array vazio [], seu conteúdo nunca muda e o efeito é executado somente após a primeira renderização do componente. Isso é exatamente o que queremos quando estamos inicializando o estado da aplicação a partir do servidor. + +No entanto, há situações em que queremos executar o efeito em outros momentos, por exemplo, quando o estado do componente muda de uma maneira específica. + +Considere a aplicação simples a seguir que consulta as taxas de câmbio da [Exchange rate API](https://www.exchangerate-api.com/): + +```js +import { useState, useEffect } from 'react' +import axios from 'axios' + +const App = () => { + const [value, setValue] = useState('') + const [rates, setRates] = useState({}) + const [currency, setCurrency] = useState(null) + + useEffect(() => { + console.log('effect run, currency is now', currency) + + // skip if currency is not defined + if (currency) { + console.log('fetching exchange rates...') + axios + .get(`https://open.er-api.com/v6/latest/${currency}`) + .then(response => { + setRates(response.data.rates) + }) + } + }, [currency]) + + const handleChange = (event) => { + setValue(event.target.value) + } + + const onSearch = (event) => { + event.preventDefault() + setCurrency(value) + } + + return ( +
    +
    + currency: + +
    +
    +        {JSON.stringify(rates, null, 2)}
    +      
    +
    + ) +} +``` + +A interface de usuário da aplicação possui um formulário, onde no campo de entrada é escrito o nome da moeda (currency) desejada. Se a moeda existir, a aplicação renderiza as taxas de câmbio da moeda inserida para outras moedas: + +![Navegador exibindo taxas de câmbio com "eur" digitado e console dizendo "buscando as taxas de câmbio..."](../../images/2/32new.png) + +Quando o botão é clicado, a aplicação pega o nome da moeda inserido no formulário e faz o set no estado _currency_. + +Quando _currency_ recebe um novo valor, a aplicação busca suas taxas de câmbio da API na função de efeito: + +```js +const App = () => { + // ... + const [currency, setCurrency] = useState(null) + + useEffect(() => { + console.log('effect run, currency is now', currency) + + // skip if currency is not defined + if (currency) { + console.log('fetching exchange rates...') + axios + .get(`https://open.er-api.com/v6/latest/${currency}`) + .then(response => { + setRates(response.data.rates) + }) + } + }, [currency]) // highlight-line + // ... +} +``` + +O hook useEffect agora tem _[currency]_ como segundo parâmetro. A função de efeito é, portanto, executada após a primeira renderização e sempre depois que a tabela que é definida no segundo parâmetro _[currency]_ muda. Ou seja, quando o estado _currency_ recebe um novo valor, o conteúdo da tabela muda e a função de efeito é executada. + +O efeito tem esta condição: + +```js +if (currency) { + // exchange rates are fetched +} +``` + +que impede a requisição das taxas de câmbio logo após a primeira renderização, quando a variável _currency_ ainda tem o valor inicial, ou seja, um valor nulo. + +Portanto, se o usuário escrever, por exemplo, eur no campo de pesquisa, a aplicação usa a biblioteca Axios para fazer uma requisição HTTP GET ao endereço https://open.er-api.com/v6/latest/eur e armazena a resposta no estado _rates_. + +Quando o usuário inserir outro valor no campo de pesquisa, por exemplo, usd, a função de efeito é executada novamente e as taxas de câmbio da nova moeda são requisitadas da API. + +A forma apresentada aqui para fazer requisições à API pode parecer um pouco estranha. +Esta aplicação em específico poderia ter sido completamente construída sem a necessidade de usar o hook useEffect, por meio de requisições feitas à API diretamente na função de gerência de envio do formulário: + +```js + const onSearch = (event) => { + event.preventDefault() + axios + .get(`https://open.er-api.com/v6/latest/${value}`) + .then(response => { + setRates(response.data.rates) + }) + } +``` + +No entanto, existem situações em que essa técnica não funcionaria. Por exemplo, é possível que você encontre uma situação dessas no exercício 2.20, onde o uso do hook useEffect possa fornecer uma solução. Observe que isso depende muito da abordagem selecionada; por exemplo, a solução do modelo não usa esse truque. + +
    + +
    + +

    Exercícios 2.18 a 2.20

    + +

    2.18*: Data for countries — 1º passo

    + +A API [https://restcountries.com](https://restcountries.com) fornece dados de diferentes países em um formato legível por máquina (machine-readable format), uma chamada API REST. + +**Nota dos tradutores:** A API recebe consultas somente em inglês. + +Crie uma aplicação onde se possa ver os dados de vários países. A aplicação vai provavelmente obter os dados do endpoint [all](https://restcountries.com/v3.1/all). + +Se o serviço não estiver disponível, você pode usar o serviço alternativo em https://studies.cs.helsinki.fi/restcountries/ + +A interface de usuário é muito simples. O país a ser exibido deve ser encontrado através de uma consulta em um campo de pesquisa. + +Se houver muitos países (mais de 10) que correspondam à consulta, é solicitado ao usuário que seja mais específico na consulta: + +![captura de tela com a resposta 'too many matches'](../../images/2/19b1.png) + +Se houver dez ou menos países, porém mais de um, todos os países que correspondem à consulta são exibidos: + +![captura de tela dos países correspondentes em uma lista](../../images/2/19b2.png) + +Quando houver apenas um país que corresponda à consulta, os dados básicos do país (capital e área, por exemplo), sua bandeira e os idiomas falados no país são exibidos: + +![captura de tela da bandeira e dos atributos adicionais](../../images/2/19c3.png) + +**Obs.:**: Já é suficiente que sua aplicação funcione para a maioria dos países. Alguns países, como o Sudan (Sudão), podem ser difíceis de ajustar, já que o nome do país faz parte do nome de outro país, South Sudan (Sudão do Sul). Você não precisa se preocupar com esses casos extremos. + +**AVISO**: "create-react-app" transformará automaticamente seu projeto em um repositório git, a menos que você crie sua aplicação dentro de um repositório git já existente.**Você muito provavelmente não quer que cada um de seus projetos seja um repositório separado**, então basta executar o comando _rm -rf .git_ na raiz de sua aplicação para aplicar as modificações. + +

    2.19*: Data for countries — 2º passo

    + +**Ainda há muito o que fazer nesta parte, então não fique preso neste exercício!** + +Melhore a aplicação do exercício anterior de modo que quando os nomes de vários países são exibidos na página, haja um botão ao lado do nome do país que, ao ser clicado, exiba as informações desse país: + +![funcionalidade atrelada que exibe botões para cada país](../../images/2/19b4.png) + +Neste exercício, é suficiente que sua aplicação funcione para a maioria dos países. Países cujo nome aparece no nome de outro país, como o Sudão, podem ser ignorados. + +

    2.20*: Data for countries — 3º passo

    + +**Ainda há muito o que fazer nesta parte, então não fique preso neste exercício!** + +Adicione à funcionalidade que exibe os dados de um único país o relatório meteorológico para a capital desse país. Existem dezenas de provedores de dados meteorológicos. Uma API sugerida é a [https://openweathermap.org](https://openweathermap.org). Observe que pode levar alguns minutos até que a chave gerada da API seja validada. + +![funcionalidade adicionada que exibe os dados meteorológicos](../../images/2/19x.png) + +Se você usar o Open Weather map, a descrição de como obter os ícones climáticos encontra-se [aqui](https://openweathermap.org/weather-conditions#Icon-list). + +**Obs.::** Em alguns navegadores (como o Firefox), a API escolhida pode enviar uma resposta de erro, o que indica que a criptografia HTTPS não é suportada, mesmo que a URL da requisição comece com _http://_. Esse problema pode ser corrigido concluindo o exercício usando o Chrome. + +**Obs.::** Quase todos os serviços meteorológicos exigem que você use uma chave de API. Não salve a chave de API no controle de versão (Git)! Nem programe usando a chave de API em seu código-fonte. Em vez disso, use uma [variável de ambiente](https://create-react-app.dev/docs/adding-custom-environment-variables/) (environment variable) para salvar a chave. + +Supondo que a chave de API seja t0p53cr3t4p1k3yv4lu3, quando a aplicação é iniciada desta forma: + +```bash +export REACT_APP_API_KEY=t0p53cr3t4p1k3yv4lu3 && npm start // Para o Bash do Linux/macOS +($env:REACT_APP_API_KEY="t0p53cr3t4p1k3yv4lu3") -and (npm start) // Para o PowerShell do Windows +set "REACT_APP_API_KEY=t0p53cr3t4p1k3yv4lu3" && npm start // Para o cmd.exe do Windows +``` + +é possível acessar o valor da chave através do objeto _process.env_: + +```js +const api_key = process.env.REACT_APP_API_KEY +// variable api_key has now the value set in startup +``` + +Observe que, se você criou a aplicação usando _npx create-react-app ..._ e deseja usar um nome diferente para sua variável de ambiente, o nome da variável de ambiente ainda deve começar com *REACT\_APP_*. Também é possível usar um arquivo `.env` em vez de defini-la na linha de comando todas a vezes, criando um arquivo chamado '.env' na raiz do projeto e adicionando o seguinte: + +``` +# .env + +REACT_APP_API_KEY=t0p53cr3t4p1k3yv4lu3 +``` + +Observe que você precisará reiniciar o servidor para aplicar as alterações. + +Este foi o último exercício para esta parte do curso, e é hora de enviar seu código para o GitHub e marcar todos os seus exercícios concluídos na guia "my submissions" do [sistema de envio de exercícios](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    diff --git a/src/content/2/zh/part2.md b/src/content/2/zh/part2.md index d4297c0ea45..340de5475f5 100644 --- a/src/content/2/zh/part2.md +++ b/src/content/2/zh/part2.md @@ -6,9 +6,12 @@ lang: zh
    + +让我们继续介绍React。首先,我们将看看如何在屏幕上渲染一个数据集合,比如一个名字的列表。在这之后,我们将探究用户如何使用HTML表单向React应用提交数据。接下来,我们的重点将转移到查看浏览器中的JavaScript代码,看其如何获取和处理存储在远程后端服务器中的数据。最后,我们将快速浏览一下向React应用添加CSS样式的几种简单方法。 - + +原文更新于2025年2月16日 + +- Node更新至版本22.3.0 -这一章让我们继续介绍React。 首先,我们将了解如何将数据集合(如一个names列表)渲染到屏幕上。 然后,我们将检查用户如何使用 HTML 表单向 React 应用提交数据。 再然后,我们的重点将向后端转移,关注浏览器中的 JavaScript 代码如何获取和处理存储(远程)后端服务器中的数据。 最后,我们将快速浏览一下向 React 应用中添加 CSS 样式的几种简单方法。
    - diff --git a/src/content/2/zh/part2a.md b/src/content/2/zh/part2a.md index 04ca9a9408d..8a6b50401d1 100644 --- a/src/content/2/zh/part2a.md +++ b/src/content/2/zh/part2a.md @@ -6,65 +6,60 @@ lang: zh ---
    - - - -在新的话题开始之前,让我们回顾一下去年的课程中认为是难点的一些话题。 + +在开始新的部分之前,让我们回顾一下去年显示出的一些难点。 ### console.log -***What's the difference between an experienced JavaScript programmer and a rookie? The experienced one uses console.log 10-100 times more.*** - -一个JavaScript 老鸟和菜鸟有什么区别? 老鸟使用 console.log的次数是菜鸟的数十倍甚至数百倍。 - + +***一个有经验的JavaScript程序员和一个菜鸟之间有什么区别?有经验的老鸟使用console.log的次数要多10~100倍***。 -矛盾的是,实际上,菜鸟比老鸟更需要 console.log (或任何其他调试方法)。 + +矛盾的是,看样子确实如此,即便一个菜鸟程序员应该比一个有经验的程序员更需要console.log(或任何调试方法)。 - -当某些事情不能正常工作时,不要只是猜测错误,而应记录或使用其他调试方法。 + +当某些东西运行不了时,不要只是猜哪里错了。而要记录或使用其他一些调试方法。 - -注意:当你使用_console.log_命令进行调试时,不要用Java的方式,将所有东西用'+'连在一起。即不要这么写: + +**注意** 正如第1章节所说的,当你使用_console.log_命令进行调试时,不要用“Java式”的加号来连接要打印的东西。不要写: ```js console.log('props value is' + props) ``` - -而应该用逗号把要打印的东西分开: + +而要用逗号分开要打印的东西: ```js console.log('props value is', props) ``` - -如果你把一个对象和一个字符串(用加号)连接起来,然后把它记录到控制台上(就像上面第一个例子那样) ,结果将是相当没有用的: + +如果你把一个对象和一个字符串用加号连接起来并记录到控制台(比如我们的第一个例子),结果将完全无用: ```js props value is [Object object] ``` - - -而当您将对象用逗号分隔,将不同参数传递给 console.log 时,就像在上面的第二个例子中一样,对象的内容将作为有意义的字符串打印到开发者控制台中。 - - -如果有必要,请阅读更多关于React 应用调试的内容[here](/zh/part1/深入_react_应用调试#debugging-react-applications)。 + +相反,如果你把对象用逗号分开,作为不同的参数传递给_console.log_,比如我们上面的第二个例子,对象的内容会被打印到开发者控制台,成为可检查的字符串。 + +必要时,请阅读更多关于[调试React应用](/zh/part1/复杂状态,调试_react应用#调试-react应用)的内容。 -### Protip: Visual Studio Code snippets -【高级技巧: Visual Studio Code 的代码片段】 - + +### 专业提示:Visual Studio Code代码片段 -使用 Visual studio code能够很容易创建“代码片段(snippets)” ,即快速生成常用代码块的快捷方式,很像 Netbeans 中的“ sout”。 + +Visual Studio Code里可以很方便地创建“Snippets”(代码片段),即快速生成常用的重复使用的代码片段,很像Netbeans中的“sout”。 - -创建代码片段的说明可以在这里找到 [here](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_creating-your-own-snippets).。 + +创建代码片段的方法可以看[这里](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_creating-your-own-snippets)。 - -有用的、现成的代码片段也可以在 VS 代码插件中找到,例如[这里](https://marketplace.visualstudio.com/items?itemName=xabikos.ReactSnippets). + +有用的、现成的代码片段也可以在[VS Code应用商店](https://marketplace.visualstudio.com/items?itemName=dsznajder.es7-react-js-snippets)里作为VS Code插件找到。 - -最重要的片段是用于 console.log() 命令的片段,例如clog: + +最重要的代码片段是用于console.log()命令的片段,比如用clog。它可以这么创建: ```js { @@ -78,127 +73,126 @@ props value is [Object object] } ``` -### JavaScript Arrays -【JavaScript 数组】 - + +使用_console.log()_来调试你的代码非常常见,因此Visual Studio Code内置了这个代码片段。要使用它,输入_log_并按tab来自动完成。在[VS Code应用商店](https://marketplace.visualstudio.com/search?term=console.log&target=VSCode&category=All%20categories&sortBy=Relevance)中可以找到为_console.log()_代码片段提供更多功能的的扩展。 -从现在开始,我们将一直使用 JavaScript [数组](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array)的函数式编程方法,比如 _find_, _filter_, 和 _map_。 它们和 Java 8中的streams 一样遵循一般原则,这些原则在过去几年里被用在大学计算机科学系的 Ohjelmoinnin perusteet 和 Ohjelmoinnin jatkokurssi 课程,以及 MOOC 编程中。 + +### JavaScript数组 - + +从现在开始,我们将一直使用JavaScript[数组](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array)的函数式编程方法,例如_find_、_filter_和_map_。 -如果使用数组的函数式编程对你来说感觉很陌生,那么至少可以看看 YouTube 视频系列的前三部分 [Functional Programming in JavaScript](https://www.youtube.com/playlist?list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84) + +如果你对用函数式编程操作数组感到陌生,那么值得看看YouTube视频系列[Functional Programming in JavaScript](https://www.youtube.com/playlist?list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84)的至少前三部分。 -- [高阶函数](https://www.youtube.com/watch?v=BMUiFMZr7vk&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84) +- [Higher-order functions](https://www.youtube.com/watch?v=BMUiFMZr7vk&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84) - [Map](https://www.youtube.com/watch?v=bCqtb-Z5YGQ&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84&index=2) -- [Reduce 基础](https://www.youtube.com/watch?v=Wl98eZpkp-c&t=31s) +- [Reduce basics](https://www.youtube.com/watch?v=Wl98eZpkp-c&t=31s) - + +### 重提事件处理函数 -### Event handlers revisited -【事件处理复习】 - -基于去年的课程,事件处理证明是一个难点内容。 - + +去年的课程显示事件处理有些难度。 -如果你觉得自己关于这个议题的知识需要复习一下,那么应该阅读上一章节结尾的复习章节 [事件处理复习](/zh/part1/深入_react_应用调试#event-handling-revisited)。 + +如果你想复习一下这个主题的知识的话,那么值得读一读上一章节最后的复习章节[重提事件处理函数](/zh/part1/复杂状态,调试_react应用#重提事件处理函数)。 - -将事件处理传递给App 组件的子组件引发了一些问题。 关于这个议题的一个小复习[在这里](/zh/part1/深入_react_应用调试#passing-event-handlers-to-child-components)。 + +还有一些问题是关于将事件处理程序传递给App组件的子组件的。关于这个话题的小复习可以参考[这里](/zh/part1/复杂状态,调试_react应用#向子组件传递事件处理函数)。 -### Rendering collections -【渲染集合】 - + +### 渲染集合 -现在,我们将在 React 中为类似于 [第0章](/zh/part0)中的示例应用,编写“前端”或叫浏览器端的应用逻辑。 + +现在我们将用React做一个类似[第0章节](/zh/part0)中示例程序的前端,或者叫用户界面(用户在浏览器中所看到的部分)。 -注意:为了统一翻译上下文,从现在开始,我将按照如下约定翻译。 + +让我们从下面开始(文件App.js): + +```js +const App = (props) => { + const { notes } = props + + return ( +
    +

    Notes

    +
      +
    • {notes[0].content}
    • +
    • {notes[1].content}
    • +
    • {notes[2].content}
    • +
    +
    + ) +} -- Note 应用实际上是在创建一个和提醒、便笺相关的应用,因此以下的Note均翻译为便笺。 +export default App +``` - -让我们从如下代码开始: + +文件main.js如下所示: ```js -import React from 'react' -import ReactDOM from 'react-dom' +import ReactDOM from 'react-dom/client' +import App from './App' const notes = [ { id: 1, content: 'HTML is easy', - date: '2019-05-30T17:30:31.098Z', important: true }, { id: 2, - content: 'Browser can execute only Javascript', - date: '2019-05-30T18:39:34.091Z', + content: 'Browser can execute only JavaScript', important: false }, { id: 3, content: 'GET and POST are the most important methods of HTTP protocol', - date: '2019-05-30T19:20:14.298Z', important: true } ] -const App = (props) => { - const { notes } = props - - return ( -
    -

    Notes

    -
      -
    • {notes[0].content}
    • -
    • {notes[1].content}
    • -
    • {notes[2].content}
    • -
    -
    - ) -} - -ReactDOM.render( - , - document.getElementById('root') +ReactDOM.createRoot(document.getElementById('root')).render( + ) ``` + +每条笔记都包含它的文本内容、一个_布尔_值来标记该笔记是否重要,还有一个独一无二的id。 + +上面的例子之所以有效,是因为数组中正好有三条笔记。 - -每个便笺都包含其文本内容、时间戳以及一个布尔值,用于标记该便笺是否重要,便笺还包含一个惟一的id。 - - -由于数组中仅有三个便笺,因此代码可以运行。 - - -也就是可以通过引用一个硬编码的索引号来访问数组中的对象来渲染单个便笺: + +每条笔记是通过硬编码索引号访问数组中的对象来渲染的。 ```js -
  • {note[1].content}
  • +
  • {notes[1].content}
  • ``` - -数组下标这种方式当然是无法通用的。 可以使用 [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) 函数从数组对象生成 React-元素,使解决方案变得更通用。 + +这当然不切实际。我们可以通过使用[map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map)函数从数组对象生成React元素来改进这一点。 ```js notes.map(note =>
  • {note.content}
  • ) ``` - -其结果是一个 li 元素的数组。 + +结果是一个li元素的数组。 ```js [
  • HTML is easy
  • , -
  • Browser can execute only Javascript
  • , +
  • Browser can execute only JavaScript
  • ,
  • GET and POST are the most important methods of HTTP protocol
  • , ] ``` - -然后可以把这些li元素放在ul 标签中: + + +然后可以将其放在ul标签内: ```js const App = (props) => { @@ -211,17 +205,17 @@ const App = (props) => {
      {notes.map(note =>
    • {note.content}
    • )}
    -// highlight-end +// highlight-end
    ) } ``` - -由于生成li 标签的代码是 JavaScript,所以就要像所有其他 JavaScript 代码一样,在 JSX 模板中使用花括号来包装它。 + +因为生成li标签的代码是JavaScript,必须将其包裹在JSX模板的大括号内,和其他所有JavaScript代码一样。 - -我们还会利用多行分隔箭头函数的定义,来提高代码的可读性: + +我们再将箭头函数的声明分成多行来使代码更易读: ```js const App = (props) => { @@ -231,12 +225,12 @@ const App = (props) => {

    Notes

      - {notes.map(note => + {notes.map(note => // highlight-start
    • {note.content}
    • - // highlight-end + // highlight-end )}
    @@ -244,19 +238,19 @@ const App = (props) => { } ``` -### Key-attribute -【Key-属性】 - -尽管该应用似乎运行良好,但在控制台上有一个烦人的警告: + +### key属性 -![](../../images/2/1a.png) + +尽管应用似乎能运行,但控制台有一个讨厌的警告: - +![](../../images/2/1a.png) -正如错误消息中的链接 [page](https://reactjs.org/docs/lists-and-keys.html#keys) 所说明的,列表项,即 map 方法生成的每个元素,都必须有一个唯一的键值: 一个名为key 的属性。 + +正如错误信息中链接的[React页面](https://react.dev/learn/rendering-lists#keeping-list-items-in-order-with-key)所提示的;列表项,也就是_map_方法生成的元素,必须都有一个独一无二的键:一个叫做key的属性。 - -让我们添加上key: + +让我们来添加键: ```js const App = (props) => { @@ -266,12 +260,10 @@ const App = (props) => {

    Notes

      - {notes.map(note => - // highlight-start -
    • + {notes.map(note => +
    • // highlight-line {note.content}
    • - // highlight-end )}
    @@ -279,47 +271,47 @@ const App = (props) => { } ``` - -错误就消失了。 + +然后错误信息就消失了。 - -React 使用数组中对象的key属性来确定组件在重新渲染时,如何更新组件生成的视图。 更多的说明在[这里](https://reactjs.org/docs/reconciliation.html#recursing-on-children)。 + +React使用数组中对象的key属性来决定如何在组件重新渲染时更新该组件生成的视图。更多内容可以查看[React文档](https://react.dev/learn/preserving-and-resetting-state#option-2-resetting-state-with-a-key)。 -### Map - -理解数组中[map](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/array/map)方法的工作原理对于本课程的后面的部分是至关重要的。 + +### map - -应用包含一个称为 notes 的数组 + +了解数组的[`map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map)方法是如何工作的,对剩下的课程至关重要。 + + +应用包含一个名为_notes_的数组: ```js const notes = [ { id: 1, content: 'HTML is easy', - date: '2019-05-30T17:30:31.098Z', important: true }, { id: 2, - content: 'Browser can execute only Javascript', - date: '2019-05-30T18:39:34.091Z', + content: 'Browser can execute only JavaScript', important: false }, { id: 3, content: 'GET and POST are the most important methods of HTTP protocol', - date: '2019-05-30T19:20:14.298Z', important: true } ] ``` - -让我们停一下,看看 _map_ 是如何工作的。 + +让我们停下来研究一下_map_是如何工作的。 + - -如果下面的代码被添加到,比如说,文件的结尾: + +如果下列代码被添加到比方说文件结尾的地方: ```js const result = notes.map(note => note.id) @@ -327,20 +319,19 @@ console.log(result) ``` -控制台会打印出[1, 2, 3]。 +[1, 2, 3]会被打印到控制台。 + +_map_总是创建一个新数组,其中的元素是通过原数组的元素映射(mapping)创建的:使用_map_方法参数的函数。 - - _map_ 总是会创建一个新数组,其元素是从原始数组的元素通过mapping映射创建的,映射的逻辑是使用作为 _map_ 方法传递进去的函数。 - - -这个函数是 + +而这个函数就是 ```js note => note.id ``` - -这是一个以紧凑形式编写的箭头函数。完整形式如下: + +这是一个紧凑形式的箭头函数。完整的形式应该是: ```js (note) => { @@ -348,61 +339,66 @@ note => note.id } ``` - -该函数获取一个 note 对象作为参数,然后返回id 字段的值。 + +该函数以一个笔记对象作为参数,并返回id字段的值。 - -如果将命令改为: + +把命令改成: ```js const result = notes.map(note => note.content) ``` - -结果是一个包含便笺内容的数组。 + +会得到一个包含笔记内容的数组。 - -这已经非常接近我们使用的React代码: + +这已经非常接近我们使用的React代码了: ```js notes.map(note => -
  • {note.content}
  • +
  • + {note.content} +
  • ) ``` - -它生成一个li 标签,其中包含每个便笺对象的便笺内容。 + +它生成每个笔记对象的li标签,包含每个笔记对象的内容。 + + - -由于函数参数的 _map_ 方法 +因为传递给_map_方法的函数参数—— ```js note =>
  • {note.content}
  • ``` -is used to create view elements, the value of the variable must be rendered inside of curly braces. Try to see what happens if the braces are removed. -用于创建视图元素,因此变量的值必须在花括号内渲染。 可以尝试如果去掉花括号会发生什么。 - -一开始使用花括号会让你头疼,但是你很快就会习惯的。 因为来自 React 的图形反馈是即时的。 + + ——是用来创建视图元素的,变量的值必须在大括号内渲染。试试如果去掉大括号会发生什么。 + + +使用大括号一开始会比较痛苦,但你很快就会习惯。React的视觉反馈是即时的。 + + +### 禁止事项:使用数组索引作为键 -### Anti-pattern: array indexes as keys -【反模式: 将数组的索引作为键】 - -通过使用数组的索引作为键,我们可以使控制台上的错误消息消失。可以通过向 map-方法 的回调函数传递的第二个参数来获取索引: + +要使控制台中的错误信息消失,我们还可以通过使用数组索引作为键的方式。索引可以通过向_map_方法的回调函数传递第二个参数获取: ```js notes.map((note, i) => ...) ``` - -当这样调用时,_i_ 根据便笺所在数组中的位置,分配到了索引值。 + +当像这样调用时,_i_会被赋值笔记在数组中所在位置的索引值。 - -因此,用于定义行生成而不产生错误的一种方法是: + +因此,这么定义生成的各行也不会出错: ```js
      - {notes.map((note, i) => + {notes.map((note, i) =>
    • {note.content}
    • @@ -410,15 +406,17 @@ notes.map((note, i) => ...)
    ``` - -然而,这是**不推荐的**,因为可能导致意想不到的问题,即使它似乎能正常工作。 - -更多内容请点击 [这里](https://medium.com/@robinpokorny/index-as-a-key-is-an-anti-pattern-e0349aece318)。 + +然而,**不建议这么做**,这会产生意想不到的问题,即使它看起来运行得很好。 -### Refactoring modules -【重构模块】 - -让我们把代码整理一下。 我们只对props的字段 _notes_ 属性感兴趣,所以让我们直接使用[解构](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment): + +阅读[这篇文章](https://robinpokorny.com/blog/index-as-a-key-is-an-anti-pattern/)了解更多。 + + +### 重构模块 + + +让我们把代码整理一下。我们只关心props的_notes_字段,所以让我们直接使用[解构](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment)来获取它: ```js const App = ({ notes }) => { //highlight-line @@ -426,7 +424,7 @@ const App = ({ notes }) => { //highlight-line

    Notes

      - {notes.map(note => + {notes.map(note =>
    • {note.content}
    • @@ -437,11 +435,11 @@ const App = ({ notes }) => { //highlight-line } ``` - -如果您忘记了解构的含义以及它是如何工作的,请复习 [这里](/zh/part1/组件状态,事件处理#destructuring) + +如果你忘记了解构是什么意思以及它是如何工作的,请复习一下[解构部分](/zh/part1/组件状态,事件处理/#解构)。 - -我们将单独显示一个便笺到它自己的Note组件: + +我们将显示一条笔记的功能分离到它自己的组件Note: ```js // highlight-start @@ -458,7 +456,7 @@ const App = ({ notes }) => {

      Notes

        // highlight-start - {notes.map(note => + {notes.map(note => )} // highlight-end @@ -468,59 +466,47 @@ const App = ({ notes }) => { } ``` - -注意,现在必须为Note 组件定义key 属性,而不是像前面那样为li 标签定义key 属性。 + +注意现在Note组件必须定义key属性,而不是像之前的li标签那样可定义可不定义。 - -可以在单个文件中编写整个 React 应用。 虽然实践中很少这么用。 通常的做法是将每个组件在其自己的文件中,声明为一个ES6-模块。 + +可以将整个React应用写在一个文件中。虽然这当然不切实际。通常的做法是将每个组件在自己的文件中声明为ES6模块。 - -我们一直在使用模块。比如文件的前几行: + +我们实际上一直都在使用模块。文件main.jsx的前几行: ```js -import React from 'react' -import ReactDOM from 'react-dom' +import ReactDOM from "react-dom/client" +import App from "./App" ``` - -为了让它们能够在代码中使用,就[imports](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 了两个模块: react 模块被放入一个名为 React 的变量中, react-dom 模块放到了 ReactDOM 变量中。 - - -让我们将我们的Note 组件移动到它自己的模块中。 + +[导入(import)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import)了两个模块来在该文件中使用。模块react-dom/client被放入变量_ReactDOM_,而定义应用主要组件的模块被放入变量_App_。 - -在较小型的应用中,组件通常放在一个名为components 的目录中,而这个components目录又放在src 目录中。 约定是:按照组件的名称来命名文件。 + +让我们把我们的Note组件移到它自己的模块中。 - -现在,我们将为应用创建一个名为components 的目录,并在其中放置一个名为Note.js 的文件。 + +在小型应用中,组件通常被放在src目录中的一个叫做components的目录中。一般用组件的名字来命名文件。 - - Note.js 文件的内容如下: + +现在,我们将为我们的应用创建一个名为components的目录,并在其中放置一个名为Note.jsx的文件。文件的内容如下: ```js -import React from 'react' - const Note = ({ note }) => { - return ( -
      • {note.content}
      • - ) + return
      • {note.content}
      • } export default Note ``` - -由于这是一个 React-组件,因此我们必须导入 React。 + +模块的最后一行[导出(export)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export)了声明的模块,即变量Note。 - -模块的最后一行 [exports](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export) ,是在声明模块,即变量Note。 - - -现在使用这个组件的文件,即index.js,可以 [import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 这个模块了: + +现在使用该组件的文件——App.jsx——就可以[导入(import)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 该模块了: ```js -import React from 'react' -import ReactDOM from 'react-dom' import Note from './components/Note' // highlight-line const App = ({ notes }) => { @@ -528,103 +514,54 @@ const App = ({ notes }) => { } ``` - -模块导出的组件现在可以在变量Note 中使用了,就像之前一样。 + +模块导出的组件现在通过变量Note使用,就像之前那样。 - -注意,当导入我们自己的组件时,它们的位置必须给出导入文件相对路径: + +注意,当导入我们自己的组件时,必须给出它们与导入文件的相对位置: ```js './components/Note' ``` - -开头的句点指的是当前工作目录,因此模块的位置是当前components 的子目录中的一个名为Note.js 的文件。 文件扩展名(_.js_)可以省略。 + +开头的点号——.——指的是当前目录,所以模块的位置是当前目录下components子目录中叫Note.jsx的文件。文件扩展名_.jsx_可以省略。 - -App也是一个组件,所以让我们在它自己的模块中声明它。 因为它是应用的根组件,所以我们将它放在 src 目录中。 文件内容如下: + +除了使组件声明分离到自己的文件中,模块还有很多其他的用途。我们将在本课程的后面再学习它们。 -```js -import React from 'react' -import Note from './components/Note' + +该应用当前的代码可以在[GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-1)上找到。 -const App = ({ notes }) => { - return ( -
        -

        Notes

        -
          - {notes.map((note) => - - )} -
        -
        - ) -} - - -export default App // highlight-line -``` - - -index.js 文件剩下的内容是: - -```js -import React from 'react' -import ReactDOM from 'react-dom' -import App from './App' // highlight-line - -const notes = [ - // ... -] - -ReactDOM.render( - , - document.getElementById('root') -) -``` - - - -除了能使组件声明能够分离到它们自己的文件中之外,模块还有许多其他用途。 我们将在本课程稍后讨论这些问题。 - - - - -应用的当前代码可以在 [GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part2-1)上找到。 - - -注意,仓库的主分支包含应用的后续版本的代码。 当前的代码在分支 [part2-1](https://github.com/fullstack-hy2020/part2-notes/tree/part2-1)中: + +注意,仓库的main分支包含了应用后期版本的代码。目前的代码在分支[part2-1](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-1)中: ![](../../images/2/2e.png) + +如果你克隆了这个项目,先运行命令_npm install_,然后再用_npm run dev_启动应用。 + +### 当应用崩溃时 - -如果您克隆了项目,请在启动应用之前运行命令_npm install_ 。 + +在你编程生涯的早期(甚至像我这样编程了30年的人也会遇到),应用程序经常会彻底崩溃。对于动态类型语言,比如JavaScript,编译器不会检查数据类型,例如函数变量或返回值的类型,那么应用完全崩溃的情况就更常见了。 -### When the application breaks -【当应用挂掉了】 - - -在您编程的早期生涯(甚至说实话,在您编写了30年代码之后) ,应用挂掉是经常发生的情况。 动态类型语言更是如此,例如 JavaScript,其编译器不检查数据类型,例如函数变量或返回值。 - - -例如,“React 崩掉” 可以是这种姿势: + +例如,“React崩了”可能会像这样: ![](../../images/2/3b.png) + +在这些情况下,你最好的解决办法是使用console.log方法。 - - -在这些情况下,你最好的方案就是 console.log.。 - - -引起崩溃的代码是长这样的: + +引起崩溃的那段代码是这样的: ```js const Course = ({ course }) => (
        -
        +
        ) @@ -641,11 +578,8 @@ const App = () => { } ``` - - - - -通过在代码中添加console.log 命令,我们将深入研究出现故障的原因。 因为要渲染的第一个东西是App 组件,所以值得将第一个console.log 放在那里: + +我们将通过在代码中添加console.log命令来深入探究崩溃的原因。因为首先要渲染的是App组件,所以值得在那儿放第一个console.log: ```js const App = () => { @@ -661,46 +595,38 @@ const App = () => { } ``` - - - -要在控制台上看到打印结果,我们必须翻过长长的红色报错墙。 + +要看到控制台中的打印内容,我们必须在满屏红色的错误信息中向上滚动鼠标。 ![](../../images/2/4b.png) - - - -当打印被发现是有效时,就是时候往更深入的地方打印记录了。 如果组件声明是单个语句,或者声明为了函数而没有返回,则会增加打印到控制台的难度。 + +当发现日志是有效的,就该深入打日志了。如果组件被声明为单个语句,或者是一个没有_return_的函数,就会使打印到控制台的难度增加。 ```js const Course = ({ course }) => (
        -
        +
        ) ``` - - - -这个组件应该更改为更长的形式,以便我们添加打印: + +我们应该把组件改为较长的形式来增加打印语句: ```js -const Course = ({ course }) => { +const Course = ({ course }) => { console.log(course) // highlight-line return (
        -
        +
        ) } ``` - - - -通常,问题的根源在于,props的类型不同,或者使用了与实际名称不同的名称调用,导致结果解构失败。 解决问题的开始通常是去掉解构的方式,来看看 props 中到底包含什么。 + +问题的根源往往是props被期望为不同的类型,或者被用与实际不同的名字调用,然后结果就是解构失败。当去掉解构,我们看到props实际包含的内容时,问题往往会开始自行解决。 ```js const Course = (props) => { // highlight-line @@ -708,66 +634,71 @@ const Course = (props) => { // highlight-line const { course } = props return (
        -
        +
        ) } ``` + +如果问题仍然没有解决,除了继续通过在你的代码周围撒上更多的_console.log_语句来寻找错误之外,真的没有什么可做的。 + +之所以把这一章加入教材,是因为在下个问题的标准答案完全崩溃了(由于props的类型不对),然后我不得不用console.log来调试它。 - -如果问题仍然没有得到解决,那么除了继续通过在代码周围添加更多 _console.log_ 语句来寻找 bug 之外,真的没有什么可做的了。 - - - -在下一个问题完全崩掉之前(由于 props 的类型错误),我不得不用 _console.log_ 来debug,于是我将这一章节加到了教材中 + +### web开发者誓言 + +在开始练习之前,让我提醒一下上一章节结尾你所保证过的。 + +编程不易,因此我要通过一切方法让它变得容易 + +- 我会始终打开我的浏览器开发者控制台 + +- 我小步前进 + +- 我会写大量的_console.log_语句来确保我理解代码是怎么运行的,并借此准确找到问题 + +- 如果我的代码出问题了,我不会写更多的代码。而是删除代码直到它能运行,或者直接回到之前代码能运行的状态 + +- 当我在课程的Discord群或者其他地方寻求帮助时,我会准确表达我的问题,点[此](http://fullstackopen.com/en/part0/general_info#how-to-get-help-in-discord)了解如何寻求帮助。
    -
    + +

    练习 2.1~2.5.

    -

    Exercises 2.1.-2.5.

    - - - -这些练习是通过 GitHub 提交的,并在[提交系统submission system](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)那样将练习标记为 done。 - - -您可以将本课程的所有练习提交到同一个仓库,或者使用多个不同的仓库。 如果您将来自不同章节的练习提交到同一个仓库中,请使用一个合理的目录命名方案。 - - - -一个章节的练习必须一次提交完。 也就是说,当你已经提交了一个章节的练习,你就不能再向这个章节提交任何其他的练习内容了。 - - -请注意,这一章与以前相比有更多的练习,所以在做完这一章节你想要完成的所有这些练习前,不要进行提交。 - - + +练习通过GitHub上交,并在[上交系统](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)中标记已完成的练习。 -**警告**: create-react-app 会自动使项目成为一个 git 仓库,除非应用是在已有仓库中创建的。 而您很可能不希望项目成为一个存储库,因此可以在项目的根目录中运行命令 *_rm -rf .git_* 。 + +你可以将本课程的所有练习上交到同一个仓库,也可以使用多个仓库。如果你将不同章节的练习上交到同一个仓库,命名好你的目录。 -

    2.1: course contents 步骤6

    + +练习是**一次上交一个章节**的。当你上交了一个章节的练习,你就不能再上交该章节任何遗漏的练习了。 - - -让我们完成练习 1.1 - 1.5中,用于渲染课程内容的代码。 您可以从模型答案的代码开始。第一章中的模型答案可以到 [提交系统](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)来找到,单击顶部的my submissions,在对应第一章中下面solutions列,点击show。 如果要查看course info 练习,点击 kurssitiedot 下的_index.js_ ("kurssitiedot" 表示课程信息) + +注意这一章节的练习比之前的要多,所以在你做完这一章节你想上交的所有练习之前,不要上交练习。 - + +

    2.1:课程信息 第6步

    -请注意,如果您将一个项目从一个地方复制到另一个地方,在启动应用之前,可能必须删除 node\_modules 目录,并使用 npm install 命令重新安装依赖项。 + +让我们完成练习1.1~1.5中渲染课程内容的代码。你可以从标准答案的代码开始。第1章节的标准答案可以在[上交系统](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)中找到,点击顶部的my submissions,在solutions栏下对应第1章节的行中点击show。要看课程信息练习的解决方案,请点击courseinfo下的_App.jsx_。 - -另外,将项目副本或将 node\_modules目录放入版本控制系统并不推荐。 + +**注意,如果你把一个项目从一个地方复制到另一个地方,你可能要删除node\_modules目录,并在启动应用之前用_npm install_命令重新安装依赖。** + +一般来说,不建议复制一个项目的全部内容和/或将node\_modules目录添加到版本控制系统中。 - -让我们像这样修改App 组件: + +让我们这么改变App组件: ```js const App = () => { @@ -793,21 +724,19 @@ const App = () => { ] } - return ( -
    - -
    - ) + return } + +export default App ``` - -定义一个组件,负责格式化单门课程Course。 + +定义一个负责格式化单个课程的组件,称为Course。 - -例如,应用的组件结构可以是: + +应用的组件结构可以类似这样: -
    +```
     App
       Course
         Header
    @@ -815,68 +744,65 @@ App
           Part
           Part
           ...
    -...
    -
    +``` - -因此, Course 组件包含前面部分中定义的组件,它们负责渲染课程名称及它的各个章节。 + +因此,Course组件包含前一章节中定义过的组件,也就是负责渲染课程名称及其各部分的组件。 - -例如,渲染的页面可以如下所示: + +渲染的页面可以类似这样: ![](../../images/teht/8e.png) + +你还不需要计算练习的总和。 + +无论课程有多少部分,应用都必须能运行,所以确保如果你增加或删除课程的各部分,应用依然能够运行。 - -你还不需要显示这些练习的总和。 - - -无论课程有多少章节,应用都必须正常工作,因此,如果您添加或删除课程的章节,请确保应用工作正常。 - - + 确保控制台没有显示任何错误! -

    2.2: Course contents 步骤7

    - -此时显示课程练习的总和。 + +

    2.2:课程信息 第7步

    -![](../../images/teht/9e.png) + +同时显示课程练习的总和。 -

    2.3*: Course contents 步骤8

    +![](../../images/teht/9e.png) - -如果你不是用reduce做的,此时用数组的[reduce](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/array/reduce)方法计算练习的总和。 + +

    2.3*:课程信息 第8步

    - + +使用数组方法[reduce](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce)来计算练习的总和。如果你已经这么做了的话,那就跳过。 -专业提示: 当你的代码看起来像这样: + +**专业提示:**当你的代码看起来如下: ```js -const total = - parts.reduce( (s, p) => someMagicHere ) +const total = + parts.reduce((s, p) => someMagicHere) ``` - -而且不起作用时,推荐使用 console.log ,它要求箭头函数以更长的形式来写(而不能写紧凑模式): + +但运行不了,值得使用console.log,这要求箭头函数以较长的形式写出来。 ```js -const total = parts.reduce( (s, p) => { +const total = parts.reduce((s, p) => { console.log('what is happening', s, p) - return someMagicHere + return someMagicHere }) ``` - + +**还运行不了吗?:**用你的搜索引擎查找_reduce_在**对象数组**中应该怎么用。 -专业提示2:有一个[VS 代码插件](https://marketplace.visualstudio.com/items?itemname=cmstead.jsrefactor)可以自动将短格式的箭头函数更改为长格式,也可以逆操作。 + +

    2.4:课程信息 第9步

    -![](../../images/2/5b.png) - -

    2.4: Course contents 步骤9

    - - -让我们扩展我们的应用,允许任意数量 的课程: + +让我们扩展我们的应用以允许任意数量的课程: ```js const App = () => { @@ -906,7 +832,7 @@ const App = () => { id: 4 } ] - }, + }, { name: 'Node.js', id: 2, @@ -933,16 +859,15 @@ const App = () => { } ``` - -例如,应用看起来应该是这样的: + +这个应用可以类似这样: ![](../../images/teht/10e.png) -

    2.5: 独立模块

    + +

    2.5:分离模块 第10步

    - - -将Course 组件声明为单独的模块,并由App 组件导入。 您可以将课程的所有子组件放到同一个模块中。 + +将Course组件声明为一个单独的模块,然后再在App组件中导入。你可以将课程的所有子组件放在同一模块。
    - diff --git a/src/content/2/zh/part2b.md b/src/content/2/zh/part2b.md index 354908cee9f..414124e53c0 100644 --- a/src/content/2/zh/part2b.md +++ b/src/content/2/zh/part2b.md @@ -7,16 +7,17 @@ lang: zh
    + +让我们继续扩展我们的应用,允许用户添加新的笔记。你可以在[这里](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-1)找到我们当前应用的代码。 - -让我们继续扩展我们的应用,允许用户添加新的便笺。 + +### 在组件状态中保存笔记 - - -为了让我们的页面在添加新便笺时更新,最好将便笺存储在App 组件的状态中。 让我们导入[useState](https://reactjs.org/docs/hooks-state.html)函数,并使用它定义一个状态,这个状态用props传进来的初始便笺数组作为状态初始化。 + +为了让我们的页面在添加新的笔记时得到更新,最好将笔记存储在App组件的状态中。让我们导入[useState](https://react.dev/reference/react/useState)函数,用它来定义一个状态片段,并将其初始化为props传递的初始笔记数组。 ```js -import React, { useState } from 'react' // highlight-line +import { useState } from 'react' // highlight-line import Note from './components/Note' const App = (props) => { // highlight-line @@ -26,7 +27,7 @@ const App = (props) => { // highlight-line

    Notes

      - {notes.map(note => + {notes.map(note => )}
    @@ -34,73 +35,77 @@ const App = (props) => { // highlight-line ) } -export default App +export default App ``` - -该组件使用 useState 函数来初始化状态,该状态用props传进来的note 数组作为初始状态,保存到notes中。 + +组件使用useState函数来将存储在notes中的状态片段初始化为props传递的笔记数组: ```js -const App = (props) => { - const [notes, setNotes] = useState(props.notes) +const App = (props) => { + const [notes, setNotes] = useState(props.notes) // ... } ``` - + +我们还可以用React开发者工具看到事实确实如此: -如果我们想从一个空的便笺列表开始,我们会将初始值设置为一个空数组,由于props不会被使用,我们可以从函数定义中省略 props 参数: +![browser showing dev react tools window](../../images/2/30.png) + + +如果我们想从一个空的笔记列表开始,我们会把初始值设置为一个空的数组,这样props就用不到了,于是我们可以从函数定义中省略props参数: ```js -const App = () => { - const [notes, setNotes] = useState([]) +const App = () => { + const [notes, setNotes] = useState([]) // ... -} +} ``` - -这里让我们暂时坚持使用传递进来的props 作为初始值。 + +让我们先继续使用props中传递的初始值。 - -接下来,让我们在组件中添加一个 HTML [表单](https://developer.mozilla.org/en-us/docs/learn/HTML/forms) ,用于添加新的便笺。 + +接下来,让我们在组件中添加一个HTML[表单](https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms),用来添加新的笔记。 ```js const App = (props) => { const [notes, setNotes] = useState(props.notes) -// highlight-start +// highlight-start const addNote = (event) => { event.preventDefault() console.log('button clicked', event.target) } - // highlight-end + // highlight-end return (

    Notes

      - {notes.map(note => + {notes.map(note => )}
    - // highlight-start + // highlight-start
    -
    - // highlight-end + + // highlight-end
    ) } ``` - -我们已经将 _addNote_ 函数作为事件处理函数添加到表单元素中,该元素将在单击 submit 按钮提交表单时被调用。 + +我们将_addNote_函数作为事件处理函数添加到表单元素中,当点击提交按钮时,就会提交表单,同时调用该函数。 - -我们使用 [第1章](/zh/part1/组件状态,事件处理#event-handling) 中讨论的方法来定义事件处理 : + +我们使用在[第1章节](/zh/part1/组件状态,事件处理#事件处理)中讨论的方法来定义我们的事件处理函数: ```js const addNote = (event) => { @@ -109,30 +114,32 @@ const addNote = (event) => { } ``` - - event 参数是触发对事件处理函数需要调用的[event](https://reactjs.org/docs/handling-events.html) : + +event参数是触发调用事件处理函数的[事件](https://react.dev/learn/responding-to-events)。 - -事件处理立即调用 event.preventDefault() 方法,它会阻止提交表单的默认操作。 因为默认操作会导致页面重新加载。 + +事件处理函数立即调用event.preventDefault()方法,防止提交表单的默认动作。默认动作会导致页面重新加载,[以及其他一些事情](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit_event)。 - -将_event.target_ 中存储的事件的记录到控制台。 + +存储在_event.target_中的事件目标被记录到控制台。 ![](../../images/2/6e.png) + +本例中的目标是我们在组件中定义的表单。 - -本例中的target是我们在组件中定义的表单。 + +那么我们如何访问表单的input元素中包含的数据呢? - -但我们如何访问表单中input 元素中包含的数据呢? + +### 受控组件 - -有许多方法可以实现这一点; 我们将介绍的第一种方法是使用所谓的[受控组件](https://reactjs.org/docs/forms.html#controlled-components)。 + +可以通过很多方法;我们要看的第一个方法是通过使用所谓的[受控组件](https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable)。 - -让我们添加一个名为 newNote 的新状态,用于存储用户提交的输入,让我们将它设置为input 元素的value 属性: + +让我们添加一个用来存储用户提交输入的新状态片段,叫做newNote,**然后**让我们把它设为input元素的value属性: ```js const App = (props) => { @@ -140,7 +147,7 @@ const App = (props) => { // highlight-start const [newNote, setNewNote] = useState( 'a new note...' - ) + ) // highlight-end const addNote = (event) => { @@ -152,36 +159,36 @@ const App = (props) => {

    Notes

      - {notes.map(note => + {notes.map(note => )}
    //highlight-line -
    +
    ) } ``` - -现在,占位符存储了newNote 状态初始值,展示在input元素中,但input 不能编辑输入文本。 而且控制台出现了一个警告,告诉我们可能哪里出错了: + +存储为newNote状态初始值的占位符文本出现在了input元素中,但输入框中的文本不能编辑。控制台显示了一个警告,提示了我们哪里可能出错: ![](../../images/2/7e.png) - -由于我们将App 组件的一部分状态指定为 input 元素的value 属性,因此App 组件现在[控制](https://reactjs.org/docs/forms.html#controlled-components) 了input 元素的行为。 + +因为我们将App组件的一个状态片段赋值给input元素的value属性,App组件现在[控制](https://reactjs.org/docs/forms.html#controlled-components)了input元素的行为。 - -为了能够编辑 input 元素,我们必须注册一个事件处理 来同步对 input 所做的更改和组件的状态: + +为了实现对输入框元素的编辑,我们必须注册一个事件处理函数来将输入框的发生变化同步到组件的状态: ```js const App = (props) => { const [notes, setNotes] = useState(props.notes) const [newNote, setNewNote] = useState( 'a new note...' - ) + ) // ... @@ -196,7 +203,7 @@ const App = (props) => {

    Notes

      - {notes.map(note => + {notes.map(note => )}
    @@ -206,16 +213,14 @@ const App = (props) => { onChange={handleNoteChange} // highlight-line /> - +
    ) } ``` - - - -我们现在已经为表单的input 元素的onChange 属性注册了一个事件处理函数: + +我们现在已经为表单的input元素的onChange属性注册了一个事件处理函数: ```js { /> ``` - - - -每当 输入元素发生变化时,都会调用事件处理函数。 事件处理函数接收事件对象作为其 event 参数: + +每当input元素发生变化时都会调用事件处理函数。事件处理函数接收事件对象作为其event参数: ```js const handleNoteChange = (event) => { @@ -236,33 +239,32 @@ const handleNoteChange = (event) => { } ``` - -事件对象的target 属性现在对应于受控的input元素, event.target.value引用该元素的输入值。 + +事件对象的target属性现在对应于被控制的input元素,而event.target.value指的是该元素的输入值。 - -注意,我们不需要像在onSubmit 事件处理中那样调用 _event.preventDefault()_方法。 这是因为与表单提交不同,输入更改上没有什么默认操作。 + +注意我们不需要像在onSubmit的事件处理函数中那样调用_event.preventDefault()_方法。这是因为在输入框变化时没有默认动作,这与提交表单时不同。 - -您可以在控制台中查看是如何调用事件处理函数的: + +你可以盯着控制台,看看事件处理函数是如何被调用的: ![](../../images/2/8e.png) - -你记得我们安装过[React devtools](https://chrome.google.com/webstore/detail/React-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) 吧? 很好。 你可以直接从 React Devtools 选项卡查看状态的变化: + +记得安装[React devtools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi)了吧?很好。你可以直接在React Devtools标签页中查看状态是如何变化的。 ![](../../images/2/9ea.png) - -现在App 组件的 newNote 状态反映了输入的当前值,这意味着我们可以完成 addNote 函数来创建新的便笺: + +现在App组件的newNote状态反映了输入框的当前值,这意味着我们可以完成创建新笔记的addNote功能了: ```js const addNote = (event) => { event.preventDefault() const noteObject = { content: newNote, - date: new Date().toISOString(), important: Math.random() < 0.5, - id: notes.length + 1, + id: String(notes.length + 1), } setNotes(notes.concat(noteObject)) @@ -270,64 +272,58 @@ const addNote = (event) => { } ``` + +首先,我们为笔记创建一个新对象noteObject,它将从组件的newNote状态接收其内容。唯一标识符id是根据笔记的总数生成的。这种方法适用于我们的应用,因为笔记永远不会被删除。在Math.random()函数的帮助下,我们的笔记有50%的几率被标记为重要的。 - - -首先,我们为名为noteObject 的便笺创建一个新对象,该对象将从组件的newNote状态接收其内容。 唯一标识符 id 是根据便笺的总数生成的。 此方法适用于我们的应用,因为便笺永远不会被删除。 在 Math.random() 命令的帮助下,我们的便笺有50% 的可能被标记为重要。 - - -使用数组的 [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat) 方法添加新便笺到便笺列表中,如 [第1章](/zh/part1/javascript#arrays) 讲的那样: + +新笔记通过[第1章节](/zh/part1/java_script#数组)中介绍过的[concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat)数组方法添加到笔记列表中: ```js setNotes(notes.concat(noteObject)) ``` + +该方法并不改变原始的notes数组,而是创建一个新的数组副本,将新项添加到最后。这很重要,因为在React中我们必须[永远不要直接改变状态](https://react.dev/learn/updating-objects-in-state#why-is-mutating-state-not-recommended-in-react)! - - -该方法不会改变原始的 notes 状态数组,而是会创建数组的一个新副本,并将新项添加到尾部。 这很重要,因为我们绝不能在React中[直接改变状态](https://reactjs.org/docs/state-and-lifecycle.html#using-state-correctly) ! - - -事件处理还通过调用 newNote 状态的 setNewNote 函数重置受控input元素的值: + +事件处理函数也通过调用newNote状态的setNewNote函数来重置受控input元素的值: ```js setNewNote('') ``` - -您可以在[Github 仓库](https://github.com/fullstack-hy2020/part2-notes/tree/part2-2)的part2-2 分支中找到我们当前应用的全部代码。 + +你可以在[这个GitHub仓库](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-2)的part2-2分支中找到我们当前应用的全部代码。 -### Filtering Displayed Elements -【过滤显示的元素】 + +### 筛选展示的元素 - -让我们为我们的应用添加一些新的功能,允许我们只查看重要的便笺。 + +让我们为我们的应用添加一些新功能,让我们可以选择只查看重要的笔记。 - -让我们在App 组件中添加一个状态,用于同步应该显示哪些便笺: + +让我们向App组件中添加一个状态片段来跟踪哪些笔记应该被显示: ```js const App = (props) => { - const [notes, setNotes] = useState(props.notes) + const [notes, setNotes] = useState(props.notes) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) // highlight-line - + // ... } ``` - - - -让我们更改组件,以便它存储要显示在 notesToShow 变量中的所有便笺的列表。 列表中的项取决于组件的状态: + +让我们改变这个组件,让它在notesToShow变量中存储一个要显示的所有笔记的列表。列表的内容取决于组件的状态: ```js -import React, { useState } from 'react' +import { useState } from 'react' import Note from './components/Note' const App = (props) => { const [notes, setNotes] = useState(props.notes) - const [newNote, setNewNote] = useState('') + const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) // ... @@ -352,8 +348,8 @@ const App = (props) => { } ``` - - notesToShow 变量的定义相当简洁: + +notesToShow变量的定义相当紧凑: ```js const notesToShow = showAll @@ -361,51 +357,51 @@ const notesToShow = showAll : notes.filter(note => note.important === true) ``` - -该定义使用了[条件](https://developer.mozilla.org/en-us/docs/web/javascript/reference/operators/conditional_operator)运算符(三目运算符),这种运算符在许多其他编程语言中也存在。 + +该定义使用了[条件](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator)运算符,在许多其他编程语言中也有。 - -操作符的功能如下: + +该运算符的功能如下。如果我们有: ```js const result = condition ? val1 : val2 ``` - -如果 condition 为真,则 result变量将设置为val1值。 如果 condition为 false,则result 变量将设置为 val2。 + +如果condition为true,result变量将被设为val1的值。如果condition为false,result变量将被设为val2的值。 - -如果 showAll 的值为 false,那么将把 notesToShow 变量分配给一个只包含important属性为 true 的便笺的列表。 过滤是通过数组[filter](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/array/filter)方法完成的: + +如果showAll的值为false,notesToShow变量将被赋值为一个只包含important属性为true的笔记的列表。筛选的过程是借助数组方法[filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter)完成的: ```js notes.filter(note => note.important === true) ``` - -比较运算符实际上是多余的,因为 note.important 的值要么是true,要么是false,这意味着我们可以简单地写为: + +比较运算符实际上是多余的,因为note.important的值不是true就是false,这意味着我们可以简写成: ```js notes.filter(note => note.important) ``` - -我们首先展示比较操作符的原因是为了强调一个重要的细节: 在 JavaScript 中,val1 == val2 并不能在所有情况下都像预期的那样工作,在比较中使用专门的val1 === val2更安全。 你可以在这里[here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness)阅读更多关于这个议题的描述 。 + +我们之所以先展示比较运算符,是为了强调一个重要的细节:在JavaScript中,val1 == val2并不是在所有情况下都与预期一样。因此,只用val1 === val2进行比较会更安全。你可以在[这里](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness)阅读更多关于这个主题的内容。 - -您可以通过更改showAll状态的初始值来测试过滤功能。 + +你可以通过改变showAll状态的初始值来测试筛选的功能。 - -接下来,让我们添加一些功能,使用户能够从用户界面切换应用的 showAll状态。 + +接下来,让我们添加让用户能够从用户界面上切换应用的showAll状态的功能。 - -有关修改如下: + +相关的改变如下: ```js -import React, { useState } from 'react' +import { useState } from 'react' import Note from './components/Note' const App = (props) => { - const [notes, setNotes] = useState(props.notes) + const [notes, setNotes] = useState(props.notes) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) @@ -414,71 +410,70 @@ const App = (props) => { return (

    Notes

    -// highlight-start +// highlight-start
    -// highlight-end +// highlight-end
      {notesToShow.map(note => )}
    - // ... + // ...
    ) } ``` - -显示便笺的方式(显示所有 还是 显示重要)由一个按钮控制。 按钮的事件处理程序非常简单,在按钮元素的属性中已经直接定义了。 事件处理程序将 showAll 的值从 true 转换为 false,反之亦然: + +显示的笔记(所有的或者重要的)是用一个按钮控制的。这个按钮的事件处理函数非常简单,所以就直接在按钮元素的属性中定义了。该事件处理函数将_showAll_的值从true切换到false,或者反过来,从false切换回true。 ```js () => setShowAll(!showAll) ``` - -按钮的文本取决于showAll状态的值: + +按钮的文本取决于showAll状态的值: ```js show {showAll ? 'important' : 'all'} ``` - -您可以在[this github repository](https://github.com/fullstack-hy2020/part2-notes/tree/part2-3)的part2-3 分支中找到我们当前应用的全部代码。 - + +你可以在[这个GitHub仓库](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-3)的part2-3分支中找到我们当前应用的全部代码。
    -

    Exercises 2.6.-2.10.

    - -在第一个练习中,我们将开始一个在后面的练习中进一步开发的应用。 在相关的练习集中,返回应用的最终版本就可以了。 还可以在完成练习集的每个部分之后进行单独的提交,但不强制。 - + +

    练习 2.6.~2.10.

    -警告: create-react-app 会自动使项目成为一个 git 仓库,除非应用是在已有仓库中创建的。 而您很可能不希望项目成为一个存储库,因此可以在项目的根目录中运行命令 **_rm -rf .git_** 。 + +在第一道练习中,我们将开始一个将在后面的练习中进一步开发的应用。在相关的练习集中,只要上交你应用的最终版本即可。你也可以在完成练习集的每一部分后就在git中提交一次,但不强求。 -

    2.6: The Phonebook 步骤1

    + +

    2.6:电话簿 第1步

    - -让我们创建一个简单的电话簿,这一章中我们仅添加名字到电话本中。 + +我们来创建一个简单的电话簿。**在这一部分,我们只会向电话簿中添加名字。** - -让我们从实现将一个人添加到电话簿中开始。 + +让我们从实现在电话簿中添加人的功能开始。 - -您可以使用下面的代码作为应用的App 组件的起点: + +你可以用下面的代码作为你应用的App组件的起点: ```js -import React, { useState } from 'react' +import { useState } from 'react' const App = () => { - const [ persons, setPersons ] = useState([ + const [persons, setPersons] = useState([ { name: 'Arto Hellas' } - ]) - const [ newName, setNewName ] = useState('') + ]) + const [newName, setNewName] = useState('') return (
    @@ -500,79 +495,75 @@ const App = () => { export default App ``` - -newName状态用于控制表单输入元素。 - - + +newName状态是用来控制表单的input元素的。 -有时,为了调试目的,将状态和其他变量作为文本渲染出来会很有用。 您可以临时向渲染的组件添加如下元素: + +有时为了调试的目的,把状态和其他变量渲染成文本是很有用的。你可以暂时在渲染的组件中加入以下元素: -``` +```html
    debug: {newName}
    ``` - - -把我们在第一章节 [调试 React 应用](/zh/part1/深入_react_应用调试) 一章中学到的东西好好利用也很重要。 特别是[React developer tools](https://chrome.google.com/webstore/detail/React-developer-tools/fmkadmapgofadopljbjfkapdkoienihi 开发工具)扩展,对于跟踪应用状态中发生的变化非常有用。 + +好好利用我们在第一章节[调试React应用](/zh/part1/复杂状态,调试_react应用)一章中学到的东西也很重要。[React开发者工具](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi)扩展对于跟踪应用的状态变化非常有用。 - -在完成这个练习之后,你的应用应该是这样的: + +在完成这道练习后,你的应用应该如下所示: ![](../../images/2/10e.png) + +注意上图中React开发者工具扩展的使用! - -请注意上图中使用的 React developer 工具扩展! - - -注意: + +**注意:** + +- 你可以用人的名字作为key属性的值 + +- 记得防止提交HTML表单的默认动作! - -- 您可以使用该人的姓名作为key 属性的值 - -- 切记阻止提交 HTML 表单的默认操作! + +

    2.7:电话簿 第2步

    -

    2.7: The Phonebook 步骤2

    + +防止用户添加已经存在于电话簿中的名字。JavaScript数组有许多合适的[方法](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array)来完成这个任务。记住在Javascript中[是怎么比较对象是否相等的](https://www.joshbritz.co/posts/why-its-so-hard-to-check-object-equality/)。 - - - -防止用户添加已经存在于电话簿中的名称。 Javascript 数组有许多合适的[方法](https://developer.mozilla.org/en-us/docs/web/JavaScript/reference/global_objects/array)来完成这个任务。 - - -尝试添加同名电话时,使用[alert](https://developer.mozilla.org/en-us/docs/web/api/window/alert)命令发出警告: + +当用户试图向电话簿中添加已存在的名字时,用[alert](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert)命令发出警告: ![](../../images/2/11e.png) - - -**注意** 当您构建包含变量值的字符串时,建议使用[模板字符串](https://developer.mozilla.org/en-us/docs/web/javascript/reference/template_literals) : + +**提示:**当你在构建包含变量值的字符串时,建议使用[模板字符串](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals): ```js `${newName} is already added to phonebook` ``` - -如果newName 变量包含值Arto Hellas,则模板字符串表达式返回字符串 + +如果newName变量的值是Arto Hellas,模板字符串表达式返回字符串 ```js `Arto Hellas is already added to phonebook` ``` - -同样的事情也可以通过使用 plus(+) 操作符,以类似 java 的方式来完成: + +同样的事也可以用更像Java的方式通过加号运算符完成: ```js newName + ' is already added to phonebook' ``` - -使用模板字符串是更具惯用性的选择,也是真正的 JavaScript 专家的标志。 + +使用模板字符串是更符合语言习惯的选择,也是真正的JavaScript专家的标志。 + + +

    2.8:电话簿 第3步

    -

    2.8: The Phonebook 步骤3

    - -扩展您的应用,允许用户将电话号码添加到电话簿。 您需要在表单中添加第二个input 元素(以及它自己的事件处理程序) : + +扩展你的应用,允许用户向电话簿中添加电话号码。你需要在表单中添加第二个input元素(以及它自己的事件处理函数): ```js
    @@ -582,55 +573,52 @@ newName + ' is already added to phonebook'
    ``` - -此时,应用可以看起来像这样。 该图片还显示了[React developer tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi)应用的状态与帮助 : + +现在该应用可能如下所示。图片中还借助[React开发者工具](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi)显示了应用的状态: ![](../../images/2/12e.png) -

    2.9*: The Phonebook 步骤4

    + +

    2.9*:电话簿 第4步

    - -实现一个搜索字段,该字段可用于按姓名筛选人员列表: + +实现一个搜索字段,可以用来按名字筛选人的列表: ![](../../images/2/13e.png) + +你可以将搜索字段实现为一个input元素,放在HTML表单之外。图片中显示筛选的逻辑是大小写不敏感的,意味着搜索arto也会返回包含大写A的Arto的结果。 - - -您可以将搜索字段实现为置于 HTML 表单之外的input 元素。 图片中显示的过滤逻辑是不区分大小写的,这意味着搜索项arto 也返回包含大写 a 的 Arto 的结果。 - - - -注意: 在开发新功能时,在应用中“硬编码”一些虚拟数据通常很有用,例如:。 + +**注意:**当你开发新的功能时,在你的应用中“硬编码”一些样本数据往往是有用的,例如: ```js const App = () => { const [persons, setPersons] = useState([ - { name: 'Arto Hellas', number: '040-123456' }, - { name: 'Ada Lovelace', number: '39-44-5323523' }, - { name: 'Dan Abramov', number: '12-43-234345' }, - { name: 'Mary Poppendieck', number: '39-23-6423122' } + { name: 'Arto Hellas', number: '040-123456', id: 1 }, + { name: 'Ada Lovelace', number: '39-44-5323523', id: 2 }, + { name: 'Dan Abramov', number: '12-43-234345', id: 3 }, + { name: 'Mary Poppendieck', number: '39-23-6423122', id: 4 } ]) // ... } ``` - -这样您就不必手动将数据输入应用来测试新功能了。 - -

    2.10: The Phonebook 步骤5

    + +这可以使你在测试新功能时不必手动将数据输入到你的应用中。 + +

    2.10:电话簿 第5步

    + +如果你是在单个组件中实现你的应用的,通过提取合适部分到新的组件中来重构应用。在App根组件中维护应用的状态和所有事件处理函数。 - -如果您已经在单个组件中实现了应用,那么可以通过将合适的部分提取到新组件中来重构它。 在App 根组件中维护应用的状态和所有事件处理程序。 + +从应用中提取**三个**组件即可。提取这些组件都是不错的选择,例如,搜索筛选器、向电话簿添加新人的表单、显示电话簿中所有人的组件,以及显示一个人详细资料的组件。 - -从应用中提取 3个 组件就足够了。 比如,搜索过滤器,在电话簿中添加新人的表单,电话簿中显示所有人的组件,以及显示单个人详细信息的组件。 - - -在重构之后,应用的根组件可能与此类似。 下面重构的根组件只渲染标题,并让提取的组件处理其余部分。 + +在重构之后,应用的根组件可能看起来类似下面。下面这个重构后的根组件只渲染标题,其余的部分则让提取的组件来处理。 ```js const App = () => { @@ -644,7 +632,7 @@ const App = () => {

    Add a new

    - @@ -656,9 +644,7 @@ const App = () => { } ``` - - -注意 : 如果将组件定义在“错误的位置” ,则可能在本练习中遇到问题。 现在是复习本章[不要在其他组件中定义组件](/zh/part1/深入_react_应用调试#do-not-define-components-within-components)的好时机,从最后一段开始。 + +**注意**:如果你在“错误的地方”定义你的组件,你就可能会在这道练习中遇到问题。现在是实践一下上一章节的[不要在组件中定义组件](/zh/part1/复杂状态,调试_react应用#不要在组件中定义组件)章节的好时机。
    - diff --git a/src/content/2/zh/part2c.md b/src/content/2/zh/part2c.md index 3fcb1e15ad9..ecf9497933f 100644 --- a/src/content/2/zh/part2c.md +++ b/src/content/2/zh/part2c.md @@ -7,92 +7,75 @@ lang: zh
    + +这段时间,我们只致力于“前端”,即客户端(浏览器)的功能。我们将在本课程的[第三章节](/zh/part3)中开始研究“后端”,即服务端的功能。尽管如此,我们现在将朝着这个方向迈出一步,熟悉浏览器中执行的代码是如何与后端通信的。 - -到目前为止,我们一直致力于“前端” ,即客户端(浏览器)功能。 我们将在本课程的第三章节开始研究“后端” ,即服务器端功能。 尽管如此,我们现在将向这个方向迈出一步,熟悉在浏览器中执行的代码如何与后端通信。 + +让我们使用一个用于软件开发过程中的工具[JSON Server](https://github.com/typicode/json-server)来作为我们的服务端。 - - - -让我们使用一个在开发过程中使用的工具,称为[JSON 服务器](https://github.com/typicode/JSON-Server 服务器) ,作为我们的服务器。 - - -在项目的根目录中创建一个名为db.json 的文件,其内容如下: + +在之前的notes项目的根目录下创建一个名为db.json的文件,内容如下: ```json { "notes": [ { - "id": 1, + "id": "1", "content": "HTML is easy", - "date": "2019-05-30T17:30:31.098Z", "important": true }, { - "id": 2, - "content": "Browser can execute only Javascript", - "date": "2019-05-30T18:39:34.091Z", + "id": "2", + "content": "Browser can execute only JavaScript", "important": false }, { - "id": 3, + "id": "3", "content": "GET and POST are the most important methods of HTTP protocol", - "date": "2019-05-30T19:20:14.298Z", "important": true } ] } ``` - - - -您可以使用命令 _npm install -g json-server_在您的机器上[安装](https://github.com/typicode/json-server#getting-started) JSON 服务器。 global 安装需要管理员权限,这意味着它不可能在教学电脑或新生的笔记本电脑上安装。 - - -但是,全局安装不是必须的。因为我们可以在应用的根目录使用 npx 命令运行json-server: + +你可以在应用的根目录下运行以下_npx_命令来启动JSON Server(_npx_无需额外安装): ```js -npx json-server --port 3001 --watch db.json +npx json-server --port 3001 db.json ``` - -默认情况下,json-server在端口3000上启动; 但是由于 create-react-app 项目设置了3000端口,因此我们必须为 json-server 定义一个备用端口,比如端口3001。 + +JSON Server默认在端口3000上开始运行,但我们现在使用另一个端口3001。让我们在浏览器中导航到这个地址。我们可以看到JSON Server以JSON格式提供我们之前写到文件中的各笔记: - -让我们在浏览器中输入地址 。 我们可以看到JSON-server 以 JSON 格式提供了我们之前写到文件的便笺: +![](../../images/2/14new.png) -![](../../images/2/14e.png) + +如果你的浏览器不能格式化显示JSON数据,那就安装一个合适的插件,例如[JSONView](https://chrome.google.com/webstore/detail/jsonview/chklaanhfefbnpoihckbnefhakgolnmc),让你的生活更轻松。 + +接下来,我们的目标是将笔记保存到服务端,在这里指保存到json-server。React代码会从服务端获取笔记并渲染到屏幕上。每当应用中添加新笔记时,React代码也会将其发送到服务端,使新笔记能在“内存”中持久保存。 + +json-server将所有数据存储在服务端的db.json文件中。在实际生产中,数据会存储在某种数据库中。然而,json-server是一个方便的工具,能让我们在开发阶段使用服务端的功能,而不需要进行任何编程。 - -如果你的浏览器无法格式化 json 数据的显示,那么安装一个合适的插件,例如[JSONView](https://chrome.google.com/webstore/detail/jsonview/chklaanhfefbnpoihckbnefhakgolnmc) ,这样会让你的生活更加轻松。 + +我们将在本课程的[第3章节](/zh/part3)中进一步熟悉实现服务端功能的原则。 + +### 浏览器作为运行环境 + +我们的第一个任务是从地址获取已存在的笔记到我们的React应用中。 - -接下来,我们的想法是将便笺保存到服务器,这在本例中意味着将便笺保存到 json-server。 React代码从服务器获取便笺并将其渲染到屏幕上。 无论何时向应用添加新便笺,React 代码都会将其发送到服务器,以使新便笺保存在“内存”中。 + +在第0章节的[示例项目](/zh/part0/web_应用的基础设施#running-application-logic-in-the-browser)中,我们已经学习了一种使用JavaScript从服务端获取数据的方法。示例中的代码是使用[XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest)来获取数据的,也就是使用XHR对象来进行HTTP请求。这是一种1999年引入的技术,现在每种浏览器都已经支持了很长时间。 - -Json-server 将所有数据存储在服务器上的db.json 文件中。 在现实世界中,数据会存储在某种数据库中。 然而,json-server 是一个方便的工具,可以在开发阶段使用服务器端功能,而不需要编写任何程序。 + +但不再推荐使用XHR了,浏览器已经广泛支持[fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch)方法,该方法基于所谓的[Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise),而不是XHR使用的事件驱动模型。 - -在本课程的[第3章节](/zh/part3)中,我们将更详细地了解如何实现服务器端的功能。 - -### The browser as a runtime environment -【浏览器作为一个运行时环境】 - -我们的第一个任务是从地址 http://localhost:3001/notes 获取已经存在的便笺到 React 应用。 - - -在 part0[示例 project](/zh/part0/web_应用的基础设施#running-application-logic-on-the-browser)中,我们已经学到了一种使用 JavaScript 从服务器获取数据的方法。 示例中的代码使用[XMLHttpRequest](https://developer.mozilla.org/en-us/docs/web/api/XMLHttpRequest)获取数据,也称为使用 XHR 对象发出的 HTTP 请求。 这是1999年引入的一项技术,现在每个浏览器都已经支持很长时间了。 - - -使用 XHR已经不再推荐了,而且浏览器已经广泛支持基于所谓的[promises](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise)的[fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch)方法,而不是 XHR 使用的事件驱动模型。 - - -作为第0章的提醒(实际上我应该记住在没有紧迫理由的情况下不要使用) ,使用 XHR 获取数据的方式如下: + +作为第0章节的提醒(如果没有迫切的理由,应该记住不要使用),使用XHR获取数据的方式如下: ```js @@ -109,21 +92,19 @@ xhttp.open('GET', '/data.json', true) xhttp.send() ``` + +在最开始时,我们给代表HTTP请求的xhttp对象注册了一个事件处理函数,每当xhttp对象的状态发生变化时,JavaScript运行时就会调用事件处理函数。如果状态的变化意味着对请求的响应已经到来,那么数据就会得到相应的处理。 + +值得注意的是,尽管事件处理函数的代码是在请求被发送到服务端之前定义的,事件处理函数中的代码将在以后的时间点执行。因此,代码不是“从上到下”同步执行的,而是异步执行的。JavaScript会在某个时间点调用注册用于请求的事件处理函数。 - -在开始时,我们将一个事件处理程序 注册到表示 HTTP 请求的xhttp对象,当 xhttp对象的状态发生变化时,JavaScript 运行时将调用该对象。 如果状态的变化意味着对请求的响应已经到达,那么数据将得到相应的处理。 - - -值得注意的是,事件处理中的代码是在请求发送到服务器之前定义的。 尽管如此,事件处理中的代码将在稍后的时间点执行。 因此,代码并不是“从顶部到底部”同步执行,而是异步执行。 Javascript 调用了事件处理,而这个事件处理是在之前某个时刻注册的。 - - -例如,一种在 Java 编程中常见的同步发出请求的方式,如下(注意,这实际上不是可运行的 Java 代码) : + +同步的请求方式常见于Java编程中,下面是一个同步请求的例子(注意,这些Java代码并不能实际运行,只是举个例子): ```java HTTPRequest request = new HTTPRequest(); -String url = "https://fullstack-exampleapp.herokuapp.com/data.json"; +String url = "https://studies.cs.helsinki.fi/exampleapp/data.json"; List notes = request.get(url); notes.forEach(m => { @@ -131,22 +112,20 @@ notes.forEach(m => { }); ``` + +在Java中,代码逐行执行,会停下来等待HTTP请求,也就是会等待命令_request.get(...)_完成。命令返回的数据,在这里是笔记,会随后被存储在一个变量中,然后我们开始以我们想要的方式操作数据。 + +相比之下,JavaScript引擎,或者叫运行环境,遵循[异步模型](https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop)。原则上,这要求所有的[IO操作](https://en.wikipedia.org/wiki/Input/output)(除了一些例外)都以非阻塞方式执行。也就是说,在调用一个IO函数后,代码会立即继续执行,而不等待IO操作返回。 - -在 Java 中,代码逐行执行并停止等待 HTTP 请求,这意味着等待_request.get(...)_ 命令完成。 命令返回的数据,在本例中是notes,然后存储在一个变量中,我们开始以所需的方式操作数据。 + +当一个异步操作完成后,或者更确切地说,在完成后的某个时间点,JavaScript引擎会调用注册用于该操作的事件处理函数。 - -另一方面,JavaScript 引擎,或者运行时环境,遵循[异步模型asynchronous model](https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop).。 原则上,这要求所有的[IO-操作](https://en.wikipedia.org/wiki/input/output)(除了一些例外)都以非阻塞方式执行。 这意味着代码执行在调用 IO 函数之后立即继续,而不需要等待它返回。 + +目前,JavaScript引擎是单线程的,这意味着它们不能并行地执行代码。因此,在实践中需要使用非阻塞模型来执行IO操作。否则,在从服务端获取数据等过程中,浏览器会“冻住”。 - -当一个异步操作完成时,或者更确切地说,在它完成之后的某个时刻,JavaScript 引擎才调用注册到该操作的事件处理。 - - -目前,JavaScript 引擎是单线程的,这意味着它们不能并行执行代码。 因此,在实践中需要使用非阻塞模型来执行 IO 操作。 否则,浏览器将在从服务器获取数据时“冻结(卡住)”。 - - -这种单线程的 Javascript 引擎的另一个后果是,如果某些代码的执行占用了大量的时间,那么浏览器将在执行期间停滞不前。 如果我们在应用顶部添加如下代码: + +JavaScript引擎的这种单线程特性的另一个结果是,如果某些代码的执行占用了大量的时间,浏览器就会在执行的过程中卡住。如果我们在我们应用的顶部添加以下代码: ```js setTimeout(() => { @@ -159,174 +138,174 @@ setTimeout(() => { }, 5000) ``` - -一切正常运转5秒钟。 但是,当运行定义为 setTimeout 参数的函数时,浏览器将在长循环执行期间停止。 即使是浏览器的标签也不能在循环执行期间关闭,至少在 Chrome 中不能。 + +5秒内一切都正常。然而,当定义为setTimeout参数的函数运行时,浏览器将在长循环的执行过程中被卡住。甚至在执行循环的过程中也不能关闭浏览器标签页,至少在Chrome中不能。 - -为了让浏览器保持responsive响应性,即能够以足够的速度连续地对用户操作作出反应,代码逻辑需要让任何单一的计算都不会花费太长的时间。 + +为了使浏览器保持响应性,即能够以足够的速度对用户的操作作出连续的反应,代码逻辑需要做到没有任何计算会花费太长时间。 - -在互联网上可以找到大量关于这个议题的补充材料。 关于这个话题,一个特别清晰的演讲是 Philip Roberts 的议题演讲[What the heck is the event loop anyway?](https://www.youtube.com/watch?v=8aGhZQkoFbQ) + +互联网上可以找到大量关于这个主题的额外资料。对这一主题的一个特别清晰的演讲是Philip Roberts的演讲[What the heck is the event loop anyway?](https://www.youtube.com/watch?v=8aGhZQkoFbQ)。 - -在当今的浏览器中,可以在所谓的 [web workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) 的帮助下运行并行化的代码。 然而,单个浏览器窗口的事件循环仍然是由一个[单线程](https://medium.com/techtrument/multithreading-javascript-46156179cf9a)处理。 + +在今天的浏览器中,可以借助所谓的[Web Worker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers)来并行地运行代码。然而,单个浏览器窗口的事件循环仍然只由[单线程](https://medium.com/techtrument/multithreading-javascript-46156179cf9a)处理。 ### npm - -让我们回到从服务器获取数据的议题。 - -我们可以使用前面提到的基于承诺promise的[fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch)函数从服务器中获取数据。 fetch是一个很好的工具。 它是标准化的,所有现代浏览器(不包括 IE,因为它不是)都支持它。 + +让我们回到从服务端获取数据的话题上来。 + + +我们可以使用之前提到的基于Promise的函数[fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch)来从服务端获取数据。fetch是一个伟大的工具。它是标准化的,被所有现代浏览器支持(除了IE)。 - -也就是说,我们将使用[axios](https://github.com/axios/axios)库来代替浏览器和服务器之间的通信。 它的功能类似于fetch,但是使用起来更友好。 使用 axios 的另一个很好的理由是,我们已经熟悉了为 React 项目添加外部库,即使用所谓的npm 包。 + +也就是说,我们将使用[axios](https://github.com/axios/axios)库来实现浏览器和服务端之间的通信。它的功能类似于fetch,但使用起来更顺手一些。使用axios的另一个很好的理由是这么做能让我们熟悉向React项目中添加外部库,即npm包。 - -现在,几乎所有的 JavaScript 项目都是使用node包管理器定义的,也就是[npm](https://docs.npmjs.com/getting-started/what-is-npm)。 使用 create-react-app 创建的项目也遵循 npm 格式。 项目使用 npm 的一个明确的说明是位于项目根目录的package.json 文件: + +现在,几乎所有JavaScript项目都是用node包管理器,也就是[npm](https://docs.npmjs.com/getting-started/what-is-npm)(node package manager)来定义的。使用Vite创建的项目也遵循npm的格式。项目使用npm的一个明显标志是位于项目根目录的package.json文件: ```json { - "name": "notes", - "version": "0.1.0", + "name": "part2-notes-frontend", "private": true, - "dependencies": { - "@testing-library/jest-dom": "^4.2.4", - "@testing-library/react": "^9.4.0", - "@testing-library/user-event": "^7.2.1", - "react": "^16.12.0", - "react-dom": "^16.12.0", - "react-scripts": "3.3.0" - }, + "version": "0.0.0", + "type": "module", "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject" + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" }, - "eslintConfig": { - "extends": "react-app" + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.17.0", + "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.16", + "globals": "^15.14.0", + "vite": "^6.0.5" } } ``` - -此时,我们对dependencies 部分最感兴趣,因为它定义了项目具有的依赖dependencies 或外部库。 + +现在,我们最感兴趣的是dependencies部分,因为它定义了项目有哪些依赖项,即外部库。 - -我们现在要使用 axios。 理论上,我们可以在package.json 文件中直接定义它,但最好是从命令行安装它。 + +我们现在想使用axios。理论上,我们可以直接在package.json文件中定义这个库,但最好从命令行中安装它。 ```js -npm install axios --save +npm install axios ``` + +**NB _npm_命令应该总是在项目根目录下运行**,也就是可以找到package.json文件的地方。 - - -注意: npm-commands 应该始终在项目根目录中运行,在这个目录中可以找到package.json 文件。 - - -Axios 现在被包含在依赖中了: + +axios现在被包含在其他依赖项中: ```json { + "name": "part2-notes-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, "dependencies": { - "@testing-library/jest-dom": "^4.2.4", - "@testing-library/react": "^9.4.0", - "@testing-library/user-event": "^7.2.1", - "axios": "^0.19.1", // highlight-line - "react": "^16.12.0", - "react-dom": "^16.12.0", - "react-scripts": "3.3.0" + "axios": "^1.7.9", // highlight-line + "react": "^18.3.1", + "react-dom": "^18.3.1" }, // ... } ``` - -除了将 axios 添加到依赖项之外,npm install 命令还下载了库代码。 与其他依赖项一样,代码可以在根目录中的node\_modules 目录中找到。 人们可能已经注意到,node\_modules 包含了大量有趣的内容。 + +除了将axios添加到依赖项中,npm install命令还会下载库的代码。和其他依赖项一样,代码可以在根目录下的node\_modules目录中找到。你可能已经注意到,node\_modules包含了相当多有趣的东西。 - -让我们做另一个补充,通过执行如下命令将json-server 安装为开发依赖项(仅在开发过程中使用) : + +让我们再添加一个包。执行以下命令,将json-server安装为开发依赖项(只在开发过程中使用): ```js npm install json-server --save-dev ``` - -在package.json 文件的scripts部分添加一个小的修改: + +并在package.json文件的scripts部分做一个小小的补充: ```json { - // ... + // ... "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject", - "server": "json-server -p3001 --watch db.json" // highlight-line + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview", + "server": "json-server -p 3001 db.json" // highlight-line }, } ``` - -现在,我们可以在没有参数定义的情况下方便地使用如下命令从项目根目录启动 json-server: + +现在我们可以方便地,不用定义任何参数,在项目根目录下用命令启动json-server: ```js npm run server ``` - -我们将在[课程的第三章节](/zh/part3)中更加熟悉 npm 工具。 - - + +我们将在[课程的第三章节](/zh/part3)中进一步熟悉_npm_工具。 -注意: 在启动新服务器之前,以前启动的 json-server必须终止,否则会出现问题: + +**注意** 在启动新的json-server之前,必须先终止之前启动的json-server;否则就会报错: ![](../../images/2/15b.png) - -错误信息中的红色打印提示我们这个问题的原因: + +错误信息中的红色字告诉我们了问题: -Cannot bind to the port 3001. Please specify another port number either through --port argument or through the json-server.json configuration file -不能绑定到3001端口。 请通过 -- port 参数或通过 json-server.json 配置文件指定另一个端口号。 +Cannot bind to port 3001. Please specify another port number either through --port argument or through the json-server.json configuration file - -正如我们所看到的,应用不能将自己绑定到[端口](https://en.wikipedia.org/wiki/port_(computer_networking))。 原因是端口3001已经被先前启动的 json-server 占用了。 + +我们可以看到,应用无法将自己绑定到[端口](https://en.wikipedia.org/wiki/Port_(computer_networking))。原因是3001端口已经被先前启动的json-server占用。 - -我们使用了两次 npm 安装命令,但是有一点不同: + +我们用了两次_npm install_的命令,但略有不同: ```js -npm install axios --save +npm install axios npm install json-server --save-dev ``` + +在参数上有细微差别。axios被安装为应用的运行时依赖项,因为程序的执行需要该库的存在。另一方面,json-server被安装为开发依赖项(_--save-dev_),因为程序本身并不需要它。它是用来在软件开发期间提供帮助的。在课程的下一部分会有更多关于不同依赖项的内容。 + +### axios和Promise - -参数之间有细微的差别。axios 被安装为应用的运行时依赖项(-- save) ,因为程序的执行需要库的存在。 而另一个, json-server 是作为开发依赖项(-- save-dev)安装的,因为程序本身并不需要它。 它用于在软件开发过程中提供帮助。 在课程的下一章节将会有更多关于不同依赖的内容。 + +现在我们已经准备好使用axios了。今后,都假定json-server在3001端口运行。 -### Axios and promises - -现在我们可以使用 axios 了。在开始之前,我已经假定你的json-server跑在3001端口了。 + +注意:为了能够同时运行json-server和你的React应用,你可能需要使用两个终端窗口。一个用于保持json-server运行,另一个用于运行我们的React应用。 - -可以像其他库一样使用这个库,就像 React那样,即使用 import 语句。 + +这个库可以通过和其他库一样的方式使用,即使用恰当的import语句。 - -将如下内容添加到文件index.js 中: + +在文件main.jsx中添加以下内容: ```js import axios from 'axios' @@ -338,36 +317,38 @@ const promise2 = axios.get('http://localhost:3001/foobar') console.log(promise2) ``` - -此时如下信息会打印到控制台 + +如果你在浏览器中打开,控制台应该会打印出以下内容 -![](../../images/2/16b.png) +![](../../images/2/16new.png) - -Axios 的 _get_ 方法会返回一个[promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises)。 + +axios的_get_方法返回一个[Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises)。 - -Mozilla's 网站上的文档对promises 做了如下解释: + +Mozilla网站上的文档对Promise有如下说明: -> A Promise is an object representing the eventual completion or failure of an asynchronous operation. -Promise承诺是一个对象,用来表示异步操作的最终完成或失败 + +> Promise 是一个对象,它代表了一个异步操作的最终完成或者失败。 - -换句话说,promise 是一个表示异步操作的对象,它可以有三种不同的状态: + +换句话说,一个Promise是一个代表异步操作的对象。一个Promise可以有三种不同的状态: -1. + +- Promise在队列中(pending):这意味着Promise对应的异步操作尚未完成,还没有最终值。 + +- Promise已完成(fulfilled):它意味着操作已经完成,已得到最终值,一般代表操作成功。 + +- Promise被拒绝(rejected):这意味着一个错误阻止了最终值的确定,一般代表操作失败。 - The promise is pending提交中: 这意味着最终值(下面两个中的一个)还不可用。 -2. -The promise is fulfilled兑现: 这意味着操作已经完成,最终的值是可用的,这通常是一个成功的操作。 这种状态有时也被称为resolve。 -3. -The promise is rejected拒绝:它意味着一个错误阻止了最终值,这通常表示一个失败操作。 + +关于Promise还有许多细节,但目前而言理解这三种状态对我们来说就足够了。如果你想,你也可以在[Mozilla的文档](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise)中了解更多。 - -我们示例中的第一个承诺是fulfilled,表示一个成功的axios.get('http://localhost:3001/notes') 请求。 而第二个是rejected,控制台告诉我们原因。 看起来我们试图向一个不存在的地址发出了 HTTP GET 请求。 + +我们例子中第一个Promise已完成,代表_axios.get('http://localhost:3001/notes')_请求成功。然而,第二个Promise被拒绝,并且控制台告诉了我们原因。看起来我们试图向一个不存在的地址发送HTTP GET请求。 - -如果我们想要访问承诺表示的操作的结果,那么必须为承诺注册一个事件处理。 这是通过 then方法实现的: + +每当我们想访问Promise所代表的操作的结果,我们必须为Promise注册一个事件处理函数。这可以通过方法then实现: ```js const promise = axios.get('http://localhost:3001/notes') @@ -376,18 +357,17 @@ promise.then(response => { console.log(response) }) ``` -The following is printed to the console: -下面的代码打印到控制台: - -![](../../images/2/17e.png) + +以下内容将被打印到控制台: +![](../../images/2/17new.png) - -Javascript 运行时环境调用由 then 方法注册的回调函数,并提供一个response 对象作为参数。response 对象包含与 HTTP GET 请求响应相关的所有基本数据,也包括返回的datastatus codeheaders。 + +JavaScript运行环境调用由then方法注册的回调函数,为其提供一个response对象作为参数。response对象包含与HTTP GET请求响应相关的所有必要数据,包括返回的数据状态代码标头。 - -通常没有必要将 promise 对象存储在一个变量中,而将 then方法调用链到 axios 方法调用是很常见的,因此它可以直接跟在 axios 方法调用后面: + +通常没有必要将Promise对象存储在一个变量中,而通常是将then方法调用链接到axios方法调用后,这样then方法就直接跟在axios方法后面: ```js axios.get('http://localhost:3001/notes').then(response => { @@ -397,14 +377,11 @@ axios.get('http://localhost:3001/notes').then(response => { ``` + +回调函数现在接收响应中包含的数据,将其存储在一个变量中,并将笔记打印到控制台。 - -回调函数获取了响应中包含的数据,将其存储在一个变量中,并将便笺打印到控制台。 - - - - -要格式化chained 方法调用,以一种更易读的方法是将每个调用放在独立的行上: + +将链式方法调用格式化为更可读的一种方法是把每个调用放在单独的一行: ```js axios @@ -415,66 +392,65 @@ axios }) ``` - -服务器返回的数据是纯文本,基本上只有一个长字符串。 Axios 库仍然能够将数据解析为一个 Javascript 数组,因为服务器使用content-type 头指定数据格式为application/json; charset=utf-8 (参见前面的图片)。 + +服务端返回的数据是纯文本,基本上就是一个长字符串。axios库仍然能够将数据解析成一个JavaScript数组,因为服务端已经用content-type标头指定数据格式为application/json; charset=utf-8(见前一张图片)。 - -我们现在终于可以开始使用从服务器获取的数据了。 + +我们终于可以开始使用从服务端上获取的数据了。 - -让我们首先“糟糕地”完成这个任务,即通过将表示应用的App 组件放在回调函数里面。 通过将index.js 更改为如下形式来实现的: + +让我们尝试从本地服务端上请求笔记,并渲染它们,作为最初的App组件。请注意,这种方法有很多问题,因为我们只有在成功获得一个响应时才会渲染整个App组件。 ```js -import ReactDOM from 'react-dom' -import React from 'react' -import App from './App' - +import ReactDOM from 'react-dom/client' import axios from 'axios' +import App from './App' axios.get('http://localhost:3001/notes').then(response => { const notes = response.data - ReactDOM.render( - , - document.getElementById('root') - ) + ReactDOM.createRoot(document.getElementById('root')).render() }) ``` - -这种方法在某些情况下是可以接受的,但是有一些问题。 让我们将数据的fetch逻辑转移到App 组件中。 + +这种方法在某些情况下是可以的,但它有些问题。让我们把获取数据的代码移到App组件中。 - -但是,命令 axios.get 应该放在组件中的哪个位置,这一点并不明显。 + +然而,不明显的是,axios.get命令应该放在组件中的什么地方。 + +### Effect Hook -### Effect-hooks - -我们已经使用了与 React version [16.8.0](https://www.npmjs.com/package/react/v/16.8.0)一起引入的 [state hooks](https://reactjs.org/docs/hooks-state.html),它为 React 组件提供了定义为函数的状态。 16.8.0版本还引入了 [effect hooks](https://reactjs.org/docs/hooks-effect.html) 新特性。 像文档里说的: + +我们已经使用了React[16.8.0](https://www.npmjs.com/package/react/v/16.8.0)版本中引入的[State Hook](https://react.dev/learn/state-a-components-memory),它为定义为函数的React组件——所谓的函数式组件提供状态。16.8.0版本还引入了[Effect Hook](https://zh-hans.react.dev/reference/react/hooks#effect-hooks)这个新功能。按照官方文档的说法: -> The Effect Hook lets you perform side effects in function components. - Effect Hook 可以让你在函数组件中执行副作用 -> Data fetching, setting up a subscription, and manually changing the DOM in React components are all examples of side effects. -数据获取、设置订阅和手动更改 React 组件中的 DOM 都是副作用的例子。 + +> Effect允许组件连接到外部系统并与之同步。 + +> 这包括处理网络、浏览器、DOM、动画、使用不同UI库编写的小部件以及其他非React代码。 - -因此,effect hooks正是从服务器获取数据时使用的正确工具。 + +因此,当要从服务端获取数据时,Effect Hook恰好是正确的工具。 - -让我们从index.js 中删除数据的获取逻辑。不再需要将数据作为props传递给App 组件。 所以我将 index.js 简化为: + +让我们从main.jsx中移除获取数据的代码。既然我们要从服务端获取笔记,就不再需要将数据作为props传递给App组件。所以main.jsx可以简化为: ```js -ReactDOM.render(, document.getElementById('root')) +import ReactDOM from "react-dom/client"; +import App from "./App"; + +ReactDOM.createRoot(document.getElementById("root")).render(); ``` - -App组件更改如下: + +App组件的变化如下: ```js -import React, { useState, useEffect } from 'react' // highlight-line +import { useState, useEffect } from 'react' // highlight-line import axios from 'axios' // highlight-line import Note from './components/Note' -const App = () => { +const App = () => { // highlight-line const [notes, setNotes] = useState([]) // highlight-line const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) @@ -497,25 +473,24 @@ const App = () => { } ``` - -我们还添加了一些有用的打印,用来清晰执行的进程。 + +我们还添加了一些有用的打印语句来阐明执行的进程。 - -这是打印到控制台的内容 + +控制台将打印出这些: -
    +```
     render 0 notes
     effect
     promise fulfilled
     render 3 notes
    -
    - - -首先执行定义组件的函数体,并首次渲染组件。 此时我打印了 render 0 notes ,这意味着还没有从服务器获取数据。 +``` - + +首先,定义组件的函数主体被执行,组件被首次渲染。这时候打印了render 0 notes,意味着还没有从服务端获取数据。 -下面的函数,或者说React 的 effect: + +下面的函数,用React的说法就是Effect: ```js () => { @@ -529,8 +504,8 @@ render 3 notes } ``` - -在渲染完成后会立即执行。 函数的执行结果是effect 被打印到控制台,axios.get 命令从服务器获取到数据,并将如下函数注册为事件处理: + +在渲染后会立即执行。函数的执行结果是effect被打印到控制台,然后命令axios.get开始从服务端获取数据,并注册以下函数作为该操作的事件处理函数: ```js response => { @@ -539,14 +514,14 @@ response => { }) ``` - -当数据从服务器到达时,JavaScript 运行时会调用注册为事件处理的函数,该函数将promise fulfilled 输出到控制台,并使用函数setNotes(response.data) 将从服务器接收的便笺存储到状态中。 + +当数据从服务端到达时,JavaScript运行时调用注册的事件处理函数,该函数将promise fulfilled打印到控制台,并用函数setNotes(response.data)将从服务端收到的笔记存储到状态中。 - -通常,对状态更新函数的调用会触发组件的重新渲染。 结果,render 3 notes 被打印到控制台,从服务器获取的便笺被显示到屏幕上。 + +一如既往,调用会更新状态的函数会触发组件的重新渲染。结果,render 3 notes被打印到控制台,而从服务端获取的笔记被渲染到屏幕上。 - -最后,让我们来整体看一下 effect hook : + +最后,让我们来看看整个Effect Hook的定义: ```js useEffect(() => { @@ -559,8 +534,8 @@ useEffect(() => { }, []) ``` - -让我们用不同的方式重写一下代码。 + +让我们以稍微不同的方式重写代码。 ```js const hook = () => { @@ -576,26 +551,26 @@ const hook = () => { useEffect(hook, []) ``` - -现在我们可以更清楚地看到函数 [useEffect](https://reactjs.org/docs/hooks-reference.html#useeffect) 实际上需要两个参数 。第一个是函数本身。 根据文档描述: + +现在我们可以更清楚地看到,函数[useEffect](https://zh-hans.react.dev/reference/react/useEffect)需要两个参数。第一个参数是一个函数,即Effect本身。根据文档: -> By default, effects run after every completed render, but you can choose to fire it only when certain values have changed. -默认情况下,effects 在每次渲染完成后运行,但是你可以选择只在某些值发生变化时才调用。 + +> 默认情况下,Effect会在每次完成渲染后运行,但你可以选择只在某些值发生变化时启动它。 - -因此,默认情况下,effect是总是 在组件渲染之后才运行。 然而,在我们的例子中,我们只想在第一次渲染的时候执行这个效果。 + +所以在默认情况下,每当渲染完组件后,就会运行Effect。然而,在我们的例子中,我们只想在第一次渲染时执行Effect。 - -useEffect的第二个参数用于[指定effect运行的频率](https://reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect)。 如果第二个参数是一个空数组 [],那么这个effect只在组件的第一次渲染时运行。 + +useEffect的第二个参数用于[指定Effect的运行频率](https://zh-hans.react.dev/reference/react/useEffect#parameters)。如果第二个参数是一个空的数组[],那么Effect就只在组件的第一次渲染时运行。 - -除了从服务器获取数据之外,Effect-Hook还有许多用例。 目前我们只了解到这。 + +除了从服务端获取数据之外,Effect Hook还有许多可能的使用场景。然而对我们来说,目前而言,这一用途已经足够了。 - -回想一下我们刚才讨论的事件顺序。 代码的哪些部分是运行的? 按什么顺序? 多久一次? 理解事件的顺序是至关重要的! + +回想一下我们刚才讨论的事件的顺序。代码的哪些部分被运行?以什么顺序?什么时候会运行?理解事件的顺序是至关重要的! - -注意,我们也可以这样编写 effect 函数的代码: + +注意我们也可以这样写Effect函数的代码: ```js useEffect(() => { @@ -611,8 +586,8 @@ useEffect(() => { }, []) ``` - -对事件处理函数的引用被分配给变量eventHandler。 Axios 的get方法返回的promise存储在变量 promise 中。 回调的注册是通过将 eventHandler变量作为参数 (事件处理函数的引用)传递给promise 的 then 方法的来实现的。 通常没有必要为函数和承诺分配变量,而是用更紧凑的表示方式,就像上面那样,就足够了。 + +一个事件处理函数的引用被赋值给变量eventHandler。axios的get方法返回的Promise被存储在变量promise中。回调函数的注册是通过把eventHandler变量,即事件处理函数的引用,作为Promise的then方法的参数来完成的。通常没有必要把函数和Promise赋值给变量,用更紧凑的方式来表示,如下所示,就足够了。 ```js useEffect(() => { @@ -626,75 +601,75 @@ useEffect(() => { }, []) ``` - -我们的应用仍然有一个问题。当添加新的便笺时,它们不存储在服务器上。 + +我们的应用中仍然有一个问题。当添加新的笔记时,新笔记没有存储到服务端。 - -到目前为止,应用的代码可以在分支part2-4 中的[github](https://github.com/fullstack-hy2020/part2-notes/tree/part2-4)上找到。 + +到目前为止,应用的代码全部可以在[github](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-4)的part2-4分支找到。 -### The development runtime environment -【开发的运行时环境】 - + +### 开发的运行环境 -我们整个应用的配置已经逐渐变得更加复杂。 让我们回顾一下发生了什么,在哪里发生的。 下图描述了应用的组成 + +整个应用的配置已经逐渐变得复杂。让我们回顾一下发生了什么,以及在哪里发生。下面的图片描述了应用的构成 ![](../../images/2/18e.png) - - -构成我们的 React 应用的 JavaScript 代码在浏览器中运行。 浏览器从React dev server 获取 Javascript,这是运行 npm start 命令后运行的应用。 dev-server 将 JavaScript 转换成浏览器可以理解的格式。 除此之外,它还将来自不同文件的 Javascript 整合到一个文件中。 我们将在本课程的第7章节中更详细地讨论开发服务器。 + +构成我们React应用的JavaScript代码在浏览器中运行。浏览器从React dev server,即运行命令npm run dev后启动的应用中获取JavaScript。dev-server将JavaScript转换为浏览器可以理解的格式。这个过程中会将不同文件的JavaScript拼接成一个文件。我们将在课程的第7章节更详细地讨论dev-server。 - -在浏览器中运行的 React 应用从计算机3001端口上运行的JSON-server 获取 JSON 格式的数据。 Json-server 从db.json 文件中获取数据。 + +浏览器中运行的React应用从运行在机器3001端口的json-server获取JSON格式的数据。我们查询数据的服务端——json-server——从文件db.json中获取数据。 - -在开发的这个阶段,应用的所有部分都放在软件开发人员的机器上,也就是本地主机。 当应用被部署到互联网上时,情况发生了变化。 我们将在第三章节讨论这个。 + +开发到现在,应用的所有部分恰好都在软件开发者的机器,或者叫localhost上。当将应用部署到互联网上时,情况又会发生变化。我们将在第3章节做这件事。
    -
    + +

    练习 2.11.

    -

    Exercises 2.11.-2.14.

    - + +

    2.11:电话簿 第6步

    -

    2.11: The Phonebook 步骤6

    - -我们继续开发电话簿。 将应用的初始状态存储在文件db.json 中,将该文件应该放在项目的根目录中。 + +我们继续开发电话簿。将应用的初始状态存储在文件db.json中,该文件应放置在项目的根目录下。 ```json { "persons":[ - { - "name": "Arto Hellas", + { + "name": "Arto Hellas", "number": "040-123456", - "id": 1 + "id": "1" }, - { - "name": "Ada Lovelace", + { + "name": "Ada Lovelace", "number": "39-44-5323523", - "id": 2 + "id": "2" }, - { - "name": "Dan Abramov", + { + "name": "Dan Abramov", "number": "12-43-234345", - "id": 3 + "id": "3" }, - { - "name": "Mary Poppendieck", + { + "name": "Mary Poppendieck", "number": "39-23-6423122", - "id": 4 + "id": "4" } ] } ``` - -在3001端口上启动 json-server,并确保服务器通过访问浏览器中的地址http://localhost:3001/persons 能够返回人员列表。 + +在3001端口启动json-server,并通过在浏览器中访问地址来确保服务端返回人员名单。 - -如果您收到如下错误消息: + + +如果你收到以下错误信息: ```js events.js:182 @@ -706,106 +681,10 @@ Error: listen EADDRINUSE 0.0.0.0:3001 at _exceptionWithHostPort (util.js:1041:20) ``` - - - -这意味着端口3001已经被另一个应用使用,例如已经运行的 json-server 正在使用。 关闭其他应用,或者更改端口,以防出现不正常的情况。 - - -修改应用,使用axios-库从服务器获取数据的初始状态。 使用[Effect hook](https://reactjs.org/docs/hooks-Effect.html)完成获取操作。 - -

    2.12* Data for countries, 步骤1

    - - - -Api [https://restcountries.eu](https://restcountries.eu) 以机器可读的格式,提供了不同国家的大量数据。即所谓的 REST API。 - - -创建一个应用,可以查看不同国家的数据。 应用能从[all](https://restcountries.eu/#api-endpoints-all)中获取数据。 - - -用户界面非常简单。 通过在搜索字段中键入搜索查询,可以找到要显示的国家。 - - -如果匹配查询的国家太多(超过10个) ,则提示用户使查询更加具体: - -![](../../images/2/19b1.png) - - - -如果少于10个国家,但多于1个,则显示所有匹配查询的国家: - -![](../../images/2/19b2.png) - - - -如果只有一个国家匹配查询,则显示该国的基本数据、国旗和该国使用的语言: - -![](../../images/2/19b3.png) - - - -注意: 你的应用在大多数国家能好用就可以了。 有些国家,如苏丹,可能会有些麻烦,因为国名是另一个国家名称的一部分,即南苏丹。 你不必担心这些边缘情况edge cases。 - - - -**警告**: create-react-app 会自动使项目成为一个 git 仓库,除非应用是在已有仓库中创建的。 而您很可能不希望项目成为一个存储库,因此可以在项目的根目录中运行命令 *_rm -rf .git_* 。 - -

    2.13*: Data for countries, 步骤2

    - - -这章节还有很多事情要做,所以不要卡在这个练习上! - - -改进前一项工作中的应用,例如,当页面上显示多个国家的名称时,在国家名称旁边有一个按钮,当按下该按钮时,显示该国的视图: - -![](../../images/2/19b4.png) - - -在这个练习中,您的应用能够在大多数国家好使就足够了。 国家名包含在其他国家的情况,如苏丹 可以被忽略。 - -

    2.14*: Data for countries, 步骤3

    - - - - -这章节还有很多事情要做,所以不要卡在这个练习上! - - - - -在显示单个国家数据的视图中添加该国首都的天气报告。 有几十个天气数据提供商。 我用了[https://weatherstack.com/](https://weatherstack.com/)。 - -![](../../images/2/19ba.png) - - - - -注意: 几乎所有气象服务都需要 api-key。 不要将 api-key 保存到源代码管理Git中! 也不能将 api-key 硬编码到源代码中。 取而代之的是使用[环境变量](https://create-react-app.dev/docs/adding-custom-environment-variables/)来保存密钥。 - - -假设 api-key 是t0p53cr3t4p1k3yv4lu3,当应用像下面这样启动时: - -```bash -REACT_APP_API_KEY=t0p53cr3t4p1k3yv4lu3 npm start -``` - + +这意味着3001端口已经被另一个应用使用,比如被一个已经运行的json-server使用。关闭另一个应用,或者如果关闭另一个应用没用的话,那就换一个端口。 - -您可以从 process.env 对象访问密钥的值: - -```js -const api_key = process.env.REACT_APP_API_KEY -// variable api_key has now the value set in startup -``` - + +修改应用,使数据的初始状态是使用axios库从服务端获取的。用[Effect Hook](https://react.dev/reference/react/useEffect)来获取。 -注意,如果你使用`npx create-react-app ...` 创建了应用,并且想要为环境变量使用其他名称,则环境变量必须以`REACT_APP_`开头。你还可以通过在项目中创建一个名为`.env`的文件并添加以下内容来使用'.env' 文件,而不是每次都在命令行中定义。 - -``` -# .env - -REACT_APP_API_KEY=t0p53cr3t4p1k3yv4lu3 -```
    - diff --git a/src/content/2/zh/part2d.md b/src/content/2/zh/part2d.md index 4a3cd0b3efa..144f679e94f 100644 --- a/src/content/2/zh/part2d.md +++ b/src/content/2/zh/part2d.md @@ -7,44 +7,44 @@ lang: zh
    + +当在我们的应用中创建笔记时,我们自然希望将它们存储在某个后端服务端中。[json-server](https://github.com/typicode/json-server)包在文档中自称是一个所谓的REST或RESTful API: - -在应用中创建便笺时,我们自然希望将它们存储在某个后端服务器中。 在文档中,[json-server](https://github.com/typicode/json-server 服务器)包提到了所谓的 REST 或 RESTful API: + +> 30秒内零编码(认真的)获得一个完整的假REST API -> Get a full fake REST API with zero coding in less than 30 seconds (seriously) -在不到30秒(严肃地)的情况下得到一个完整的模拟 REST API,0编码 + +json-server并不完全符合教科书对REST API的描述[定义](https://en.wikipedia.org/wiki/Representational_state_transfer),但其他大多数自称是RESTful的API也不符合。 - -Json-server 与 REST API 的教科书[定义](https://en.wikipedia.org/wiki/representational_state_transfer)提供的描述不完全匹配,但是自称是 RESTful 的大多数服务都不完全匹配。 - - -我们将在本课程的[下一章节](/zh/part3)中进一步了解 REST,但是熟悉 json-server 和 REST api 经常使用的一些[约定](https://en.wikipedia.org/wiki/representational_state_transfer#applied_to_web_services)是很重要的。 特别是,我们将会看到在 REST 中常规使用[路由](https://github.com/typicode/json-server#routes) ,即 url 和 HTTP 请求类型。 + +我们将在课程的[下一章节](/zh/part3)中仔细研究REST。但是现在的重点是熟悉json-server和各种REST API通用的一些[约定](https://en.wikipedia.org/wiki/Representational_state_transfer#Applied_to_web_services)。特别是REST中[路由](https://github.com/typicode/json-server#routes),也就是URL和HTTP请求类型的常规使用方法。 ### REST - -在 REST 术语中,我们将单个数据对象(如应用中的便笺)称为resources。 每个资源都有一个唯一的地址——它的 URL。 根据 json-server 使用的一般约定,我们将能够在资源 URL, 即notes/3上定位某个便笺,其中3是资源的 id。 另一方面, notes url 指向包含所有便笺的资源集合。 - -通过 HTTP GET 请求从服务器获取资源。 例如,对 URLnotes/3 的 HTTP GET 请求将返回 id 为3的便笺。 对notes URL 的 HTTP GET 请求将返回所有便笺的列表。 + +在REST术语中,我们把单个数据对象,比如我们应用中的笔记,称为资源。每个资源都有一个与之相关的独一无二的地址——它的URL。根据json-server使用的一般惯例,我们可以通过资源URL notes/3来定位某一条笔记,其中3是资源的id。另一方面,notes URL将指向一个包含所有笔记的资源集合。 + + +资源是通过HTTP GET请求从服务端获取的。例如,对URL notes/3的HTTP GET请求将返回ID为3的笔记。对notes URL的HTTP GET请求将返回所有笔记的列表。 - -根据 json 服务器遵守的 REST 约定,通过向notes URL 发出 HTTP POST 请求来创建、存储新的便笺。 新便笺资源的数据在请求的body 中发送。 + +根据json-server所遵守的REST惯例,创建用于存储笔记的新资源是通过向notes URL发送HTTP POST请求来实现的。新笔记资源的数据在请求中发送。 - -Json-server 要求以 JSON 格式发送所有数据。 实际上,这意味着数据必须是格式正确的字符串,并且请求必须包含值为application/jsonContent-Type 请求头。 + +json-server要求所有数据以JSON格式发送。这实际上意味着数据必须是格式正确的字符串,而且请求必须包含Content-Type标头,且Content-Type的值为application/json。 -### Sending Data to the Server -【发送数据到服务器】 - -让我们对负责创建新便笺的事件处理进行如下更改: + +### 向服务端发送数据 + + +让我们对负责创建新笔记的事件处理函数做如下修改: ```js -addNote = event => { +const addNote = event => { event.preventDefault() const noteObject = { content: newNote, - date: new Date(), - important: Math.random() > 0.5, + important: Math.random() < 0.5, } // highlight-start @@ -57,43 +57,49 @@ addNote = event => { } ``` + +我们为笔记创建一个新的对象,但省略了id属性,因为id最好让服务端为我们的资源生成。 - -我们为便笺创建了一个新对象,但忽略了id 属性,因为最好让服务器为我们的资源生成 id! + +使用axios的post方法将对象发送到服务端。注册的事件处理函数记录了从服务端发回控制台的响应。 - -使用 axios post 方法将对象发送到服务器。 已注册的事件处理函数从服务器发送回控制台的响应记录。 - - -当我们尝试创建一个新的便笺时,控制台会弹出如下输出: + +当我们尝试创建一条新的笔记时,控制台中会弹出以下输出: ![](../../images/2/20e.png) - -新创建的便笺资源存储在response对象的data 属性值中。 + +新创建的笔记资源被存储在_response_对象的data属性值中。 + + +在Chrome开发工具中的Network标签页中检查HTTP请求经常会很有用,[第0章节](/zh/part0/web_应用的基础设施#http-get)的开头就大量使用了这个标签页。 + + +我们可以使用检查器来检查POST请求中发送的标头是否是我们所期望的: - -有时在 Chrome 开发工具的Network 选项卡中检查 HTTP 请求是很有用的,这个选项卡在[第 0 章](/zh/part0/web_应用的基础设施#http-get) 开始时被大量使用: +![](../../images/2/21new1.png) -![](../../images/2/21e.png) + +由于我们在POST请求中发送的数据是一个JavaScript对象,axios自动知道为将Content-Type标头设为正确的application/json值。 + +负载标签页可以用来查看请求的数据: +![](../../images/2/21new2.png) - -我们可以使用检查器来检查 POST 请求中发送的头文件是否符合我们的预期,以及它们的值是否正确。 + +响应标签页也有用,它展示了服务端响应的数据是什么: - -由于我们在 POST 请求中发送的数据是一个 JavaScript 对象,axios 自动懂得为Content-Type 头设置适当的application/json 值。 +![](../../images/2/21new3.png) - -新的便笺还没有渲染到屏幕上。 这是因为我们在创建新便笺时没有更新App 组件的状态。 让我们来解决这个问题: + +新笔记还没有渲染到屏幕上。这是因为我们在创建新笔记时没有更新App组件的状态。让我们来解决这个问题: ```js -addNote = event => { +const addNote = event => { event.preventDefault() const noteObject = { content: newNote, - date: new Date(), important: Math.random() > 0.5, } @@ -108,40 +114,34 @@ addNote = event => { } ``` - -后端服务器返回的新便笺将按照使用 setNotes 函数然后重置便笺创建表单的惯例方式添加到应用状态的便笺列表中。 需要记住的一个 [重要细节important detail](/zh/part1/深入_react_应用调试#handling-arrays) 是 concat 方法不会改变组件的原始状态,而是创建列表的新副本。 + +通过惯常的方式,将后端服务端返回的新笔记添加到我们应用状态中的笔记列表中,使用setNotes函数,然后重置创建笔记的表单。一个需要记住的[重要细节](/zh/part1/复杂状态,调试_react应用#处理数组)是,concat方法并不改变组件的状态本体,而是创建一个新的列表副本。 - -一旦服务器返回的数据开始影响我们 web 应用的行为,我们就会立即面临一系列全新的挑战,例如,通信的异步性。 这就需要新的调试策略,控制台日志和其他调试手段变得越来越重要,我们还必须对 JavaScript 运行时和 React 组件的原理有充分的理解。 光靠猜是不够的。 + +一旦服务端返回的数据开始影响我们Web应用的行为,我们就会立即面临一系列全新的挑战,例如,通信的异步性。这就需要新的调试策略,控制台记录和其他调试方法变得越来越重要。我们还必须对JavaScript运行时和React组件的原理有足够的理解。仅仅通过猜是不够的。 - -通过浏览器检查后端服务器的状态是有益的: + +检查后端服务端的状态是有好处的,比如通过浏览器: ![](../../images/2/22e.png) + +这让我们可以验证我们打算发送的所有数据是否真的被服务端收到。 + +在课程的下一部分,我们将学习如何在后端实现我们自己的逻辑。然后我们将仔细研究像[Postman](https://www.postman.com/downloads/)这些可以帮助我们调试我们的服务端应用的工具。不过目前而言,通过浏览器检查json-server的状态就足够满足我们的需要了。 - -这样就可以验证我们打算发送的所有数据是否实际上已经被服务器接收。 + +我们应用当前状态的代码可以在[GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-5)上的part2-5分支找到。 - -在本课程的下一章节中,我们将学习如何在后端实现我们自己的逻辑。 然后,我们将进一步研究一些工具,如[postman](https://chrome.google.com/webstore/detail/postman/fhbjgbiflinjbdggehcddcbncdddomop) ,这些工具可以帮助我们调试服务器应用。 但是,通过浏览器检查 json-server 的状态就足以满足我们当前的需求。 + +### 改变笔记的重要性 -> -注意: 在当前版本的应用中,浏览器在便笺中添加了创建日期属性。 由于运行浏览器的机器的时钟可能错误地配置,所以让后端服务器为我们生成这个时间戳要明智得多。 这实际上就是我们在下一章节课程中要做的。 + +让我们为每条笔记添加一个可以用来切换笔记的重要性的按钮。 - -我们应用当前状态的代码可以在[github](https://github.com/fullstack-hy2020/part2-notes/tree/part2-5)上的part2-5 分支中找到。 - - -### Changing the importance of notes -【改变便笺的重要性】 - - -让我们为每个便笺添加一个按钮,用于切换它的重要性。 - - -我们对Note 组件进行如下更改: + +我们对Note组件做如下修改: ```js const Note = ({ note, toggleImportance }) => { @@ -150,18 +150,18 @@ const Note = ({ note, toggleImportance }) => { return (
  • - {note.content} + {note.content}
  • ) } ``` - -我们向组件添加一个按钮,并将其事件处理作为toggleImportance函数的 props 传递到组件中。 + +我们给组件添加一个按钮,并将其事件处理函数赋值为组件的props中传递的toggleImportance函数。 - -App组件定义了 toggleImportanceOf事件处理函数的初始版本,并将其传递给每个Note组件: + +App组件定义了一个初始版本的toggleImportanceOf事件处理函数,并将其传递给每个Note组件: ```js const App = () => { @@ -186,12 +186,12 @@ const App = () => { -
    +
      - {notesToShow.map((note, i) => + {notesToShow.map(note => toggleImportanceOf(note.id)} // highlight-line /> )} @@ -202,38 +202,38 @@ const App = () => { } ``` - -注意每个便笺是如何接收它自己唯一的 事件处理函数的,因为每个便笺的 id 是唯一的。 + +注意每条笔记是如何得到它自己独有的事件处理函数的,因为每条笔记的id都是独一无二的。 - -例如,如果我 note.id 是3, _toggleImportance(note.id)_ 返回的事件处理函数将是: + +例如,如果note.id是3,由_toggleImportance(note.id)_返回的事件处理函数将是: ```js () => { console.log('importance of 3 needs to be toggled') } ``` - -这里有一个简短的提醒: 事件处理以类 java 的方式通过加号连接字符串定义字符串: + +在此简单提醒一下。事件处理函数所打印的字符串是以类似Java的方式使用加号连接字符串来定义的: ```js console.log('importance of ' + id + ' needs to be toggled') ``` - -在 ES6中,添加 [template string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals) 语法可以用一种更好的方式来编写类似的字符串: + +可以用ES6中添加的[模板字符串](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals)语法来以更好的方式编写类似的字符串: ```js console.log(`importance of ${id} needs to be toggled`) ``` - -我们现在可以使用“ dollar-bracket”语法向字符串中添加内容来计算 JavaScript 表达式,例如变量的值。 注意,模板字符串中使用的反引号与常规 JavaScript 字符串中使用的引号不同。 + +我们现在可以使用“${}”语法在字符串中添加要计算JavaScript表达式的部分,例如变量的值。注意,我们在模板字符串中使用的是反引号*\`*,而非用于普通JavaScript字符串的引号*'*或*"*。 - -存储在 json-server 后端中的各个便笺可以通过对便笺的唯一 URL 发出 HTTP 请求,以两种不同的方式进行修改。 我们可以用 HTTP PUT 请求替换 整个便笺,或者只用 HTTP PATCH 请求更改便笺的一些属性。 + +存储在json-server后端的每条笔记可以通过两种不同方式进行修改,两种方式都是对笔记的唯一URL进行HTTP请求。我们可以用HTTP PUT请求来替换整条笔记,也可以用HTTP PATCH请求只改变笔记的某些属性。 - -事件处理函数的最终形式如下: + +事件处理函数的最终形式是这样的: ```js const toggleImportanceOf = id => { @@ -242,36 +242,32 @@ const toggleImportanceOf = id => { const changedNote = { ...note, important: !note.important } axios.put(url, changedNote).then(response => { - setNotes(notes.map(note => note.id !== id ? note : response.data)) + setNotes(notes.map(note => note.id === id ? response.data : note)) }) } ``` + +函数体中的几乎每一行代码都包含了重要的细节。第一行定义了基于笔记id的每条笔记资源的唯一URL。 - -函数体中几乎每一行代码都包含重要的细节。 第一行根据每个便笺资源的 id 定义其唯一的 url。 - - -数组的 [find](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/array/find)方法用于查找要修改的便笺,然后将其分配给note变量。 + +数组的[find方法](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find)用来查找我们要修改的笔记,然后我们把要修改的笔记赋值给_note_变量。 - -在此之后,我们创建一个新对象,除了重要性属性,它完全是旧便笺的副本。 + +在这之后,我们创建一个新对象,它是旧笔记的精确拷贝,除了important属性的值被翻转(从true变为false或从false变为true)。 - -使用[对象展开object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax)语法创建新对象的代码 - -可能看起来有点奇怪: + +创建新对象的代码使用了[对象展开](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax)语法,一开始可能看起来有点奇怪: ```js const changedNote = { ...note, important: !note.important } ``` - -实际上, { ...note } 创建一个新对象,其中包含来自 note 对象的所有属性的副本。 当我们在 spreaded 对象后面的花括号中添加属性时,例如{ ...note, important: true },那么新对象的重要性属性的值将为 true。 在我们的示例中,important 属性在原始对象中取其先前值的反值。 - + +实际上,{ ...note }创建了一个复制了_note_对象所有属性的新对象。当我们在展开对象后面的大括号内添加属性时,比如{ ...note, important: true },那么新对象的_important_属性值将是_true_。在我们的例子中,important属性是原来对象中先前important值的相反值。 - -有几点需要指出。 为什么我们要复制我们想要修改的 note 对象,而下面的代码似乎也可以工作: + +有几件事需要指出。为什么我们要创建一个我们要修改的笔记对象的拷贝,明明下面的代码看起来也能运行? ```js const note = notes.find(n => n.id === id) @@ -281,45 +277,45 @@ axios.put(url, note).then(response => { // ... ``` - -不建议这样做,因为变量 note 是对处于组件状态 notes 数组中某个项的引用,而且我们记得在 React 中绝不能直接修改状态。 + +不建议这样做,因为变量note是对组件状态中notes数组中一项的引用,而我们记得我们在React中[决不能直接改变状态](https://react.dev/learn/updating-objects-in-state#why-is-mutating-state-not-recommended-in-react)。 - -值得注意的是,新对象 changedNote 只是一个所谓的[浅拷贝](https://en.wikipedia.org/wiki/object_copying#shallow_copy) ,这意味着新对象的值与旧对象的值相同。 如果旧对象的值本身就是对象,那么**新对象中复制的值将引用旧对象中的相同对象**。 + +还有一点值得注意,新对象_changedNote_只是一个所谓的[浅拷贝](https://en.wikipedia.org/wiki/Object_copying#Shallow_copy),意味着新对象中各属性的值与旧对象中各属性的值相同。如果旧对象中某属性的值也是对象,那么新对象中该属性的复制值将和旧对象中的该属性的原始值引用相同的对象。 - -然后这个新便笺与一个 PUT 请求一起发送到后端,它将在后端替换旧对象。 + +然后,新的笔记会通过PUT请求发送到后端,在那里它将替换旧的对象。 - -回调函数将组件的 notes 状态设置为一个新数组,该数组包含前一个notes 数组中的所有条目,但旧的条目被服务器返回的更新版本所替换: + +回调函数将组件的notes状态设为一个新的数组,该数组包含了原先notes数组中的所有项,除了旧的笔记被替换为服务端返回的更新版本: ```js axios.put(url, changedNote).then(response => { - setNotes(notes.map(note => note.id !== id ? note : response.data)) + setNotes(notes.map(note => note.id === id ? response.data : note)) }) ``` - -这是通过 map方法实现的: + +这是用map方法完成的: ```js -notes.map(note => note.id !== id ? note : response.data) +notes.map(note => note.id === id ? response.data : note) ``` - -Map 方法通过将旧数组中的每个项映射到新数组中的一个项来创建一个新数组。 在我们的示例中,新数组被有条件地创建,即如果note.id !== id为true,我们只需将项从旧数组复制到新数组中。 如果条件为 false,则将服务器返回的 note 对象添加到数组中。 + +map方法会通过将旧数组中的每一项映射到新数组的每一项来创建一个新数组。在我们的例子中,新数组是通过条件创建的,如果note.id === id为true,那么就会把服务端返回的笔记对象添加到数组中。如果条件为false,那么我们就只是简单把旧数组的项复制到新数组中。 - -这个map 技巧起初可能看起来有点奇怪,但是它值得你花一些时间去理解它。 在整个课程中,我们将多次使用这种方法。 + +这个map技巧一开始可能看起来有点奇怪,但值得花些时间去琢磨它。我们将在整个课程中多次使用这种方法。 -### Extracting communication with the backend into a separate module -【将与后端的通信提取到单独的模块中】 + +### 把和后端的通信提取到单独的模块中 - -在添加了用于与后端服务器通信的代码之后,App 组件变得有些臃肿。 本着[单一职责原则](https://en.wikipedia.org/wiki/single_responsibility_principle)的精神,我们认为将这种通信提取到它自己的[模块](/zh/part2/从渲染集合到模块学习#refactoring- 模块s)是明智的。 + +在添加了与后端服务端通信的代码后,App组件变得有些臃肿。本着[单一职责原则](https://en.wikipedia.org/wiki/Single_responsibility_principle),我们认为将与后端服务端的通信提取到自己的[模块](/zh/part2/渲染集合与模块#重构模块)是明智的。 - -让我们创建一个src/services目录,并添加一个名为notes.js 的文件: + +让我们创建一个src/services目录,并向其中添加一个名为notes.js的文件: ```js import axios from 'axios' @@ -337,18 +333,18 @@ const update = (id, newObject) => { return axios.put(`${baseUrl}/${id}`, newObject) } -export default { - getAll: getAll, - create: create, - update: update +export default { + getAll: getAll, + create: create, + update: update } ``` - -该模块返回一个具有三个函数(getAll, create, and update)的对象,作为其处理便笺的属性。 函数直接返回 axios 方法返回的允诺Promise。 + +该模块返回一个对象,该对象有三个处理笔记的函数(getAllcreateupdate)作为其属性。这些函数直接返回axios方法所返回的Promise。 - -App 组件使用 import访问模块: + +App组件使用import来访问模块: ```js import noteService from './services/notes' // highlight-line @@ -356,8 +352,8 @@ import noteService from './services/notes' // highlight-line const App = () => { ``` - -该模块的功能可以直接与导入的变量 noteService 一起使用,具体如下: + +可以直接通过导入的变量_noteService_使用该模块的函数,如下所示: ```js const App = () => { @@ -381,7 +377,7 @@ const App = () => { noteService .update(id, changedNote) .then(response => { - setNotes(notes.map(note => note.id !== id ? note : response.data)) + setNotes(notes.map(note => note.id === id ? response.data : note)) }) // highlight-end } @@ -390,7 +386,6 @@ const App = () => { event.preventDefault() const noteObject = { content: newNote, - date: new Date().toISOString(), important: Math.random() > 0.5 } @@ -410,9 +405,8 @@ const App = () => { export default App ``` - - -我们可以将我们的实现更进一步。 当App 组件使用这些函数时,它接收到一个包含 HTTP 请求的整个响应的对象: + +我们可以深入看一下我们的实现。当App组件调用这些函数时,它会收到一个包含HTTP请求全部响应的对象: ```js noteService @@ -422,12 +416,11 @@ noteService }) ``` - -App 组件只使用response对象的 response.data 属性。 - + +App组件只使用响应对象的response.data属性。 - -如果我们只获得响应数据,而不是整个 HTTP 响应,那么使用这个模块会更好。 使用这个模块看起来是这样的: + +如果只获得响应数据,而非整个HTTP响应,那么这个模块的就会明显更好用。于是使用这个模块就会像这样: ```js noteService @@ -437,8 +430,8 @@ noteService }) ``` - -我们可以通过如下变更模块中的代码来实现这一点(当前的代码包含一些复制粘贴,但我们暂时可以容忍这一点) : + +要实现这个需求,我们可以将模块的代码改成这样(目前的代码包含一些复制粘贴的内容,但我们现在暂且不管): ```js import axios from 'axios' @@ -459,15 +452,16 @@ const update = (id, newObject) => { return request.then(response => response.data) } -export default { - getAll: getAll, - create: create, - update: update +export default { + getAll: getAll, + create: create, + update: update } ``` - -我们不再直接回应axios 的承诺。 相反,我们将 promise 分配给 request 变量,并调用它的then 方法: + + +我们不再直接返回axios返回的Promise。而是将Promise赋值给request变量并调用其then方法: ```js const getAll = () => { @@ -476,9 +470,8 @@ const getAll = () => { } ``` - - -我们函数的最后一行只是相同代码的更简洁的表达式,如下所示: + +我们函数中的最后一行只是对下面相同代码的一个更紧凑的表达: ```js const getAll = () => { @@ -491,14 +484,14 @@ const getAll = () => { } ``` - -修改后的getAll 函数仍然返回一个 promise,因为 promise 的 then 方法也[返回一个 promise](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/promise/then)。 + +修改后的getAll函数仍然返回一个Promise,因为Promise的then方法也[返回一个Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then)。 - -在定义了then 方法的参数直接返回response.data 之后,我们已经让 getAll 函数按照我们希望的方式工作。 当 HTTP 请求成功时,承诺将返回从后端响应中发送回来的数据。 + +在定义then方法的参数来让getAll函数直接返回response.data之后,getAll已经函数满足了我们的需求。当HTTP请求成功时,Promise会返回后端响应发回的数据。 - -我们必须更新App 组件来处理对模块所做的更改。 我们必须修复作为参数给予noteService对象方法的回调函数,以便它们使用直接返回的响应数据: + +我们必须更新App组件以配合我们对模块的更改。我们必须更改传给noteService对象方法的参数的回调函数,让它们直接使用返回的响应数据: ```js const App = () => { @@ -507,10 +500,10 @@ const App = () => { useEffect(() => { noteService .getAll() - // highlight-start - .then(initialNotes => { + // highlight-start + .then(initialNotes => { setNotes(initialNotes) - // highlight-end + // highlight-end }) }, []) @@ -520,9 +513,9 @@ const App = () => { noteService .update(id, changedNote) - // highlight-start - .then(returnedNote => { - setNotes(notes.map(note => note.id !== id ? note : returnedNote)) + // highlight-start + .then(returnedNote => { + setNotes(notes.map(note => note.id === id ? returnedNote : note)) // highlight-end }) } @@ -531,16 +524,15 @@ const App = () => { event.preventDefault() const noteObject = { content: newNote, - date: new Date().toISOString(), important: Math.random() > 0.5 } noteService .create(noteObject) - // highlight-start - .then(returnedNote => { + // highlight-start + .then(returnedNote => { setNotes(notes.concat(returnedNote)) - // highlight-end + // highlight-end setNewNote('') }) } @@ -549,25 +541,23 @@ const App = () => { } ``` - -这一切都相当复杂,试图解释它可能只会让它更难理解。 互联网上充满了讨论这个话题的材料,比如这个[this](https://javascript.info/promise-chaining)。 - - -在[You do not know JS](https://github.com/getify/You-Dont-Know-JS/tree/1st-ed) 一书中,对这个议题进行了很好的解释[well](https://github.com/getify/You-Dont-Know-JS/blob/1st-ed/async%20%26%20performance/ch3.md),但是解释有很多页 + +这一切都很复杂,试图解释它可能只会让它更难理解。互联网上有很多讨论这个话题的资料,比如[这](https://javascript.info/promise-chaining)个。 - -承诺Promise是现代 JavaScript 开发的核心,强烈建议投入合理的时间来理解它们。 + +[You do not know JS](https://github.com/getify/You-Dont-Know-JS/tree/1st-ed)丛书中的《Async and performance》一书很好地[解释了这个话题](https://github.com/getify/You-Dont-Know-JS/blob/1st-ed/async%20%26%20performance/ch3.md),但解释的篇幅很长。 + +Promise是现代JavaScript开发的核心,强烈建议投入一定时间来理解它们。 -### Cleaner syntax for defining object literals -【用于定义对象字面量的更清晰的语法】 + +### 更清晰地定义对象字面量的语法 - -定义便笺相关服务的模块目前导出一个具有属性getAllcreateupdate 的对象,这些属性分配给处理便笺的函数。 + +定义笔记相关服务的模块目前导出了一个对象,其属性getAllcreateupdate被赋值了处理笔记的函数。 - - -模块的定义是: + +模块的定义是: ```js import axios from 'axios' @@ -588,43 +578,40 @@ const update = (id, newObject) => { return request.then(response => response.data) } -export default { - getAll: getAll, - create: create, - update: update +export default { + getAll: getAll, + create: create, + update: update } ``` - -该模块导出下面这个看起来相当奇怪的对象: + +该模块导出了下面看起来相当奇怪的对象: ```js -{ - getAll: getAll, - create: create, - update: update +{ + getAll: getAll, + create: create, + update: update } ``` + +对象定义中,冒号左边的标签是对象的,而冒号右边的是模块中定义的变量。 - -在对象定义中,冒号左侧的标签是对象的,而它右侧的标签是在模块内部定义的variables。 - - - -由于键和赋值变量的名称是相同的,我们可以用更简洁的语法来编写对象定义: + +由于键名和赋值的变量名是一样的,我们可以使用更紧凑的语法来定义对象: ```js -{ - getAll, - create, - update +{ + getAll, + create, + update } ``` - - -因此,模块定义被简化为如下形式: + +于是,模块的定义被简化为: ```js import axios from 'axios' @@ -648,48 +635,45 @@ const update = (id, newObject) => { export default { getAll, create, update } // highlight-line ``` - -在使用这种较短的符号定义对象时,我们利用了通过 ES6引入到 JavaScript 中的一个[新特性new feature](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer#Property_definitions) ,使得使用变量定义对象的方法更加简洁。 + +在使用这种更简洁的符号定义对象时,我们利用了在ES6中引入到JavaScript中的[新特性](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer#Property_definitions),它让使用变量定义对象的方式稍微更紧凑了一些。 - -为了演示这个特性,让我们考虑这样一种情况: 我们给变量赋值如下: + +为了演示这个特性,让我们考虑这种情况,我们给变量赋了以下的值: -```js +```js const name = 'Leevi' const age = 0 ``` + +在旧版本的JavaScript中,我们必须这样定义一个对象: - -在旧版本的 JavaScript 中,我们必须这样定义一个对象: - -```js +```js const person = { name: name, age: age } ``` - -然而,由于对象中的属性字段和变量名称都是相同的,只需在 ES6 JavaScript 中写入如下内容就足够了: + +然而,由于对象中的属性字段名和变量名都是一样的,所以在ES6 JavaScript中,只需这么写就够了: -```js +```js const person = { name, age } ``` + +两个表达式的结果都是一样的。它们都创建了一个对象,其name属性值为Leeviage属性值为0。 - -两个表达式的结果是相同的。 它们都创建了一个值为Leeviname 属性和值为0age 属性的对象。 - - -### Promises and errors -【承诺和错误】 + +### Promise和错误 - -如果我们的应用允许用户删除便笺,那么我们可能会出现这样的情况: 用户试图更改已经从系统中删除的便笺的重要性。 + +如果我们的应用允许用户删除笔记,我们可能会出现这种情况:用户试图改变一条笔记的重要性,而这条笔记在系统中已经被删除了。 - -让我们通过使 note 服务的getAll 函数返回一个“硬编码”的便笺来模拟这种情况,这个便笺实际上并不存在于后端服务器中: + +让我们模拟这种情况,让笔记服务的getAll函数返回一条“硬编码”的笔记,而这条笔记实际上并不存在于后端服务端上。 ```js const getAll = () => { @@ -697,33 +681,31 @@ const getAll = () => { const nonExisting = { id: 10000, content: 'This note is not saved to server', - date: '2019-05-30T17:30:31.098Z', important: true, } return request.then(response => response.data.concat(nonExisting)) } ``` - -当我们试图更改硬编码说明的重要性时,我们在控制台中看到如下错误消息。 错误说明后端服务器用状态码404not found 响应了我们的 HTTP PUT 请求。 + +当我们试图改变这条硬编码笔记的重要性时,我们在控制台中看到了以下错误信息。该错误说后端服务端对我们的HTTP PUT请求的响应是状态代码404 not found。 ![](../../images/2/23e.png) - -应用应该能够很好地处理这些类型的错误情况。 除非用户碰巧打开了自己的控制台,否则他们无法判断错误确实发生了。 在应用中可以看到错误的唯一方式是单击按钮看看对便笺的重要性没有影响。 + +应用应当能优雅地处理这些类型的错误情况。用户无法得知发生了错误,除非他们碰巧打开了他们的控制台。在应用中可以看到错误的唯一方法是,点击按钮没有影响笔记的重要性。 - -我们 [之前](/zh/part2/从服务器获取数据#axios-and-promises) 提到,一个承诺可以处于三种不同的状态之一。 当 HTTP 请求失败时,相关的承诺是rejected。 我们当前的代码没有以任何方式处理这种拒绝。 + +我们[之前](/zh/part2/获取服务端的数据#axios和-promise)提到,一个Promise可能有三种不同的状态。当一个axios HTTP请求失败时,相关的Promise被拒绝。我们目前的代码没有以任何方式处理被拒绝的情况。 - -拒绝承诺是通过给then 方法提供第二个回调函数来处理的,这个[handled](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises) 在承诺被拒绝的情况下被调用。 + +Promise被拒绝的情况是由为then方法提供的第二个回调函数来[处理](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises)的,第二个回调函数在Promise被拒绝的情况下被调用。 - -为被拒绝的承诺添加处理程序的更常见的方法是使用[catch](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/promise/catch)方法。 + +为被拒绝的Promise添加处理函数的更常见的方式是使用[catch](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch)方法。 - - -实际上,拒绝承诺的错误处理程序的定义如下: + +在实践中,被拒绝的Promise的错误处理函数是像这样定义的: ```js axios @@ -736,32 +718,32 @@ axios }) ``` - -如果请求失败,则调用catch 方法注册的事件处理程序。 + +如果请求失败,catch方法注册的事件处理函数就会被调用。 - -catch 方法通常是通过将其置于Promise链的更深处来使用的。 + +catch方法经常用于放在Promise链中的更深处。 - -当我们的应用发出一个 HTTP 请求时,我们实际上是在创建一个 [promise chain](https://javascript.info/promise-chaining): + +当将多个_.then_方法链接在一起时,我们实际上是在创建一个[Promise链](https://javascript.info/promise-chaining): ```js axios - .put(`${baseUrl}/${id}`, newObject) + .get('http://...') .then(response => response.data) - .then(changedNote => { + .then(data => { // ... }) ``` - -catch 方法可用于在承诺链的末尾定义一个处理程序函数,一旦承诺链中的任何承诺抛出错误,承诺就变成rejected,就会调用该函数。 + +catch方法可以用来在Promise链的最后定义一个处理函数,一旦Promise链中的任何一个Promise抛出错误,整个Promise被拒绝时,就会调用_catch_方法。 ```js axios - .put(`${baseUrl}/${id}`, newObject) + .get('http://...') .then(response => response.data) - .then(changedNote => { + .then(data => { // ... }) .catch(error => { @@ -769,9 +751,8 @@ axios }) ``` - - -让我们使用这个特性并在App 组件中注册一个错误处理程序: + +让我们利用这个功能。我们将把我们应用的错误处理函数放在App组件中: ```js const toggleImportanceOf = id => { @@ -780,7 +761,7 @@ const toggleImportanceOf = id => { noteService .update(id, changedNote).then(returnedNote => { - setNotes(notes.map(note => note.id !== id ? note : returnedNote)) + setNotes(notes.map(note => note.id === id ? returnedNote : note)) }) // highlight-start .catch(error => { @@ -793,56 +774,90 @@ const toggleImportanceOf = id => { } ``` + +错误信息会通过久经考验的[alert](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert)对话框弹窗显示给用户,并且删除的笔记会从状态中筛除。 - -错误消息会通过弹出可靠的老式[alert](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert)对话框显示给用户,并且已删除的便笺会从状态中过滤掉。 - - -从应用的状态中删除已经删除的便笺是通过数组的 [filter](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/array/filter)方法完成的,该方法返回一个新的数组,其中只包含列表中的项目,作为参数传递的函数返回 true : + +从应用的状态中删除一条已经删除的笔记是通过数组的[filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter)方法完成的,它返回一个新数组,该数组只包括列表中,作为参数传递的函数返回true的项: ```js notes.filter(n => n.id !== id) ``` - -在更严肃的 React 应用中使用alert可能不是一个好主意。 我们很快就会学到一种更先进的向用户显示消息和通知的方式。 然而,在某些情况下,像alert这样简单的、经过实战检验的方法可以作为一个起点。 如果有时间和精力的话,可以在以后添加一个更高级的方法。 + +在更严肃的React应用中,使用alert很可能不是一个好主意。我们很快就会学到一种向用户显示消息和通知的更高级的方法。然而在有些情况下,像alert这样简单的、经过实践检验的方法可以作为一个起点。在时间和精力允许的情况下,总是可以在以后添加更高级的方法。 - -我们应用当前状态的代码可以在[github](https://github.com/fullstack-hy2020/part2-notes/tree/part2-6)上的part2-6 分支中找到。 + +我们应用当前状态的代码可以在[GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-6)的part2-6分支中找到。 -
    + +### 全栈开发者誓言 + + +又到了练习的时间了。我们应用的复杂性正在增加,因为除了处理前端的React组件之外,我们还有一个后端来持久保存应用的数据。 + + +为了应对日益增加的复杂性,我们应该将Web开发者誓言扩展为全栈开发者誓言,提醒我们确保前后端之间的通信如预期进行。 + +下面是更新后的誓言: + + +全栈开发非常难,所以我要尽一切可能方法让它变得更容易 + + +- 我会始终打开浏览器开发者控制台 + +- 我会用浏览器开发工具的网络标签页确保前后端的通信如我所预期 + +- 我会时刻关注服务端的状态,确保前端发送的数据如我所预期地保存在服务端 + +- 我会小步前进 + +- 我会写大量_console.log_语句来确保我理解代码的行为,并借助其定位问题 + +- 如果我的代码无法运行,我不会写更多的代码。相反,我会开始删除代码,直到它起作用,或者直接回到一切正常的状态 + +- 当我在课程的Discord频道或其他地方寻求帮助时,我会正确地表述我的问题,参见[这里](/en/part0/general_info#how-to-get-help-in-discord)了解如何提问 + +
    + +

    练习2.12.~2.15

    + + +

    2.12:电话簿 第7步

    -

    Exercises 2.15.-2.18.

    -

    2.15: Phonebook 步骤7

    - + 让我们回到我们的电话簿应用。 - -目前,添加到电话簿中的号码没有保存到后端服务器中。修复这种情况 + +目前,添加到电话簿的号码没有被保存到后端服务端。修复这一状况。 -

    2.16: Phonebook 步骤8

    - -通过遵循课程教材本章前面所示的示例,将处理与后端的通信的代码提取到它自己的模块中。 + +

    2.13:电话簿 第8步

    -

    2.17: Phonebook 步骤9

    - -使用户可以从电话簿中删除条目。 删除可以通过电话簿列表中每个人的专用按钮来完成。 你可以通过使用[window.confirm](https://developer.mozilla.org/en-us/docs/web/api/window/confirm)方法来确认用户的操作: + +按照本章节教材前面的例子,将处理与后端通信的代码提取到它自己的模块中。 -![](../../images/2/24e.png) + +

    2.14:电话簿 第9步

    - -通过对资源的 URL 发出 HTTP DELETE 请求,可以删除后端中人员的关联资源。 例如,如果我们要删除一个拥有id 为2的人,我们必须向 localhost:3001/persons/2. 发出 HTTP DELETE 请求。 请求没有发送任何数据。 + +让用户有可以从电话簿中删除记录。删除可以通过电话簿列表中每个人专用的按钮来完成。你可以用[window.confirm](https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm)方法来确认用户的操作: - -您可以使用[axios](https://github.com/axios/axios)库发出 HTTP DELETE 请求,就像我们发出所有其他请求一样。 +![](../../images/2/24e.png) + + +要删除后端中某个人的相关资源,可以通过对资源的URL发送HTTP DELETE请求。例如,如果我们要删除一个id为2的人,我们就必须向URL localhost:3001/persons/2发送HTTP DELETE请求。HTTP DELETE请求不发送任何数据。 - + +你依然可以用[axios](https://github.com/axios/axios)库发送HTTP DELETE请求,和我们发送其他所有请求的方式一样。 -注意:不能对变量使用 delete 这个名称,因为它在 JavaScript 中是一个保留字。 例如,下列情况是不可能的: + +**注意:**你不能将delete作为变量名,因为它是JavaScript中的一个保留词。例如,不可以这么写: ```js // use some other name for variable! @@ -851,15 +866,18 @@ const delete = (id) => { } ``` -

    2.18*: Phonebook 步骤10

    - -更改功能,以便如果一个号码被添加到一个已经存在的用户,新的号码将取代旧的号码。 建议使用 HTTP PUT 方法更新电话号码。 + +

    2.15*:电话簿 第10步

    - -如果用户的信息已经在电话簿中,应用可以确认用户的操作: + +为什么这道练习有个星号?[这里](/zh/part0/基础知识#taking-the-course)有解释。 -![](../../images/teht/16e.png) + +改变功能,如果向一个已经存在的用户添加号码,新的号码将替换旧的号码。建议使用HTTP PUT方法来更新电话号码。 + +如果这个人的信息已经在电话簿中,应用可以让用户确认这个操作: -
    +![](../../images/teht/16e.png) +
    diff --git a/src/content/2/zh/part2e.md b/src/content/2/zh/part2e.md index 9ae32be0704..c2c749d2520 100644 --- a/src/content/2/zh/part2e.md +++ b/src/content/2/zh/part2e.md @@ -7,25 +7,21 @@ lang: zh
    + +我们目前的笔记应用的外观是相当简陋的。在[练习0.2](/zh/part0/web_应用的基础设施#练习-0-1-0-6)中,作业是阅读Mozilla的[CSS教程](https://developer.mozilla.org/en-US/docs/Learn/Getting_started_with_the_web/CSS_basics)。 - + +让我们看一下如何在React应用中添加样式。有几种不同的方法,我们将在后面看一下其他的方法。首先,我们将以传统的方式向我们的应用添加CSS;把CSS写入单个文件中,不使用[CSS预处理器](https://developer.mozilla.org/en-US/docs/Glossary/CSS_preprocessor)(尽管我们将在后面学习到,实际上我们并非完全没有使用)。 -我们当前应用的外观是相当克制的。 在 [exercise 0.2](/zh/part0/web_应用的基础设施#exercises-0-1-0-6)中,作业是浏览 Mozilla 的[CSS 教程](https://developer.Mozilla.org/en-us/docs/learn/getting_started_with_the_web/css_basics)。 - - -在进入下一章节之前,让我们先看看如何向 React 应用添加样式。 有几种不同的方法可以做到这一点,我们将在稍后介绍其他的方法。 首先,我们将以传统的方式将 CSS 放在一个单独的文件中来添加到我们的应用中; 先不使用[CSS preprocessor](https://developer.mozilla.org/en-US/docs/Glossary/CSS_preprocessor) 尽管这并不完全正确,我们将在后面来学习)。 - - - - -让我们在src 目录下添加一个新的index.css 文件,然后通过导入index.js 文件将其添加到应用中: + +让我们在src目录下添加一个新的index.css文件,然后在main.jsx文件中导入它来将其添加到应用: ```js import './index.css' ``` - -让我们在index.CSS 文件中添加如下 CSS 规则: + +让我们在index.css文件中添加以下CSS规则: ```css h1 { @@ -33,15 +29,14 @@ h1 { } ``` - -CSS 规则由选择器声明 组成。 选择器定义规则应该应用于哪些元素。 上面的选择器是h1,它将匹配我们应用中的所有h1 头标记。 - + +CSS规则包括选择器声明。选择器定义了规则应该应用于哪些元素。上面的选择器是h1,因此选择器将匹配我们应用中所有h1标题的标签。 - -声明将 color 属性设置为值green。 + +声明将_color_属性设为值green。 - -一个 CSS 规则可以包含任意数量的属性。 让我们修改前面的规则,将字体样式定义为italic: + +一条CSS规则可以包含任意数量的属性。让我们修改前面的规则,通过定义字体样式为italic,把文字变成草体。 ```css h1 { @@ -50,29 +45,30 @@ h1 { } ``` - -使用不同类型的 CSS 选择器有许多匹配元素的方法,参考 [different types of CSS selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors)。 - -如果我们想针对每个便笺的风格,我们可以使用选择器li,因为所有便笺都包装在li 标签中: + +通过使用[不同类型的CSS选择器](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors),可以实现许多匹配元素的方法。 + + +如果我们想把我们的样式应用于,比方说,每一条笔记,我们可以使用选择器li,因为所有的笔记都被包裹在li标签里: ```js const Note = ({ note, toggleImportance }) => { - const label = note.important - ? 'make not important' + const label = note.important + ? 'make not important' : 'make important'; return (
  • - {note.content} + {note.content}
  • ) } ``` - -让我们在样式表中加入如下规则(因为我对优雅网页设计的知识接近于零,所以这种样式没有多大意义) : + +让我们在我们的样式表中加入以下规则(因为我对优雅的网页设计的知识接近于零,所以这些样式并没有什么意义): ```css li { @@ -82,34 +78,31 @@ li { } ``` + +使用元素类型来定义CSS规则是有点问题的。如果我们的应用包含其他的li标签,它们也会应用同样的样式规则。 - -使用元素类型来定义 CSS 规则有点问题。 如果我们的应用包含其他li 标签,那么同样的样式规则也应用于它们。 + +如果我们想把我们的样式专门应用于笔记,那么最好使用[类选择器](https://developer.mozilla.org/en-US/docs/Web/CSS/Class_selectors)。 - - -如果我们想把我们的风格特别地应用到便笺上,那么最好使用[类选择器](https://developer.mozilla.org/en-us/docs/web/css/class_selectors)。 - - - -在常规 HTML 中,class 被定义为class 属性的值: + +在通常的HTML中,类的定义是class属性的值: ```html
  • some text...
  • ``` - -在React中,我们必须使用[className](https://reactjs.org/docs/dom-elements.html#className)属性而不是 class 属性。 考虑到这一点,让我们对Note 组件进行如下更改: + +在React中,我们必须使用[className](https://react.dev/learn#adding-styles)属性而不是class属性。考虑到这一点,让我们对我们的Note组件做如下修改: ```js const Note = ({ note, toggleImportance }) => { - const label = note.important - ? 'make not important' + const label = note.important + ? 'make not important' : 'make important'; return (
  • // highlight-line - {note.content} + {note.content}
  • ) @@ -117,8 +110,8 @@ const Note = ({ note, toggleImportance }) => { ``` - -类选择器使用. classname 语法定义: + +类选择器是用_.classname_语法定义的: ```css .note { @@ -128,18 +121,17 @@ const Note = ({ note, toggleImportance }) => { } ``` + +现在如果你在应用中添加其他li元素,它们就不会受上述样式规则的影响了。 - -如果您现在向应用添加其他li 元素,它们将不会受到上述样式规则的影响。 + +### 改进错误信息 + +我们之前用alert方法实现了当用户试图切换已删除笔记的重要性时显示的错误信息。让我们把这个错误信息实现为它自己的React组件。 -### Improved error message -【改进错误信息】 - -我们先前实现了当用户试图通过alert方法切换删除便笺的重要性时,显示错误消息。 让我们将错误消息实现为它自己的 React 组件。 - - -这个组件非常简单: + +组件很简单: ```js const Notification = ({ message }) => { @@ -148,24 +140,22 @@ const Notification = ({ message }) => { } return ( -
    +
    {message}
    ) } ``` + +如果message props的值是null,那么就不会在屏幕上渲染信息,而在其他情况下,会把信息渲染到一个div元素中。 - - -如果 message prop 的值为 null,则不会向屏幕渲染任何内容,在其他情况下,消息会在 div 元素中渲染。 - - -让我们在App 组件中添加一个名为errorMessage 的新状态。 让我们用一些错误信息来初始化它,这样我们就可以立即测试我们的组件: + +让我们向App组件中添加一个叫做errorMessage的新状态片段。让我们用一些错误信息来初始化它,这样我们就可以立即测试我们的组件: ```js const App = () => { - const [notes, setNotes] = useState([]) + const [notes, setNotes] = useState([]) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) const [errorMessage, setErrorMessage] = useState('some error happened...') // highlight-line @@ -180,15 +170,15 @@ const App = () => { -
    +
    // ...
    ) } ``` - -然后让我们添加一个适合错误消息的样式规则: + +然后让我们添加一个适合错误信息的样式规则: ```css .error { @@ -202,8 +192,8 @@ const App = () => { } ``` - -现在,我们准备添加显示错误消息的逻辑。 让我们用下面的方法更改 toggleImportanceOf 函数: + +现在我们准备添加显示错误信息的逻辑。让我们将toggleImportanceOf函数改成: ```js const toggleImportanceOf = id => { @@ -211,7 +201,7 @@ const App = () => { const changedNote = { ...note, important: !note.important } noteService - .update(changedNote).then(returnedNote => { + .update(id, changedNote).then(returnedNote => { setNotes(notes.map(note => note.id !== id ? note : returnedNote)) }) .catch(error => { @@ -228,75 +218,79 @@ const App = () => { } ``` - -当出现错误时,我们向 errorMessage 状态添加一个错误描述消息。 与此同时,我们启动一个计时器,它将在5秒后将 errorMessage状态设置为null。 + +当发生错误时,我们把错误信息的描述添加到errorMessage状态中。同时,我们启动一个定时器,在五秒后将errorMessage状态设为null。 - - -结果如下: + +结果看起来是这样的: ![](../../images/2/26e.png) + +我们应用当前状态的代码可以在[GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-7)上的part2-7分支找到。 + +### 内联样式 - -我们应用当前状态的代码可以在[github](https://github.com/fullstack-hy2020/part2-notes/tree/part2-7)上的part2-7 分支中找到。 - - -### Inline styles -【内嵌样式】 + +在React中也可以直接在代码中编写样式,即所谓的[内联样式](https://react-cn.github.io/react/tips/inline-styles.html)。 - -React也使得直接在代码中编写样式成为可能,即所谓的[内联样式](https://react-cn.github.io/react/tips/inline-styles.html)。 + +定义内联样式的想法非常简单。可以将一组CSS属性作为JavaScript对象通过[style](https://reactjs.org/docs/dom-elements.html#style)属性提供给任何React组件或元素。 - -定义内联样式背后的思想非常简单。 任何 React 组件或元素都可以通过[style](https://reactjs.org/docs/dom-elements.html#style)属性作为 JavaScript 对象提供一组 CSS 属性。 - - -CSS 规则在 JavaScript 中的定义与普通 CSS 文件中的定义稍有不同。 假设我们想给一些元素绿色和斜体字体,大小为16像素。 在 CSS 中,它看起来像这样: + +JavaScript中的CSS规则定义与普通的CSS文件略有不同。比方说,我们想给某个元素加上绿色和斜体。CSS中会是这样的: ```css { color: green; font-style: italic; - font-size: 16px; } ``` - -但是作为一个 React inline style 内置样式对象,它看起来是这样的: + +但定义成React内联样式的对象是这样的: ```js - { +{ color: 'green', - fontStyle: 'italic', - fontSize: 16 + fontStyle: 'italic' } ``` - -每个 CSS 属性都被定义为 JavaScript 对象的一个独立属性。 像素的数值可以简单地定义为整数。 与常规 CSS 相比,一个主要的区别是连字符(kebab case)的 CSS 属性是用 camelCase 编写的。 - + +每个CSS属性都被定义为JavaScript对象的一个单独的属性。像素的数字值可以简单地用整数定义。和普通CSS相比的一个主要区别是,CSS属性是用连字符*-*连接的(烤串命名法),JavaScript对象的属性是用驼峰式命名法(camelCase)的。 - -接下来,我们可以通过创建一个Footer 组件向应用添加一个“ bottom block” ,并为它定义如下行内样式: + +让我们向我们的应用中添加一个脚注组件,Footer,并为其定义内联样式。组件像下面定义在文件_components/Footer.jsx_中,并在_App.jsx_文件中使用: ```js const Footer = () => { const footerStyle = { color: 'green', - fontStyle: 'italic', - fontSize: 16 + fontStyle: 'italic' } return (

    - Note app, Department of Computer Science, University of Helsinki 2020 -
    +

    + Note app, Department of Computer Science, University of Helsinki 2025 +

    + ) } +export default Footer +``` + +```js +import { useState, useEffect } from 'react' +import Footer from './components/Footer' // highlight-line +import Note from './components/Note' +import Notification from './components/Notification' +import noteService from './services/notes' + const App = () => { // ... @@ -306,7 +300,7 @@ const App = () => { - // ... + // ...
    // highlight-line @@ -314,58 +308,436 @@ const App = () => { } ``` - -内联样式有一定的限制,例如,所谓的[pseudo-classes](https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes)不能直接使用。 + +内联样式有某些限制。例如,不能直接使用所谓的[伪类](https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes)。 + +内联样式以及一些其他为React组件添加样式的方法完全违背了旧的惯例。传统上,人们认为最好的做法是将CSS与内容(HTML)和功能(JavaScript)完全分开。根据这一传统的思想,我们要将CSS、HTML和JavaScript分开写成单独的文件。 + +React的哲学事实上与此截然相反。由于将CSS、HTML和JavaScript分离到不同的文件中,似乎会使大型应用不能很好地扩展,所以React将应用的划分建立在其逻辑功能实体的基础上。 - -内联样式和其他一些将样式添加到 React 组件的方法完全违背了旧的惯例。 传统上,将 CSS 与内容(HTML)和功能(JavaScript)解耦被认为是最佳实践。 根据这个古老的思想流派,我们的目标是将 CSS、 HTML 和 JavaScript 编写到它们各自的文件中。 + +构成应用功能实体的结构单元是React组件。一个React组件定义了构造内容的HTML,决定功能的JavaScript函数,以及组件的样式;所有内容都在一个地方定义。这是为了创建尽可能独立和可重复使用的单个组件。 - -React的哲学,事实上,是这个极端的对立面。 由于将 CSS、 HTML 和 JavaScript 分离成单独的文件在大型应用中似乎不利于伸缩,所以 React 将应用按照其逻辑功能实体进行划分。 - - -构成应用功能实体的结构单元是 React 组件。 React 组件定义了组织内容的 HTML,确定功能的 JavaScript 函数,以及组件的样式; 所有这些都放在一个地方。 这是为了创建尽可能独立和可重用的单个组件。 - - -我们应用最终版本的代码可以在[github](https://github.com/fullstack-hy2020/part2-notes/tree/part2-8)上的part2-8 分支中找到。 + +我们应用的最终版本的代码可以在[GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part2-8)上的part2-8分支中找到。 -
    + +

    练习 2.16.~2.17.

    -

    Exercises 2.19.-2.20.

    -

    2.19: Phonebook 步骤11

    - + +

    2.16:电话簿 第11步

    - -使用第二章节中的[improved error message](/zh/part2/给_react应用加点样式#improved-error-message)示例作为指导,显示一个在成功操作执行后持续几秒钟的通知(添加一个人或更改一个数字) : + +参考第2章节中[改进错误信息](/zh/part2/给_react应用加点样式#改进错误信息)的例子,在执行了成功的操作(添加了一个人或更改了一个号码)后,显示一个持续几秒钟的通知: ![](../../images/2/27e.png) + +

    2.17*:电话簿 第12步

    -

    2.20*: Phonebook 步骤12

    - - -在两个浏览器中打开应用。 **如果你在浏览器1中删除一个人** ,尝试在浏览器2中更改该人的电话号码,你会得到如下错误消息: + +在两个浏览器中打开你的应用。**如果你在浏览器1中删除一个人**,然后又在浏览器2中尝试更改这个人的电话号码,你会得到以下错误信息: ![](../../images/2/29b.png) - -根据第2章节中显示的[promise and errors](/zh/part2/在服务端将数据_alert出来#promises-and-errors) 中的示例修复该问题。 修改此示例,以便在操作不成功时向用户显示消息。 成功和不成功的事件所显示的信息应该看起来不同: + +按照第2章节中[Promise和错误](/zh/part2/修改服务端的数据#promise和错误)所示的例子来修复这个问题。修改这个例子,使用户在操作不成功时显示一条信息。操作不成功时显示的信息应与操作成功时的看起来不同: ![](../../images/2/28e.png) + +**注意**即使你处理了异常,第一个“404”的错误信息也会打印到控制台。但你应该看不到“Uncaught (in promise) Error”了。 + +
    + +
    + +### 一些重要的注意事项 + + +在本部分的最后,有一些更具挑战性的练习。如果这些练习让你感到头疼,可以先跳过,我们后面会再次回到这些主题。无论如何,这部分的材料都值得阅读。 + + +在我们的应用中,我们做的一件事掩盖了一个非常典型的错误来源。 + + +我们将状态_notes_的初始值设为一个空数组: - +```js +const App = () => { + const [notes, setNotes] = useState([]) + + // ... +} +``` -注意 :即使您捕获并处理异常,错误消息也会打印到控制台。 + +这是一个非常自然的初始值,因为_notes_是一组笔记,也就是说,状态将存储许多笔记。 - -这是本课程这一章节的最后一个练习,现在是时候把你的代码推送到 GitHub,并将所有完成的练习标记到[练习提交系统](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)。 + +如果状态只保存“一个东西”,更合适的初始值是 _null_,表示一开始状态中什么都没有。让我们看看如果使用_null_为初始值会发生什么: + +```js +const App = () => { + const [notes, setNotes] = useState(null) // highlight-line + + // ... +} +``` + + +应用崩溃了: + +![](../../images/2/31a.png) + + +错误信息给出了错误的原因和位置。导致问题的代码如下: + +```js + // notesToShow gets the value of notes + const notesToShow = showAll + ? notes + : notes.filter(note => note.important) + + // ... + + {notesToShow.map(note => // highlight-line + + )} +``` + + +错误信息是 + +```bash +Cannot read properties of null (reading 'map') +``` + + +变量_notesToShow_首先被赋值为状态_notes_的值,然后代码尝试对一个不存在的对象,也就是_null_,调用_map_方法。 + + +这是什么原因呢? + + +Effect Hook使用函数_setNotes_将_notes_设为后端返回的笔记: + +```js + useEffect(() => { + noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) // highlight-line + }) + }, []) +``` + + +然而,问题在于Effect只在第一次渲染之后执行。 + +并且因为_notes_的初始值是null: + +```js +const App = () => { + const [notes, setNotes] = useState(null) // highlight-line + + // ... +``` + + +在第一次渲染时,以下代码被执行: + +```js +notesToShow = notes + +// ... + +notesToShow.map(note => ...) +``` + + +这导致应用崩溃,因为我们不能对_null_值调用_map_方法。 + + +当我们将_notes_的初始值设为空数组时,就不会出现错误,因为空数组是可以调用_map_的。 + + +因此,状态的初始化“掩盖”了由于数据尚未从后端获取而导致的问题。 + + +另一种解决问题的方法是使用条件渲染,如果组件状态未被正确初始化,则返回null: + +```js +const App = () => { + const [notes, setNotes] = useState(null) // highlight-line + // ... + + useEffect(() => { + noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) + }) + }, []) + + // do not render anything if notes is still null + // highlight-start + if (!notes) { + return null + } + // highlight-end + + // ... +} +``` + + +因此,在第一次渲染时,不会渲染任何内容。当笔记从后端到达时,Effect使用函数_setNotes_来设状态_notes_的值。这会导致组件再次渲染,于是在第二次渲染时,笔记会被渲染到屏幕上。 + + +基于条件渲染的方法适用于无法定义状态以致无法首次渲染的情况。 + + +另一件我们还需要进一步观察的事情是useEffect的第二个参数: + +```js + useEffect(() => { + noteService + .getAll() + .then(initialNotes => { + setNotes(initialNotes) + }) + }, []) // highlight-line +``` + + +useEffect的第二个参数用于[指定Effect的运行频率](https://react.dev/reference/react/useEffect#parameters)。原则是Effect总是在组件第一次渲染后以及第二个参数的值发生变化时执行。 + + +如果第二个参数是一个空数组[],它的内容永远不会改变,于是Effect只会在组件第一次渲染后运行。这正是我们在从服务端初始化应用状态时所需要的。 + + +然而,有些情况下我们还希望在其他时候执行 Effect,例如当组件的状态以特定方式发生变化时。 + + +考虑以下用于从[Exchange rate API](https://www.exchangerate-api.com/)查询货币汇率的简单应用: + +```js +import { useState, useEffect } from 'react' +import axios from 'axios' + +const App = () => { + const [value, setValue] = useState('') + const [rates, setRates] = useState({}) + const [currency, setCurrency] = useState(null) + + useEffect(() => { + console.log('effect run, currency is now', currency) + + // skip if currency is not defined + if (currency) { + console.log('fetching exchange rates...') + axios + .get(`https://open.er-api.com/v6/latest/${currency}`) + .then(response => { + setRates(response.data.rates) + }) + } + }, [currency]) + + const handleChange = (event) => { + setValue(event.target.value) + } + + const onSearch = (event) => { + event.preventDefault() + setCurrency(value) + } + + return ( +
    +
    + currency: + +
    +
    +        {JSON.stringify(rates, null, 2)}
    +      
    +
    + ) +} + +export default App +``` + + +该应用的用户界面有一个表单,用户可以在输入框中输入想要查询的货币的名称。如果货币存在,应用会渲染该货币和其他货币的汇率: + +![](../../images/2/32new.png) + + +在按下按钮时,应用将表单中输入的货币名称设为状态_currency_。 + + +当_currency_得到新值时,应用会在Effect函数中从API获取其汇率: + +```js +const App = () => { + // ... + const [currency, setCurrency] = useState(null) + + useEffect(() => { + console.log('effect run, currency is now', currency) + + // skip if currency is not defined + if (currency) { + console.log('fetching exchange rates...') + axios + .get(`https://open.er-api.com/v6/latest/${currency}`) + .then(response => { + setRates(response.data.rates) + }) + } + }, [currency]) // highlight-line + // ... +} +``` + + +现在useEffect Hook的第二个参数是_[currency]_。因此,Effect函数会在第一次渲染后执行,并且总是在表发生变化,也就是函数的第二个参数_[currency]_的值发生变化时执行。也就是说,每当状态_currency_获得新值时,表的内容发生变化,Effect函数被执行。 + + +选择_null_作为变量_currency_的初始值是很自然的,因为_currency_只代表一件物品。初始值_null_表示状态中尚无任何内容,并且可以简单通过if语句检查变量是否已被赋值。Effect有以下条件: + +```js +if (currency) { + // exchange rates are fetched +} +``` + + +这可以防止首次渲染后在变量_currency_仍然是初始值_null_的时候立即请求汇率。 + + +所以如果用户在搜索框中输入例如eur,应用会使用axios向地址发送HTTP GET请求,并将响应存储在_rates_状态中。 + + +当用户随后在搜索框中输入另一个值,例如usd,Effect函数会再次执行,并通过API请求新货币的汇率。 + + +这里展示的进行API请求的方法可能看起来有点不方便。 + +这个特定的应用完全可以不使用useEffect,而直接在表单的提交处理函数中进行API请求: + +```js + const onSearch = (event) => { + event.preventDefault() + axios + .get(`https://open.er-api.com/v6/latest/${value}`) + .then(response => { + setRates(response.data.rates) + }) + } +``` + + +然而在有些情况下,这种方法是行不通的。例如,你可能会在练习2.20中遇到这种行不通的情况,此时可以使用useEffect来解决。注意这在很大程度上取决于你选择的方法,例如,model solution就没有使用这一技巧。
    +
    + + +

    练习2.18.~2.20.

    + + +

    2.18*:各国数据,第1步

    + + +你可以在[https://studies.cs.helsinki.fi/restcountries/](https://studies.cs.helsinki.fi/restcountries/)找到一个服务,它通过REST API以所谓机器可读的格式提供关于不同国家的许多信息。做一个可以让你查看不同国家信息的应用。 + + +用户界面非常简单。通过在搜索框里输入一个搜索查询来找到要显示的国家。 + + +如果有太多(超过10个)国家符合查询条件,则提示用户查得再具体些: + +![](../../images/2/19b1.png) + + +如果有十个及以下,但超过一个国家,那么就会显示所有符合查询条件的国家: + +![](../../images/2/19b2.png) + + +如果只有一个国家符合查询条件,则显示该国的基本数据(如首都和面积)、国旗和使用的语言: + +![](../../images/2/19c3.png) + + +**注意**:你的应用对大多数国家都有效就可以了。有些国家,如苏丹(Sudan),可能很难支持,因为这个国家的名字是另一个国家南苏丹(South Sudan)名字的一部分。你不需要担心这些边缘情况。 + + +

    2.19*:各国数据,第2步

    + + +**这部分还有很多事情要做,所以不要在这道练习上卡住!** + + +改进前一道练习中的应用,使得当页面上显示多个国家的名字时,在国名旁边有一个按钮,按下后会展示该国家: + +![](../../images/2/19b4.png) + + +在这道练习中,也只要让你的应用对大多数国家都有效就足够了。可以忽略那些名字出现在另一个国家名字中的国家,如苏丹。 + + +

    2.20*:各国数据,第3步

    + + +在显示单个国家数据的界面中,添加该国首都的天气预报。有许多提供天气数据的API。一个建议的API是[https://openweathermap.org](https://openweathermap.org)。注意生成的API密钥可能需要过一段时间才会有效。 + +![](../../images/2/19x.png) + + +如果你使用Open weather map,[这里](https://openweathermap.org/weather-conditions#Icon-list)描述了如何获取天气图标。 + + +**注意:**在某些浏览器(比如Firefox)中,你选用的API可能会发送一个错误响应,这表明不支持HTTPS加密,尽管请求的URL以_http://_ 开头。这个问题可以通过使用Chrome完成练习来解决。 + + +**注意:**几乎所有的气象服务都需要你有api密钥才能使用。不要把api密钥保存到源代码管理(如git)中!也不要在你的源代码中硬编码api密钥。而是用[环境变量](https://vitejs.dev/guide/env-and-mode.html)来保存这道练习中使用的密钥。在实际的应用中,直接在浏览器中发送这些密钥是不安全的,因为任何能打开开发者控制台的人都可以窃取你的密钥!我们将在课程的下一章节集中实现独立的后端。 + + +假设api密钥是54l41n3n4v41m34rv0,这么启动应用后: + +```bash +export VITE_SOME_KEY=54l41n3n4v41m34rv0 && npm run dev // For Linux/macOS Bash +($env:VITE_SOME_KEY="54l41n3n4v41m34rv0") -and (npm run dev) // For Windows PowerShell +set "VITE_SOME_KEY=54l41n3n4v41m34rv0" && npm run dev // For Windows cmd.exe +``` + + +你就可以通过_import.meta.env_对象来访问密钥的值: + +```js +const api_key = import.meta.env.VITE_SOME_KEY +// variable api_key now has the value set in startup +``` + + +**注意:**为了防止环境变量意外泄露到客户端,只有以VITE_开头的变量才对Vite可见。 + + +同时记得如果你更改了环境变量,你需要重启开发服务端来使更改生效。 + + +这是课程中这一章节的最后一道练习。是时候把你的代码推送到GitHub,并在[练习上交系统](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)中标记你所有完成的练习了。 + +
    diff --git a/src/content/3/en/part3.md b/src/content/3/en/part3.md index 75f30724ede..f853d582649 100644 --- a/src/content/3/en/part3.md +++ b/src/content/3/en/part3.md @@ -8,4 +8,9 @@ lang: en In this part our focus shifts towards the backend, that is, towards implementing functionality on the server side of the stack. We will implement a simple REST API in Node.js by using the Express library, and the application's data will be stored in a MongoDB database. At the end of this part, we will deploy our application to the internet. +Part updated on 16th March 2025 +- Node updated to version v22.3.0 +- Nodemon replaced with the node --watch command +- MongoDB instructions updated and reformatted + diff --git a/src/content/3/en/part3a.md b/src/content/3/en/part3a.md index ee1c6d63e94..9913f2d34a1 100644 --- a/src/content/3/en/part3a.md +++ b/src/content/3/en/part3a.md @@ -7,29 +7,21 @@ lang: en
    - -In this part our focus shifts towards the backend: that is, towards implementing functionality on the server side of the stack. - +In this part, our focus shifts towards the backend: that is, towards implementing functionality on the server side of the stack. We will be building our backend on top of [NodeJS](https://nodejs.org/en/), which is a JavaScript runtime based on Google's [Chrome V8](https://developers.google.com/v8/) JavaScript engine. +This course material was written with version v22.3.0 of Node.js. Please make sure that your version of Node is at least as new as the version used in the material (you can check the version by running _node -v_ in the command line). -This course material was written with the version v10.18.0 of Node.js. Please make sure that your version of Node is at least as new as the version used in the material (you can check the version by running _node -v_ in the command line). - - -As mentioned in [part 1](/en/part1/javascript), browsers don't yet support the newest features of JavaScript, and that is why the code running in the browser must be transpiled with e.g. [babel](https://babeljs.io/). The situation with JavaScript running in the backend is different. The newest version of Node supports a large majority of the latest features of JavaScript, so we can use the latest features without having to transpile our code. - +As mentioned in [part 1](/en/part1/java_script), browsers don't yet support the newest features of JavaScript, and that is why the code running in the browser must be transpiled with e.g. [babel](https://babeljs.io/). The situation with JavaScript running in the backend is different. The newest version of Node supports a large majority of the latest features of JavaScript, so we can use the latest features without having to transpile our code. Our goal is to implement a backend that will work with the notes application from [part 2](/en/part2/). However, let's start with the basics by implementing a classic "hello world" application. - -**Notice** that the applications and exercises in this part are not all React applications, and we will not use the create-react-app utility for initializing the project for this application. - +**Notice** that the applications and exercises in this part are not all React applications, and we will not use the create vite@latest -- --template react utility for initializing the project for this application. We had already mentioned [npm](/en/part2/getting_data_from_server#npm) back in part 2, which is a tool used for managing JavaScript packages. In fact, npm originates from the Node ecosystem. - -Let's navigate to an appropriate directory, and create a new template for our application with the _npm init_ command. We will answer the questions presented by the utility, and the result will be an automatically generated package.json file at the root of the project, that contains information about the project. +Let's navigate to an appropriate directory, and create a new template for our application with the _npm init_ command. We will answer the questions presented by the utility, and the result will be an automatically generated package.json file at the root of the project that contains information about the project. ```json { @@ -45,11 +37,9 @@ Let's navigate to an appropriate directory, and create a new template for our ap } ``` - The file defines, for instance, that the entry point of the application is the index.js file. - -Let's make a small change to the scripts object: +Let's make a small change to the scripts object by adding a new script command. ```bash { @@ -62,28 +52,24 @@ Let's make a small change to the scripts object: } ``` - Next, let's create the first version of our application by adding an index.js file to the root of the project with the following code: ```js console.log('hello world') ``` - We can run the program directly with Node from the command line: ```bash node index.js ``` - Or we can run it as an [npm script](https://docs.npmjs.com/misc/scripts): ```bash npm start ``` - The start npm script works because we defined it in the package.json file: ```bash @@ -97,33 +83,29 @@ The start npm script works because we defined it in the package.jsonpackage.json file also defines another commonly used npm script called npm test. Since our project does not yet have a testing library, the _npm test_ command simply executes the following command: +By default, the package.json file also defines another commonly used npm script called npm test. Since our project does not yet have a testing library, the _npm test_ command simply executes the following command: ```bash echo "Error: no test specified" && exit 1 ``` - ### Simple web server - -Let's change the application into a web server: +Let's change the application into a web server by editing the _index.js_ file as follows: ```js const http = require('http') -const app = http.createServer((req, res) => { - res.writeHead(200, { 'Content-Type': 'text/plain' }) - res.end('Hello World') +const app = http.createServer((request, response) => { + response.writeHead(200, { 'Content-Type': 'text/plain' }) + response.end('Hello World') }) -const port = 3001 -app.listen(port) -console.log(`Server running on port ${port}`) +const PORT = 3001 +app.listen(PORT) +console.log(`Server running on port ${PORT}`) ``` Once the application is running, the following message is printed in the console: @@ -134,12 +116,11 @@ Server running on port 3001 We can open our humble application in the browser by visiting the address : -![](../../images/3/1.png) - -In fact, the server works the same way regardless of the latter part of the URL. Also the address will display the same content. +![hello world screen capture](../../images/3/1.png) +The server works the same way regardless of the latter part of the URL. Also the address will display the same content. -**NB** if the port 3001 is already in use by some other application, then starting the server will result in the following error message: +**NB** If port 3001 is already in use by some other application, then starting the server will result in the following error message: ```bash ➜ hello npm start @@ -157,8 +138,7 @@ Error: listen EADDRINUSE :::3001 at listenInCluster (net.js:1378:12) ``` - -You have two options. Either shutdown the application using the port 3001 (the json-server in the last part of the material was using the port 3001), or use a different port for this application. +You have two options. Either shut down the application using port 3001 (the JSON Server in the last part of the material was using port 3001), or use a different port for this application. Let's take a closer look at the first line of the code: @@ -166,15 +146,15 @@ Let's take a closer look at the first line of the code: const http = require('http') ``` -In the first row, the application imports Node's built-in [web server](https://nodejs.org/docs/latest-v8.x/api/http.html) module. This is practically what we have already been doing in our browser-side code, but with a slightly different syntax: +In the first row, the application imports Node's built-in [web server](https://nodejs.org/docs/latest-v18.x/api/http.html) module. This is practically what we have already been doing in our browser-side code, but with a slightly different syntax: ```js import http from 'http' ``` -These days, code that runs in the browser uses ES6 modules. Modules are defined with an [export](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export) and taken into use with an [import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import). +These days, code that runs in the browser uses ES6 modules. Modules are defined with an [export](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export) and included in the current file with an [import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import). -However, Node.js uses so-called [CommonJS](https://en.wikipedia.org/wiki/CommonJS) modules. The reason for this is that the Node ecosystem had a need for modules long before JavaScript supported them in the language specification. At the time of writing this material, Node does not support ES6 modules, but support for them [is coming](https://nodejs.org/api/esm.html) somewhere down the road. +Node.js uses [CommonJS](https://en.wikipedia.org/wiki/CommonJS) modules. The reason for this is that the Node ecosystem needed modules long before JavaScript supported them in the language specification. Currently, Node also supports the use of ES6 modules, but since the support is not quite perfect yet, we'll stick to CommonJS modules. CommonJS modules function almost exactly like ES6 modules, at least as far as our needs in this course are concerned. @@ -187,13 +167,11 @@ const app = http.createServer((request, response) => { }) ``` -The code uses the _createServer_ method of the [http](https://nodejs.org/docs/latest-v8.x/api/http.html) module to create a new web server. An event handler is registered to the server, that is called every time an HTTP request is made to the server's address http:/localhost:3001. - +The code uses the _createServer_ method of the [http](https://nodejs.org/docs/latest-v18.x/api/http.html) module to create a new web server. An event handler is registered to the server that is called every time an HTTP request is made to the server's address . The request is responded to with the status code 200, with the Content-Type header set to text/plain, and the content of the site to be returned set to Hello World. - -The last rows bind the http server assigned to the _app_ variable, to listen to HTTP requests sent to the port 3001: +The last rows bind the http server assigned to the _app_ variable, to listen to HTTP requests sent to port 3001: ```js const PORT = 3001 @@ -201,8 +179,7 @@ app.listen(PORT) console.log(`Server running on port ${PORT}`) ``` - -The primary purpose of the backend server in this course is to offer raw data in the JSON format to the frontend. For this reason, let's immediately change our server to return a hardcoded list of notes in the JSON format: +The primary purpose of the backend server in this course is to offer raw data in JSON format to the frontend. For this reason, let's immediately change our server to return a hardcoded list of notes in the JSON format: ```js const http = require('http') @@ -210,21 +187,18 @@ const http = require('http') // highlight-start let notes = [ { - id: 1, + id: "1", content: "HTML is easy", - date: "2019-05-30T17:30:31.098Z", important: true }, { - id: 2, - content: "Browser can execute only Javascript", - date: "2019-05-30T18:39:34.091Z", + id: "2", + content: "Browser can execute only JavaScript", important: false }, { - id: 3, + id: "3", content: "GET and POST are the most important methods of HTTP protocol", - date: "2019-05-30T19:20:14.298Z", important: true } ] @@ -235,29 +209,29 @@ const app = http.createServer((request, response) => { }) // highlight-end -const port = 3001 -app.listen(port) -console.log(`Server running on port ${port}`) +const PORT = 3001 +app.listen(PORT) +console.log(`Server running on port ${PORT}`) ``` Let's restart the server (you can shut the server down by pressing _Ctrl+C_ in the console) and let's refresh the browser. -The application/json value in the Content-Type header informs the receiver that the data is in the JSON format. The _notes_ array gets transformed into JSON with the JSON.stringify(notes) method. +The application/json value in the Content-Type header informs the receiver that the data is in the JSON format. The _notes_ array gets transformed into JSON formatted string with the JSON.stringify(notes) method. This is necessary because the response.end() method expects a string or a buffer to send as the response body. When we open the browser, the displayed format is exactly the same as in [part 2](/en/part2/getting_data_from_server/) where we used [json-server](https://github.com/typicode/json-server) to serve the list of notes: -![](../../images/3/2e.png) +![formatted JSON notes data](../../images/3/2new.png) ### Express -Implementing our server code directly with Node's built-in [http](https://nodejs.org/docs/latest-v8.x/api/http.html) web server is possible. However, it is cumbersome, especially once the application grows in size. +Implementing our server code directly with Node's built-in [http](https://nodejs.org/docs/latest-v18.x/api/http.html) web server is possible. However, it is cumbersome, especially once the application grows in size. -Many libraries have been developed to ease server side development with Node, by offering a more pleasing interface to work with than the built-in http module. By far the most popular library intended for this purpose is [express](http://expressjs.com). +Many libraries have been developed to ease server-side development with Node, by offering a more pleasing interface to work with the built-in http module. These libraries aim to provide a better abstraction for general use cases we usually require to build a backend server. By far the most popular library intended for this purpose is [Express](http://expressjs.com). -Let's take express into use by defining it as a project dependency with the command: +Let's take Express into use by defining it as a project dependency with the command: ```bash -npm install express --save +npm install express ``` The dependency is also added to our package.json file: @@ -266,33 +240,26 @@ The dependency is also added to our package.json file: { // ... "dependencies": { - "express": "^4.17.1" + "express": "^5.1.0" } } - ``` +The source code for the dependency is installed in the node\_modules directory located at the root of the project. In addition to Express, you can find a great number of other dependencies in the directory: -The source code for the dependency is installed to the node\_modules directory located in the root of the project. In addition to express, you can find a great amount of other dependencies in the directory: +![ls command listing of dependencies in directory](../../images/3/4.png) -![](../../images/3/4.png) +These are the dependencies of the Express library and the dependencies of all of its dependencies, and so forth. These are called the [transitive dependencies](https://lexi-lambda.github.io/blog/2016/08/24/understanding-the-npm-dependency-model/) of our project. - -These are in fact the dependencies of the express library, and the dependencies of all of its dependencies, and so forth. These are called the [transitive dependencies](https://lexi-lambda.github.io/blog/2016/08/24/understanding-the-npm-dependency-model/) of our project. - - -The version 4.17.1. of express was installed in our project. What does the caret in front of the version number in package.json mean? +Version 5.1.0 of Express was installed in our project. What does the caret in front of the version number in package.json mean? ```json -"express": "^4.17.1" +"express": "^5.1.0" ``` +The versioning model used in npm is called [semantic versioning](https://docs.npmjs.com/about-semantic-versioning). -The versioning model used in npm is called [semantic versioning](https://docs.npmjs.com/getting-started/semantic-versioning). - - -The caret in the front of ^4.17.1 means, that if and when the dependencies of a project are updated, the version of express that is installed will be at least 4.17.1. However, the installed version of express can also be one that has a larger patch number (the last number), or a larger minor number (the middle number). The major version of the library indicated by the first major number must be the same. - +The caret in the front of ^5.1.0 means that if and when the dependencies of a project are updated, the version of Express that is installed will be at least 5.1.0. However, the installed version of Express can also have a larger patch number (the last number), or a larger minor number (the middle number). The major version of the library indicated by the first major number must be the same. We can update the dependencies of the project with the command: @@ -300,15 +267,15 @@ We can update the dependencies of the project with the command: npm update ``` -Likewise, if we start working on the project on another computer, we can install all up-to-date dependencies of the project defined in package.json with the command: +Likewise, if we start working on the project on another computer, we can install all up-to-date dependencies of the project defined in package.json by running this next command in the project's root directory: ```bash npm install ``` -If the major number of a dependency does not change, then the newer versions should be [backwards compatible](https://en.wikipedia.org/wiki/Backward_compatibility). This means that if our application happened to use version 4.99.175 of express in the future, then all the code implemented in this part would still have to work without making changes to the code. In contrast, the future 5.0.0. version of express [may contain](https://expressjs.com/en/guide/migrating-5.html) changes, that would cause our application to no longer work. +If the major number of a dependency does not change, then the newer versions should be [backwards compatible](https://en.wikipedia.org/wiki/Backward_compatibility). This means that if our application happened to use version 5.99.175 of Express in the future, then all the code implemented in this part would still have to work without making changes to the code. In contrast, the future 6.0.0 version of Express may contain changes that would cause our application to no longer work. -### Web and express +### Web and Express Let's get back to our application and make the following changes: @@ -320,12 +287,12 @@ let notes = [ ... ] -app.get('/', (req, res) => { - res.send('

    Hello World!

    ') +app.get('/', (request, response) => { + response.send('

    Hello World!

    ') }) -app.get('/api/notes', (req, res) => { - res.json(notes) +app.get('/api/notes', (request, response) => { + response.json(notes) }) const PORT = 3001 @@ -334,19 +301,16 @@ app.listen(PORT, () => { }) ``` +To get the new version of our application into use, first we have to restart it. -In order to get the new version of our application into use, we have to restart the application. - - -The application did not change a whole lot. Right at the beginning of our code we're importing _express_, which this time is a function that is used to create an express application stored in the _app_ variable: +The application did not change a whole lot. Right at the beginning of our code, we're importing _express_, which this time is a function that is used to create an Express application stored in the _app_ variable: ```js const express = require('express') const app = express() ``` - -Next, we define two routes to the application. The first one defines an event handler, that is used to handle HTTP GET requests made to the application's / root: +Next, we define two routes to the application. The first one defines an event handler that is used to handle HTTP GET requests made to the application's / root: ```js app.get('/', (request, response) => { @@ -354,19 +318,15 @@ app.get('/', (request, response) => { }) ``` - The event handler function accepts two parameters. The first [request](http://expressjs.com/en/4x/api.html#req) parameter contains all of the information of the HTTP request, and the second [response](http://expressjs.com/en/4x/api.html#res) parameter is used to define how the request is responded to. - -In our code, the request is answered by using the [send](http://expressjs.com/en/4x/api.html#res.send) method of the _response_ object. Calling the method makes the server respond to the HTTP request by sending a response containing the string \

    Hello World!\

    , that was passed to the _send_ method. Since the parameter is a string, express automatically sets the value of the Content-Type header to be text/html. The status code of the response defaults to 200. - +In our code, the request is answered by using the [send](http://expressjs.com/en/4x/api.html#res.send) method of the _response_ object. Calling the method makes the server respond to the HTTP request by sending a response containing the string \

    Hello World!\

    that was passed to the _send_ method. Since the parameter is a string, Express automatically sets the value of the Content-Type header to be text/html. The status code of the response defaults to 200. We can verify this from the Network tab in developer tools: -![](../../images/3/5.png) +![network tab in dev tools](../../images/3/5.png) - -The second route defines an event handler, that handles HTTP GET requests made to the notes path of the application: +The second route defines an event handler that handles HTTP GET requests made to the notes path of the application: ```js app.get('/api/notes', (request, response) => { @@ -374,169 +334,105 @@ app.get('/api/notes', (request, response) => { }) ``` - The request is responded to with the [json](http://expressjs.com/en/4x/api.html#res.json) method of the _response_ object. Calling the method will send the __notes__ array that was passed to it as a JSON formatted string. Express automatically sets the Content-Type header with the appropriate value of application/json. -![](../../images/3/6ea.png) +![api/notes gives the formatted JSON data again](../../images/3/6new.png) -Next, let's take a quick look at the data sent in the JSON format. +Next, let's take a quick look at the data sent in JSON format. -In the earlier version where we were only using Node, we had to transform the data into the JSON format with the _JSON.stringify_ method: +In the earlier version where we were only using Node, we had to transform the data into the JSON formatted string with the _JSON.stringify_ method: ```js response.end(JSON.stringify(notes)) ``` +With Express, this is no longer required, because this transformation happens automatically. -With express, this is no longer required, because this transformation happens automatically. - - -It's worth noting, that [JSON](https://en.wikipedia.org/wiki/JSON) is a string, and not a JavaScript object like the value assigned to _notes_. - +It's worth noting that [JSON](https://en.wikipedia.org/wiki/JSON) is a data format. However, it's often represented as a string and is not the same as a JavaScript object, like the value assigned to _notes_. The experiment shown below illustrates this point: -![](../../assets/3/5.png) - - -The experiment above was done in the interactive [node-repl](https://nodejs.org/docs/latest-v8.x/api/repl.html). You can start the interactive node-repl by typing in _node_ in the command line. The repl is particularly useful for testing how commands work while you're writing application code. I highly recommend this! - -### nodemon - -If we make changes to the application's code we have to restart the application in order to see the changes. We restart the application by first shutting it down by typing _Ctrl+C_ and then restarting the application. Compared to the convenient workflow in React where the browser automatically reloaded after changes were made, this feels slightly cumbersome. - -The solution to this problem is [nodemon](https://github.com/remy/nodemon): - -> nodemon will watch the files in the directory in which nodemon was started, and if any files change, nodemon will automatically restart your node application. - - -Let's install nodemon by defining it as a development dependency with the command: - -```bash -npm install --save-dev nodemon -``` - -The contents of package.json have also changed: - -```json -{ - //... - "dependencies": { - "express": "^4.17.1", - }, - "devDependencies": { - "nodemon": "^2.0.2" - } -} -``` +![node terminal demonstrating json is of type string](../../assets/3/5.png) +The experiment above was done in the interactive [node-repl](https://nodejs.org/docs/latest-v18.x/api/repl.html). You can start the interactive node-repl by typing in _node_ in the command line. The repl is particularly useful for testing how commands work while you're writing application code. I highly recommend this! -If you accidentally used the wrong command and the nodemon dependency was added under "dependencies" instead of "devDependencies", then manually change the contents of package.json to match what is shown above. +### Automatic Change Tracking +If we change the application's code, we first need to stop the application from the console (_ctrl_ + _c_) and then restart it for the changes to take effect. Restarting feels cumbersome compared to React's smooth workflow, where the browser automatically updates when the code changes. -By development dependencies, we are referring to tools that are needed only during the development of the application, e.g. for testing or automatically restarting the application, like nodemon. - - -These development dependencies are not needed when the application is run in production mode on the production server (e.g. Heroku). - - -We can start our application with nodemon like this: +You can make the server track our changes by starting it with the _--watch_ option: ```bash -node_modules/.bin/nodemon index.js +node --watch index.js ``` +Now, changes to the application's code will cause the server to restart automatically. Note that although the server restarts automatically, you still need to refresh the browser. Unlike with React, we do not have, nor could we have, a hot reload functionality that updates the browser in this scenario (where we return JSON data). -Changes to the application code now causes the server to restart automatically. It's worth noting, that even though the backend server restarts automatically, the browser still has to be manually refreshed. This is because unlike when working in React, we could not even have the [hot reload](https://gaearon.github.io/react-hot-loader/getstarted/) functionality needed to automatically reload the browser. - - -The command is long and quite unpleasant, so let's define a dedicated npm script for it in the package.json file: +Let's define a custom npm script in the package.json file to start the development server: ```bash { // .. "scripts": { "start": "node index.js", - "dev": "nodemon index.js", + "dev": "node --watch index.js", // highlight-line "test": "echo \"Error: no test specified\" && exit 1" }, // .. } ``` - -In the script there is no need to specify the node\_modules/.bin/nodemon path to nodemon, because _npm_ automatically knows to search for the file from that directory. - - -We can now start the server in the development mode with the command: +We can now start the server in development mode with the command ```bash npm run dev ``` - -Unlike with the start and test scripts, we also have to add run to the command. - +Unlike when running the start or test scripts, the command must include run. ### REST +Let's expand our application so that it provides the same RESTful HTTP API as [json-server](https://github.com/typicode/json-server#routes). -Let's expand our application so that it provides the RESTful HTTP API as [json-server](https://github.com/typicode/json-server#routes). - - -Representational State Transfer, aka. REST was introduced in 2000 in Roy Fielding's [dissertation](https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm). REST is an architectural style meant for building scalable web applications. - - -We are not going to dig into Fielding's definition of REST or spend time pondering about what is and isn't RESTful. Instead, we take a more [narrow view](https://en.wikipedia.org/wiki/Representational_state_transfer#Applied_to_Web_services) by only concerning ourselves with how RESTful API's are typically understood in web applications. The original definition of REST is in fact not even limited to web applications. +Representational State Transfer, aka REST, was introduced in 2000 in Roy Fielding's [dissertation](https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm). REST is an architectural style meant for building scalable web applications. +We are not going to dig into Fielding's definition of REST or spend time pondering about what is and isn't RESTful. Instead, we take a more [narrow view](https://en.wikipedia.org/wiki/Representational_state_transfer#Applied_to_web_services) by only concerning ourselves with how RESTful APIs are typically understood in web applications. The original definition of REST is not even limited to web applications. We mentioned in the [previous part](/en/part2/altering_data_in_server#rest) that singular things, like notes in the case of our application, are called resources in RESTful thinking. Every resource has an associated URL which is the resource's unique address. - -One convention is to create the unique address for resources by combining the name of the resource type with the resource's unique identifier. - +One convention for creating unique addresses is to combine the name of the resource type with the resource's unique identifier. Let's assume that the root URL of our service is www.example.com/api. - -If we define the resource type of notes to be note, then the address of a note resource with the identifier 10, has the unique address www.example.com/api/notes/10. - +If we define the resource type of note to be notes, then the address of a note resource with the identifier 10, has the unique address www.example.com/api/notes/10. The URL for the entire collection of all note resources is www.example.com/api/notes. - We can execute different operations on resources. The operation to be executed is defined by the HTTP verb: | URL | verb | functionality | | --------------------- | ------------------- | -----------------------------------------------------------------| -| notes/10    | GET | fetches a single resource | +| notes/10 | GET | fetches a single resource | | notes | GET | fetches all resources in the collection | | notes | POST | creates a new resource based on the request data | -| notes/10 | DELETE    | removes the identified resource | +| notes/10 | DELETE | removes the identified resource | | notes/10 | PUT | replaces the entire identified resource with the request data | | notes/10 | PATCH | replaces a part of the identified resource with the request data | | | | | +This is how we manage to roughly define what REST refers to as a [uniform interface](https://en.wikipedia.org/wiki/Representational_state_transfer#Architectural_constraints), which means a consistent way of defining interfaces that makes it possible for systems to cooperate. -This is how we manage to roughly define what REST refers to as a [uniform interface](https://en.wikipedia.org/wiki/Representational_state_transfer#Architectural_constraints), which means a consistent way of defining interfaces that makes it possible for systems to co-operate. - - -This way of interpreting REST falls under the [second level of RESTful maturity](https://martinfowler.com/articles/richardsonMaturityModel.html) in the Richardson Maturity Model. According to the definition provided by Roy Fielding, we have not actually defined a [REST API](http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven). In fact, a large majority of the world's purported "REST" API's do not meet Fielding's original criteria outlined in his dissertation. - - -In some places (see e.g. [Richardson, Ruby: RESTful Web Services](http://shop.oreilly.com/product/9780596529260.do)) you will see our model for a straightforward [CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete) API, being referred to as an example of [resource oriented architecture](https://en.wikipedia.org/wiki/Resource-oriented_architecture) instead of REST. We will avoid getting stuck arguing semantics and instead return to working on our application. +This way of interpreting REST falls under the [second level of RESTful maturity](https://martinfowler.com/articles/richardsonMaturityModel.html) in the Richardson Maturity Model. According to the definition provided by Roy Fielding, we have not defined a [REST API](http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven). In fact, a large majority of the world's purported "REST" APIs do not meet Fielding's original criteria outlined in his dissertation. +In some places (see e.g. [Richardson, Ruby: RESTful Web Services](http://shop.oreilly.com/product/9780596529260.do)) you will see our model for a straightforward [CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete) API, being referred to as an example of [resource-oriented architecture](https://en.wikipedia.org/wiki/Resource-oriented_architecture) instead of REST. We will avoid getting stuck arguing semantics and instead return to working on our application. ### Fetching a single resource - -Let's expand our application so that it offers a REST interface for operating on individual notes. First let's create a [route](http://expressjs.com/en/guide/routing.html) for fetching a single resource. - +Let's expand our application so that it offers a REST interface for operating on individual notes. First, let's create a [route](http://expressjs.com/en/guide/routing.html) for fetching a single resource. The unique address we will use for an individual note is of the form notes/10, where the number at the end refers to the note's unique id number. - -We can define [parameters](http://expressjs.com/en/guide/routing.html#route-parameters) for routes in express by using the colon syntax: +We can define [parameters](http://expressjs.com/en/guide/routing.html#route-parameters) for routes in Express by using the colon syntax: ```js app.get('/api/notes/:id', (request, response) => { @@ -546,11 +442,9 @@ app.get('/api/notes/:id', (request, response) => { }) ``` +Now app.get('/api/notes/:id', ...) will handle all HTTP GET requests that are of the form /api/notes/SOMETHING, where SOMETHING is an arbitrary string. -Now app.get('/api/notes/:id', ...) will handle all HTTP GET requests, that are of the form /api/notes/SOMETHING, where SOMETHING is an arbitrary string. - - -The id parameter in the route of a request, can be accessed through the [request](http://expressjs.com/en/api.html#req) object: +The id parameter in the route of a request can be accessed through the [request](http://expressjs.com/en/api.html#req) object: ```js const id = request.params.id @@ -558,93 +452,26 @@ const id = request.params.id The now familiar _find_ method of arrays is used to find the note with an id that matches the parameter. The note is then returned to the sender of the request. +We can now test our application by going to in our browser: -When we test our application by going to in our browser, we notice that it does not appear to work, as the browser displays an empty page. This comes as no surprise to us as software developers, and it's time to debug. - - -Adding _console.log_ commands into our code is a time-proven trick: - -```js -app.get('/api/notes/:id', (request, response) => { - const id = request.params.id - console.log(id) - const note = notes.find(note => note.id === id) - console.log(note) - response.json(note) -}) -``` - - -When we visit again in the browser, the console which is the terminal in this case, will display the following: - -![](../../images/3/8.png) - - -The id parameter from the route is passed to our application but the _find_ method does not find a matching note. - - -To further our investigation, we also add a console log inside the comparison function passed to the _find_ method. In order to do this, we have to get rid of the compact arrow function syntax note => note.id === id, and use the syntax with an explicit return statement: - -```js -app.get('/api/notes/:id', (request, response) => { - const id = request.params.id - const note = notes.find(note => { - console.log(note.id, typeof note.id, id, typeof id, note.id === id) - return note.id === id - }) - console.log(note) - response.json(note) -}) -``` - - -When we visit the URL again in the browser, each call to the comparison function prints a few different things to the console. The console output is the following: - -
    -1 'number' '1' 'string' false
    -2 'number' '1' 'string' false
    -3 'number' '1' 'string' false
    -
    - - -The cause of the bug becomes clear. The _id_ variable contains a string '1', whereas the id's of notes are integers. In JavaScript, the "triple equals" comparison === considers all values of different types to not be equal by default, meaning that 1 is not '1'. - - -Let's fix the issue by changing the id parameter from a string into a [number](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number): - -```js -app.get('/api/notes/:id', (request, response) => { - const id = Number(request.params.id) - const note = notes.find(note => note.id === id) - response.json(note) -}) -``` - - -Now fetching an individual resource works. - -![](../../images/3/9ea.png) +![api/notes/1 gives a single note as JSON](../../images/3/9new.png) However, there's another problem with our application. - If we search for a note with an id that does not exist, the server responds with: -![](../../images/3/10ea.png) - - -The HTTP status code that is returned is 200, which means that the response succeeded. There is no data sent back with the response, since the value of the content-length header is 0, and the same can be verified from the browser. - +![network tools showing 200 and content-length 0](../../images/3/10ea.png) -The reason for this behavior is that the _note_ variable is set to _undefined_ if no matching note is found. The situation needs to be handled on the server in a better way. If no note is found, the server should respond with the status code [404 not found](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.5) instead of 200. +The HTTP status code that is returned is 200, which means that the response succeeded. There is no data sent back with the response, since the value of the content-length header is 0, and the same can be verified from the browser. +The reason for this behavior is that the _note_ variable is set to _undefined_ if no matching note is found. The situation needs to be handled on the server in a better way. If no note is found, the server should respond with the status code [404 not found](https://www.rfc-editor.org/rfc/rfc9110.html#name-404-not-found) instead of 200. Let's make the following change to our code: ```js app.get('/api/notes/:id', (request, response) => { - const id = Number(request.params.id) + const id = request.params.id const note = notes.find(note => note.id === id) // highlight-start @@ -657,75 +484,72 @@ app.get('/api/notes/:id', (request, response) => { }) ``` - -Since no data is attached to the response, we use the [status](http://expressjs.com/en/4x/api.html#res.status) method for setting the status, and the [end](http://expressjs.com/en/4x/api.html#res.end) method for responding to the request without sending any data. - +Since no data is attached to the response, we use the [status](http://expressjs.com/en/4x/api.html#res.status) method for setting the status and the [end](http://expressjs.com/en/4x/api.html#res.end) method for responding to the request without sending any data. The if-condition leverages the fact that all JavaScript objects are [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy), meaning that they evaluate to true in a comparison operation. However, _undefined_ is [falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy) meaning that it will evaluate to false. - -Our application works and sends the error status code if no note is found. However, the application doesn't return anything to show to the user, like web applications normally do when we visit a page that does not exist. We do not actually need to display anything in the browser because REST API's are interfaces that are intended for programmatic use, and the error status code is all that is needed. - +Our application works and sends the error status code if no note is found. However, the application doesn't return anything to show to the user, like web applications normally do when we visit a page that does not exist. We do not need to display anything in the browser because REST APIs are interfaces that are intended for programmatic use, and the error status code is all that is needed. + +Anyway, it's possible to give a clue about the reason for sending a 404 error by [overriding the default NOT FOUND message](https://stackoverflow.com/questions/14154337/how-to-send-a-custom-http-status-message-in-node-express/36507614#36507614). ### Deleting resources - -Next let's implement a route for deleting resources. Deletion happens by making an HTTP DELETE request to the url of the resource: +Next, let's implement a route for deleting resources. Deletion happens by making an HTTP DELETE request to the URL of the resource: ```js app.delete('/api/notes/:id', (request, response) => { - const id = Number(request.params.id) + const id = request.params.id notes = notes.filter(note => note.id !== id) response.status(204).end() }) ``` +If deleting the resource is successful, meaning that the note exists and is removed, we respond to the request with the status code [204 no content](https://www.rfc-editor.org/rfc/rfc9110.html#name-204-no-content) and return no data with the response. -If deleting the resource is successful, meaning that the note exists and it is removed, we respond to the request with the status code [204 no content](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.2.5) and return no data with the response. - - -There's no consensus on what status code should be returned to a DELETE request if the resource does not exist. Really, the only two options are 204 and 404. For the sake of simplicity our application will respond with 204 in both cases. +There's no consensus on what status code should be returned to a DELETE request if the resource does not exist. The only two options are 204 and 404. For the sake of simplicity, our application will respond with 204 in both cases. ### Postman - So how do we test the delete operation? HTTP GET requests are easy to make from the browser. We could write some JavaScript for testing deletion, but writing test code is not always the best solution in every situation. +Many tools exist for making the testing of backends easier. One of these is a command line program [curl](https://curl.haxx.se). However, instead of curl, we will take a look at using [Postman](https://www.postman.com) for testing the application. -Many tools exist for making the testing of backends easier. One of these is the command line program [curl](https://curl.haxx.se) that was mentioned briefly in the previous part of the material. - -Instead of curl, we will take a look at using [Postman](https://www.getpostman.com/) for testing the application. +Let's install the Postman desktop client [from here](https://www.postman.com/downloads/) and try it out: -Let's install Postman and try it out: +![postman screenshot on api/notes/2](../../images/3/11x.png) +NB: Postman is also available on VS Code which can be downloaded from the Extension tab on the left -> search for Postman -> First result (Verified Publisher) -> Install +You will then see an extra icon added on the activity bar below the extensions tab. Once you log in, you can follow the steps below -![](../../images/3/11ea.png) +Using Postman is quite easy in this situation. It's enough to define the URL and then select the correct request type (DELETE). -Using Postman is quite easy in this situation. It's enough to define the url and then select the correct request type (DELETE). +The backend server appears to respond correctly. By making an HTTP GET request to we see that the note with the id 2 is no longer in the list, which indicates that the deletion was successful. -The backend server appears to respond correctly. By making an HTTP GET request to we see that the note with the id 2 is no longer in the list, which indicates that the deletion was successful. - -Because the notes in the application are only saved to memory, the list of notes will return to its original state when we restart the application. +Currently, the notes in the application are hard-coded and not yet saved in a database, so the list of notes will reset to its original state when we restart the application. ### The Visual Studio Code REST client If you use Visual Studio Code, you can use the VS Code [REST client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) plugin instead of Postman. -Once the plugin is installed, using it is very simple. We make a directory at the root of application named requests. We save all the REST client requests in the directory as files that end with the .rest extension. +Once the plugin is installed, using it is very simple. We make a directory at the root of the application named requests. We save all the REST client requests in the directory as files that end with the .rest extension. Let's create a new get\_all\_notes.rest file and define the request that fetches all notes. -![](../../images/3/12ea.png) +![get all notes rest file with get request on notes](../../images/3/12ea.png) + +By clicking the Send Request text, the REST client will execute the HTTP request and the response from the server is opened in the editor. -By clicking the Send Request text, the REST client will execute the HTTP request and response from the server is opened in the editor. +![response from vs code from get request](../../images/3/13new.png) -![](../../images/3/13ea.png) +### The WebStorm HTTP Client + +If you use *IntelliJ WebStorm* instead, you can use a similar procedure with its built-in HTTP Client. Create a new file with extension `.rest` and the editor will display your options to create and run your requests. You can learn more about it by following [this guide](https://www.jetbrains.com/help/webstorm/http-client-in-product-code-editor.html). ### Receiving data -Next, let's make it possible to add new notes to the server. Adding a note happens by making an HTTP POST request to the address http://localhost:3001/api/notes, and by sending all the information for the new note in the request [body](https://www.w3.org/Protocols/rfc2616/rfc2616-sec7.html#sec7) in the JSON format. +Next, let's make it possible to add new notes to the server. Adding a note happens by making an HTTP POST request to the address , and by sending all the information for the new note in the request [body](https://www.rfc-editor.org/rfc/rfc9112#name-message-body) in JSON format. -In order to access the data easily, we need the help of the express [json-parser](https://expressjs.com/en/api.html), that is taken to use with command _app.use(express.json())_. +To access the data easily, we need the help of the Express [json-parser](https://expressjs.com/en/api.html) that we can use with the command _app.use(express.json())_. Let's activate the json-parser and implement an initial handler for dealing with the HTTP POST requests: @@ -733,95 +557,96 @@ Let's activate the json-parser and implement an initial handler for dealing with const express = require('express') const app = express() -app.use(express.json()) +app.use(express.json()) // highlight-line //... +// highlight-start app.post('/api/notes', (request, response) => { const note = request.body console.log(note) response.json(note) }) +// highlight-end ``` - The event handler function can access the data from the body property of the _request_ object. - -Without the json-parser, the body property would be undefined. The json-parser functions so that it takes the JSON data of a request, transforms it into a JavaScript object and then attaches it to the body property of the _request_ object before the route handler is called. - +Without the json-parser, the body property would be undefined. The json-parser takes the JSON data of a request, transforms it into a JavaScript object and then attaches it to the body property of the _request_ object before the route handler is called. For the time being, the application does not do anything with the received data besides printing it to the console and sending it back in the response. +Before we implement the rest of the application logic, let's verify with Postman that the data is in fact received by the server. In addition to defining the URL and request type in Postman, we also have to define the data sent in the body: -Before we implement the rest of the application logic, let's verify with Postman that the data is actually received by the server. In addition to defining the URL and request type in Postman, we also have to define the data sent in the body: - -![](../../images/3/14ea.png) - +![postman post on api/notes with post content](../../images/3/14new.png) The application prints the data that we sent in the request to the console: -![](../../images/3/15e.png) - - -**NB** Keep the terminal running the application visible at all times when you are working on the backend. Thanks to Nodemon any changes we make to the code will restart the application. If you pay attention to the console, you will immediately be able to pick up on errors that occur in the application: +![terminal printing content provided in postman](../../images/3/15c.png) -![](../../images/3/16.png) +**NOTE:** When programming the backend, keep the console running the application visible at all times. The development server will restart if changes are made to the code, so by monitoring the console, you will immediately notice if there is an error in the application's code: +![console error about SyntaxError](../../images/3/16_25.png) -Similarly, it is useful to check the console for making sure that the backend behaves like we expect it to in different situations, like when we send data with an HTTP POST request. Naturally, it's a good idea to add lots of console.log commands to the code while the application is still being developed. - +Similarly, it is useful to check the console to make sure that the backend behaves as we expect it to in different situations, like when we send data with an HTTP POST request. Naturally, it's a good idea to add lots of console.log commands to the code while the application is still being developed. A potential cause for issues is an incorrectly set Content-Type header in requests. This can happen with Postman if the type of body is not defined correctly: -![](../../images/3/17e.png) - +![postman having text as content-type](../../images/3/17new.png) The Content-Type header is set to text/plain: -![](../../images/3/18e.png) - +![postman showing headers and content-type as text/plain](../../images/3/18new.png) The server appears to only receive an empty object: -![](../../images/3/19.png) +![console output showing empty curly braces](../../images/3/19_25.png) +The server will not be able to parse the data correctly without the correct value in the header. It won't even try to guess the format of the data since there's a [massive amount](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) of potential Content-Types. -The server will not be able to parse the data correctly without the correct value in the header. It won't even try to guess the format of the data, since there's a [massive amount](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) of potential Content-Types. +If you are using VS Code, then you should install the REST client from the previous chapter now, if you haven't already. The POST request can be sent with the REST client like this: +![sample post request in vscode with JSON data](../../images/3/20new.png) -If you are using VS Code, then you should install the REST client from the previous chapter now, if you haven't already. The POST request can be sent with the REST client like this: +We created a new create\_note.rest file for the request. The request is formatted according to the [instructions in the documentation](https://github.com/Huachao/vscode-restclient/blob/master/README.md#usage). -![](../../images/3/20eb.png) +One benefit that the REST client has over Postman is that the requests are handily available at the root of the project repository, and they can be distributed to everyone in the development team. You can also add multiple requests in the same file using `###` separators: +```text +GET http://localhost:3001/api/notes/ -We created a new create\_note.rest file for the request. The request is formatted according to the [instructions in the documentation](https://github.com/Huachao/vscode-restclient/blob/master/README.md#usage). +### +POST http://localhost:3001/api/notes/ HTTP/1.1 +content-type: application/json +{ + "name": "sample", + "time": "Wed, 21 Oct 2015 18:27:50 GMT" +} +``` -One benefit that the REST client has over Postman is that the requests are handily available at the root of the project repository, and they can be distributed to everyone in the development team. Postman also allows users to save requests, but the situation can get quite chaotic especially when you're working on multiple unrelated projects. +Postman also allows users to save requests, but the situation can get quite chaotic especially when you're working on multiple unrelated projects. > **Important sidenote** > > Sometimes when you're debugging, you may want to find out what headers have been set in the HTTP request. One way of accomplishing this is through the [get](http://expressjs.com/en/4x/api.html#req.get) method of the _request_ object, that can be used for getting the value of a single header. The _request_ object also has the headers property, that contains all of the headers of a specific request. > - > Problems can occur with the VS REST client if you accidentally add an empty line between the top row and the row specifying the HTTP headers. In this situation, the REST client interprets this to mean that all headers are left empty, which leads to the backend server not knowing that the data it has received is in the JSON format. > - -You will be able to spot this missing Content-Type header if at some point in your code you print all of the request headers with the _console.log(request.headers)_ command. - +> +> You will be able to spot this missing Content-Type header if at some point in your code you print all of the request headers with the _console.log(request.headers)_ command. Let's return to the application. Once we know that the application receives data correctly, it's time to finalize the handling of the request: ```js app.post('/api/notes', (request, response) => { const maxId = notes.length > 0 - ? Math.max(...notes.map(n => n.id)) + ? Math.max(...notes.map(n => Number(n.id))) : 0 const note = request.body - note.id = maxId + 1 + note.id = String(maxId + 1) notes = notes.concat(note) @@ -829,18 +654,16 @@ app.post('/api/notes', (request, response) => { }) ``` +We need a unique id for the note. First, we find out the largest id number in the current list and assign it to the _maxId_ variable. The id of the new note is then defined as _maxId + 1_ as a string. This method is not recommended, but we will live with it for now as we will replace it soon enough. -We need a unique id for the note. First, we find out the largest id number in the current list and assign it to the _maxId_ variable. The id of the new note is then defined as _maxId + 1_. This method is in fact not recommended, but we will live with it for now as we will replace it soon enough. - - -The current version still has the problem that the HTTP POST request can be used to add objects with arbitrary properties. Let's improve the application by defining that the content property may not be empty. The important and date properties will be given default values. All other properties are discarded: +The current version still has the problem that the HTTP POST request can be used to add objects with arbitrary properties. Let's improve the application by defining that the content property may not be empty. The important property will be given a default value of false. All other properties are discarded: ```js const generateId = () => { const maxId = notes.length > 0 - ? Math.max(...notes.map(n => n.id)) + ? Math.max(...notes.map(n => Number(n.id))) : 0 - return maxId + 1 + return String(maxId + 1) } app.post('/api/notes', (request, response) => { @@ -855,7 +678,6 @@ app.post('/api/notes', (request, response) => { const note = { content: body.content, important: body.important || false, - date: new Date(), id: generateId(), } @@ -865,11 +687,9 @@ app.post('/api/notes', (request, response) => { }) ``` - The logic for generating the new id number for notes has been extracted into a separate _generateId_ function. - -If the received data is missing a value for the content property, the server will respond to the request with the status code [400 bad request](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1): +If the received data is missing a value for the content property, the server will respond to the request with the status code [400 bad request](https://www.rfc-editor.org/rfc/rfc9110.html#name-400-bad-request): ```js if (!body.content) { @@ -879,137 +699,129 @@ if (!body.content) { } ``` +Notice that calling return is crucial because otherwise the code will execute to the very end and the malformed note gets saved to the application. -Notice that calling return is crucial, because otherwise the code will execute to the very end and the malformed note gets saved to the application. - - -If the content property has a value, the note will be based on the received data. As mentioned previously, it is better to generate timestamps on the server than in the browser, since we can't trust that host machine running the browser has its clock set correctly. The generation of the date property is now done by the server. - - +If the content property has a value, the note will be based on the received data. If the important property is missing, we will default the value to false. The default value is currently generated in a rather odd-looking way: ```js important: body.important || false, ``` - -If the data saved in the _body_ variable has the important property, the expression will evaluate to its value. If the property does not exist, then the expression will evaluate to false which is defined on the right-hand side of the vertical lines. - +If the data saved in the _body_ variable has the important property, the expression will evaluate its value and convert it to a boolean value. If the property does not exist, then the expression will evaluate to false which is defined on the right-hand side of the vertical lines. > To be exact, when the important property is false, then the body.important || false expression will in fact return the false from the right-hand side... +You can find the code for our current application in its entirety in the part3-1 branch of [this GitHub repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-1). -You can find the code for our current application in its entirety in the part3-1 branch of [this github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-1). - - -Notice that the master branch of the repository contains the code from a later version of the application. The code for the current state of the application is specifically in branch [part3-1](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-1). - -![](../../images/3/21.png) - +![GitHub screenshot of branch 3-1](../../images/3/21.png) If you clone the project, run the _npm install_ command before starting the application with _npm start_ or _npm run dev_. - -One more thing before we move onto the exercises. The function for generating IDs looks currently like this: +One more thing before we move on to the exercises. The function for generating IDs looks currently like this: ```js const generateId = () => { const maxId = notes.length > 0 - ? Math.max(...notes.map(n => n.id)) + ? Math.max(...notes.map(n => Number(n.id))) : 0 - return maxId + 1 + return String(maxId + 1) } ``` - The function body contains a row that looks a bit intriguing: ```js -Math.max(...notes.map(n => n.id)) +Math.max(...notes.map(n => Number(n.id))) ``` -What exactly is happening in that line of code? notes.map(n => n.id) creates a new array that contains all the id's of the notes. [Math.max](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/max) returns the maximum value of the numbers that are passed to it. However, notes.map(n => n.id) is an array so it can't directly be given as a parameter to _Math.max_. The array can be transformed into individual numbers by using the "three dot" [spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) syntax .... +What exactly is happening in that line of code? notes.map(n => n.id) creates a new array that contains all the ids of the notes in number form. [Math.max](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/max) returns the maximum value of the numbers that are passed to it. However, notes.map(n => Number(n.id)) is an array so it can't directly be given as a parameter to _Math.max_. The array can be transformed into individual numbers by using the "three dot" [spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) syntax ....
    - ### Exercises 3.1.-3.6. -**NB:** It's recommended to do all of the exercises from this part into a new dedicated git repository, and place your source code right at the root of the repository. Otherwise you will run into problems in exercise 3.10. - - -**NB:** Because this is not a frontend project and we are not working with React, the application is not created with create-react-app. You initialize this project with the npm init command that was demonstrated earlier in this part of the material. +**NB:** Because this is not a frontend project and we are not working with React, the application is not created with create vite@latest -- --template react. You initialize this project with the npm init command that was demonstrated earlier in this part of the material. +**NB:** Because the "node\_modules" is created using "npm init", it will not be excluded when you are trying to add your code to git using "git add .", therefore please create a file called ".gitignore" and write "node\_modules" so that git ignores it everytime you try to add, commit or push to a remote repo. **Strong recommendation:** When you are working on backend code, always keep an eye on what's going on in the terminal that is running your application. +#### 3.1: Phonebook backend step 1 -#### 3.1: Phonebook backend step1 - - -Implement a Node application that returns a hardcoded list of phonebook entries from the address : - -![](../../images/3/22e.png) - +Implement a Node application that returns a hardcoded list of phonebook entries from the address . + +Data: + +```js +[ + { + "id": "1", + "name": "Arto Hellas", + "number": "040-123456" + }, + { + "id": "2", + "name": "Ada Lovelace", + "number": "39-44-5323523" + }, + { + "id": "3", + "name": "Dan Abramov", + "number": "12-43-234345" + }, + { + "id": "4", + "name": "Mary Poppendieck", + "number": "39-23-6423122" + } +] +``` -Notice that the forward slash in the route api/persons is not a special character, and is just like any other character in the string. +Output in the browser after GET request: + +![JSON data of 4 people in browser from api/persons](../../images/3/22e.png) +Notice that the forward slash in the route api/persons is not a special character, and is just like any other character in the string. The application must be started with the command _npm start_. - The application must also offer an _npm run dev_ command that will run the application and restart the server whenever changes are made and saved to a file in the source code. - -#### 3.2: Phonebook backend step2 - +#### 3.2: Phonebook backend step 2 Implement a page at the address that looks roughly like this: -![](../../images/3/23ea.png) - +![Screenshot for 3.2](../../images/3/23x.png) The page has to show the time that the request was received and how many entries are in the phonebook at the time of processing the request. - -#### 3.3: Phonebook backend step3 - +#### 3.3: Phonebook backend step 3 Implement the functionality for displaying the information for a single phonebook entry. The url for getting the data for a person with the id 5 should be - If an entry for the given id is not found, the server has to respond with the appropriate status code. - -#### 3.4: Phonebook backend step4 - +#### 3.4: Phonebook backend step 4 Implement functionality that makes it possible to delete a single phonebook entry by making an HTTP DELETE request to the unique URL of that phonebook entry. - Test that your functionality works with either Postman or the Visual Studio Code REST client. - -#### 3.5: Phonebook backend step5 - +#### 3.5: Phonebook backend step 5 Expand the backend so that new phonebook entries can be added by making HTTP POST requests to the address . +Generate a new id for the phonebook entry with the [Math.random](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random) function. Use a big enough range for your random values so that the likelihood of creating duplicate ids is small. -Generate a new id for the phonebook entry with the [Math.random](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random) function. Use a big enough range for your random values so that the likelihood of creating duplicate id's is small. - - -#### 3.6: Phonebook backend step6 - - - +#### 3.6: Phonebook backend step 6 Implement error handling for creating new entries. The request is not allowed to succeed, if: -- The name or number is missing -- The name already exists in the phonebook +- The name or number is missing +- The name already exists in the phonebook Respond to requests like these with the appropriate status code, and also send back information that explains the reason for the error, e.g.: @@ -1021,58 +833,44 @@ Respond to requests like these with the appropriate status code, and also send b
    - ### About HTTP request types -[The HTTP standard](https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html) talks about two properties related to request types, **safety** and **idempotence**. +[The HTTP standard](https://www.rfc-editor.org/rfc/rfc9110.html#name-common-method-properties) talks about two properties related to request types, **safety** and **idempotency**. The HTTP GET request should be safe: > In particular, the convention has been established that the GET and HEAD methods SHOULD NOT have the significance of taking an action other than retrieval. These methods ought to be considered "safe". +Safety means that the executing request must not cause any side effects on the server. By side effects, we mean that the state of the database must not change as a result of the request, and the response must only return data that already exists on the server. -Safety means that the executing request must not cause any side effects in the server. By side-effects we mean that the state of the database must not change as a result of the request, and the response must only return data that already exists on the server. - - -Nothing can ever guarantee that a GET request is actually safe, this is in fact just a recommendation that is defined in the HTTP standard. By adhering to RESTful principles in our API, GET requests are in fact always used in a way that they are safe. - - -The HTTP standard also defines the request type [HEAD](https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4), that ought to be safe. In practice HEAD should work exactly like GET but it does not return anything but the status code and response headers. The response body will not be returned when you make a HEAD request. +Nothing can ever guarantee that a GET request is safe, this is just a recommendation that is defined in the HTTP standard. By adhering to RESTful principles in our API, GET requests are always used in a way that they are safe. +The HTTP standard also defines the request type [HEAD](https://www.rfc-editor.org/rfc/rfc9110.html#name-head), which ought to be safe. In practice, HEAD should work exactly like GET but it does not return anything but the status code and response headers. The response body will not be returned when you make a HEAD request. All HTTP requests except POST should be idempotent: > Methods can also have the property of "idempotence" in that (aside from error or expiration issues) the side-effects of N > 0 identical requests is the same as for a single request. The methods GET, HEAD, PUT and DELETE share this property +This means that if a request does generate side effects, then the result should be the same regardless of how many times the request is sent. -This means that if a request has side-effects, then the result should be same regardless of how many times the request is sent. - - -If we make an HTTP PUT request to the url /api/notes/10 and with the request we send the data { content: "no side effects!", important: true }, the result is the same regardless of many times the request is sent. - +If we make an HTTP PUT request to the URL /api/notes/10 and with the request we send the data { content: "no side effects!", important: true }, the result is the same regardless of how many times the request is sent. Like safety for the GET request, idempotence is also just a recommendation in the HTTP standard and not something that can be guaranteed simply based on the request type. However, when our API adheres to RESTful principles, then GET, HEAD, PUT, and DELETE requests are used in such a way that they are idempotent. - POST is the only HTTP request type that is neither safe nor idempotent. If we send 5 different HTTP POST requests to /api/notes with a body of {content: "many same", important: true}, the resulting 5 notes on the server will all have the same content. - ### Middleware -The express [json-parser](https://expressjs.com/en/api.html) we took into use earlier is a so-called [middleware](http://expressjs.com/en/guide/using-middleware.html). - +The Express [json-parser](https://expressjs.com/en/api.html) used earlier is a [middleware](http://expressjs.com/en/guide/using-middleware.html). Middleware are functions that can be used for handling _request_ and _response_ objects. -The json-parser we used earlier takes the raw data from the requests that's stored in the _request_ object, parses it into a JavaScript object and assigns it to the _request_ object as a new property body. - - -In practice, you can use several middleware at the same time. When you have more than one, they're executed one by one in the order that they were taken into use in express. +The json-parser we used earlier takes the raw data from the requests that are stored in the _request_ object, parses it into a JavaScript object and assigns it to the _request_ object as a new property body. +In practice, you can use several middlewares at the same time. When you have more than one, they're executed one by one in the order that they were listed in the application code. Let's implement our own middleware that prints information about every request that is sent to the server. - Middleware is a function that receives three parameters: ```js @@ -1085,21 +883,19 @@ const requestLogger = (request, response, next) => { } ``` -At the end of the function body the _next_ function that was passed as a parameter is called. The _next_ function yields control to the next middleware. +At the end of the function body, the _next_ function that was passed as a parameter is called. The _next_ function yields control to the next middleware. -Middleware are taken into use like this: +Middleware is used like this: ```js app.use(requestLogger) ``` -Middleware functions are called in the order that they're taken into use with the express server object's _use_ method. Notice that json-parser is taken into use before the _requestLogger_ middleware, because otherwise request.body will not be initialized when the logger is executed! +Remember, middleware functions are called in the order that they're encountered by the JavaScript engine. Notice that _json-parser_ is listed before _requestLogger_ , because otherwise request.body will not be initialized when the logger is executed! +Middleware functions have to be used before routes when we want them to be executed by the route event handlers. Sometimes, we want to use middleware functions after routes. We do this when the middleware functions are only called if no route handler processes the HTTP request. -Middleware functions have to be taken into use before routes if we want them to be executed before the route event handlers are called. There are also situations where we want to define middleware functions after routes. In practice, this means that we are defining middleware functions that are only called if no route handles the HTTP request. - - -Let's add the following middleware after our routes, that is used for catching requests made to non-existent routes. For these requests, the middleware will return an error message in the JSON format. +Let's add the following middleware after our routes. This middleware will be used for catching requests made to non-existent routes. For these requests, the middleware will return an error message in the JSON format. ```js const unknownEndpoint = (request, response) => { @@ -1109,8 +905,7 @@ const unknownEndpoint = (request, response) => { app.use(unknownEndpoint) ``` - -You can find the code for our current application in its entirety in the part3-2 branch of [this github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-2). +You can find the code for our current application in its entirety in the part3-2 branch of [this GitHub repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-2).
    @@ -1118,28 +913,26 @@ You can find the code for our current application in its entirety in the part ### Exercises 3.7.-3.8. -#### 3.7: Phonebook backend step7 +#### 3.7: Phonebook backend step 7 Add the [morgan](https://github.com/expressjs/morgan) middleware to your application for logging. Configure it to log messages to your console based on the tiny configuration. The documentation for Morgan is not the best, and you may have to spend some time figuring out how to configure it correctly. However, most documentation in the world falls under the same category, so it's good to learn to decipher and interpret cryptic documentation in any case. - Morgan is installed just like all other libraries with the _npm install_ command. Taking morgan into use happens the same way as configuring any other middleware by using the _app.use_ command. - -#### 3.8*: Phonebook backend step8 - +#### 3.8*: Phonebook backend step 8 Configure morgan so that it also shows the data sent in HTTP POST requests: -![](../../images/3/24.png) +![terminal showing post data being sent](../../images/3/24.png) +Note that logging data even in the console can be dangerous since it can contain sensitive data and may violate local privacy law (e.g. GDPR in EU) or business-standard. In this exercise, you don't have to worry about privacy issues, but in practice, try not to log any sensitive data. This exercise can be quite challenging, even though the solution does not require a lot of code. - This exercise can be completed in a few different ways. One of the possible solutions utilizes these two techniques: + - [creating new tokens](https://github.com/expressjs/morgan#creating-new-tokens) - [JSON.stringify](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) diff --git a/src/content/3/en/part3b.md b/src/content/3/en/part3b.md index 2fbd6a3d2e2..6a184a62ad8 100644 --- a/src/content/3/en/part3b.md +++ b/src/content/3/en/part3b.md @@ -7,13 +7,10 @@ lang: en
    - -Next let's connect the frontend we made in [part 2](/en/part2) to our own backend. +Next, let's connect the frontend we made in [part 2](/en/part2) to our own backend. - -In the previous part, the frontend could ask for the list of notes from the json-server we had as a backend at from the address http://localhost:3001/notes. -Our backend has a bit different url structure, and the notes can be found from http//localhost:3001/api/notes. -Let's change the attribute __baseUrl__ in the src/services/notes.js like so: +In the previous part, the frontend could ask for the list of notes from the json-server we had as a backend, from the address . +Our backend has a slightly different URL structure now, as the notes can be found at . Let's change the attribute __baseUrl__ in the frontend notes app at src/services/notes.js like so: ```js import axios from 'axios' @@ -29,36 +26,46 @@ const getAll = () => { export default { getAll, create, update } ``` - Now frontend's GET request to does not work for some reason: -![](../../images/3/3ae.png) +![Get request showing error in dev tools](../../images/3/3ae.png) - What's going on here? We can access the backend from a browser and from postman without any problems. ### Same origin policy and CORS -The issue lies with a thing called CORS, or Cross-Origin Resource Sharing. +The issue lies with a thing called _same origin policy_. A URL's origin is defined by the combination of protocol (AKA scheme), hostname, and port. -According to [Wikipedia](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing): +```text +http://example.com:80/index.html + +protocol: http +host: example.com +port: 80 +``` + +When you visit a website (e.g. ), the browser issues a request to the server on which the website (example.com) is hosted. The response sent by the server is an HTML file that may contain one or more references to external assets/resources hosted either on the same server that example.com is hosted on or a different website. When the browser sees reference(s) to a URL in the source HTML, it issues a request. If the request is issued using the URL that the source HTML was fetched from, then the browser processes the response without any issues. However, if the resource is fetched using a URL that doesn't share the same origin(scheme, host, port) as the source HTML, the browser will have to check the _Access-Control-Allow-origin_ response header. If it contains _*_ on the URL of the source HTML, the browser will process the response, otherwise the browser will refuse to process it and throws an error. + +The same-origin policy is a security mechanism implemented by browsers in order to prevent session hijacking among other security vulnerabilities. + +In order to enable legitimate cross-origin requests (requests to URLs that don't share the same origin) W3C came up with a mechanism called CORS(Cross-Origin Resource Sharing). According to [Wikipedia](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing): > Cross-origin resource sharing (CORS) is a mechanism that allows restricted resources (e.g. fonts) on a web page to be requested from another domain outside the domain from which the first resource was served. A web page may freely embed cross-origin images, stylesheets, scripts, iframes, and videos. Certain "cross-domain" requests, notably Ajax requests, are forbidden by default by the same-origin security policy. -In our context the problem is that, by default, the JavaScript code of an application that runs in a browser can only communicate with a server in the same [origin](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy). -Because our server is in localhost port 3001, and our frontend in localhost port 3000, they do not have the same origin. +The problem is that, by default, the JavaScript code of an application that runs in a browser can only communicate with a server in the same [origin](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy). +Because our server is in localhost port 3001, while our frontend is in localhost port 5173, they do not have the same origin. -Keep in mind, that [same origin policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy) and CORS are not specific to React or Node. They are in fact universal principles of the operation of web applications. +Keep in mind, that [same-origin policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy) and CORS are not specific to React or Node. They are universal principles regarding the safe operation of web applications. We can allow requests from other origins by using Node's [cors](https://github.com/expressjs/cors) middleware. -Install cors with the command +In your backend repository, install cors with the command ```bash -npm install cors --save +npm install cors ``` -take the middleware to use and allow for requests from all origins: +take the middleware to use and allow for requests from all origins: ```js const cors = require('cors') @@ -66,23 +73,38 @@ const cors = require('cors') app.use(cors()) ``` -And the frontend works! However, the functionality for changing the importance of notes has not yet been implemented to the backend. +**Note:** When you are enabling cors, you should think about how you want to configure it. In the case of our application, since the backend is not expected to be visible to the public in the production environment, it may make more sense to only enable cors from a specific origin (e.g. the front end). + +Now most of the features in the frontend work! The functionality for changing the importance of notes has not yet been implemented on the backend so naturally that does not yet work in the frontend. We shall fix that later. -You can read more about CORS from [Mozillas page](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS). +You can read more about CORS from [Mozilla's page](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS). + +The setup of our app now looks as follows: + +![diagram of react app and browser](../../images/3/100_25.png) + +The react app running in the browser now fetches the data from node/express-server that runs in localhost:3001. ### Application to the Internet -Now that the whole stack is ready, let's move our application to the internet. We'll use good old [Heroku](https://www.heroku.com) for this. +Now that the whole stack is ready, let's move our application to Internet. ->If you have never used Heroku before, you can find instructions from [Heroku documentation](https://devcenter.heroku.com/articles/getting-started-with-nodejs) or by Googling. +There is an ever-growing number of services that can be used to host an app on the internet. The developer-friendly services like PaaS (i.e. Platform as a Service) take care of installing the execution environment (eg. Node.js) and could also provide various services such as databases. -Add a file called Procfile to the project's root to tell Heroku how to start the application. +For a decade, [Heroku](http://heroku.com) was dominating the PaaS scene. Unfortunately the free tier Heroku ended at 27th November 2022. This is very unfortunate for many developers, especially students. Heroku is still very much a viable option if you are willing to spend some money. They also have [a student program](https://www.heroku.com/students) that provides some free credits. -```bash -web: node index.js -``` +We are now introducing two services [Fly.io](https://fly.io/) and [Render](https://render.com/). Fly.io offers more flexibility as a service, but it has also recently become paid. Render offers some free compute time, so if you want to complete the course without costs, choose Render. Setting up Render might also be easier in some cases, as Render does not require any installations on your own machine. + +There are also some other free hosting options that work well for this course, at least for all parts other than part 11 (CI/CD) which might have one tricky exercise for other platforms. + +Some course participants have also used the following services: + +- [Replit](https://replit.com) +- [CodeSandBox](https://codesandbox.io) -Change the definition of the port our application uses at the bottom of the index.js file like so: +If you know easy-to-use and free services for hosting NodeJS, please let us know! + +For both Fly.io and Render, we need to change the definition of the port our application uses at the bottom of the index.js file in the backend like so: ```js const PORT = process.env.PORT || 3001 // highlight-line @@ -91,42 +113,149 @@ app.listen(PORT, () => { }) ``` -Now we are using the port defined in [environment variable](https://en.wikipedia.org/wiki/Environment_variable) _PORT_ or port 3001 if the environment variable _PORT_ is undefined. -Heroku configures application port based on the environment variable. +Now we are using the port defined in the [environment variable](https://en.wikipedia.org/wiki/Environment_variable) _PORT_ or port 3001 if the environment variable _PORT_ is undefined. It is possible to configure the application port based on the environment variable both in Fly.io and in Render. + +#### Fly.io + +Note that you may need to give your credit card number to Fly.io! + +If you decide to use [Fly.io](https://fly.io/) begin by installing their flyctl executable following [this guide](https://fly.io/docs/hands-on/install-flyctl/). After that, you should [create a Fly.io account](https://fly.io/docs/hands-on/sign-up/). + +Start by [authenticating](https://fly.io/docs/hands-on/sign-in/) via the command line with the command + +```bash +fly auth login +``` + +Note if the command _fly_ does not work on your machine, you can try the longer version _flyctl_. Eg. on MacOS, both forms of the command work. + +If you do not get the flyctl to work in your machine, you could try Render (see next section), it does not require anything to be installed in your machine. + +Initializing an app happens by running the following command in the root directory of the app + +```bash +fly launch --no-deploy +``` -Create a Git repository in the project directory, and add .gitignore with the following contents +Give the app a name or let Fly.io auto-generate one. Pick a region where the app will be run. Do not create a Postgres database for the app and do not create an Upstash Redis database, since these are not needed. + +Fly.io creates a file fly.toml in the root of your app where we can configure it. To get the app up and running we might need to do a small addition to the configuration: ```bash -node_modules +[build] + +[env] + PORT = "3001" # add this + +[http_service] + internal_port = 3001 # ensure that this is same as PORT + force_https = true + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 0 + processes = ["app"] ``` -Create a Heroku application with the command heroku create, commit your code to the repository and move it to Heroku with command git push heroku master. +We have now defined in the part [env] that environment variable PORT will get the correct port (defined in part [http_service]) where the app should create the server. -If everything went well, the application works: +We are now ready to deploy the app to the Fly.io servers. That is done with the following command: + +```bash +fly deploy +``` -![](../../images/3/25ea.png) +If all goes well, the app should now be up and running. You can open it in the browser with the command -If not, the issue can be found by reading heroku logs with command heroku logs. +```bash +fly apps open +``` ->**NB** At least in the beginning it's good to keep an eye on the heroku logs at all times. The best way to do this is with command heroku logs -t which prints the logs to console whenever something happens on the server. +A particularly important command is _fly logs_. This command can be used to view server logs. It is best to keep logs always visible! -The frontend also works with the backend on Heroku. You can check this by changing the backend's address on the frontend to be the backend's address in Heroku instead of http://localhost:3001. +**Note:** Fly may create 2 machines for your app, if it does then the state of the data in your app will be inconsistent between requests, i.e. you would have two machines each with its own notes variable, you could POST to one machine then your next GET could go to another machine. You can check the number of machines by using the command "$ fly scale show", if the COUNT is greater than 1 then you can enforce it to be 1 with the command "$ fly scale count 1". The machine count can also be checked on the dashboard. -The next question is, how do we deploy the frontend to the Internet? We have multiple options. Let's go through one of them next. +**Note:** In some cases (the cause is so far unknown) running Fly.io commands especially on Windows WSL (Windows Subsystem for Linux) has caused problems. If the following command just hangs + +```bash +flyctl ping -o personal +``` + +your computer can not for some reason connect to Fly.io. If this happens to you, [this](https://github.com/fullstack-hy2020/misc/blob/master/fly_io_problem.md) describes one possible way to proceed. + +If the output of the below command looks like this: + +```bash +$ flyctl ping -o personal +35 bytes from fdaa:0:8a3d::3 (gateway), seq=0 time=65.1ms +35 bytes from fdaa:0:8a3d::3 (gateway), seq=1 time=28.5ms +35 bytes from fdaa:0:8a3d::3 (gateway), seq=2 time=29.3ms +... +``` + +then there are no connection problems! + +Whenever you make changes to the application, you can take the new version to production with a command + +```bash +fly deploy +``` + +#### Render + +Note that you may need to give your credit card number to Render! + +The following assumes that the [sign in](https://dashboard.render.com/) has been made with a GitHub account. + +After signing in, let us create a new "web service": + +![Image showing the option to create a new Web Service](../../images/3/r1.png) + +The app repository is then connected to Render: + +![Image showing the application repository on Render.](../../images/3/r2.png) + +The connection seems to require that the app repository is public. + +Next we will define the basic configurations. If the app is not at the root of the repository the Root directory needs to be given a proper value: + +![image showing the Root Directory field as optional](../../images/3/r3.png) + +After this, the app starts up in the Render. The dashboard tells us the app state and the url where the app is running: + +![The top left corner of the image shows the status of the application and its URL](../../images/3/r4.png) + +According to the [documentation](https://render.com/docs/deploys) every commit to GitHub should redeploy the app. For some reason this is not always working. + +Fortunately, it is also possible to manually redeploy the app: + +![Menu with the option to deploy latest commit highlighted](../../images/3/r5.png) + +Also, the app logs can be seen in the dashboard: + +![Image with the logs tab highlighted on the left corner. On the right side, the application logs](../../images/3/r7.png) + +We notice now from the logs that the app has been started in the port 10000. The app code gets the right port through the environment variable PORT so it is essential that the file index.js has been updated in the backend as follows: + +```js +const PORT = process.env.PORT || 3001 // highlight-line +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +``` ### Frontend production build -So far we have been running React code in development mode. In development mode the application is configured to give clear error messages, immediately render code changes to the browser, and so on. +So far we have been running React code in development mode. In development mode the application is configured to give clear error messages, immediately render code changes to the browser, and so on. -When the application is deployed, we must create a [production build](https://reactjs.org/docs/optimizing-performance.html#use-the-production-build) or a version of the application which is optimized for production. +When the application is deployed, we must create a [production build](https://vitejs.dev/guide/build.html) or a version of the application that is optimized for production. -A production build of applications created with create-react-app can be created with command [npm run build](https://github.com/facebookincubator/create-react-app#npm-run-build-or-yarn-build). +A production build for applications created with Vite can be created with the command [npm run build](https://vitejs.dev/guide/build.html). -Let's run this command from the root of the frontend project. +Let's run this command from the root of the notes frontend project that we developed in [Part 2](/en/part2). -This creates a directory called build (which contains the only HTML file of our application, index.html ) which contains the directory static. [Minified]() version of our application's JavaScript code will be generated to the static directory. Even though the application code is in multiple files, all of the JavaScript will be minified into one file. Actually all of the code from all of the application's dependencies will also be minified into this single file. +This creates a directory called dist which contains the only HTML file of our application (index.html) and the directory assets. [Minified]() version of our application's JavaScript code will be generated in the dist directory. Even though the application code is in multiple files, all of the JavaScript will be minified into one file. All of the code from all of the application's dependencies will also be minified into this single file. -The minified code is not very readable. The beginning of the code looks like this: +The minified code is not very readable. The beginning of the code looks like this: ```js !function(e){function r(r){for(var n,f,i=r[0],l=r[1],a=r[2],c=0,s=[];cbuild directory) to the root of the backend repository and configure the backend to show the frontend's main page (the file build/index.html) as its main page. +One option for deploying the frontend is to copy the production build (the dist directory) to the root of the backend directory and configure the backend to show the frontend's main page (the file dist/index.html) as its main page. -We begin by copying the production build of the frontend to the root of the backend. With my computer the copying can be done from the frontend directory with the command +We begin by copying the production build of the frontend to the root of the backend. With a Mac or Linux computer, the copying can be done from the frontend directory with the command ```bash -cp -r build ../../../osa3/notes-backend +cp -r dist ../backend ``` +If you are using a Windows computer, you may use either [copy](https://www.windows-commandline.com/windows-copy-command-syntax-examples/) or [xcopy](https://www.windows-commandline.com/xcopy-command-syntax-examples/) command instead. Otherwise, simply copy and paste. + The backend directory should now look as follows: -![](../../images/3/27ea.png) +![bash screenshot of ls showing dist directory](../../images/3/27v.png) -To make express show static content, the page index.html and the JavaScript etc. it fetches, we need a built-in middleware from express called [static](http://expressjs.com/en/starter/static-files.html). +To make Express show static content, the page index.html and the JavaScript, etc., it fetches, we need a built-in middleware from Express called [static](http://expressjs.com/en/starter/static-files.html). When we add the following amidst the declarations of middlewares + ```js -app.use(express.static('build')) +app.use(express.static('dist')) ``` -whenever express gets an HTTP GET request it will first check if the build directory contains a file corresponding to the request's address. If a correct file is found, express will return it. +whenever Express gets an HTTP GET request it will first check if the dist directory contains a file corresponding to the request's address. If a correct file is found, Express will return it. -Now HTTP GET requests to the address www.serversaddress.com/index.html or www.serversaddress.com will show the React frontend. GET requests to the address www.serversaddress.com/api/notes will be handled by the backend's code. +Now HTTP GET requests to the address www.serversaddress.com/index.html or www.serversaddress.com will show the React frontend. GET requests to the address www.serversaddress.com/api/notes will be handled by the backend code. -Because on our situation, both the frontend and the backend are at the same address, we can declare _baseUrl_ as a [relative](https://www.w3.org/TR/WD-html40-970917/htmlweb.html#h-5.1.2) URL. This means we can leave out the part declaring the server. +Because of our situation, both the frontend and the backend are at the same address, we can declare _baseUrl_ as a [relative](https://www.w3.org/TR/WD-html40-970917/htmlweb.html#h-5.1.2) URL. This means we can leave out the part declaring the server. ```js import axios from 'axios' @@ -171,154 +303,225 @@ const getAll = () => { // ... ``` -After the change, we have to create a new production build and copy it to the root of the backend repository. +After the change, we have to create a new production build of the frontend and copy it to the root of the backend directory. The application can now be used from the backend address : -![](../../images/3/28e.png) +![Notes application in localhost:3001](../../images/3/28new.png) -Our application now works exactly like the [single-page app](/en/part0/fundamentals_of_web_apps#single-page-app) example application we studied in part 0. +Our application now works exactly like the [single-page app](/en/part0/fundamentals_of_web_apps#single-page-app) example application we studied in part 0. -When we use a browser to go to the address , the server returns the index.html file from the build repository. Summarized contents of the file are as follows: +When we use a browser to go to the address , the server returns the index.html file from the dist directory. The contents of the file are as follows: ```html - - - React App - - - -
    - - - + + + + + + + Vite + React + + + + +
    + + + ``` -The file contains instructions to fetch a CSS stylesheet defining the styles of the application, and two script tags which instruct the browser to fetch the JavaScript code of the application - the actual React application. +The file contains instructions to fetch a CSS stylesheet defining the styles of the application, and one script tag that instructs the browser to fetch the JavaScript code of the application - the actual React application. + +The React code fetches notes from the server address and renders them to the screen. The communication between the server and the browser can be seen in the Network tab of the developer console: + +![Network tab of notes application on backend](../../images/3/29new.png) + +The setup that is ready for a product deployment looks as follows: + +![diagram of deployment ready react app](../../images/3/101.png) + +Unlike when running the app in a development environment, everything is now in the same node/express-backend that runs in localhost:3001. When the browser goes to the page, the file index.html is rendered. That causes the browser to fetch the production version of the React app. Once it starts to run, it fetches the json-data from the address localhost:3001/api/notes. + +### The whole app to the internet + +After ensuring that the production version of the application works locally, we are ready to move the whole application to the selected host service. + +In the case of Fly.io the new deployment is done with the command + +```bash +fly deploy +``` +NOTE: The _.dockerignore_ file in your project directory lists files not uploaded during deployment. The dist directory may be included by default. If that's the case, remove its reference from the .dockerignore file, ensuring your app is properly deployed. + + +In the case of Render, commit the changes, and push the code to GitHub again. Make sure the directory dist is not ignored by git on the backend. A push to GitHub might be enough. If the automatic deployment does not work, select the "manual deploy" from the Render dashboard. + +The application works perfectly, except we haven't added the functionality for changing the importance of a note to the backend yet. + + +![screenshot of notes application](../../images/3/30new.png) + +**NOTE:** changing the importance DOES NOT work yet since the backend has no implementation for it yet. + +Our application saves the notes to a variable. If the application crashes or is restarted, all of the data will disappear. + +The application needs a database. Before we introduce one, let's go through a few things. + +The setup now looks like as follows: + +![diagram of react app on fly.io](../../images/3/102.png) + +The node/express-backend now resides in the Fly.io/Render server. When the root address is accessed, the browser loads and executes the React app that fetches the json-data from the Fly.io/Render server. -The React code fetches notes from the server address and renders them to the screen. The communications between the server and the browser can be seen in the Network tab of the developer console: +### Streamlining deploying of the frontend -![](../../images/3/29ea.png) +To create a new production build of the frontend without extra manual work, let's add some npm-scripts to the package.json of the backend repository. -After ensuring that the production version of the application works locally, commit the production build of the frontend to the backend repository, and push the code to Heroku again. +#### Fly.io script -[The application](https://vast-oasis-81447.herokuapp.com/) works perfectly, except we haven't added the functionality for changing the importance of a note to the backend yet. +The scripts look like this: -![](../../images/3/30ea.png) +```json +{ + "scripts": { + // ... + "build:ui": "rm -rf dist && cd ../notes-frontend/ && npm run build && cp -r dist ../notes-backend", + "deploy": "fly deploy", + "deploy:full": "npm run build:ui && npm run deploy", + "logs:prod": "fly logs" + } +} +``` + +The script _npm run build:ui_ builds the frontend and copies the production version under the backend repository. The script _npm run deploy_ releases the current backend to Fly.io. -Our application saves the notes to a variable. If the application crashes or is restarted, all of the data will disappear. +_npm run deploy:full_ combines these two scripts, i.e., _npm run build:ui_ and _npm run deploy_. -The application needs a database. Before we introduce one, let's go through a few things. +There is also a script _npm run logs:prod_ to show the Fly.io logs. -### Streamlining deploying of the frontend +Note that the directory paths in the script build:ui depend on the location of the frontend and backend directories in the file system. + +##### Note for Windows users -To create a new production build of the frontend without extra manual work, let's add some npm-scripts to the package.json of the backend repository: +Note that the standard shell commands in `build:ui` do not natively work in Windows. Powershell in Windows works differently, in which case the script could be written as + +```json +"build:ui": "@powershell Remove-Item -Recurse -Force dist && cd ../frontend && npm run build && @powershell Copy-Item dist -Recurse ../backend", +``` + +If the script does not work on Windows, confirm that you are using Powershell and not Command Prompt. If you have installed Git Bash or another Linux-like terminal, you may be able to run Linux-like commands on Windows as well. + +#### Render + +Note: When you attempt to deploy your backend to Render, make sure you have a separate repository for the backend and deploy that github repo through Render, attempting to deploy through your Fullstackopen repository will often throw "ERR path ....package.json". + +In case of Render, the scripts look like the following ```json { "scripts": { //... - "build:ui": "rm -rf build && cd ../../osa2/materiaali/notes-new && npm run build --prod && cp -r build ../../../osa3/notes-backend/", - "deploy": "git push heroku master", - "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && npm run deploy", - "logs:prod": "heroku logs --tail" + "build:ui": "rm -rf dist && cd ../frontend && npm run build && cp -r dist ../backend", + "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push" } } ``` -The script _npm run build:ui_ builds the frontend and copies the production version under the backend repository. _npm run deploy_ releases the current backend to heroku. +The script _npm run build:ui_ builds the frontend and copies the production version under the backend repository. _npm run deploy:full_ contains also the necessary git commands to update the backend repository. -_npm run deploy:full_ combines these two and contains the necessary git commands to update the backend repository. +Note that the directory paths in the script build:ui depend on the location of the frontend and backend directories in the file system. -There is also a script _npm run logs:prod_ to show the heroku logs. +>**NB** On Windows, npm scripts are executed in cmd.exe as the default shell which does not support bash commands. For the above bash commands to work, you can change the default shell to Bash (in the default Git for Windows installation) as follows: -Note that the directory paths in the script build:ui depend on the location of repositories in the file system. +```md +npm config set script-shell "C:\\Program Files\\git\\bin\\bash.exe" +``` ->**NB** build:ui does not work on Windows, go to [Solution](https://github.com/fullstackopen-2019/fullstackopen-2019.github.io/issues/420) +Another option is the use of [shx](https://www.npmjs.com/package/shx). ### Proxy -Changes on the frontend have caused it to no longer work in development mode (when started with command _npm start_), as the connection to the backend does not work. +Changes on the frontend have caused it to no longer work in development mode (when started with command _npm run dev_), as the connection to the backend does not work. -![](../../images/3/32ea.png) +![Network dev tools showing a 404 on getting notes](../../images/3/32new.png) -This is due to changing the backend address to a relative URL: +This is due to changing the backend address to a relative URL: ```js const baseUrl = '/api/notes' ``` -Because in development mode the frontend is at the address localhost:3000, the requests to the backend go to the wrong address localhost:3000/api/notes. The backend is at localhost:3001. +Because in development mode the frontend is at the address localhost:5173, the requests to the backend go to the wrong address localhost:5173/api/notes. The backend is at localhost:3001. -If the project was created with create-react-app, this problem is easy to solve. It is enough to add the following declaration to the package.json file of the frontend repository. +If the project was created with Vite, this problem is easy to solve. It is enough to add the following declaration to the vite.config.js file of the frontend directory. ```bash -{ - "dependencies": { - // ... - }, - "scripts": { - // ... +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + // highlight-start + server: { + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + } }, - "proxy": "http://localhost:3001" // highlight-line -} -``` + // highlight-end +}) -After a restart, the React development environment will work as a [proxy](https://create-react-app.dev/docs/proxying-api-requests-in-development/). If the React code does an HTTP request to a server address at http://localhost:3000 not managed by the React application itself (i.e when requests are not about fetching the CSS or JavaScript of the application), the request will be redirected to the server at http://localhost:3001. +``` -Now the frontend is also fine, working with the server both in development- and production mode. +After restarting, the React development environment will act as [proxy](https://vitejs.dev/config/server-options.html#server-proxy). If the React code makes an HTTP request to a path starting with http://localhost:5173/api, the request will be forwarded to the server at http://localhost:3001. Requests to other paths will be handled normally by the development server. -A negative aspect of our approach is how complicated it is to deploy the frontend. Deploying a new version requires generating new production build of the frontend and copying it to the backend repository. This makes creating an automated [deployment pipeline](https://martinfowler.com/bliki/DeploymentPipeline.html) more difficult. Deployment pipeline means an automated and controlled way to move the code from the computer of the developer through different tests and quality checks to the production environment. +Now the frontend is also working correctly. It functions both in development mode and in production mode together with the server. Since from the frontend's perspective all requests are made to http://localhost:5173, which is the single origin, there is no longer a need for the backend's cors middleware. Therefore, we can remove references to the cors library from the backend's index.js file and remove cors from the project's dependencies: -There are multiple ways to achieve this (for example placing both backend and frontend code [to the same repository](https://github.com/mars/heroku-cra-node) ) but we will not go into those now. +```bash +npm remove cors +``` -In some situations it may be sensible to deploy the frontend code as its own application. With apps created with create-react-app it is [straightforward](https://github.com/mars/create-react-app-buildpack). +We have now successfully deployed the entire application to the internet. There are many other ways to implement deployments. For example, deploying the frontend code as its own application may be sensible in some situations, as it can facilitate the implementation of an automated [deployment pipeline](https://martinfowler.com/bliki/DeploymentPipeline.html). A deployment pipeline refers to an automated and controlled way to move code from the developer's machine through various tests and quality control stages to the production environment. This topic is covered in [part 11](/en/part11) of the course. -Current code of the backend can be found on [Github](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-3), in the branch part3-3. The changes in frontend code are in part3-1 branch of the [frontend repository](https://github.com/fullstack-hy2020/part2-notes/tree/part3-1). +The current backend code can be found on [Github](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-3), in the branch part3-3. The changes in frontend code are in part3-1 branch of the [frontend repository](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part3-1).
    -### Exercises 3.9.-3.11. - -The following exercises don't require many lines of code. They can however be challenging, because you must understand exactly what is happening and where, and the configurations must be just right. - -#### 3.9 phonebook backend step9 - -Make the backend work with the frontend from the previous part. Do not implement the functionality for making changes to the phone numbers yet, that will be implemented in exercise 3.17. - -You will probably have to do some small changes to the frontend, at least to the URLs for the backend. Remember to keep the developer console open in your browser. If some HTTP requests fail, you should check from the Network-tab what is going on. Keep an eye on the backend's console as well. If you did not do the previous exercise, it is worth it to print the request data or request.body to the console in the event handler responsible for POST requests. - -#### 3.10 phonebook backend step10 +### Exercises 3.9.-3.11 -Deploy the backend to the internet, for example to Heroku. +The following exercises don't require many lines of code. They can however be challenging, because you must understand exactly what is happening and where, and the configurations must be just right. -**NB** the command _heroku_ works on the department's computers and the freshman laptops. If for some reason you cannot [install](https://devcenter.heroku.com/articles/heroku-cli) Heroku to your computer, you can use the command [npx heroku-cli](https://www.npmjs.com/package/heroku-cli). +#### 3.9 Phonebook backend step 9 -Test the deployed backend with a browser and Postman or VS Code REST client to ensure it works. +Make the backend work with the phonebook frontend from the exercises of the previous part. Do not implement the functionality for making changes to the phone numbers yet, that will be implemented in exercise 3.17. -**PRO TIP:** When you deploy your application to Heroku, it is worth it to at least in the beginning keep an eye on the logs of the heroku application **AT ALL TIMES** with the command heroku logs -t. +You will probably have to do some small changes to the frontend, at least to the URLs for the backend. Remember to keep the developer console open in your browser. If some HTTP requests fail, you should check from the Network-tab what is going on. Keep an eye on the backend's console as well. If you did not do the previous exercise, it is worth it to print the request data or request.body to the console in the event handler responsible for POST requests. -The following is a log about one typical problem. Heroku cannot find application dependency express: +#### 3.10 Phonebook backend step 10 -![](../../images/3/33.png) +Deploy the backend to the internet, for example to Fly.io or Render. If you are using Fly.io the commands should be run in the root directory of the backend (that is, in the same directory where the backend package.json is). -The reason is that the option --save was forgotten when express was installed, so information about the dependency was not saved to the file package.json. +**PRO TIP:** When you deploy your application to Internet, it is worth it to at least in the beginning keep an eye on the logs of the application **AT ALL TIMES**. -Another typical problem is that the application is not configured to use the port set to environment variable PORT: +Test the deployed backend with a browser and Postman or VS Code REST client to ensure it works. -![](../../images/3/34.png) +Create a README.md at the root of your repository, and add a link to your online application to it. -Create a README.md at the root of your repository, and add a link to your online application to it. +#### 3.11 Full Stack Phonebook -#### 3.11 phonebook full stack +Generate a production build of your frontend, and add it to the Internet application using the method introduced in this part. -Generate a production build of your frontend, and add it to the internet application using the method introduced in this part. +Also, make sure that the frontend still works locally (in development mode when started with command _npm run dev_). -**NB** Make sure the directory build is not gitignored +If you use Render, make sure the directory dist is not ignored by git on the backend. -Also make sure that the frontend still works locally. +**NOTE:** You shall NOT be deploying the frontend directly at any stage of this part. Only the backend repository is deployed throughout the whole part. The frontend production build is added to the backend repository, and the backend serves it as described in the section [Serving static files from the backend](/en/part3/deploying_app_to_internet#serving-static-files-from-the-backend).
    diff --git a/src/content/3/en/part3c.md b/src/content/3/en/part3c.md index 2f13073344e..ef5d6c1469c 100644 --- a/src/content/3/en/part3c.md +++ b/src/content/3/en/part3c.md @@ -11,21 +11,23 @@ Before we move into the main topic of persisting data in a database, we will tak ### Debugging Node applications -Debugging Node applications is slightly more difficult than debugging JavaScript running in your browser. Printing to the console is a tried and true method, and it's always worth doing. There are people who think that more sophisticated methods should be used instead, but I disagree. Even the world's elite open source developers [use](https://tenderlovemaking.com/2016/02/05/i-am-a-puts-debuggerer.html) this [method](https://swizec.com/blog/javascript-debugging-slightly-beyond-console-log/swizec/6633). +Debugging Node applications is slightly more difficult than debugging JavaScript running in your browser. Printing to the console is a tried and true method, and it's always worth doing. Some people think that more sophisticated methods should be used instead, but I disagree. Even the world's elite open-source developers [use](https://tenderlovemaking.com/2016/02/05/i-am-a-puts-debuggerer.html) this [method](https://swizec.com/blog/javascript-debugging-slightly-beyond-consolelog/). #### Visual Studio Code -The Visual Studio Code debugger can be useful in some situations. You can launch the application in debugging mode like this: +The Visual Studio Code debugger can be useful in some situations. You can launch the application in debugging mode like this (in this and the next few images, the notes have a field _date_ which has been removed from the current version of the application): -![](../../images/3/35.png) +![screenshot showing how to launch debugger in vscode](../../images/3/35x.png) Note that the application shouldn't be running in another console, otherwise the port will already be in use. +__NB__ A newer version of Visual Studio Code may have _Run_ instead of _Debug_. Furthermore, you may have to configure your _launch.json_ file to start debugging. This can be done by choosing _Add Configuration..._ on the drop-down menu, which is located next to the green play button and above _VARIABLES_ menu, and select _Run "npm start" in a debug terminal_. For more detailed setup instructions, visit Visual Studio Code's [Debugging documentation](https://code.visualstudio.com/docs/editor/debugging). + Below you can see a screenshot where the code execution has been paused in the middle of saving a new note: -![](../../images/3/36e.png) +![vscode screenshot of execution at a breakpoint](../../images/3/36x.png) -The execution has stopped at the breakpoint in line 63. In the console you can see the value of the note variable. In the top left window you can see other things related to the state of the application. +The execution stopped at the breakpoint in line 69. In the console, you can see the value of the note variable. In the top left window, you can see other things related to the state of the application. The arrows at the top can be used for controlling the flow of the debugger. @@ -39,189 +41,170 @@ Debugging is also possible with the Chrome developer console by starting your ap node --inspect index.js ``` -You can access the debugger by clicking the green icon that appears in the Chrome developer console: +You can access the debugger by clicking the green icon - the node logo - that appears in the Chrome developer console: -![](../../images/3/37.png) +![dev tools with green node logo icon](../../images/3/37.png) The debugging view works the same way as it did with React applications. The Sources tab can be used for setting breakpoints where the execution of the code will be paused. -![](../../images/3/38eb.png) +![dev tools sources tab breakpoint and watch variables](../../images/3/38eb.png) -All of the application's console.log messages will appear in the Console tab of the debugger. You can also inspect values of variables and execute your own JavaScript code. +All of the application's console.log messages will appear in the Console tab of the debugger. You can also inspect values of variables and execute your own JavaScript code. -![](../../images/3/39ea.png) +![dev tools console tab showing note object typed in](../../images/3/39ea.png) #### Question everything Debugging Full Stack applications may seem tricky at first. Soon our application will also have a database in addition to the frontend and backend, and there will be many potential areas for bugs in the application. -When the application "does not work", we have to first figure out where the problem actually occurs. It's very common for the problem to exist in a place where you didn't expect it to, and it can take minutes, hours, or even days before you find the source of the problem. +When the application "does not work", we have to first figure out where the problem actually occurs. It's very common for the problem to exist in a place where you didn't expect it, and it can take minutes, hours, or even days before you find the source of the problem. The key is to be systematic. Since the problem can exist anywhere, you must question everything, and eliminate all possibilities one by one. Logging to the console, Postman, debuggers, and experience will help. -When bugs occur, the worst of all possible strategies is to continue writing code. It will guarantee that your code will soon have ten more bugs, and debugging them will be even more difficult. The [stop and fix](http://gettingtolean.com/toyota-principle-5-build-culture-stopping-fix/#.Wjv9axP1WCQ) principle from Toyota Production Systems is very effective in this situation as well. +When bugs occur, the worst of all possible strategies is to continue writing code. It will guarantee that your code will soon have even more bugs, and debugging them will be even more difficult. The [Jidoka](https://leanscape.io/principles-of-lean-13-jidoka/) (stop and fix) principle from Toyota Production Systems is very effective in this situation as well. ### MongoDB -In order to store our saved notes indefinitely, we need a database. Most of the courses taught at the University of Helsinki use relational databases. In this course we will use [MongoDB](https://www.mongodb.com/) which is a so-called [document database](https://en.wikipedia.org/wiki/Document-oriented_database). - -Document databases differ from relational databases in how they organize data as well as the query languages they support. Document databases are usually categorized under the [NoSQL](https://en.wikipedia.org/wiki/NoSQL) umbrella term. +To store our saved notes indefinitely, we need a database. Most of the courses taught at the University of Helsinki use relational databases. In most parts of this course, we will use [MongoDB](https://www.mongodb.com/) which is a [document database](https://en.wikipedia.org/wiki/Document-oriented_database). -You can read more about document databases and NoSQL from the course material for [week 7](https://tikape-s18.mooc.fi/part7/) of the Introduction to Databases course. Unfortunately the material is currently only available in Finnish. +The reason for using Mongo as the database is its lower complexity compared to a relational database. [Part 13](/en/part13) of the course shows how to build Node.js backends that use a relational database. -Read now the chapters on [collections](https://docs.mongodb.com/manual/core/databases-and-collections/) and [documents](https://docs.mongodb.com/manual/core/document/) from the MongoDB manual to get a basic idea on how a document database stores the data. +Document databases differ from relational databases in how they organize data as well as in the query languages they support. Document databases are usually categorized under the [NoSQL](https://en.wikipedia.org/wiki/NoSQL) umbrella term. -Naturally, you can install and run MongoDB on your own computer. However, the internet is also full of Mongo database services that you can use. Our preferred MongoDB provider in this course will be [MongoDB Atlas](https://www.mongodb.com/cloud/atlas). +You can read more about document databases and NoSQL from the course material for [week 7](https://tikape-s18.mooc.fi/part7/) of the Introduction to Databases course. Unfortunately, the material is currently only available in Finnish. -Once you've created and logged into your account, Atlas will recommend creating a cluster: +Read now the chapters on [collections](https://docs.mongodb.com/manual/core/databases-and-collections/) and [documents](https://docs.mongodb.com/manual/core/document/) from the MongoDB manual to get a basic idea of how a document database stores data. -![](../../images/3/57.png) +Naturally, you can install and run MongoDB on your computer. However, the internet is also full of Mongo database services that you can use. Our preferred MongoDB provider in this course will be [MongoDB Atlas](https://www.mongodb.com/atlas/database). -Let's choose AWS and Frankfurt and create a cluster. +Once you've created and logged into your account, let's create a new cluster using the button visible on the front page. From the view that opens, select the free plan, determine the cloud provider and data center, and create the cluster: -![](../../images/3/58.png) +![mongodb picking shared, aws and region](../../images/3/mongo2.png) -Let's wait for the cluster to be ready for use. This will take approximately 10 minutes. +The provider selected is AWS and the region is Stockholm (eu-north-1). Note that if you choose something else, your database connection string will be slightly different from this example. Wait for the cluster to be ready, which will take a few minutes. **NB** do not continue before the cluster is ready. -Let's use the database access tab for creating user credentials for the database. Please note that these are not the same credentials you use for logging into MongoDB Atlas. - -![](../../images/3/59.png) - -Let's grant the user with permissions to read and write to the databases. - -![](../../images/3/60.png) - -**NB** for some people the new user credentials have not worked immediately after creation. In some cases it has taken minutes before the credentials have worked. - -Next we have to define the IP addresses that are allowed access to the database. +Let's use the security tab for creating user credentials for the database. Please note that these are not the same credentials you use for logging into MongoDB Atlas. These will be used for your application to connect to the database. -![](../../images/3/61ea.png) +![mongodb security quickstart](../../images/3/mongo3.png) -For the sake of simplicity we will allow access from all IP addresses: +Next, we have to define the IP addresses that are allowed access to the database. For the sake of simplicity we will allow access from all IP addresses: -![](../../images/3/62.png) +![mongodb network access/add ip access list](../../images/3/mongo4.png) -Finally we are ready to connect to our database. Start by clicking connect +Note: In case the modal menu is different for you, according to MongoDB documentation, adding 0.0.0.0 as an IP allows access from anywhere as well. -![](../../images/3/63ea.png) +Finally, we are ready to connect to our database. To do this, we need the database connection string, which can be found by selecting Connect and then Drivers from the view, under the Connect to your application section: -and choose Connect your application: +![mongodb database deployment connect](../../images/3/mongo5.png) -![](../../images/3/64ea.png) +The view displays the MongoDB URI, which is the address of the database that we will supply to the MongoDB client library we will add to our application: -The view displays the MongoDB URI, which is the address of the database that we will supply to the MongoDB client library we will add to our application. +![mongodb connect application](../../images/3/mongo6new.png) The address looks like this: -```bash -mongodb+srv://fullstack:@cluster0-ostce.mongodb.net/test?retryWrites=true +```js +mongodb+srv://fullstack:thepasswordishere@cluster0.a5qfl.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0 ``` We are now ready to use the database. -We could use the database directly from our JavaScript code with the [official MongoDb Node.js driver](https://mongodb.github.io/node-mongodb-native/) library, but it is quite cumbersome to use. We will instead use the [Mongoose](http://mongoosejs.com/index.html) library that offers a higher level API. +We could use the database directly from our JavaScript code with the [official MongoDB Node.js driver](https://mongodb.github.io/node-mongodb-native/) library, but it is quite cumbersome to use. We will instead use the [Mongoose](http://mongoosejs.com/index.html) library that offers a higher-level API. -Mongoose could be described as an object document mapper (ODM), and saving JavaScript objects as Mongo documents is straightforward with the library. +Mongoose could be described as an object document mapper (ODM), and saving JavaScript objects as Mongo documents is straightforward with this library. -Let's install Mongoose: +Let's install Mongoose in our notes project backend: ```bash -npm install mongoose --save +npm install mongoose ``` -Let's not add any code dealing with Mongo to our backend just yet. Instead, let's make a practice application into the file mongo.js: +Let's not add any code dealing with Mongo to our backend just yet. Instead, let's make a practice application by creating a new file, mongo.js in the root of the notes backend application: ```js const mongoose = require('mongoose') -if ( process.argv.length<3 ) { +if (process.argv.length < 3) { console.log('give password as argument') process.exit(1) } const password = process.argv[2] -const url = - `mongodb+srv://fullstack:${password}@cluster0-ostce.mongodb.net/test?retryWrites=true` +const url = `mongodb+srv://fullstack:${password}@cluster0.a5qfl.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0` -mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) +mongoose.set('strictQuery',false) + +mongoose.connect(url) const noteSchema = new mongoose.Schema({ content: String, - date: Date, important: Boolean, }) const Note = mongoose.model('Note', noteSchema) const note = new Note({ - content: 'HTML is Easy', - date: new Date(), + content: 'HTML is easy', important: true, }) -note.save().then(response => { +note.save().then(result => { console.log('note saved!') mongoose.connection.close() }) ``` -**NB** Depending on which region you selected when building your cluster, the MongoDB URImay be different from the example provided above. You should verify and use the correct URI that was generated from MongoDB Atlas. +**NB:** Depending on which region you selected when building your cluster, the MongoDB URI may be different from the example provided above. You should verify and use the correct URI that was generated from MongoDB Atlas. -The code also assumes that it will be passed the password from the credentials we created in MongoDB Atlas as a command line parameter. We can access the command line parameter like this: +The code also assumes that it will be passed the password from the credentials we created in MongoDB Atlas, as a command line parameter. We can access the command line parameter like this: ```js const password = process.argv[2] ``` -When the code is run with the command node mongo.js password, Mongo will add a new document to the database. - -**NB** Please note the password is the password created for the database user, not your MongoDB Atlas password. Also, if you created password with special characters, then you'll need to [URL encode password](https://docs.atlas.mongodb.com/troubleshoot-connection/#special-characters-in-connection-string-password). - -We can view the current state of the database from the MongoDB Atlas from Collections -in the Overview tab. +When the code is run with the command node mongo.js yourPassword, Mongo will add a new document to the database. -![](../../images/3/65.png) +**NB:** Please note the password is the password created for the database user, not your MongoDB Atlas password. Also, if you created a password with special characters, then you'll need to [URL encode that password](https://docs.atlas.mongodb.com/troubleshoot-connection/#special-characters-in-connection-string-password). -As the view states, the document matching the note has been added to the notes collection in the test database. +We can view the current state of the database from the MongoDB Atlas from Browse collections, in the Database tab. -![](../../images/3/66a.png) +![mongodb databases browse collections button](../../images/3/mongo7.png) -We should give a better name to the database. Like the documentation says, we can change the name of the database from the URI: +As the view states, the document matching the note has been added to the notes collection in the myFirstDatabase database. -![](../../images/3/67.png) +![mongodb collections tab db myfirst app notes](../../images/3/mongo8new.png) -Let's destroy the test database. Let's change the name of database to note-app instead, by modifying the URI: +Let's destroy the default database test and change the name of the database referenced in our connection string to noteApp instead, by modifying the URI: -```bash -mongodb+srv://fullstack:@cluster0-ostce.mongodb.net/note-app?retryWrites=true +```js +const url = `mongodb+srv://fullstack:${password}@cluster0.a5qfl.mongodb.net/noteApp?retryWrites=true&w=majority&appName=Cluster0` ``` -Let's run our code again. +Let's run our code again: -![](../../images/3/68.png) +![mongodb collections tab noteApp notes](../../images/3/mongo9.png) -The data is now stored in the right database. The view also offers the create database functionality, that can be used to create new databases from the website. Creating the database like this is not necessary, since MongoDB Atlas automatically creates a new database when an application tries to connect to a database that does not exist yet. +The data is now stored in the right database. The view also offers the create database functionality, that can be used to create new databases from the website. Creating a database like this is not necessary, since MongoDB Atlas automatically creates a new database when an application tries to connect to a database that does not exist yet. ### Schema -After establishing the connection to the database, we define the [schema](http://mongoosejs.com/docs/guide.html) for a note and the matching [model](http://mongoosejs.com/docs/models.html): +After establishing the connection to the database, we define the [schema](https://mongoosejs.com/docs/guide.html#schemas) for a note and the matching [model](https://mongoosejs.com/docs/models.html): ```js const noteSchema = new mongoose.Schema({ content: String, - date: Date, important: Boolean, }) const Note = mongoose.model('Note', noteSchema) ``` -First we define the [schema](http://mongoosejs.com/docs/guide.html) of a note that is stored in the _noteSchema_ variable. The schema tells Mongoose how the note objects are to be stored in the database. +First, we define the [schema](https://mongoosejs.com/docs/guide.html#schemas) of a note that is stored in the _noteSchema_ variable. The schema tells Mongoose how the note objects are to be stored in the database. -In the _Note_ model definition, the first "Note" parameter is the singular name of the model. The name of the collection will be the lowercased plural notes, because the [Mongoose convention](http://mongoosejs.com/docs/models.html) is to automatically name collections as the plural (e.g. notes) when the schema refers to them in the singular (e.g. Note). +In the _Note_ model definition, the first "Note" parameter is the singular name of the model. The name of the collection will be the lowercase plural notes, because the [Mongoose convention](https://mongoosejs.com/docs/models.html#compiling) is to automatically name collections as the plural (e.g. notes) when the schema refers to them in the singular (e.g. Note). Document databases like Mongo are schemaless, meaning that the database itself does not care about the structure of the data that is stored in the database. It is possible to store documents with completely different fields in the same collection. @@ -229,19 +212,18 @@ The idea behind Mongoose is that the data stored in the database is given a s ### Creating and saving objects -Next, the application creates a new note object with the help of the Note [model](http://mongoosejs.com/docs/models.html): +Next, the application creates a new note object with the help of the Note [model](https://mongoosejs.com/docs/models.html): ```js const note = new Note({ - content: 'Browser can execute only Javascript', - date: new Date(), + content: 'HTML is Easy', important: false, }) ``` -Models are so-called constructor functions that create new JavaScript objects based on the provided parameters. Since the objects are created with the model's constructor function, they have all the properties of the model, which include methods for saving the object to the database. +Models are constructor functions that create new JavaScript objects based on the provided parameters. Since the objects are created with the model's constructor function, they have all the properties of the model, which include methods for saving the object to the database. -Saving the object to the database happens with the appropriately named _save_ method, that can be provided with an event handler with the _then_ method: +Saving the object to the database happens with the appropriately named _save_ method, which can be provided with an event handler with the _then_ method: ```js note.save().then(result => { @@ -250,13 +232,13 @@ note.save().then(result => { }) ``` -When the object is saved to the database, the event handler provided to _then_ gets called. The event handler closes the database connection with the command mongoose.connection.close(). If the connection is not closed, the program will never finish its execution. +When the object is saved to the database, the event handler provided to _then_ gets called. The event handler closes the database connection with the command mongoose.connection.close(). If the connection is not closed, the connection remains open until the program terminates. -The result of the save operation is in the _result_ parameter of the event handler. The result is not that interesting when we're storing one object to the database. You can print the object to the console if you want to take a closer look at it while implementing your application or during debugging. +The result of the save operation is in the _result_ parameter of the event handler. The result is not that interesting when we're storing one object in the database. You can print the object to the console if you want to take a closer look at it while implementing your application or during debugging. Let's also save a few more notes by modifying the data in the code and by executing the program again. -**NB** unfortunately the Mongoose documentation uses callbacks in its examples, so it is not recommended to copy paste code directly from there. Mixing promises with old-school callbacks in the same code is not recommended. +**NB:** Unfortunately the Mongoose documentation is not very consistent, with parts of it using callbacks in its examples and other parts, other styles, so it is not recommended to copy and paste code directly from there. Mixing promises with old-school callbacks in the same code is not recommended. ### Fetching objects from the database @@ -273,9 +255,9 @@ Note.find({}).then(result => { When the code is executed, the program prints all the notes stored in the database: -![](../../images/3/70ea.png) +![node mongo.js outputs notes as JSON](../../images/3/70new.png) -The objects are retrieved from the database with the [find](https://mongoosejs.com/docs/api.html#model_Model.find) method of the _Note_ model. The parameter of the method is an object expressing search conditions. Since the parameter is an empty object{}, we get all of the notes stored in the _notes_ collection. +The objects are retrieved from the database with the [find](https://mongoosejs.com/docs/api/model.html#model_Model-find) method of the _Note_ model. The parameter of the method is an object expressing search conditions. Since the parameter is an empty object{}, we get all of the notes stored in the _notes_ collection. The search conditions adhere to the Mongo search query [syntax](https://docs.mongodb.com/manual/reference/operator/). @@ -295,11 +277,11 @@ Note.find({ important: true }).then(result => { #### 3.12: Command-line database -Create a cloud-based MongoDB database for the phonebook application with MongoDB Atlas. +Create a cloud-based MongoDB database for the phonebook application with MongoDB Atlas. Create a mongo.js file in the project directory, that can be used for adding entries to the phonebook, and for listing all of the existing entries in the phonebook. -**NB** Do not include the password in the file that you commit and push to GitHub! +**NB:** Do not include the password in the file that you commit and push to GitHub! The application should work as follows. You use the program by passing three command-line arguments (the first is the password), e.g.: @@ -316,7 +298,7 @@ added Anna number 040-1234556 to phonebook The new entry to the phonebook will be saved to the database. Notice that if the name contains whitespace characters, it must be enclosed in quotes: ```bash -node mongo.js yourpassword "Arto Vihavainen" 040-1234556 +node mongo.js yourpassword "Arto Vihavainen" 045-1232456 ``` If the password is the only parameter given to the program, meaning that it is invoked like this: @@ -327,14 +309,14 @@ node mongo.js yourpassword Then the program should display all of the entries in the phonebook: -
    +```
     phonebook:
     Anna 040-1234556
     Arto Vihavainen 045-1232456
     Ada Lovelace 040-1231236
    -
    +``` -You can get the command-line parameters from the [process.argv](https://nodejs.org/docs/latest-v8.x/api/process.html#process_process_argv) variable. +You can get the command-line parameters from the [process.argv](https://nodejs.org/docs/latest-v18.x/api/process.html#process_process_argv) variable. **NB: do not close the connection in the wrong place**. E.g. the following code will not work: @@ -361,37 +343,37 @@ Person }) ``` -**NB2** if you define a model with the name Person, mongoose will automatically name the associated collection as people. +**NB:** If you define a model with the name Person, mongoose will automatically name the associated collection as people.
    -### Backend connected to a database +### Connecting the backend to a database -Now we have enough knowledge to start using Mongo in our application. +Now we have enough knowledge to start using Mongo in our notes application backend. -Let's get a quick start by copy pasting the Mongoose definitions to the index.js file: +Let's get a quick start by copy-pasting the Mongoose definitions to the index.js file: ```js const mongoose = require('mongoose') // DO NOT SAVE YOUR PASSWORD TO GITHUB!! -const url = - 'mongodb+srv://fullstack:sekred@cluster0-ostce.mongodb.net/note-app?retryWrites=true' +const password = process.argv[2] +const url = `mongodb+srv://fullstack:${password}@cluster0.a5qfl.mongodb.net/noteApp?retryWrites=true&w=majority&appName=Cluster0` -mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) +mongoose.set('strictQuery',false) +mongoose.connect(url) const noteSchema = new mongoose.Schema({ content: String, - date: Date, important: Boolean, }) const Note = mongoose.model('Note', noteSchema) ``` -Let's change the handler for fetching all notes into the following form: +Let's change the handler for fetching all notes to the following form: ```js app.get('/api/notes', (request, response) => { @@ -401,13 +383,13 @@ app.get('/api/notes', (request, response) => { }) ``` -We can verify in the browser that the backend works for displaying all of the documents: +Let's start the backend with the command node --watch index.js yourpassword so we can verify in the browser that the backend correctly displays all notes saved to the database: -![](../../images/3/44ea.png) +![api/notes in browser shows notes in JSON](../../images/3/44ea.png) The application works almost perfectly. The frontend assumes that every object has a unique id in the id field. We also don't want to return the mongo versioning field \_\_v to the frontend. -One way to format the objects returned by Mongoose is to [modify](https://stackoverflow.com/questions/7034848/mongodb-output-id-instead-of-id) the _toJSON_ method of the objects. Modifying the method happens like this: +One way to format the objects returned by Mongoose is to [modify](https://stackoverflow.com/questions/7034848/mongodb-output-id-instead-of-id) the _toJSON_ method of the schema, which is used on all instances of the models produced with that schema. Modification can be done as follows: ```js noteSchema.set('toJSON', { @@ -419,46 +401,46 @@ noteSchema.set('toJSON', { }) ``` -Even though the \_id property of Mongoose objects looks like a string, it is in fact an object. The _toJSON_ method we defined transforms it into a string just to be safe. If we didn't make this change, it would cause more harm for us in the future once we start writing tests. +Even though the \_id property of Mongoose objects looks like a string, it is in fact an object. The _toJSON_ method we defined transforms it into a string just to be safe. If we didn't make this change, it would cause more harm to us in the future once we start writing tests. -Let's respond to the HTTP request with a list of objects formatted with the _toJSON_ method: +No changes are needed in the handler: ```js app.get('/api/notes', (request, response) => { Note.find({}).then(notes => { - response.json(notes.map(note => note.toJSON())) + response.json(notes) }) }) ``` -Now the _notes_ variable is assigned to an array of objects returned by Mongo. When we call notes.map(note => note.toJSON()) the result is a new array, where every item from the old one is mapped to a new object with the _toJSON_ method. +The code automatically uses the defined _toJSON_ when formatting notes to the response. -### Database configuration into its own module +### Moving db configuration to its own module -Before we refactor the rest of the backend to use the database, let's extract the Mongoose specific code into its own module. +Before we refactor the rest of the backend to use the database, let's extract the Mongoose-specific code into its own module. Let's create a new directory for the module called models, and add a file called note.js: ```js const mongoose = require('mongoose') -const url = process.env.MONGODB_URI // highlight-line +mongoose.set('strictQuery', false) -console.log('connecting to', url) // highlight-line +const url = process.env.MONGODB_URI // highlight-line -mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) +console.log('connecting to', url) +mongoose.connect(url) // highlight-start .then(result => { console.log('connected to MongoDB') }) - .catch((error) => { + .catch(error => { console.log('error connecting to MongoDB:', error.message) }) // highlight-end const noteSchema = new mongoose.Schema({ content: String, - date: Date, important: Boolean, }) @@ -473,75 +455,80 @@ noteSchema.set('toJSON', { module.exports = mongoose.model('Note', noteSchema) // highlight-line ``` -Defining Node [modules](https://nodejs.org/docs/latest-v8.x/api/modules.html) differs slightly from the way of defining [ES6 modules](/en/part2/rendering_a_collection_modules#refactoring-modules) in part 2. +There are some changes in the code compared to before. The database connection URL is now passed to the application via the MONGODB_URI environment variable, as hardcoding it into the application is not a good idea: -The public interface of the module is defined by setting a value to the _module.exports_ variable. We will set the value to be the Note model. The other things defined inside of the module, like the variables _mongoose_ and _url_ will not be accessible or visible to users of the module. +```js +const url = process.env.MONGODB_URI +``` -Importing the module happens by adding the following line to index.js: +There are many ways to define the value of an environment variable. For example, we can define it when starting the application as follows: -```js -const Note = require('./models/note') +```bash +MONGODB_URI="your_connection_string_here" npm run dev ``` -This way the _Note_ variable will be assigned to the same object that the module defines. +We will soon learn a more sophisticated way to define environment variables. The way that the connection is made has changed slightly: ```js -const url = process.env.MONGODB_URI - -console.log('connecting to', url) - -mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) +mongoose.connect(url) .then(result => { console.log('connected to MongoDB') }) - .catch((error) => { + .catch(error => { console.log('error connecting to MongoDB:', error.message) }) ``` -It's not a good idea to hardcode the address of the database into the code, so instead the address of the database is passed to the application via the MONGODB_URI environment variable. - The method for establishing the connection is now given functions for dealing with a successful and unsuccessful connection attempt. Both functions just log a message to the console about the success status: -![](../../images/3/45e.png) +![node output when wrong username/password](../../images/3/45e.png) -There are many ways to define the value of an environment variable. One way would be to define it when the application is started: -```bash -MONGODB_URI=address_here npm run watch +Defining Node [modules](https://nodejs.org/docs/latest-v18.x/api/modules.html) differs slightly from the way of defining [ES6 modules](/en/part2/rendering_a_collection_modules#refactoring-modules) in part 2. + +The public interface of the module is defined by setting a value to the _module.exports_ variable. We will set the value to be the Note model. The other things defined inside of the module, like the variables _mongoose_ and _url_ will not be accessible or visible to users of the module. + +Importing the module happens by adding the following line to index.js: + +```js +const Note = require('./models/note') ``` -A more sophisticated way is to use the [dotenv](https://github.com/motdotla/dotenv#readme) library. You can install the library with the command: +This way the _Note_ variable will be assigned to the same object that the module defines. + +### Defining environment variables using the dotenv library + +A more sophisticated way to define environment variables is to use the [dotenv](https://github.com/motdotla/dotenv#readme) library. You can install the library with the command: ```bash -npm install dotenv --save +npm install dotenv ``` To use the library, we create a .env file at the root of the project. The environment variables are defined inside of the file, and it can look like this: ```bash -MONGODB_URI='mongodb+srv://fullstack:sekred@cluster0-ostce.mongodb.net/note-app?retryWrites=true' +MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.a5qfl.mongodb.net/noteApp?retryWrites=true&w=majority&appName=Cluster0 PORT=3001 ``` We also added the hardcoded port of the server into the PORT environment variable. -**The .env file should be gitignored right away, since we do not want to publish any confidential information publicly online!** +**The .env file should be gitignored right away since we do not want to publish any confidential information publicly online!** -![](../../images/3/45ae.png) +![.gitignore in vscode with .env line added](../../images/3/45ae.png) -The environment variables defined in the dotenv file can be taken into use with the command require('dotenv').config() and you can reference them in your code just like you would reference normal environment variables, with the familiar process.env.MONGODB_URI syntax. +The environment variables defined in the .env file can be taken into use with the expression require('dotenv').config() and you can reference them in your code just like you would reference normal environment variables, with the process.env.MONGODB_URI syntax. -Let's change the index.js file in the following way: +Let's load the environment variables at the beginning of the index.js file so that they are available throughout the entire application. Let's change the index.js file in the following way: ```js require('dotenv').config() // highlight-line const express = require('express') -const app = express() const Note = require('./models/note') // highlight-line +const app = express() // .. const PORT = process.env.PORT // highlight-line @@ -550,7 +537,29 @@ app.listen(PORT, () => { }) ``` -It's important that dotenv gets imported before the note model is imported. This ensures that the environment variables from the .env file are available globally before the code from the other modules are imported. +It's important that dotenv gets imported before the note model is imported. This ensures that the environment variables from the .env file are available globally before the code from the other modules is imported. + +#### Important note about defining environment variables in Fly.io and Render + +**Fly.io users:** Because GitHub is not used with Fly.io, the file .env also gets to the Fly.io servers when the app is deployed. Because of this, the env variables defined in the file will be available there. + +However, a [better option](https://community.fly.io/t/clarification-on-environment-variables/6309) is to prevent .env from being copied to Fly.io by creating in the project root the file _.dockerignore_, with the following contents + +```bash +.env +``` + +and set the env value from the command line with the command: + +```bash +fly secrets set MONGODB_URI="mongodb+srv://fullstack:thepasswordishere@cluster0.a5qfl.mongodb.net/noteApp?retryWrites=true&w=majority&appName=Cluster0" +``` + +**Render users:** When using Render, the database url is given by defining the proper env in the dashboard: + +![browser showing render environment variables](../../images/3/render-env.png) + +Set just the URL starting with mongodb+srv://... to the _value_ field. ### Using database in route handlers @@ -562,36 +571,35 @@ Creating a new note is accomplished like this: app.post('/api/notes', (request, response) => { const body = request.body - if (body.content === undefined) { + if (!body.content) { return response.status(400).json({ error: 'content missing' }) } const note = new Note({ content: body.content, important: body.important || false, - date: new Date(), }) note.save().then(savedNote => { - response.json(savedNote.toJSON()) + response.json(savedNote) }) }) ``` -The note objects are created with the _Note_ constructor function. The response for the request is sent inside of the callback function for the _save_ operation. This ensures that the response is sent only if the operation succeeded. We will discuss error handling a little bit later. +The note objects are created with the _Note_ constructor function. The response is sent inside of the callback function for the _save_ operation. This ensures that the response is sent only if the operation succeeded. We will discuss error handling a little bit later. -The _savedNote_ parameter in the callback function is the saved and newly created note. The data sent back in the response is the formatted version created with the _toJSON_ method: +The _savedNote_ parameter in the callback function is the saved and newly created note. The data sent back in the response is the formatted version created automatically with the _toJSON_ method: ```js -response.json(savedNote.toJSON()) +response.json(savedNote) ``` -Fetching an individual note gets changed into the following: +Using Mongoose's [findById](https://mongoosejs.com/docs/api/model.html#model_Model-findById) method, fetching an individual note gets changed into the following: ```js app.get('/api/notes/:id', (request, response) => { Note.findById(request.params.id).then(note => { - response.json(note.toJSON()) + response.json(note) }) }) ``` @@ -600,15 +608,33 @@ app.get('/api/notes/:id', (request, response) => { When the backend gets expanded, it's a good idea to test the backend first with **the browser, Postman or the VS Code REST client**. Next, let's try creating a new note after taking the database into use: -![](../../images/3/46e.png) +![VS code rest client doing a post](../../images/3/46new.png) Only once everything has been verified to work in the backend, is it a good idea to test that the frontend works with the backend. It is highly inefficient to test things exclusively through the frontend. -It's probably a good idea to integrate the frontend and backend one functionality at a time. First, we could implement fetching all of the notes from the database and test it through the backend endpoint in the browser. After this, we could verify that the frontend works with the new backend. Once everything seems to work, we would move onto the next feature. +It's probably a good idea to integrate the frontend and backend one functionality at a time. First, we could implement fetching all of the notes from the database and test it through the backend endpoint in the browser. After this, we could verify that the frontend works with the new backend. Once everything seems to be working, we would move on to the next feature. Once we introduce a database into the mix, it is useful to inspect the state persisted in the database, e.g. from the control panel in MongoDB Atlas. Quite often little Node helper programs like the mongo.js program we wrote earlier can be very helpful during development. -You can find the code for our current application in its entirety in the part3-4 branch of [this Github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-4). +You can find the code for our current application in its entirety in the part3-4 branch of [this GitHub repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-4). + +### A true full stack developer's oath + +It is again time for the exercises. The complexity of our app has now taken another step since besides frontend and backend we also have a database. +There are indeed really many potential sources of error. + +So we should once more extend our oath: + +Full stack development is extremely hard, that is why I will use all the possible means to make it easier + +- I will have my browser developer console open all the time +- I will use the network tab of the browser dev tools to ensure that frontend and backend are communicating as I expect +- I will constantly keep an eye on the state of the server to make sure that the data sent there by the frontend is saved there as I expect +- I will keep an eye on the database: does the backend save data there in the right format +- I progress with small steps +- I will write lots of _console.log_ statements to make sure I understand how the code behaves and to help pinpoint problems +- If my code does not work, I will not write more code. Instead, I start deleting the code until it works or just return to a state when everything was still working +- When I ask for help in the course Discord channel or elsewhere I formulate my questions properly, see [here](https://fullstackopen.com/en/part0/general_info#how-to-get-help-in-discord) how to ask for help
    @@ -616,21 +642,21 @@ You can find the code for our current application in its entirety in the part ### Exercises 3.13.-3.14. -The following exercises are pretty straightforward, but if your frontend stops working with the backend, then finding and fixing the bugs can be quite interesting. +The following exercises are pretty straightforward, but if your frontend stops working with the backend, then finding and fixing the bugs can be quite interesting. -#### 3.13: Phonebook database, step1 +#### 3.13: Phonebook database, step 1 Change the fetching of all phonebook entries so that the data is fetched from the database. Verify that the frontend works after the changes have been made. -In the following exercises, write all Mongoose-specific code into its own module, just like we did in the chapter [Database configuration into its own module](/en/part3/saving_data_to_mongo_db#database-configuration-into-its-own-module). +In the following exercises, write all Mongoose-specific code into its own module, just like we did in the chapter [Database configuration into its own module](/en/part3/saving_data_to_mongo_db#moving-db-configuration-to-its-own-module). -#### 3.14: Phonebook database, step2 +#### 3.14: Phonebook database, step 2 Change the backend so that new numbers are saved to the database. Verify that your frontend still works after the changes. -At this point, you can choose to simply allow users to create all phonebook entries. At this stage, the phonebook can have multiple entries for a person with the same name. +At this stage, you can ignore whether there is already a person in the database with the same name as the person you are adding. @@ -638,73 +664,61 @@ At this point, you can choose to simply allow users to create all phonebook entr ### Error handling -If we try to visit the URL of a note with an id that does not actually exist e.g. where 5c41c90e84d891c15dfa3431 is not an id stored in the database, then the browser will simply get "stuck" since the server never responds to the request. +If we try to visit the URL of a note with an id that does not exist e.g. where 5c41c90e84d891c15dfa3431 is not an id stored in the database, then the response will be _null_. -We can see the following error message appear in the logs for the backend: - -![](../../images/3/47.png) - -The request has failed and the associated Promise has been rejected. Since we don't handle the rejection of the promise, the request never gets a response. In part 2, we already acquainted ourselves with [handling errors in promises](/en/part2/altering_data_in_server#promises-and-errors). - -Let's add a simple error handler: +Let's change this behavior so that if a note with the given id doesn't exist, the server will respond to the request with the HTTP status code 404 not found. In addition let's implement a simple catch block to handle cases where the promise returned by the findById method is rejected: ```js app.get('/api/notes/:id', (request, response) => { Note.findById(request.params.id) .then(note => { - response.json(note.toJSON()) + // highlight-start + if (note) { + response.json(note) + } else { + response.status(404).end() + } + // highlight-end }) + // highlight-start .catch(error => { console.log(error) - response.status(404).end() + response.status(500).end() }) + // highlight-end }) ``` -Every request that leads to an error will be responded to with the HTTP status code 404 not found. The console displays more detailed information about the error. +If no matching object is found in the database, the value of _note_ will be _null_ and the _else_ block is executed. This results in a response with the status code 404 not found. If a promise returned by the findById method is rejected, the response will have the status code 500 internal server error. The console displays more detailed information about the error. -There's actually two different types of error situations. In one of those situations, we are trying to fetch a note with a wrong kind of _id_, meaning an _id_ that doesn't match the mongo identifier format. +On top of the non-existing note, there's one more error situation that needs to be handled. In this situation, we are trying to fetch a note with the wrong kind of _id_, meaning an _id_ that doesn't match the Mongo identifier format. If we make the following request, we will get the error message shown below: -
    +```
     Method: GET
    -Path:   /api/notes/5a3b7c3c31d61cb9f8a0343
    +Path:   /api/notes/someInvalidId
     Body:   {}
     ---
    -{ CastError: Cast to ObjectId failed for value "5a3b7c3c31d61cb9f8a0343" at path "_id"
    +{ CastError: Cast to ObjectId failed for value "someInvalidId" at path "_id"
         at CastError (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/error/cast.js:27:11)
         at ObjectId.cast (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/schema/objectid.js:158:13)
         ...
    -
    - -The other error situation happens when the id is in the correct format, but no note is found in the database for that id. - -
    -Method: GET
    -Path:   /api/notes/5a3b7c3c31d61cbd9f8a0343
    -Body:   {}
    ----
    -TypeError: Cannot read property 'toJSON' of null
    -    at Note.findById.then.note (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/index.js:27:24)
    -    at process._tickCallback (internal/process/next_tick.js:178:7)
    -
    +``` -We should distinguish between these two different types of error situations. The latter is in fact an error caused by our own code. +Given a malformed id as an argument, the findById method will throw an error causing the returned promise to be rejected. This will cause the callback function defined in the catch block to be called. -Let's change the code in the following way: +Let's make some small adjustments to the response in the catch block: ```js app.get('/api/notes/:id', (request, response) => { Note.findById(request.params.id) .then(note => { - // highlight-start if (note) { - response.json(note.toJSON()) + response.json(note) } else { response.status(404).end() } - // highlight-end }) .catch(error => { console.log(error) @@ -713,43 +727,41 @@ app.get('/api/notes/:id', (request, response) => { }) ``` -If no matching object is found in the database, the value of _note_ will be undefined and the _else_ block is executed. This results in a response with the status code 404 not found. - -If the format of the id is incorrect, then we will end up in the error handler defined in the _catch_ block. The appropriate status code for the situation is [400 bad request](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1), because the situation fits the description perfectly: +If the format of the id is incorrect, then we will end up in the error handler defined in the _catch_ block. The appropriate status code for the situation is [400 Bad Request](https://www.rfc-editor.org/rfc/rfc9110.html#name-400-bad-request) because the situation fits the description perfectly: -> The request could not be understood by the server due to malformed syntax. The client SHOULD NOT repeat the request without modifications. +> The 400 (Bad Request) status code indicates that the server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing). We have also added some data to the response to shed some light on the cause of the error. -When dealing with Promises, it's almost always a good idea to add error and exception handling, because otherwise you will find yourself dealing with strange bugs. +When dealing with Promises, it's almost always a good idea to add error and exception handling. Otherwise, you will find yourself dealing with strange bugs. It's never a bad idea to print the object that caused the exception to the console in the error handler: ```js .catch(error => { - console.log(error) + console.log(error) // highlight-line response.status(400).send({ error: 'malformatted id' }) }) ``` -The reason the error handler gets called might be something completely different than what you had anticipated. If you log the error to the console, you may save yourself from long and frustrating debugging sessions. +The reason the error handler gets called might be something completely different than what you had anticipated. If you log the error to the console, you may save yourself from long and frustrating debugging sessions. Moreover, most modern services where you deploy your application support some form of logging system that you can use to check these logs. As mentioned, Fly.io is one. Every time you're working on a project with a backend, it is critical to keep an eye on the console output of the backend. If you are working on a small screen, it is enough to just see a tiny slice of the output in the background. Any error messages will catch your attention even when the console is far back in the background: -![](../../images/3/15b.png) +![sample screenshot showing tiny slice of output](../../images/3/15b.png) ### Moving error handling into middleware -We have written the code for the error handler among the rest of our code. This can be a reasonable solution at times, but there are cases where it is better to implement all error handling in a single place. This can be particularly useful if we later on want to report data related to errors to an external error tracking system like [Sentry](https://sentry.io/welcome/). +We have written the code for the error handler among the rest of our code. This can be a reasonable solution at times, but there are cases where it is better to implement all error handling in a single place. This can be particularly useful if we want to report data related to errors to an external error-tracking system like [Sentry](https://sentry.io/welcome/) later on. -Let's change the handler for the /api/notes/:id route, so that it passes the error forward with the next function. The next function is passed to the handler as the third parameter: +Let's change the handler for the /api/notes/:id route so that it passes the error forward with the next function. The next function is passed to the handler as the third parameter: ```js app.get('/api/notes/:id', (request, response, next) => { // highlight-line Note.findById(request.params.id) .then(note => { if (note) { - response.json(note.toJSON()) + response.json(note) } else { response.status(404).end() } @@ -758,7 +770,7 @@ app.get('/api/notes/:id', (request, response, next) => { // highlight-line }) ``` -The error that is passed forwards is given to the next function as a parameter. If next was called without a parameter, then the execution would simply move onto the next route or middleware. If the next function is called with a parameter, then the execution will continue to the error handler middleware. +The error that is passed forward is given to the next function as a parameter. If next was called without an argument, then the execution would simply move onto the next route or middleware. If the next function is called with an argument, then the execution will continue to the error handler middleware. Express [error handlers](https://expressjs.com/en/guide/error-handling.html) are middleware that are defined with a function that accepts four parameters. Our error handler looks like this: @@ -773,21 +785,24 @@ const errorHandler = (error, request, response, next) => { next(error) } +// this has to be the last loaded middleware, also all the routes should be registered before this! app.use(errorHandler) ``` -The error handler checks if the error is a CastError exception, in which case we know that the error was caused by an invalid object id for Mongo. In this situation the error handler will send a response to the browser with the response object passed as a parameter. In all other error situations, the middleware passes the error forward to the default Express error handler. +The error handler checks if the error is a CastError exception, in which case we know that the error was caused by an invalid object id for Mongo. In this situation, the error handler will send a response to the browser with the response object passed as a parameter. In all other error situations, the middleware passes the error forward to the default Express error handler. + +Note that the error-handling middleware has to be the last loaded middleware, also all the routes should be registered before the error-handler! ### The order of middleware loading -The execution order of middleware is the same as the order that they are loaded into express with the _app.use_ function. For this reason it is important to be careful when defining middleware. +The execution order of middleware is the same as the order that they are loaded into Express with the _app.use_ function. For this reason, it is important to be careful when defining middleware. The correct order is the following: ```js -app.use(express.static('build')) +app.use(express.static('dist')) app.use(express.json()) -app.use(logger) +app.use(requestLogger) app.post('/api/notes', (request, response) => { const body = request.body @@ -812,10 +827,10 @@ app.use(errorHandler) The json-parser middleware should be among the very first middleware loaded into Express. If the order was the following: ```js -app.use(logger) // request.body is empty! +app.use(requestLogger) // request.body is undefined! app.post('/api/notes', (request, response) => { - // request.body is empty! + // request.body is undefined! const body = request.body // ... }) @@ -823,11 +838,9 @@ app.post('/api/notes', (request, response) => { app.use(express.json()) ``` -Then the JSON data sent with the HTTP requests would not be available for the logger middleware or the POST route handler, since the _request.body_ would be an empty object. +Then the JSON data sent with the HTTP requests would not be available for the logger middleware or the POST route handler, since the _request.body_ would be _undefined_ at that point. -It's also important that the middleware for handling unsupported routes is next to the last middleware that is loaded into Express, just before the error handler. - -For example, the following loading order would cause an issue: +It's also important that the middleware for handling unsupported routes is loaded only after all the endpoints have been defined, just before the error handler. For example, the following loading order would cause an issue: ```js const unknownEndpoint = (request, response) => { @@ -848,11 +861,11 @@ Now the handling of unknown endpoints is ordered before the HTTP request hand Let's add some missing functionality to our application, including deleting and updating an individual note. -The easiest way to delete a note from the database is with the [findByIdAndRemove](https://mongoosejs.com/docs/api.html#model_Model.findByIdAndRemove) method: +The easiest way to delete a note from the database is with the [findByIdAndDelete](https://mongoosejs.com/docs/api/model.html#Model.findByIdAndDelete()) method: ```js app.delete('/api/notes/:id', (request, response, next) => { - Note.findByIdAndRemove(request.params.id) + Note.findByIdAndDelete(request.params.id) .then(result => { response.status(204).end() }) @@ -860,52 +873,59 @@ app.delete('/api/notes/:id', (request, response, next) => { }) ``` -In both of the "successful" cases of deleting a resource, the backend responds with the status code 204 no content. The two different cases are deleting a note that exists, and deleting a note that does not exist in the database. The _result_ callback parameter could be used for checking if a resource actually was deleted, and we could use that information for returning different status codes for the two cases if we deemed it necessary. Any exception that occurs is passed onto the error handler. +In both of the "successful" cases of deleting a resource, the backend responds with the status code 204 no content. The two different cases are deleting a note that exists, and deleting a note that does not exist in the database. The _result_ callback parameter could be used for checking if a resource was actually deleted, and we could use that information for returning different status codes for the two cases if we deem it necessary. Any exception that occurs is passed onto the error handler. + -The toggling of the importance of a note can be easily accomplished with the [findByIdAndUpdate](https://mongoosejs.com/docs/api.html#model_Model.findByIdAndUpdate) method. +Let's implement the functionality to update a single note, allowing the importance of the note to be changed. The note updating is done as follows: ```js app.put('/api/notes/:id', (request, response, next) => { - const body = request.body + const { content, important } = request.body - const note = { - content: body.content, - important: body.important, - } + Note.findById(request.params.id) + .then(note => { + if (!note) { + return response.status(404).end() + } + + note.content = content + note.important = important - Note.findByIdAndUpdate(request.params.id, note, { new: true }) - .then(updatedNote => { - response.json(updatedNote.toJSON()) + return note.save().then((updatedNote) => { + response.json(updatedNote) + }) }) .catch(error => next(error)) }) ``` -In the code above, we also allow the content of the note to be edited. However, we will not support changing the creation date for obvious reasons. - -Notice that the findByIdAndUpdate method receives a regular JavaScript object as its parameter, and not a new note object created with the Note constructor function. - -There is one important detail regarding the use of the findByIdAndUpdate method. By default, the updatedNote parameter of the event handler receives the original document [without the modifications](https://mongoosejs.com/docs/api.html#model_Model.findByIdAndUpdate). We added the optional { new: true } parameter, which will cause our event handler to be called with the new modified document instead of the original. +The note to be updated is first fetched from the database using the _findById_ method. If no object is found in the database with the given id, the value of the variable _note_ is _null_, and the query responds with the status code 404 Not Found. -After testing the backend directly with Postman and the VS Code REST client, we can verify that it seems to work. The frontend also appears to work with the backend using the database. +If an object with the given id is found, its _content_ and _important_ fields are updated with the data provided in the request, and the modified note is saved to the database using the _save()_ method. The HTTP request responds by sending the updated note in the response. -When we toggle the importance of a note, we see the following worrisome error message in the console: - -![](../../images/3/48.png) - -Googling the error message will lead to [instructions](https://stackoverflow.com/questions/52572852/deprecationwarning-collection-findandmodify-is-deprecated-use-findoneandupdate) for fixing the problem. Following [the suggestion in the Mongoose documentation](https://mongoosejs.com/docs/deprecations.html), we add the following line to the note.js file: +One notable point is that the code now has nested promises, meaning that within the outer _.then_ method, another [promise chain](https://javascript.info/promise-chaining) is defined: ```js -const mongoose = require('mongoose') + .then(note => { + if (!note) { + return response.status(404).end() + } -mongoose.set('useFindAndModify', false) // highlight-line + note.content = content + note.important = important -// ... - -module.exports = mongoose.model('Note', noteSchema) + // highlight-start + return note.save().then((updatedNote) => { + response.json(updatedNote) + }) + // highlight-end ``` -You can find the code for our current application in its entirety in the part3-5 branch of [this github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-5). +Usually, this is not recommended because it can make the code difficult to read. In this case, however, the solution works because it ensures that the _.then_ block following the _save()_ method is only executed if a note with the given id is found in the database and the _save()_ method is called. In the fourth part of the course, we will explore the async/await syntax, which offers an easier and clearer way to handle such situations. + +After testing the backend directly with Postman or the VS Code REST client, we can verify that it seems to work. The frontend also appears to work with the backend using the database. + +You can find the code for our current application in its entirety in the part3-5 branch of [this GitHub repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-5). @@ -913,17 +933,17 @@ You can find the code for our current application in its entirety in the part ### Exercises 3.15.-3.18. -#### 3.15: Phonebook database, step3 +#### 3.15: Phonebook database, step 3 Change the backend so that deleting phonebook entries is reflected in the database. Verify that the frontend still works after making the changes. -#### 3.16: Phonebook database, step4 +#### 3.16: Phonebook database, step 4 -Move the error handling of the application to a new error handler middleware. +Move the error handling of the application to a new error handler middleware. -#### 3.17*: Phonebook database, step5 +#### 3.17*: Phonebook database, step 5 If the user tries to create a new phonebook entry for a person whose name is already in the phonebook, the frontend will try to update the phone number of the existing entry by making an HTTP PUT request to the entry's unique URL. @@ -931,12 +951,12 @@ Modify the backend to support this request. Verify that the frontend works after making your changes. -#### 3.18*: Phonebook database step6 +#### 3.18*: Phonebook database step 6 Also update the handling of the api/persons/:id and info routes to use the database, and verify that they work directly with the browser, Postman, or VS Code REST client. Inspecting an individual phonebook entry from the browser should look like this: -![](../../images/3/49.png) +![screenshot of browser showing one person with api/persons/their_id](../../images/3/49.png) diff --git a/src/content/3/en/part3d.md b/src/content/3/en/part3d.md index 75cfc9c17ed..408fbb2fb01 100644 --- a/src/content/3/en/part3d.md +++ b/src/content/3/en/part3d.md @@ -7,14 +7,13 @@ lang: en
    - There are usually constraints that we want to apply to the data that is stored in our application's database. Our application shouldn't accept notes that have a missing or empty content property. The validity of the note is checked in the route handler: ```js app.post('/api/notes', (request, response) => { const body = request.body // highlight-start - if (body.content === undefined) { + if (!body.content) { return response.status(400).json({ error: 'content missing' }) } // highlight-end @@ -23,12 +22,9 @@ app.post('/api/notes', (request, response) => { }) ``` - If the note does not have the content property, we respond to the request with the status code 400 bad request. - -One smarter way of validating the format of the data before it is stored in the database, is to use the [validation](https://mongoosejs.com/docs/validation.html) functionality available in Mongoose. - +One smarter way of validating the format of the data before it is stored in the database is to use the [validation](https://mongoosejs.com/docs/validation.html) functionality available in Mongoose. We can define specific validation rules for each field in the schema: @@ -37,11 +33,7 @@ const noteSchema = new mongoose.Schema({ // highlight-start content: { type: String, - minlength: 5, - required: true - }, - date: { - type: Date, + minLength: 5, required: true }, // highlight-end @@ -49,12 +41,9 @@ const noteSchema = new mongoose.Schema({ }) ``` +The content field is now required to be at least five characters long and it is set as required, meaning that it can not be missing. We have not added any constraints to the important field, so its definition in the schema has not changed. -The content field is now required to be at least five characters long. The date field is set as required, meaning that it can not be missing. The same constraint is also applied to the content field, since the minimum length constraint allows the field to be missing. We have not added any constraints to the important field, so its definition in the schema has not changed. - - -The minlength and required validators are [built-in](https://mongoosejs.com/docs/validation.html#built-in-validators) and provided by Mongoose. The Mongoose [custom validator](https://mongoosejs.com/docs/validation.html#custom-validators) functionality allows us to create new validators, if none of the built-in ones cover our needs. - +The minLength and required validators are [built-in](https://mongoosejs.com/docs/validation.html#built-in-validators) and provided by Mongoose. The Mongoose [custom validator](https://mongoosejs.com/docs/validation.html#custom-validators) functionality allows us to create new validators if none of the built-in ones cover our needs. If we try to store an object in the database that breaks one of the constraints, the operation will throw an exception. Let's change our handler for creating a new note so that it passes any potential exceptions to the error handler middleware: @@ -65,18 +54,16 @@ app.post('/api/notes', (request, response, next) => { // highlight-line const note = new Note({ content: body.content, important: body.important || false, - date: new Date(), }) note.save() .then(savedNote => { - response.json(savedNote.toJSON()) + response.json(savedNote) }) .catch(error => next(error)) // highlight-line }) ``` - Let's expand the error handler to deal with these validation errors: ```js @@ -93,131 +80,64 @@ const errorHandler = (error, request, response, next) => { } ``` - When validating an object fails, we return the following default error message from Mongoose: -![](../../images/3/50.png) +![postman showing error message](../../images/3/50.png) -### Promise chaining +### Deploying the database backend to production -Many of the route handlers changed the response data into the right format by calling the _toJSON_ method. When we created a new note, the _toJSON_ method was called for the object passed as a parameter to _then_: +The application should work almost as-is in Fly.io/Render. We do not have to generate a new production build of the frontend since changes thus far were only on our backend. -```js -app.post('/api/notes', (request, response, next) => { - // ... +The environment variables defined in dotenv will only be used when the backend is not in production mode, i.e. Fly.io or Render. - note.save() - .then(savedNote => { - response.json(savedNote.toJSON()) - }) - .catch(error => next(error)) -}) -``` +For production, we have to set the database URL in the service that is hosting our app. -We can accomplish the same functionality in a much cleaner way with [promise chaining](https://javascript.info/promise-chaining): +In Fly.io that is done _fly secrets set_: -```js -app.post('/api/notes', (request, response, next) => { - // ... - - note - .save() - // highlight-start - .then(savedNote => { - return savedNote.toJSON() - }) - .then(savedAndFormattedNote => { - response.json(savedAndFormattedNote) - }) - // highlight-end - .catch(error => next(error)) -}) +```bash +fly secrets set MONGODB_URI='mongodb+srv://fullstack:thepasswordishere@cluster0.a5qfl.mongodb.net/noteApp?retryWrites=true&w=majority' ``` +When the app is being developed, it is more than likely that something fails. Eg. when I deployed my app for the first time with the database, not a single note was seen: -In the first _then_ we receive _savedNote_ object returned by Mongoose and format it. The result of the operation is returned. Then as [we discussed earlier](/en/part2/altering_data_in_server#extracting-communication-with-the-backend-into-a-separate-module), the _then_ method of a promise also returns a promise and we can access the formatted note by registering a new callback function with the _then_ method. +![browser showing no notes appearing](../../images/3/fly-problem1.png) -We can clean up our code even more by using the more compact syntax for arrow functions: +The network tab of the browser console revealed that fetching the notes did not succeed, the request just remained for a long time in the _pending_ state until it failed with status code 502. -```js -app.post('/api/notes', (request, response, next) => { - // ... +The browser console has to be open all the time! - note - .save() - .then(savedNote => savedNote.toJSON()) // highlight-line - .then(savedAndFormattedNote => { - response.json(savedAndFormattedNote) - }) - .catch(error => next(error)) -}) -``` +It is also vital to follow continuously the server logs. The problem became obvious when the logs were opened with _fly logs_: -In this example, Promise chaining does not provide much of a benefit. The situation would change if there were many asynchronous operations that had to be done in sequence. We will not dive further into the topic. In the next part of the course we will learn about the async/await syntax in JavaScript, that will make writing subsequent asynchronous operations a lot easier. +![fly.io server log showing connecting to undefined](../../images/3/fly-problem3.png) -### Deploying the database backend to production +The database url was _undefined_, so the command *fly secrets set MONGODB\_URI* was forgotten. -The application should work almost as-is in Heroku. We do have to generate a new production build of the frontend due to the changes that we have made to our frontend. +You will also need to whitelist the fly.io app's IP address in MongoDB Atlas. If you don't MongoDB will refuse the connection. -The environment variables defined in dotenv will only be used when the backend is not in production mode, i.e. Heroku. +Sadly, fly.io does not provide you a dedicated IPv4 address for your app, so you will need to allow all IP addresses in MongoDB Atlas. -We defined the environment variables for development in file .env, but the environment variable that defines the database URL in production should be set to Heroku with the _heroku config:set_ command. +When using Render, the database url is given by defining the proper env in the dashboard: -```bash -$ heroku config:set MONGODB_URI=mongodb+srv://fullstack:secretpasswordhere@cluster0-ostce.mongodb.net/note-app?retryWrites=true -``` +![render dashboard showing the MONGODB_URI env variable](../../images/3/render-env.png) -**NB:** if the command causes an error, give the value of MONGODB_URI in apostrophes: +The Render Dashboard shows the server logs: -```bash -$ heroku config:set MONGODB_URI='mongodb+srv://fullstack:secretpasswordhere@cluster0-ostce.mongodb.net/note-app?retryWrites=true' -``` +![render dashboard with arrow pointing to server running on port 10000](../../images/3/r7.png) -The application should now work. Sometimes things don't go according to plan. If there are problems, heroku logs will be there to help. My own application did not work after making the changes. The logs showed the following: - -![](../../images/3/51a.png) - -For some reason the URL of the database was undefined. The heroku config command revealed that I had accidentally defined the URL to the MONGO\_URL environment variable, when the code expected it to be in MONGODB\_URI. - -You can find the code for our current application in its entirety in the part3-5 branch of [this github repository](https://github.com/fullstack-hy2019/part3-notes-backend/tree/part3-5). -
    +You can find the code for our current application in its entirety in the part3-6 branch of [this GitHub repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-6).
    - ### Exercises 3.19.-3.21. +#### 3.19*: Phonebook database, step 7 -#### 3.19: Phonebook database, step7 - - -Add validation to your phonebook application, that will make sure that a newly added person has a unique name. Our current frontend won't allow users to try and create duplicates, but we can attempt to create them directly with Postman or the VS Code REST client. - - -Mongoose does not offer a built-in validator for this purpose. Install the [mongoose-unique-validator](https://github.com/blakehaswell/mongoose-unique-validator#readme) package with npm and use it instead. - -If an HTTP POST request tries to add a name that is already in the phonebook, the server must respond with an appropriate status code and error message. - - -**NB:** unique-validator causes a warning to be printed to the console - -``` -(node:49251) DeprecationWarning: collection.ensureIndex is deprecated. Use createIndexes instead. -connected to MongoDB -``` - -Read the mongoose [documentation](https://mongoosejs.com/docs/deprecations.html) to find out how to get rid of the warning. - -#### 3.20*: Phonebook database, step8 - -Expand the validation so that the name stored in the database has to be at least three characters long, and the phone number must have at least 8 digits. - +Expand the validation so that the name stored in the database has to be at least three characters long. Expand the frontend so that it displays some form of error message when a validation error occurs. Error handling can be implemented by adding a catch block as shown below: - ```js personService .create({ ... }) @@ -226,114 +146,173 @@ personService }) .catch(error => { // this is the way to access the error message - console.log(error.response.data) + console.log(error.response.data.error) }) ``` - You can display the default error message returned by Mongoose, even though they are not as readable as they could be: -![](../../images/3/56e.png) +![phonebook screenshot showing person validation failure](../../images/3/56e.png) +**NB:** On update operations, mongoose validators are off by default. [Read the documentation](https://mongoosejs.com/docs/validation.html) to determine how to enable them. -#### 3.21 Deploying the database backend to production +#### 3.20*: Phonebook database, step 8 + +Add validation to your phonebook application, which will make sure that phone numbers are of the correct form. A phone number must: +- have length of 8 or more +- be formed of two parts that are separated by -, the first part has two or three numbers and the second part also consists of numbers + - eg. 09-1234556 and 040-22334455 are valid phone numbers + - eg. 1234556, 1-22334455 and 10-22-334455 are invalid + +Use a [Custom validator](https://mongoosejs.com/docs/validation.html#custom-validators) to implement the second part of the validation. + +If an HTTP POST request tries to add a person with an invalid phone number, the server should respond with an appropriate status code and error message. + +#### 3.21 Deploying the database backend to production -Generate a new "full stack" version of the application by creating a new production build of the frontend, and copy it to the backend repository. Verify that everything works locally by using the entire application from the address . +Generate a new "full stack" version of the application by creating a new production build of the frontend, and copying it to the backend directory. Verify that everything works locally by using the entire application from the address . +Push the latest version to Fly.io/Render and verify that everything works there as well. -Push the latest version to Heroku and verify that everything works there as well. +**NOTE:** You shall NOT be deploying the frontend directly at any stage of this part. Only the backend repository is deployed throughout the whole part. The frontend production build is added to the backend repository, and the backend serves it as described in the section [Serving static files from the backend](/en/part3/deploying_app_to_internet#serving-static-files-from-the-backend).
    - ### Lint - -Before we move onto the next part, we will take a look at an important tool called [lint](). Wikipedia says the following about lint: +Before we move on to the next part, we will take a look at an important tool called [lint](). Wikipedia says the following about lint: > Generically, lint or a linter is any tool that detects and flags errors in programming languages, including stylistic errors. The term lint-like behavior is sometimes applied to the process of flagging suspicious language usage. Lint-like tools generally perform static analysis of source code. +In compiled statically typed languages like Java, IDEs like NetBeans can point out errors in the code, even ones that are more than just compile errors. Additional tools for performing [static analysis](https://en.wikipedia.org/wiki/Static_program_analysis) like [checkstyle](https://checkstyle.sourceforge.io), can be used for expanding the capabilities of the IDE to also point out problems related to style, like indentation. -In compiled statically typed languages like Java, IDEs like NetBeans can point out errors in the code, even ones that are more than just compile errors. Additional tools for performing [static analysis](https://en.wikipedia.org/wiki/Static_program_analysis) like [checkstyle](http://checkstyle.sourceforge.net/), can be used for expanding the capabilities of the IDE to also point out problems related to style, like indentation. - +In the JavaScript universe, the current leading tool for static analysis (aka "linting") is [ESlint](https://eslint.org/). -In the JavaScript universe, the current leading tool for static analysis aka. "linting" is [ESlint](https://eslint.org/). +Let's add ESLint as a development dependency for the backend. Development dependencies are tools that are only needed during the development of the application. For example, tools related to testing are such dependencies. When the application is run in production mode, development dependencies are not needed. -Let's install ESlint as a development dependency to the backend project with the command: +Install ESLint as a development dependency for the backend with the command: ```bash -npm install eslint --save-dev +npm install eslint @eslint/js --save-dev ``` +The contents of the package.json file will change as follows: + +```js +{ + //... + "dependencies": { + "dotenv": "^16.4.7", + "express": "^5.1.0", + "mongoose": "^8.11.0" + }, + "devDependencies": { // highlight-line + "@eslint/js": "^9.22.0", // highlight-line + "eslint": "^9.22.0" // highlight-line + } +} +``` + +The command added a devDependencies section to the file and included the packages eslint and @eslint/js, and installed the required libraries into the node_modules directory. After this we can initialize a default ESlint configuration with the command: ```bash -node_modules/.bin/eslint --init +npx eslint --init ``` - We will answer all of the questions: -![](../../images/3/52be.png) +![terminal output from ESlint init](../../images/3/lint1.png) +The configuration will be saved in the generated _eslint.config.mjs_ file. -The configuration will be saved in the _.eslintrc.js_ file: +### Formatting the Configuration File + +Let's reformat the configuration file _eslint.config.mjs_ from its current form to the following: ```js -module.exports = { - 'env': { - 'commonjs': true, - 'es6': true, - 'node': true - }, - 'extends': 'eslint:recommended', - 'globals': { - 'Atomics': 'readonly', - 'SharedArrayBuffer': 'readonly' +import globals from 'globals' + +export default [ + { + files: ['**/*.js'], + languageOptions: { + sourceType: 'commonjs', + globals: { ...globals.node }, + ecmaVersion: 'latest', }, - 'parserOptions': { - 'ecmaVersion': 2018 - }, - 'rules': { - 'indent': [ - 'error', - 4 - ], - 'linebreak-style': [ - 'error', - 'unix' - ], - 'quotes': [ - 'error', - 'single' - ], - 'semi': [ - 'error', - 'never' - ] - } -} + }, +] +``` + +So far, our ESLint configuration file defines the _files_ option with _["**/*.js"]_, which tells ESLint to look at all JavaScript files in our project folder. The _languageOptions_ property specifies options related to language features that ESLint should expect, in which we defined the _sourceType_ option as "commonjs". This indicates that the JavaScript code in our project uses the CommonJS module system, allowing ESLint to parse the code accordingly. + +The _globals_ property specifies global variables that are predefined. The spread operator applied here tells ESLint to include all global variables defined in the _globals.node_ settings such as the _process_. In the case of browser code we would define here _globals.browser_ to allow browser specific global variables like _window_, and _document_. + +Finally, the _ecmaVersion_ property is set to "latest". This sets the ECMAScript version to the latest available version, meaning ESLint will understand and properly lint the latest JavaScript syntax and features. + +We want to make use of [ESLint's recommended](https://eslint.org/docs/latest/use/configure/configuration-files#using-predefined-configurations) settings along with our own. The _@eslint/js_ package we installed earlier provides us with predefined configurations for ESLint. We'll import it and enable it in the configuration file: + +```js +import globals from 'globals' +import js from '@eslint/js' // highlight-line +// ... + +export default [ + js.configs.recommended, // highlight-line + { + // ... + }, +] ``` +We've added the _js.configs.recommended_ to the top of the configuration array, this ensures that ESLint's recommended settings are applied first before our own custom options. -Let's immediately change the rule concerning indentation, so that the indentation level is two spaces. +Let's continue building the configuration file. Install a [plugin](https://eslint.style/packages/js) that defines a set of code style-related rules: + +```bash +npm install --save-dev @stylistic/eslint-plugin-js +``` + +Import and enable the plugin, and add these four code style rules: ```js -"indent": [ - "error", - 2 -], +import globals from 'globals' +import js from '@eslint/js' +import stylisticJs from '@stylistic/eslint-plugin-js' // highlight-line + +export default [ + { + // ... + // highlight-start + plugins: { + '@stylistic/js': stylisticJs, + }, + rules: { + '@stylistic/js/indent': ['error', 2], + '@stylistic/js/linebreak-style': ['error', 'unix'], + '@stylistic/js/quotes': ['error', 'single'], + '@stylistic/js/semi': ['error', 'never'], + }, + // highlight-end + }, +] ``` +The [plugins](https://eslint.org/docs/latest/use/configure/plugins) property provides a way to extend ESLint's functionality by adding custom rules, configurations, and other capabilities that are not available in the core ESLint library. We've installed and enabled the _@stylistic/eslint-plugin-js_, which adds JavaScript stylistic rules for ESLint. In addition, rules for indentation, line breaks, quotes, and semicolons have been added. These four rules are all defined in the [Eslint styles plugin](https://eslint.style/packages/js). + +**Note for Windows users:** The linebreak style is set to _unix_ in the style rules. It is recommended to use Unix-style linebreaks (_\n_) regardless of your operating system, as they are compatible with most modern operating systems and facilitate collaboration when multiple people are working on the same files. If you are using Windows-style linebreaks, ESLint will produce the following errors: Expected linebreaks to be 'LF' but found 'CRLF'. In this case, configure Visual Studio Code to use Unix-style linebreaks by following [this guide](https://stackoverflow.com/questions/48692741/how-can-i-make-all-line-endings-eols-in-all-files-in-visual-studio-code-unix). + +### Running the Linter Inspecting and validating a file like _index.js_ can be done with the following command: ```bash -node_modules/.bin/eslint index.js +npx eslint index.js ``` It is recommended to create a separate _npm script_ for linting: @@ -343,130 +322,169 @@ It is recommended to create a separate _npm script_ for linting: // ... "scripts": { "start": "node index.js", - "dev": "nodemon index.js", + "dev": "node --watch index.js", + "test": "echo \"Error: no test specified\" && exit 1", + "lint": "eslint ." // highlight-line // ... - "lint": "eslint ." }, // ... } ``` - Now the _npm run lint_ command will check every file in the project. +Files in the dist directory also get checked when the command is run. We do not want this to happen, and we can accomplish this by adding an object with the [ignores](https://eslint.org/docs/latest/use/configure/ignore) property that specifies an array of directories and files we want to ignore. -Also the files in the build directory get checked when the command is run. We do not want this to happen, and we can accomplish this by creating an [.eslintignore](https://eslint.org/docs/user-guide/configuring#ignoring-files-and-directories) file in the project's root with the following contents: - -```bash -build +```js +// ... +export default [ + js.configs.recommended, + { + files: ['**/*.js'], + // ... + }, + // highlight-start + { + ignores: ['dist/**'], + }, + // highlight-end +] ``` -This causes the entire build directory to not be checked by ESlint. +This causes the entire dist directory to not be checked by ESlint. Lint has quite a lot to say about our code: -![](../../images/3/53ea.png) - -Let's not fix these issues just yet. - -A better alternative to executing the linter from the command line is to configure a eslint-plugin to the editor, that runs the linter continuously. By using the plugin you will see errors in your code immediately. You can find more information about the Visual Studio ESLint plugin [here](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint). +![terminal output of ESlint errors](../../images/3/53ea.png) +A better alternative to executing the linter from the command line is to configure an _eslint-plugin_ to the editor, that runs the linter continuously. By using the plugin you will see errors in your code immediately. You can find more information about the Visual Studio ESLint plugin [here](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint). The VS Code ESlint plugin will underline style violations with a red line: -![](../../images/3/54a.png) - +![Screenshot of vscode ESlint plugin showing errors](../../images/3/54a.png) This makes errors easy to spot and fix right away. +### Adding More Style Rules -ESlint has a vast array of [rules](https://eslint.org/docs/rules/) that are easy to take into use by editing the .eslintrc.js file. - +ESlint has a vast array of [rules](https://eslint.org/docs/rules/) that are easy to take into use by editing the _eslint.config.mjs_ file. -Let's add the [eqeqeq](https://eslint.org/docs/rules/eqeqeq) rule that warns us, if equality is checked with anything but the triple equals operator. The rule is added under the rules field in the configuration file. +Let's add the [eqeqeq](https://eslint.org/docs/rules/eqeqeq) rule that warns us if equality is checked with anything but the triple equals operator. The rule is added under the rules field in the configuration file. ```js -{ +export default [ // ... - 'rules': { + rules: { // ... - 'eqeqeq': 'error', + eqeqeq: 'error', // highlight-line }, -} + // ... +] ``` While we're at it, let's make a few other changes to the rules. -Let's prevent unnecessary [trailing spaces](https://eslint.org/docs/rules/no-trailing-spaces) at the ends of lines, let's require that [there is always a space before and after curly braces](https://eslint.org/docs/rules/object-curly-spacing), and let's also demand a consistent use of whitespaces in the function parameters of arrow functions. +Let's prevent unnecessary [trailing spaces](https://eslint.org/docs/rules/no-trailing-spaces) at the ends of lines, require that [there is always a space before and after curly braces](https://eslint.org/docs/rules/object-curly-spacing), and also demand a consistent use of whitespaces in the function parameters of arrow functions. ```js -{ +export default [ // ... - 'rules': { + rules: { // ... - 'eqeqeq': 'error', + eqeqeq: 'error', + // highlight-start 'no-trailing-spaces': 'error', - 'object-curly-spacing': [ - 'error', 'always' - ], - 'arrow-spacing': [ - 'error', { 'before': true, 'after': true } - ] + 'object-curly-spacing': ['error', 'always'], + 'arrow-spacing': ['error', { before: true, after: true }], + // highlight-end }, -} +] ``` +Our default configuration takes a bunch of predefined rules into use from: -Our default configuration takes a bunch of predetermined rules into use from eslint:recommended: +```js +// ... -```bash -'extends': 'eslint:recommended', +export default [ + js.configs.recommended, + // ... +] ``` - -This includes a rule that warns about _console.log_ commands. [Disabling](https://eslint.org/docs/user-guide/configuring#configuring-rules) a rule can be accomplished by defining its "value" as 0 in the configuration file. Let's do this for the no-console rule in the meantime. +This includes a rule that warns about console.log commands which we don't want to use. Disabling a rule can be accomplished by defining its "value" as 0 or _off_ in the configuration file. Let's do this for the _no-console_ rule in the meantime. ```js -{ - // ... - 'rules': { +[ + { // ... - 'eqeqeq': 'error', - 'no-trailing-spaces': 'error', - 'object-curly-spacing': [ - 'error', 'always' - ], - 'arrow-spacing': [ - 'error', { 'before': true, 'after': true } - ], - 'no-console': 0 // highlight-line + rules: { + // ... + eqeqeq: 'error', + 'no-trailing-spaces': 'error', + 'object-curly-spacing': ['error', 'always'], + 'arrow-spacing': ['error', { before: true, after: true }], + 'no-console': 'off', // highlight-line + }, }, -} +] ``` -**NB** when you make changes to the .eslintrc.js file, it is recommended to run the linter from the command line. This will verify that the configuration file is correctly formatted: +Disabling the no-console rule will allow us to use console.log statements without ESLint flagging them as issues. This can be particularly useful during development when you need to debug your code. Here's the complete configuration file with all the changes we have made so far: + +```js +import globals from 'globals' +import js from '@eslint/js' +import stylisticJs from '@stylistic/eslint-plugin-js' + +export default [ + js.configs.recommended, + { + files: ['**/*.js'], + languageOptions: { + sourceType: 'commonjs', + globals: { ...globals.node }, + ecmaVersion: 'latest', + }, + plugins: { + '@stylistic/js': stylisticJs, + }, + rules: { + '@stylistic/js/indent': ['error', 2], + '@stylistic/js/linebreak-style': ['error', 'unix'], + '@stylistic/js/quotes': ['error', 'single'], + '@stylistic/js/semi': ['error', 'never'], + eqeqeq: 'error', + 'no-trailing-spaces': 'error', + 'object-curly-spacing': ['error', 'always'], + 'arrow-spacing': ['error', { before: true, after: true }], + 'no-console': 'off', + }, + }, + { + ignores: ['dist/**'], + }, +] +``` -![](../../images/3/55.png) +**NB** when you make changes to the _eslint.config.mjs_ file, it is recommended to run the linter from the command line. This will verify that the configuration file is correctly formatted: +![terminal output from npm run lint](../../images/3/lint2.png) If there is something wrong in your configuration file, the lint plugin can behave quite erratically. - Many companies define coding standards that are enforced throughout the organization through the ESlint configuration file. It is not recommended to keep reinventing the wheel over and over again, and it can be a good idea to adopt a ready-made configuration from someone else's project into yours. Recently many projects have adopted the Airbnb [Javascript style guide](https://github.com/airbnb/javascript) by taking Airbnb's [ESlint](https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb) configuration into use. +You can find the code for our current application in its entirety in the part3-7 branch of [this GitHub repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-7). -You can find the code for our current application in its entirety in the part3-6 branch of [this github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-6).
    - ### Exercise 3.22. - #### 3.22: Lint configuration - Add ESlint to your application and fix all the warnings. This was the last exercise of this part of the course. It's time to push your code to GitHub and mark all of your finished exercises to the [exercise submission system](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). diff --git a/src/content/3/es/part3.md b/src/content/3/es/part3.md new file mode 100644 index 00000000000..e54b8d639a7 --- /dev/null +++ b/src/content/3/es/part3.md @@ -0,0 +1,14 @@ +--- +mainImage: ../../../images/part-3.svg +part: 3 +lang: es +--- + +
    + +En esta parte, nuestro enfoque se desplaza hacia el backend, es decir, hacia la implementación de la funcionalidad en el lado del servidor. Implementaremos una API REST simple en Node.js utilizando la librería Express, y los datos de la aplicación se almacenarán en una base de datos MongoDB. Al final de esta parte, desplegaremos nuestra aplicación en Internet. + +Parte actualizada el 9 de Febrero de 2024 +- No hay cambios importantes + +
    diff --git a/src/content/3/es/part3a.md b/src/content/3/es/part3a.md new file mode 100644 index 00000000000..755f58ff844 --- /dev/null +++ b/src/content/3/es/part3a.md @@ -0,0 +1,1028 @@ +--- +mainImage: ../../../images/part-3.svg +part: 3 +letter: a +lang: es +--- + +
    + +En esta parte, nuestro enfoque se desplaza hacia el backend: es decir, hacia la implementación de la funcionalidad en el lado del servidor. + +Construiremos nuestro backend sobre [NodeJS](https://nodejs.org/en/), que es un entorno de ejecución basado en JavaScript y en el motor [Chrome V8](https://developers.google.com/v8/) de Google. + +Este material del curso fue escrito con la versión v20.11.0 de Node.js. Asegúrate de que tu versión de Node sea al menos tan nueva como la versión utilizada en el material (puedes verificar la versión ejecutando _node -v_ en la línea de comando). + +Como se mencionó en la [parte 1](/es/part1/java_script), los navegadores aún no son compatibles con las funciones más nuevas de JavaScript, y es por eso que el código que se ejecuta en el navegador debe transpilarse con, por ejemplo, [babel](https://babeljs.io/). La situación con JavaScript ejecutándose en el backend es diferente. La versión más reciente de Node es compatible con la gran mayoría de las funciones más recientes de JavaScript, por lo que podemos usar las funciones más recientes sin tener que transpilar nuestro código. + +Nuestro objetivo es implementar un backend que funcione con la aplicación de notas de la [parte 2](/es/part2/). Sin embargo, comencemos con lo básico implementando una aplicación clásica de "hola mundo". + +**Ten en cuenta** que las aplicaciones y ejercicios de esta parte no son todas aplicaciones de React, y no usaremos la utilidad create vite@latest -- --template react para inicializar el proyecto para esta aplicación. + +Ya habíamos mencionado [npm](/es/part2/obteniendo_datos_del_servidor#npm) en la parte 2, que es una herramienta utilizada para administrar paquetes de JavaScript. De hecho, npm se origina en el ecosistema Node. + +Naveguemos a un directorio apropiado y creemos una nueva plantilla para nuestra aplicación con el comando _npm init_. Responderemos a las preguntas presentadas por la utilidad y el resultado será un archivo package.json generado automáticamente en la raíz del proyecto, que contiene información sobre el proyecto. + +```json +{ + "name": "backend", + "version": "0.0.1", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Matti Luukkainen", + "license": "MIT" +} +``` + +El archivo define, por ejemplo, que el punto de entrada de la aplicación es el archivo index.js. + +Hagamos un pequeño cambio en el objeto scripts agregando un nuevo comando de script: + +```bash +{ + // ... + "scripts": { + "start": "node index.js", // highlight-line + "test": "echo \"Error: no test specified\" && exit 1" + }, + // ... +} +``` + +A continuación, creemos la primera versión de nuestra aplicación agregando un archivo index.js a la raíz del proyecto con el siguiente código: + +```js +console.log('hello world') +``` + +Podemos ejecutar el programa directamente con Node desde la línea de comando: + +```bash +node index.js +``` + +O podemos ejecutarlo como un [script npm](https://docs.npmjs.com/misc/scripts): + +```bash +npm start +``` + +El script npm start funciona porque lo definimos en el archivo package.json: + +```bash +{ + // ... + "scripts": { + "start": "node index.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + // ... +} +``` + +Aunque la ejecución del proyecto funciona cuando se inicia llamando a _node index.js_ desde la línea de comando, es habitual que los proyectos npm ejecuten estas tareas como scripts npm. + +De forma predeterminada, el archivo package.json también define otro script npm de uso común llamado npm test. Dado que nuestro proyecto aún no tiene una librería de testing, el comando _npm test_ simplemente ejecuta el siguiente comando: + +```bash +echo "Error: no test specified" && exit 1 +``` + +### Servidor web simple + +Cambiemos la aplicación para que sea un servidor web al editar el archivo _index.js_ de la siguiente manera: + +```js +const http = require('http') + +const app = http.createServer((request, response) => { + response.writeHead(200, { 'Content-Type': 'text/plain' }) + response.end('Hello World') +}) + +const PORT = 3001 +app.listen(PORT) +console.log(`Server running on port ${PORT}`) +``` + +Una vez que la aplicación se está ejecutando, el siguiente mensaje se imprime en la consola: + +```bash +Server running on port 3001 +``` + +Podemos abrir nuestra humilde aplicación en el navegador visitando la dirección : + +![hello world en el navegador al acceder a localhost:3001](../../images/3/1.png) + +De hecho, el servidor funciona de la misma manera independientemente de la última parte de la URL. Además, la dirección mostrará el mismo contenido. + +**NB** Si el puerto 3001 ya está siendo utilizado por alguna otra aplicación, al iniciar el servidor aparecerá el siguiente mensaje de error: + +```bash +➜ hello npm start + +> hello@1.0.0 start /Users/mluukkai/opetus/_2019fullstack-code/part3/hello +> node index.js + +Server running on port 3001 +events.js:167 + throw er; // Unhandled 'error' event + ^ + +Error: listen EADDRINUSE :::3001 + at Server.setupListenHandle [as _listen2] (net.js:1330:14) + at listenInCluster (net.js:1378:12) +``` + +Tienes dos opciones. Apaga la aplicación usando el puerto 3001 (el JSON Server en la última parte del material estaba usando el puerto 3001), o usa un puerto diferente para esta aplicación. + +Echemos un vistazo más de cerca a la primera línea del código: + +```js +const http = require('http') +``` + +En la primera linea, la aplicación importa el módulo de [servidor web](https://nodejs.org/docs/latest-v18.x/api/http.html) integrado de Node. Esto es prácticamente lo que ya hemos estado haciendo en nuestro código del lado del navegador, pero con una sintaxis ligeramente diferente: + +```js +import http from 'http' +``` + +En estos días, el código que se ejecuta en el navegador utiliza módulos ES6. Los módulos se definen con un [export](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Statements/export) y se utilizan con un [import](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Statements/import). + +Node.js usa módulos [CommonJS](https://es.wikipedia.org/wiki/CommonJS). La razón de esto es que el ecosistema de Node necesitaba módulos mucho antes de que JavaScript los admitiera en la especificación del lenguaje. Actualmente, Node es compatible con los módulos ES6, pero ya que la compatibilidad aún no es del todo perfecta continuaremos con módulos CommonJS. + +Los módulos de CommonJS funcionan casi exactamente como los módulos de ES6, al menos en lo que respecta a nuestras necesidades en este curso. + +El siguiente fragmento de nuestro código se ve así: + +```js +const app = http.createServer((request, response) => { + response.writeHead(200, { 'Content-Type': 'text/plain' }) + response.end('Hello World') +}) +``` + +El código usa el método _createServer_ del módulo [http](https://nodejs.org/docs/latest-v18.x/api/http.html) para crear un nuevo servidor web. Se registra un controlador de eventos en el servidor, que se llama cada vez que se realiza una solicitud HTTP a la dirección del servidor . + +La solicitud se responde con el código de estado 200, con el cabecera Content-Type establecido en text/plain, y el contenido del sitio que se devolverá establecido en Hello World. + +Las últimas filas enlazan el servidor http asignado a la variable _app_, para escuchar las solicitudes HTTP enviadas al puerto 3001: + +```js +const PORT = 3001 +app.listen(PORT) +console.log(`Server running on port ${PORT}`) +``` + +El propósito principal del servidor backend en este curso es ofrecer datos sin procesar en formato JSON al frontend. Por esta razón, cambiemos inmediatamente nuestro servidor para devolver una lista codificada de notas en formato JSON: + +```js +const http = require('http') + +// highlight-start +let notes = [ + { + id: 1, + content: "HTML is easy", + important: true + }, + { + id: 2, + content: "Browser can execute only JavaScript", + important: false + }, + { + id: 3, + content: "GET and POST are the most important methods of HTTP protocol", + important: true + } +] + +const app = http.createServer((request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify(notes)) +}) +// highlight-end + +const PORT = 3001 +app.listen(PORT) +console.log(`Server running on port ${PORT}`) +``` + +Reiniciemos el servidor (puedes apagar el servidor presionando _Ctrl+C_ en la consola) y refresquemos el navegador. + +El valor application/json en la cabecera Content-Type informa al receptor que los datos están en formato JSON. El array _notes_ se transforma en un string con formato JSON con el método JSON.stringify(notes). Esto es necesario ya que el metodo response.end() espera un string o un buffer para enviar como el cuerpo de la respuesta. + +Cuando abrimos el navegador, el formato que se muestra es exactamente el mismo que en la [parte 2](/es/part2/obteniendo_datos_del_servidor), donde usamos [json-server](https://github.com/typicode/json-server) para servir la lista de notas: + +![datos de las notas formateados en JSON](../../images/3/2new.png) + +### Express + +Es posible implementar nuestro código de servidor directamente con el servidor web [http](https://nodejs.org/docs/latest-v18.x/api/http.html) integrado de Node. Sin embargo, es engorroso, especialmente una vez que la aplicación aumenta de tamaño. + +Se han desarrollado muchas librerías para facilitar el desarrollo del lado del servidor con Node, al ofrecer una interfaz más agradable para trabajar con el módulo http integrado. Estas librerías tienen como objetivo proporcionar una mejor abstracción para los casos de uso general que generalmente requerimos para construir un servidor backend. Por lejos, la librería más popular destinada a este propósito es [Express](http://expressjs.com). + +Usemos Express definiéndolo como una dependencia del proyecto con el comando: + +```bash +npm install express +``` + +La dependencia también se agrega a nuestro archivo package.json: + +```json +{ + // ... + "dependencies": { + "express": "^4.18.2" + } +} +``` + +El código fuente de la dependencia se instala en el directorio node\_modules ubicado en la raíz del proyecto. Además de Express, puedes encontrar una gran cantidad de otras dependencias en el directorio: + +![comando ls listando las dependencias en directorio](../../images/3/4.png) + +Estas son, de hecho, las dependencias de la librería Express y las dependencias de todas sus dependencias, etc. Estas son las [dependencias transitivas](https://lexi-lambda.github.io/blog/2016/08/24/understanding-the-npm-dependency-model/) de nuestro proyecto. + +La versión 4.18.2 de Express se instaló en nuestro proyecto. ¿Qué significa el signo de intercalación delante del número de versión en package.json? + +```json +"express": "^4.18.2" +``` + +El modelo de control de versiones utilizado en npm se denomina control de [versiones semántico](https://docs.npmjs.com/about-semantic-versioning). + +El signo de intercalación al frente de ^4.18.2 significa que si y cuando se actualizan las dependencias de un proyecto, la versión de Express que se instala será al menos 4.18.2. Sin embargo, la versión instalada de Express también puede ser una que tenga un número de parche más grande (el último número) o un número menor más grande (el número del medio). La versión principal de la librería indicada por el primer número mayor debe ser la misma. + +Podemos actualizar las dependencias del proyecto con el comando: + +```bash +npm update +``` + +Asimismo, si empezamos a trabajar en el proyecto en otra computadora, podemos instalar todas las dependencias actualizadas del proyecto definidas en package.json con el comando: + +```bash +npm install +``` + +Si el número mayor de una dependencia no cambia, las versiones más nuevas deberían ser [compatibles con versiones anteriores](https://es.wikipedia.org/wiki/Retrocompatibilidad). Esto significa que si nuestra aplicación usara la versión 4.99.175 de Express en el futuro, entonces todo el código implementado en esta parte aún tendría que funcionar sin realizar cambios en el código. Por el contrario, la futura versión 5.0.0. de Express [puede contener](https://expressjs.com/en/guide/migrating-5.html) cambios que provocarían que nuestra aplicación dejara de funcionar. + +### Web y Express + +Volvamos a nuestra aplicación y realicemos los siguientes cambios: + +```js +const express = require('express') +const app = express() + +let notes = [ + ... +] + +app.get('/', (request, response) => { + response.send('

    Hello World!

    ') +}) + +app.get('/api/notes', (request, response) => { + response.json(notes) +}) + +const PORT = 3001 +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +``` + +Para poder utilizar la nueva versión de nuestra aplicación, primero tenemos que reiniciarla. + +La aplicación no cambió mucho. Justo al comienzo de nuestro código estamos importando _express_, que esta vez es una función que se usa para crear una aplicación Express almacenada en la variable _app_: + +```js +const express = require('express') +const app = express() +``` + +A continuación, definimos dos rutas a la aplicación. El primero define un controlador de eventos, que se utiliza para manejar las solicitudes HTTP GET realizadas a la raíz / de la aplicación: + +```js +app.get('/', (request, response) => { + response.send('

    Hello World!

    ') +}) +``` + +La función del controlador de eventos acepta dos parámetros. El primer parámetro [request](http://expressjs.com/en/4x/api.html#req) contiene toda la información de la solicitud HTTP y el segundo parámetro [response](http://expressjs.com/en/4x/api.html#res) se utiliza para definir cómo se responde a la solicitud. + +En nuestro código, la solicitud se responde utilizando el método [send](http://expressjs.com/en/4x/api.html#res.send) del objeto _response_. Llamar al método hace que el servidor responda a la solicitud HTTP enviando una respuesta que contiene el string \

    Hello World!\

    , que se pasó al método _send_. Dado que el parámetro es un string, Express establece automáticamente el valor de la cabecera Content-Type en text/html. El código de estado de la respuesta predeterminado es 200. + +Podemos verificar esto desde la pestaña Network en las herramientas para desarrolladores: + +![pestaña network en herramientas para desarrolladores](../../images/3/5.png) + +La segunda ruta define un controlador de eventos, que maneja las solicitudes HTTP GET realizadas a la ruta notes de la aplicación: + +```js +app.get('/api/notes', (request, response) => { + response.json(notes) +}) +``` + +La solicitud se responde con el método [json](http://expressjs.com/en/4x/api.html#res.json) del objeto _response_. Llamar al método enviará el array __notes__ que se le pasó como un string con formato JSON. Express establece automáticamente la cabecera Content-Type con el valor apropiado de application/json. + +![api/notes devuelve los datos en formato JSON otra vez](../../images/3/6new.png) + +A continuación, echemos un vistazo rápido a los datos enviados en formato JSON. + +En la versión anterior donde solo usábamos Node, teníamos que transformar los datos a un string conf formato JSON con el método _JSON.stringify_: + +```js +response.end(JSON.stringify(notes)) +``` + +Con Express, esto ya no es necesario, porque esta transformación ocurre automáticamente. + +Vale la pena señalar que[JSON](https://es.wikipedia.org/wiki/JSON) es una cadena y no un objeto JavaScript como el valor asignado a _notes_. + +El experimento que se muestra a continuación ilustra este punto: + +![terminal de node demostrando que json es de tipo string](../../assets/3/5.png) + +El experimento anterior se realizó en el [node-repl](https://nodejs.org/docs/latest-v18.x/api/repl.html) interactivo. Puedes iniciar el node-repl interactivo escribiendo _node_ en la línea de comando. repl es particularmente útil para probar cómo funcionan los comandos mientras escribes el código de la aplicación. ¡Lo recomiendo mucho! + +### nodemon + +Si hacemos cambios en el código de la aplicación, tenemos que reiniciar la aplicación para ver los cambios. Reiniciamos la aplicación cerrándola primero escribiendo _Ctrl+C_ y luego reiniciando la aplicación. En comparación con el conveniente flujo de trabajo en React, donde el navegador se recarga automáticamente después de realizar los cambios, esto se siente un poco engorroso. + +La solución a este problema es [nodemon](https://github.com/remy/nodemon): + +> nodemon observará los archivos en el directorio en el que se inició nodemon, y si algún archivo cambia, nodemon reiniciará automáticamente tu aplicación de node. + +Instalemos nodemon definiéndolo como una dependencia de desarrollo con el comando: + +```bash +npm install --save-dev nodemon +``` + +El contenido de package.json también ha cambiado: + +```json +{ + //... + "dependencies": { + "express": "^4.18.2", + }, + "devDependencies": { + "nodemon": "^3.0.3" + } +} +``` + +Si accidentalmente utilizaste el comando incorrecto y la dependencia de nodemon se agregó en "dependencias" en lugar de "devDependencies", cambia manualmente el contenido de package.json para que coincida con lo que se muestra arriba. + +Por dependencias de desarrollo, nos referimos a herramientas que son necesarias solo durante el desarrollo de la aplicación, por ejemplo, para probar o reiniciar automáticamente la aplicación, como nodemon. + +Estas dependencias de desarrollo no son necesarias cuando la aplicación se ejecuta en modo de producción en el servidor de producción (por ejemplo, Fly.io o Heroku). + +Podemos iniciar nuestra aplicación con nodemon así: + +```bash +node_modules/.bin/nodemon index.js +``` + +Los cambios en el código de la aplicación ahora hacen que el servidor se reinicie automáticamente. Vale la pena señalar que, aunque el servidor backend se reinicia automáticamente, el navegador aún debe actualizarse manualmente. Esto se debe a que, a diferencia de cuando se trabaja en React, ni siquiera podemos tener la funcionalidad de [recarga en caliente](https://gaearon.github.io/react-hot-loader/getstarted/) necesaria para recargar automáticamente el navegador. + +El comando es largo y bastante desagradable, así que definamos un script npm dedicado para él en el archivo package.json: + +```bash +{ + // .. + "scripts": { + "start": "node index.js", + "dev": "nodemon index.js", // highlight-line + "test": "echo \"Error: no test specified\" && exit 1" + }, + // .. +} +``` + +En el script no es necesario especificar la ruta node\_modules/.bin/nodemon a nodemon, porque _npm_ automáticamente sabe buscar el archivo desde ese directorio. + +Ahora podemos iniciar el servidor en el modo de desarrollo con el comando: + +```bash +npm run dev +``` + +A diferencia de los scripts de start y test, también tenemos que agregar run al comando ya que se trata de un script no nativo. + +### REST + +Ampliemos nuestra aplicación para que proporcione la API HTTP RESTful como [json-server](https://github.com/typicode/json-server#routes). + +Representational State Transfer, también conocido como REST, se introdujo en 2000 en la [disertación](https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm) de Roy Fielding . REST es un estilo arquitectónico destinado a crear aplicaciones web escalables. + +No vamos a profundizar en la definición de REST de Fielding ni a perder tiempo reflexionando sobre qué es y qué no es REST. En cambio, tomamos una [visión más estrecha](https://en.wikipedia.org/wiki/Representational_state_transfer#Applied_to_web_services) al preocuparnos solo por cómo las API RESTful se entienden generalmente en las aplicaciones web. De hecho, la definición original de REST ni siquiera se limita a las aplicaciones web. + +Mencionamos en la [parte anterior](/es/part2/alterando_datos_en_el_servidor#rest) que las cosas singulares, como las notas en el caso de nuestra aplicación, se llaman recursos en el pensamiento REST. Cada recurso tiene una URL asociada que es la dirección única del recurso. + +Una convención es crear la dirección única para los recursos combinando el nombre del tipo de recurso con el identificador único del recurso. + +Supongamos que la URL raíz de nuestro servicio eswww.example.com/api. + +Si definimos el tipo de recurso de notas a ser note, entonces la dirección de un recurso de nota con el identificador 10, tiene la dirección única www.example.com/api/notes/10. + +La URL de la colección completa de todos los recursos de notas es www.example.com/api/notes. + +Podemos ejecutar diferentes operaciones sobre recursos. La operación a ejecutar está definida por el verbo HTTP: + +| URL | verbo | funcionalidad | +| --------------------- | ------------------- | -----------------------------------------------------------------| +| notes/10 | GET | obtiene un solo recurso | +| notes | GET | obtiene todos los recursos en una colección | +| notes | POST | crea un nuevo recurso basado en los datos de la solicitud | +| notes/10 | DELETE | elimina el recurso identificado | +| notes/10 | PUT | reemplaza todo el recurso identificado con los datos de la solicitud | +| notes/10 | PATCH | reemplaza una parte del recurso identificado con los datos de la solicitud | +| | | | + +Así es como logramos definir aproximadamente a qué se refiere REST como una [interfaz uniforme](https://en.wikipedia.org/wiki/Representational_state_transfer#Architectural_constraints), lo que significa una forma consistente de definir interfaces que hace posible que los sistemas cooperen. + +Esta forma de interpretar REST cae dentro del [segundo nivel de madurez RESTful](https://martinfowler.com/articles/richardsonMaturityModel.html) en el Modelo de Madurez de Richardson. Según la definición proporcionada por Roy Fielding, en realidad no hemos definido una [API REST](http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven). De hecho, una gran mayoría de las API "REST" supuestas del mundo no cumplen con los criterios originales de Fielding descritos en su disertación. + +En algunos lugares (ver por ejemplo, [Richardson, Ruby: RESTful Web Services](http://shop.oreilly.com/product/9780596529260.do) ) verá nuestro modelo para una API [CRUD](https://es.wikipedia.org/wiki/CRUD) sencilla, que se conoce como un ejemplo de [arquitectura orientada a recursos](https://en.wikipedia.org/wiki/Resource-oriented_architecture) en lugar de REST. Evitaremos quedarnos atascados discutiendo semántica y en su lugar volveremos a trabajar en nuestra aplicación. + +### Obteniendo un solo recurso + +Ampliemos nuestra aplicación para que ofrezca una interfaz REST para operar con notas individuales. Primero, creemos una [ruta](http://expressjs.com/en/guide/routing.html) para buscar un solo recurso. + +La dirección única que usaremos para una nota individual es de la forma notes/10, donde el número al final se refiere al número de id único de la nota. + +Podemos definir [parámetros](http://expressjs.com/en/guide/routing.html#route-parameters) para rutas en Express usando la sintaxis de dos puntos: + +```js +app.get('/api/notes/:id', (request, response) => { + const id = request.params.id + const note = notes.find(note => note.id === id) + response.json(note) +}) +``` + +Ahora app.get('/api/notes/:id', ...) manejará todas las solicitudes HTTP GET, que tienen el formato /api/notes/SOMETHING, donde SOMETHING es una cadena arbitraria. + +Se puede acceder al parámetro id en la ruta de una solicitud a través del objeto [request](http://expressjs.com/en/api.html#req): + +```js +const id = request.params.id +``` + +El ahora familiar método _find_ de arrays se utiliza para encontrar la nota con un id que coincida con el parámetro. Luego, la nota se devuelve al remitente de la solicitud. + +Cuando probamos nuestra aplicación yendo a en nuestro navegador, notamos que no parece funcionar, ya que el navegador muestra una página vacía. Esto no nos sorprende como desarrolladores de software, y es hora de depurar. + +Agregar comandos de _console.log_ a nuestro código es un viejo truco comprobado en batalla: + +```js +app.get('/api/notes/:id', (request, response) => { + const id = request.params.id + console.log(id) + const note = notes.find(note => note.id === id) + console.log(note) + response.json(note) +}) +``` + +Cuando visitemos nuevamente en el navegador, la consola que es el terminal en este caso, mostrará lo siguiente: + +![terminal mostrando 1 y luego undefined](../../images/3/8.png) + +El parámetro id de la ruta se pasa a nuestra aplicación, pero el método _find_ no encuentra una nota con ese id. + +Para profundizar nuestra investigación, también agregamos un console log dentro de la función de comparación pasada al método _find_. Para hacer esto, tenemos que deshacernos de la sintaxis de la función de flecha compacta note => note.id === id, y usar la sintaxis con una declaración de retorno explícita: + +```js +app.get('/api/notes/:id', (request, response) => { + const id = request.params.id + const note = notes.find(note => { + console.log(note.id, typeof note.id, id, typeof id, note.id === id) + return note.id === id + }) + console.log(note) + response.json(note) +}) +``` + +Cuando volvemos a visitar la URL en el navegador, cada llamada a la función de comparación imprime algunas cosas diferentes en la consola. La salida de la consola es la siguiente: + +``` +1 'number' '1' 'string' false +2 'number' '1' 'string' false +3 'number' '1' 'string' false +``` + +La causa del error se aclara. La variable _id_ contiene una cadena '1', mientras que los ids de las notas son números enteros. En JavaScript, la comparación "triple iguales" === considera que todos los valores de diferentes tipos no son iguales por defecto, lo que significa que 1 no es '1'. + +Solucionemos el problema cambiando el parámetro id de un string a [number](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Number): + +```js +app.get('/api/notes/:id', (request, response) => { + const id = Number(request.params.id) // highlight-line + const note = notes.find(note => note.id === id) + response.json(note) +}) +``` + +Ahora la búsqueda de un recurso individual funciona. + +![api/notes/1 devuelve una nota como JSON](../../images/3/9new.png) + +Sin embargo, hay otro problema con nuestra aplicación. + +Si buscamos una nota con un id que no existe, el servidor responde con: + +![herramientas de network muestra 200 y content-length 0](../../images/3/10ea.png) + +El código de estado HTTP que se devuelve es 200, lo que significa que la respuesta se realizó correctamente. No se devuelven datos con la respuesta, ya que el valor de la cabecera de content-length es 0, y lo mismo se puede verificar desde el navegador. + +El motivo de este comportamiento es que la variable _note_ se establece en _undefined_ si no se encuentra una nota coincidente. La situación debe manejarse en el servidor de una mejor manera. Si no se encuentra ninguna nota, el servidor debe responder con el código de estado [404 not found](https://www.rfc-editor.org/rfc/rfc9110.html#name-404-not-found) en lugar de 200. + +Hagamos el siguiente cambio en nuestro código: + +```js +app.get('/api/notes/:id', (request, response) => { + const id = Number(request.params.id) + const note = notes.find(note => note.id === id) + + // highlight-start + if (note) { + response.json(note) + } else { + response.status(404).end() + } + // highlight-end +}) +``` + +Dado que no se adjuntan datos a la respuesta, utilizamos el método [status](http://expressjs.com/en/4x/api.html#res.status) para establecer el estado y el método [end](http://expressjs.com/en/4x/api.html#res.end) para responder a la solicitud sin enviar ningún dato. + +La condición if aprovecha el hecho de que todos los objetos JavaScript son [truthy](https://developer.mozilla.org/es/docs/Glossary/Truthy), lo que significa que se evalúan como verdaderos en una operación de comparación. Sin embargo, _undefined_ es [falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy), lo que significa que se evaluará como falso. + +Nuestra aplicación funciona y envía el código de estado de error si no se encuentra ninguna nota. Sin embargo, la aplicación no devuelve nada para mostrar al usuario, como suelen hacer las aplicaciones web cuando visitamos una página que no existe. En realidad, no necesitamos mostrar nada en el navegador porque las API REST son interfaces diseñadas para uso programático, y el código de estado de error es todo lo que se necesita. + +De todos modos, es posible dar una pista sobre la razón de enviar un error 404 al [sobrescribir el mensaje predeterminado de NO ENCONTRADO](https://stackoverflow.com/questions/14154337/how-to-send-a-custom-http-status-message-in-node-express/36507614#36507614). + +### Eliminar recursos + +A continuación, implementemos una ruta para eliminar recursos. La eliminación ocurre al realizar una solicitud HTTP DELETE a la URL del recurso: + +```js +app.delete('/api/notes/:id', (request, response) => { + const id = Number(request.params.id) + notes = notes.filter(note => note.id !== id) + + response.status(204).end() +}) +``` + +Si la eliminación del recurso es exitosa, lo que significa que la nota existe y se elimina, respondemos a la solicitud con el código de estado [204 no content](https://www.rfc-editor.org/rfc/rfc9110.html#name-204-no-content) y no devolvemos datos con la respuesta. + +No hay consenso sobre qué código de estado debe devolverse a una solicitud DELETE si el recurso no existe. Realmente, las únicas dos opciones son 204 y 404. En aras de la simplicidad, nuestra aplicación responderá con 204 en ambos casos. + +### Postman + +Entonces, ¿cómo probamos la operación de eliminación? Las solicitudes HTTP GET son fáciles de realizar desde el navegador. Podríamos escribir algo de JavaScript para probar la eliminación, pero escribir código de prueba no siempre es la mejor solución en todas las situaciones. + +Existen muchas herramientas para facilitar las pruebas de backends. Uno de ellos es un programa de línea de comandos [curl](https://curl.haxx.se). Sin embargo, en lugar de curl, analizaremos el uso de [Postman](https://www.getpostman.com/) para probar la aplicación. + +Instalemos Postman y probémoslo: + +![postman en api/notes/2](../../images/3/11x.png) +**NB:** Postman también está disponible en VS Code y se puede descargar desde la pestaña Extensiones a la izquierda -> buscar Postman -> Primer resultado (Editor verificado) -> Instalar. +Luego verás un icono adicional agregado en la barra de actividades debajo de la pestaña de extensiones. Una vez que inicies sesión, puedes seguir los pasos a continuación. + +Usar Postman es bastante fácil en esta situación. Es suficiente definir la URL y luego seleccionar el tipo de solicitud correcto (DELETE). + +El servidor backend parece responder correctamente. Al realizar una solicitud HTTP GET a , vemos que la nota con el id 2 ya no está en la lista, lo que indica que la eliminación fue exitosa. + +Debido a que las notas de la aplicación solo se guardan en la memoria, la lista de notas volverá a su estado original cuando reiniciemos la aplicación. + +### El cliente REST de Visual Studio Code + +Si usas Visual Studio Code, puedes usar el plugin [REST client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) de VS Code en lugar de Postman. + +Una vez que el plugin está instalado, usarlo es muy simple. Creamos un directorio en la raíz de la aplicación llamada requests. Guardamos todas las solicitudes del cliente REST en el directorio como archivos que terminan con la extensión .rest. + +Creemos un nuevo archivo get\_all\_notes.rest y definamos la solicitud que obtiene todas las notas. + +![archivo rest, get all notes con solicitud get en notes](../../images/3/12ea.png) + +Al hacer clic en el texto Send Request, el cliente REST ejecutará la solicitud HTTP y la respuesta del servidor se abre en el editor. + +![respuesta de vs code al get request](../../images/3/13new.png) + +### El Cliente HTTP de WebStorm + +Si usas *IntelliJ WebStorm* en cambio, puedes usar un procedimiento similar con su Cliente HTTP incorporado. Crea un nuevo archivo con la extensión `.rest` y el editor te mostrará opciones para crear y ejecutar tus solicitudes. Puedes obtener más información al respecto siguiendo [esta guía](https://www.jetbrains.com/help/webstorm/http-client-in-product-code-editor.html). + +### Recibiendo información + +A continuación, hagamos posible agregar nuevas notas al servidor. La adición de una nota ocurre al hacer una solicitud HTTP POST a la dirección , y al enviar toda la información de la nueva nota en el [body](https://www.rfc-editor.org/rfc/rfc9112#name-message-body) de la solicitud en formato JSON. + +Para acceder a los datos fácilmente, necesitamos la ayuda del [json-parser](https://expressjs.com/en/api.html) de Express, que se usa con el comando _app.use(express.json())_. + +Activemos json-parser e implementemos un controlador inicial para manejar las solicitudes HTTP POST: + +```js +const express = require('express') +const app = express() + +app.use(express.json()) // highlight-line + +//... + +// highlight-start +app.post('/api/notes', (request, response) => { + const note = request.body + console.log(note) + + response.json(note) +}) +// highlight-end +``` + +La función del controlador de eventos puede acceder a los datos de la propiedad body del objeto _request_. + +Sin json-parser, la propiedad body no estaría definida. El json-parser funciona para que tome los datos JSON de una solicitud, los transforme en un objeto JavaScript y luego los adjunte a la propiedad body del objeto _request_ antes de llamar al controlador de ruta. + +Por el momento, la aplicación no hace nada con los datos recibidos además de imprimirlos a la consola y devolverlos en la respuesta. + +Antes de implementar el resto de la lógica de la aplicación, verifiquemos con Postman que el servidor realmente recibe los datos. Además de definir la URL y el tipo de solicitud en Postman, también tenemos que definir los datos enviados en body: + +![post en postman a api/notes con contenido](../../images/3/14new.png) + +La aplicación imprime los datos que enviamos en la solicitud a la consola: + +![terminal imprimiendo contenido enviado con postman](../../images/3/15new.png) + +**NB** Mantén visible el terminal que ejecuta la aplicación en todo momento cuando trabajes en el backend. Gracias a Nodemon, cualquier cambio que hagamos en el código reiniciará la aplicación. Si prestas atención a la consola, inmediatamente podrás detectar los errores que ocurren en la aplicación: + +![error de nodemon: require no esta definido](../../images/3/16e.png) + +De manera similar, es útil verificar la consola para asegurarnos de que el backend se comporte como esperamos en diferentes situaciones, como cuando enviamos datos con una solicitud HTTP POST. Naturalmente, es una buena idea agregar muchos comandos console.log al código mientras la aplicación aún se está desarrollando. + +Una posible causa de problemas es un cabecera Content-Type configurado incorrectamente en las solicitudes. Esto puede suceder con Postman si el tipo de body no está definido correctamente: + +![Postman con texto como Content-Type](../../images/3/17new.png) + +La cabecera Content-Type se establece en text/plain: + +![Postman mostrando cabeceras y Content-Type como text/plain](../../images/3/18new.png) + +El servidor parece recibir solo un objeto vacío: + +![Salida de nodemon mostrando llaves vacías](../../images/3/19.png) + +El servidor no podrá parsear los datos correctamente sin el valor correcto en la cabecera. Ni siquiera intentará adivinar el formato de los datos, ya que hay una [gran cantidad](https://developer.mozilla.org/es/docs/Web/HTTP/Basics_of_HTTP/MIME_types) de Content-Types potenciales. + +Si utilizas VS Code, deberías instalar ahora el cliente REST del capítulo anterior, si aún no lo has hecho. La solicitud POST se puede enviar con el cliente REST de esta manera: + +![post request de muestra en vscode con datos JSON](../../images/3/20new.png) + +Creamos un nuevo archivo create\_note.rest para la solicitud. La solicitud se formatea de acuerdo con las [instrucciones de la documentación](https://github.com/Huachao/vscode-restclient/blob/master/README.md#usage). + +Un beneficio que tiene el cliente REST sobre Postman es que las solicitudes están fácilmente disponibles en la raíz del repositorio del proyecto y se pueden distribuir a todos en el equipo de desarrollo. También puedes agregar varias solicitudes en el mismo archivo usando `###` como separadores: + +```text +GET http://localhost:3001/api/notes/ + +### +POST http://localhost:3001/api/notes/ HTTP/1.1 +content-type: application/json + +{ + "name": "sample", + "time": "Wed, 21 Oct 2015 18:27:50 GMT" +} +``` + +Postman también permite a los usuarios guardar solicitudes, pero la situación puede volverse bastante caótica, especialmente cuando se trabaja en varios proyectos no relacionados. + +> **Nota al margen importante** +> +> A veces, cuando estas depurando, es posible que desees averiguar qué cabeceras se han configurado en la solicitud HTTP. Una forma de lograr esto es mediante el método [get](http://expressjs.com/en/4x/api.html#req.get) del objeto _request_, que se puede usar para obtener el valor de una sola cabecera. El objeto _request_ también tiene la propiedad headers (cabeceras), que contiene todas los cabeceras de una solicitud específica. +> +> Pueden ocurrir problemas con el cliente VS REST si agrega accidentalmente una línea vacía entre la fila superior y la fila que especifica los cabeceras HTTP. En esta situación, el cliente REST interpreta que esto significa que todos los cabeceras se dejan vacíos, lo que hace que el servidor backend no sepa que los datos que ha recibido están en formato JSON. +> +> +> Podrás identificar la ausencia de la cabecera Content-Type si en algún momento en tu código imprimes todas las cabeceras de la solicitud con el comando _console.log(request.headers)_. + +Volvamos a la aplicación. Una vez que sabemos que la aplicación recibe los datos correctamente, es el momento de finalizar el manejo de la solicitud: + +```js +app.post('/api/notes', (request, response) => { + const maxId = notes.length > 0 + ? Math.max(...notes.map(n => n.id)) + : 0 + + const note = request.body + note.id = maxId + 1 + + notes = notes.concat(note) + + response.json(note) +}) +``` + +Necesitamos un id única para la nota. Primero, encontramos el número de id más grande en la lista actual y lo asignamos a la variable _maxId_. La id de la nueva nota se define _como maxId + 1_. De hecho, este método no se recomienda, pero lo haremos así por ahora, ya que lo reemplazaremos pronto. + +La versión actual todavía tiene el problema de que la solicitud HTTP POST se puede usar para agregar objetos con propiedades arbitrarias. Mejoremos la aplicación definiendo que la propiedad content no puede estar vacía. La propiedad important recibirá el valore predeterminado false. Todas las demás propiedades se descartan: + +```js +const generateId = () => { + const maxId = notes.length > 0 + ? Math.max(...notes.map(n => n.id)) + : 0 + return maxId + 1 +} + +app.post('/api/notes', (request, response) => { + const body = request.body + + if (!body.content) { + return response.status(400).json({ + error: 'content missing' + }) + } + + const note = { + content: body.content, + important: Boolean(body.important) || false, + id: generateId(), + } + + notes = notes.concat(note) + + response.json(note) +}) +``` + +La lógica para generar el nuevo número de id para notas se ha separado en una función _generateId_. + +Si a los datos recibidos les falta un valor para la propiedad content, el servidor responderá a la solicitud con el código de estado [400 bad request](https://www.rfc-editor.org/rfc/rfc9110.html#name-400-bad-request): + +```js +if (!body.content) { + return response.status(400).json({ + error: 'content missing' + }) +} +``` + +Ten en cuenta que llamar a return es crucial, porque de lo contrario el código se ejecutará hasta el final y la nota con formato incorrecto se guardará en la aplicación. + +Si la propiedad content tiene un valor, la nota se basará en los datos recibidos. +Si falta la propiedad important, el valor predeterminado será false. El valor predeterminado se genera actualmente de una manera bastante extraña: + +```js +important: Boolean(body.important) || false, +``` + +Si los datos guardados en la variable _body_ tienen la propiedad important, la expresión evaluará su valor. Si la propiedad no existe, la expresión se evaluará como falsa, que se define en el lado derecho de las líneas verticales. + +> Para ser exactos, cuando la propiedad important es false, entonces la expresión body.important || false devolverá el false del lado derecho ... + +Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part3-1 de [este repositorio de GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-1). + +![rama 3-1 en el repositorio de GitHub](../../images/3/21.png) + +Si clonas el proyecto, ejecuta el comando _npm install_ antes de iniciar la aplicación con _npm start_ o _npm run dev_. + +Una cosa más antes de pasar a los ejercicios. La función para generar IDs se ve actualmente así: + +```js +const generateId = () => { + const maxId = notes.length > 0 + ? Math.max(...notes.map(n => n.id)) + : 0 + return maxId + 1 +} +``` + +El cuerpo de la función contiene una línea que parece un poco intrigante: + +```js +Math.max(...notes.map(n => n.id)) +``` + +¿Qué está sucediendo exactamente en esa línea de código? notes.map(n => n.id) crea un nuevo array que contiene todos los ids de las notas. [Math.max](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Math/max) devuelve el valor máximo de los números que se le pasan. Sin embargo, notes.map(n => n.id) es un array, por lo que no se puede asignar directamente como parámetro a _Math.max_. El array se puede transformar en números individuales mediante el uso de la sintaxis de [spread](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Operators/Spread_syntax) (tres puntos) .... + +
    + +
    + +### Ejercicios 3.1-3.6. + +**NB:** Se recomienda hacer todos los ejercicios de esta parte en un nuevo repositorio de git y colocar tu código fuente directamente en la raíz del repositorio. De lo contrario, tendrás problemas en el ejercicio 3.10. + +**NB:** Dado que este no es un proyecto de frontend y no estamos trabajando con React, la aplicación **no se crea** con create vite@latest -- --template react. Inicias este proyecto con el comando npm init que se demostró anteriormente en esta parte del material. + +**Fuerte recomendación:** Cuando estés trabajando en código del lado del servidor, siempre mantén un ojo en lo que sucede en la terminal que está ejecutando tu aplicación. + +#### 3.1: Backend de la Agenda Telefónica paso 1 + +Implementa una aplicación Node que devuelva una lista codificada de entradas de la agenda telefónica desde la dirección . + +Datos: + +```js +[ + { + "id": 1, + "name": "Arto Hellas", + "number": "040-123456" + }, + { + "id": 2, + "name": "Ada Lovelace", + "number": "39-44-5323523" + }, + { + "id": 3, + "name": "Dan Abramov", + "number": "12-43-234345" + }, + { + "id": 4, + "name": "Mary Poppendieck", + "number": "39-23-6423122" + } +] +``` + +Salida en el navegador después de la solicitud GET: + +![Datos JSON de 4 personas en el navegador desde api/persons](../../images/3/22e.png) + +Observa que la barra inclinada hacia adelante en la ruta api/persons no es un carácter especial y es como cualquier otro carácter en la cadena. + +La aplicación debe iniciarse con el comando _npm start_. + +La aplicación también debe ofrecer un comando _npm run dev_ que ejecutará la aplicación y reiniciará el servidor cada vez que se hagan cambios en un archivo en el código fuente. + +#### 3.2: Backend de la Agenda Telefónica, paso 2 + +Implementa una página en la dirección que se parezca más o menos a esto: + +![Captura de pantalla de 3.2](../../images/3/23x.png) + +La página tiene que mostrar la hora en que se recibió la solicitud y cuántas entradas hay en la agenda telefónica en el momento de procesar la solicitud. + +Solo puede haber una declaración response.send() en una ruta de la aplicación Express. Una vez que envías una respuesta al cliente usando response.send(), el ciclo de solicitud-respuesta está completo y no se pueden enviar más respuestas. + +Para incluir un espacio en blanco en la salida, utiliza la etiqueta `
    ` o envuelve las declaraciones en etiquetas `

    `. + +#### 3.3: Backend de la Agenda Telefónica, paso 3 + +Implementa la funcionalidad para mostrar la información de una sola entrada de la agenda. La URL para obtener los datos de una persona con la identificación 5 debe ser + +Si no se encuentra una entrada para la identificación dada, el servidor debe responder con el código de estado apropiado. + +#### 3.4: Backend de la Agenda Telefónica, paso 4 + +Implementa la funcionalidad que hace posible eliminar una sola entrada de la agenda telefónica mediante una solicitud HTTP DELETE a la URL única de esa entrada de la agenda. + +Prueba que tu funcionalidad funcione con Postman o el cliente REST de Visual Studio Code. + +#### 3.5: Backend de la Agenda Telefónica, paso 5 + +Expande el backend para que se puedan agregar nuevas entradas a la agenda telefónica realizando solicitudes HTTP POST a la dirección . + +Genera un nuevo id para la entrada de la agenda con la función [Math.random](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Math/random). Utiliza un rango lo suficientemente grande para tus valores aleatorios de modo que la probabilidad de crear IDs duplicados sea pequeña. + +#### 3.6: Backend de la Agenda Telefónica, paso 6 + +Implementa el manejo de errores para crear nuevas entradas. No se permite que la solicitud se realice correctamente si: + +- Falta el nombre o el número +- El nombre ya existe en la agenda + +Responde a solicitudes como estas con el código de estado apropiado y también envía información que explique el motivo del error, por ejemplo: + +```js +{ error: 'name must be unique' } +``` + +

    + +
    + +### Acerca de los tipos de solicitudes HTTP + +[El estándar HTTP](https://www.rfc-editor.org/rfc/rfc9110.html#name-common-method-properties) habla de dos propiedades relacionadas con los tipos de solicitud, **segura** e **idempotente**. + +La solicitud HTTP GET debe ser segura: + +> En particular, se ha establecido la convención de que los métodos GET y HEAD NO DEBEN tener el poder de ejecutar una acción que no sea la recuperación. Estos métodos deben considerarse "seguros". + +Seguridad significa que la solicitud en ejecución no debe causar ningún efecto secundario en el servidor. Por efectos secundarios queremos decir que el estado de la base de datos no debe cambiar como resultado de la solicitud, y la respuesta solo debe devolver datos que ya existen en el servidor. + +Nada puede garantizar que una solicitud GET sea realmente segura; de hecho, esto es solo una recomendación que se define en el estándar HTTP. Al adherirse a los principios RESTful en nuestra API, las solicitudes GET se utilizan siempre de una manera segura. + +El estándar HTTP también define el tipo de solicitud [HEAD](https://www.rfc-editor.org/rfc/rfc9110.html#name-head), que debería ser seguro. En la práctica, HEAD debería funcionar exactamente como GET, pero no devuelve nada más que el código de estado y las cabeceras de respuesta. El cuerpo de la respuesta no se devolverá cuando realice una solicitud HEAD. + +Todas las solicitudes HTTP excepto POST deben ser idempotentes: + +> Los métodos también pueden tener la propiedad de "idempotencia" en el sentido de que (aparte de errores o problemas de caducidad) los efectos secundarios de N > 0 solicitudes idénticas son los mismos que para una sola solicitud. Los métodos GET, HEAD, PUT y DELETE comparten esta propiedad + +Esto significa que si una solicitud tiene efectos secundarios, el resultado debería ser el mismo independientemente de cuántas veces se envíe la solicitud. + +Si hacemos una solicitud HTTP PUT a la url /api/notes/10 y con la solicitud enviamos los datos { content: "no side effects!", important: true }, el resultado es el mismo independientemente de cuántas veces se envía la solicitud. + +Al igual que la seguridad para la solicitud GET, la idempotencia también es solo una recomendación en el estándar HTTP y no algo que se pueda garantizar simplemente en función del tipo de solicitud. Sin embargo, cuando nuestra API se adhiere a los principios RESTful, las solicitudes GET, HEAD, PUT y DELETE se utilizan de tal manera que son idempotentes. + +POST es el único tipo de solicitud HTTP que no es ni seguro ni idempotente. Si enviamos 5 solicitudes HTTP POST diferentes a /api/notes con un cuerpo de {content: "many same", important: true}, las 5 notas resultantes en el servidor tendrán todas el mismo contenido. + +### Middleware + +El [json-parser](https://expressjs.com/en/api.html) de Express que utilizamos anteriormente es un [middleware](https://expressjs.com/es/guide/using-middleware.html). + +Los middleware son funciones que se pueden utilizar para manejar objetos de _request_ y _response_. + +El json-parser que usamos anteriormente toma los datos sin procesar de las solicitudes que están almacenadas en el objeto _request_, los parsea en un objeto de JavaScript y lo asigna al objeto _request_ como una nueva propiedad body. + +En la práctica, puedes utilizar varios middleware al mismo tiempo. Cuando tienes más de uno, se ejecutan uno por uno en el orden en el que se definieron en el código de la aplicación. + +Implementemos nuestro propio middleware que imprime información sobre cada solicitud que se envía al servidor. + +Middleware es una función que recibe tres parámetros: + +```js +const requestLogger = (request, response, next) => { + console.log('Method:', request.method) + console.log('Path: ', request.path) + console.log('Body: ', request.body) + console.log('---') + next() +} +``` + +Al final del cuerpo de la función, se llama a la función _next_ que se pasó como parámetro. La función _next_ cede el control al siguiente middleware. + +El middleware se utiliza así: + +```js +app.use(requestLogger) +``` + +Recuerda, las funciones middleware se llaman en el orden en el que son encontradas por el motor de JavaScript. Ten en cuenta que _json-parser_ se encuentra definido antes que _requestLogger_, porque de lo contrario, ¡request.body no se inicializará cuando se ejecute el logger! + +Las funciones de middleware deben utilizarse antes que las rutas cuando queremos que sean ejecutadas por los controladores de eventos de ruta. A veces, queremos usar funciones de middleware después que las rutas. Hacemos esto cuando las funciones de middleware solo son llamadas si ningún controlador de ruta se encarga de la solicitud HTTP. + +Agreguemos el siguiente middleware después de nuestras rutas, que se usa para capturar solicitudes realizadas a rutas inexistentes. Para estas solicitudes, el middleware devolverá un mensaje de error en formato JSON. + +```js +const unknownEndpoint = (request, response) => { + response.status(404).send({ error: 'unknown endpoint' }) +} + +app.use(unknownEndpoint) +``` + +Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part3-2 de [este repositorio de GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-2). + +
    + +
    + +### Ejercicios 3.7.-3.8. + +#### 3.7: Backend de la Agenda Telefónica, paso 7 + +Agrega el middleware [morgan](https://github.com/expressjs/morgan) a tu aplicación para el registro de mensajes. Configúralo para registrar mensajes en tu consola según la configuración tiny. + +La documentación de Morgan no es la mejor y es posible que debas dedicar algún tiempo a averiguar cómo configurarlo correctamente. Sin embargo, la mayor parte de la documentación del mundo cae en la misma categoría, por lo que es bueno aprender a descifrar e interpretar documentación críptica en cualquier caso. + +Morgan se instala como todas las demás librerías con el comando _npm install_. La puesta en funcionamiento de Morgan ocurre de la misma manera que la configuración de cualquier otro middleware mediante el comando _app.use_. + +#### 3.8*: Backend de la Agenda Telefónica, paso 8 + +Configura morgan para que también muestre los datos enviados en las solicitudes HTTP POST: + +![terminal mostrando los datos de post siendo enviados](../../images/3/24.png) + +Ten en cuenta que el registro de datos incluso en la consola puede ser peligroso, ya que puede contener datos confidenciales y puede violar la ley de privacidad local (por ejemplo, GDPR en la UE) o el estándar comercial. En este ejercicio, no tienes que preocuparse por los problemas de privacidad, pero en la práctica, intenta no registrar ningún dato sensible. + +Este ejercicio puede resultar bastante complicado, aunque la solución no requiere mucho código. + +Este ejercicio se puede completar de diferentes formas. Una de las posibles soluciones utiliza estas dos técnicas: + +- [creando nuevos tokens](https://github.com/expressjs/morgan#creating-new-tokens) +- [JSON.stringify](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) + +
    diff --git a/src/content/3/es/part3b.md b/src/content/3/es/part3b.md new file mode 100644 index 00000000000..a0b3f8b2955 --- /dev/null +++ b/src/content/3/es/part3b.md @@ -0,0 +1,537 @@ +--- +mainImage: ../../../images/part-3.svg +part: 3 +letter: b +lang: es +--- + +
    + +A continuación, conectemos el frontend que creamos en la [parte 2](/es/part2) a nuestro propio backend. + +En la parte anterior, el frontend podía pedir la lista de notas del servidor json que teníamos como backend, desde la dirección http://localhost:3001/notes. +Nuestro backend tiene ahora una estructura de URL ligeramente diferente, ya que las notas se pueden encontrar en . Cambiemos el atributo _baseUrl_ en src/services/notes.js así: + +```js +import axios from 'axios' +const baseUrl = 'http://localhost:3001/api/notes' //highlight-line + +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} + +// ... + +export default { getAll, create, update } +``` + +Ahora la solicitud GET del frontend a no funciona por alguna razón: + +![solicitud get mostrando error en herramientas de desarrollo](../../images/3/3ae.png) + +¿Que está pasando aquí? Podemos acceder al backend desde un navegador y desde postman sin ningún problema. + +### Política de mismo origen y CORS + +El problema radica en algo llamado _same origin policy (política de mismo origen)_. El origen de una URL es definido por la combinación de protocolo (también conocido como esquema), nombre de host y puerto. + +```text +http://example.com:80/index.html + +protocol: http +host: example.com +port: 80 +``` + +Cuando visitas un sitio web (por ejemplo, ), el navegador emite una solicitud al servidor en el que está alojado el sitio web (example.com). La respuesta enviada por el servidor es un archivo HTML que puede contener una o más referencias a recursos externos alojados ya sea en el mismo servidor que example.com o en un sitio web diferente. Cuando el navegador ve referencia(s) a una URL en el HTML fuente, emite una solicitud. Si la solicitud se realiza utilizando la URL desde la cual se obtuvo el HTML fuente, entonces el navegador procesa la respuesta sin problemas. Sin embargo, si el recurso se obtiene utilizando una URL que no comparte el mismo origen (esquema, host, puerto) que el HTML fuente, el navegador tendrá que verificar el encabezado de respuesta _Access-Control-Allow-Origin_. Si contiene _*_ en la URL del HTML fuente, el navegador procesará la respuesta; de lo contrario, el navegador se negará a procesarla y generará un error. + +La política de mismo origen es un mecanismo de seguridad implementado por los navegadores para prevenir el secuestro de sesiones, entre otras vulnerabilidades de seguridad. + +Para habilitar solicitudes cruzadas legítimas (solicitudes a URLs que no comparten el mismo origen), W3C ideó un mecanismo llamado CORS (Cross-Origin Resource Sharing). Según [Wikipedia](https://es.wikipedia.org/wiki/Intercambio_de_recursos_de_origen_cruzado): + +> El intercambio de recursos de origen cruzado (CORS) es un mecanismo que permite solicitar recursos restringidos (por ejemplo, tipografías) en una página web desde otro dominio fuera del dominio desde el que se sirvió el primer recurso. Una página web puede incrustar libremente imágenes, hojas de estilo, scripts, iframes y videos de origen cruzado. Ciertas solicitudes "entre dominios", en particular las solicitudes Ajax, están prohibidas de forma predeterminada por la política de seguridad del mismo origen. + +En nuestro contexto, el problema es que, por defecto, el código JavaScript de una aplicación que se ejecuta en un navegador solo puede comunicarse con un servidor en el mismo [origen](https://developer.mozilla.org/es/docs/Web/Security/Same-origin_policy). +Debido a que nuestro servidor está en el puerto localhost 3001 y nuestra interfaz en el puerto localhost 3000, no tienen el mismo origen. + +Ten en cuenta que la [política de mismo origen](https://developer.mozilla.org/es/docs/Web/Security/Same-origin_policy) y CORS no son específicos de React o Node. De hecho, son principios universales del funcionamiento de las aplicaciones web. + +Podemos permitir solicitudes de otros orígenes utilizando el middleware [cors](https://github.com/expressjs/cors) de Node. + +En tu repositorio backend, Instala cors con el comando + +```bash +npm install cors +``` + +usemos el middleware y así permitimos solicitudes de todos los orígenes: + +```js +const cors = require('cors') + +app.use(cors()) +``` + +Ahora, ¡la mayoría de las funcionalidades del frontend funcionan! La funcionalidad para cambiar la importancia de las notas aún no se ha implementado en el backend, por lo que naturalmente, esto todavía no funciona en el frontend. Deberemos arreglarlo luego. + +Puedes leer más sobre CORS en la [página de Mozilla](https://developer.mozilla.org/es/docs/Web/HTTP/CORS). + +La configuración de nuestra aplicación se ve así ahora: + +![diagrama de la aplicación React y el navegador](../../images/3/100.png) + +La aplicación React que se ejecuta en el navegador ahora obtiene los datos del servidor node/express que se ejecuta en localhost:3001. + +### Aplicación a Internet + +Ahora que todo el stack está listo, movamos nuestra aplicación a Internet. + +Hay un número cada vez mayor de servicios que se pueden utilizar para alojar una aplicación en Internet. Los servicios orientados al desarrollador, como PaaS (es decir, Plataforma como Servicio), se encargan de instalar el entorno de ejecución (por ejemplo, Node.js) y también pueden proporcionar varios servicios, como bases de datos. + +Durante una década, [Heroku](http://heroku.com) dominó la escena de PaaS. Desafortunadamente, el nivel gratuito de Heroku terminó el 27 de noviembre de 2022. Esto es muy desafortunado para muchos desarrolladores, especialmente estudiantes. Heroku sigue siendo una opción viable si estás dispuesto a gastar algo de dinero. También tienen [un programa para estudiantes](https://www.heroku.com/students) que proporciona algunos créditos gratuitos. + +Ahora estamos presentando dos servicios [Fly.io](https://fly.io/) y [Render](https://render.com/) que ambos tienen un plan gratuito (limitado). Fly.io es nuestro servicio de alojamiento "oficial", ya que se puede utilizar con seguridad también en las partes 11 y 13 del curso. Render también funcionará bien al menos para las otras partes de este curso. + +Ten en cuenta que a pesar de utilizar solo el nivel gratuito, Fly.io puede requerir que ingreses los detalles de tu tarjeta de crédito. En este momento, Render se puede usar sin una tarjeta de crédito. + +Render podría ser un poco más fácil de usar, ya que no requiere que se instale ningún software en tu máquina. + +También hay algunas otras opciones de alojamiento gratuitas que funcionan bien para este curso, al menos para todas las partes excepto la parte 11 (CI/CD), la cual podría tener un ejercicio complicado para otras plataformas. + +Algunos participantes del curso también han utilizado los siguientes servicios: + +- [Cyclic](https://www.cyclic.sh/) +- [Replit](https://replit.com) +- [CodeSandBox](https://codesandbox.io) + +Si conoces otros servicios buenos y fáciles de usar para alojar NodeJS, ¡háznoslo saber! + +Para Fly.io y Render, necesitamos cambiar la definición del puerto que nuestra aplicación utiliza al final del archivo index.js en el backend de la siguiente manera: + +```js +const PORT = process.env.PORT || 3001 // highlight-line +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +``` + +Ahora estamos utilizando el puerto definido en la [variable de entorno](https://es.wikipedia.org/wiki/Variable_de_entorno) _PORT_ o el puerto 3001 si la variable de entorno _PORT_ no está definida. Fly.io y Render configuran el puerto de la aplicación en función de esa variable de entorno. + +#### Fly.io + +Ten en cuenta que es posible que necesites proporcionar el número de tu tarjeta de crédito a Fly.io incluso si estás utilizando solo el nivel gratuito. De hecho, ha habido informes contradictorios al respecto, se sabe con certeza que algunos de los estudiantes en este curso están utilizando Fly.io sin ingresar la información de su tarjeta de crédito. En este momento, [Render](https://render.com/) se puede utilizar sin una tarjeta de crédito. + +Por defecto, todos obtienen dos máquinas virtuales gratuitas que se pueden utilizar para ejecutar dos aplicaciones al mismo tiempo. + +Si decides utilizar [Fly.io](https://fly.io/), comienza instalando su ejecutable flyctl siguiendo [esta guía](https://fly.io/docs/hands-on/install-flyctl/). Después de eso, debes [crear una cuenta en Fly.io](https://fly.io/docs/hands-on/sign-up/). + +Comienza [autenticándote](https://fly.io/docs/hands-on/sign-in/) a través de la línea de comandos con el siguiente comando: + +```bash +fly auth login +``` + +Ten en cuenta que si el comando _fly_ no funciona en tu máquina, puedes probar la versión más larga _flyctl_. Por ejemplo, en MacOS, ambas formas del comando funcionan. + +Si no logras que flyctl funcione en tu máquina, puedes probar Render (ver la próxima sección), no requiere que se instale nada en tu máquina. + +La inicialización de una aplicación se realiza ejecutando el siguiente comando en el directorio raíz de la aplicación: + +```bash +fly launch +``` + +Dale un nombre a la aplicación o permite que Fly.io genere uno automáticamente. Selecciona una región donde se ejecutará la aplicación. No crees una base de datos PostgreSQL para la aplicación y no crees una base de datos Upstash Redis, ya que no son necesarias. + +La última pregunta es "Would you like to deploy now? (¿Quieres desplegar ahora?)". Deberíamos responder "no" ya que aún no estamos listos. + +Fly.io crea un archivo fly.toml en la raíz de tu aplicación donde podemos configurarlo. Para poner en marcha la aplicación, podríamos necesitar hacer una pequeña adición a la configuración: + +```bash +[build] + +[env] + PORT = "3000" # add this + +[http_service] + internal_port = 3000 # ensure that this is same as PORT + force_https = true + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 0 + processes = ["app"] +``` + +Hemos definido ahora en la sección [env] que la variable de entorno PORT obtendrá el puerto correcto (definido en la sección [http_service]) donde la aplicación debe crear el servidor. + +Ahora estamos listos para implementar la aplicación en los servidores de Fly.io. Esto se hace con el siguiente comando: + +```bash +fly deploy +``` + +Si todo va bien, la aplicación debería estar ahora activa y funcionando. Puedes abrirla en el navegador con el siguiente comando + +```bash +fly apps open +``` + +Un comando especialmente importante es _fly logs_. Este comando se puede utilizar para ver los registros del servidor. Es mejor mantener siempre visibles los registros. + +**Nota:** Fly podría crear 2 máquinas para tu aplicación. Si esto sucede, el estado de los datos en tu aplicación será inconsistente entre las solicitudes. Es decir, tendrías dos máquinas, cada una con su propia variable de notas. Podrías realizar un POST en una máquina y luego tu siguiente GET podría ir a otra máquina. Puedes verificar el número de máquinas usando el comando "$ fly scale show". Si el recuento es mayor que 1, puedes forzar que sea 1 con el comando "$ fly scale count 1". El recuento de máquinas también se puede verificar en el panel de control. + +**Nota:** En algunos casos (la causa aún se desconoce), ejecutar comandos de Fly.io, especialmente en Windows WSL (Subsistema de Windows para Linux), ha causado problemas. Si el siguiente comando se cuelga + +```bash +flyctl ping -o personal +``` + +tu computadora no puede conectarse por alguna razón a Fly.io. Si esto te sucede, [este enlace](https://github.com/fullstack-hy2020/misc/blob/master/fly_io_problem.md) describe una posible manera de proceder. + +Si la salida del siguiente comando se ve así: + +```bash +$ flyctl ping -o personal +35 bytes from fdaa:0:8a3d::3 (gateway), seq=0 time=65.1ms +35 bytes from fdaa:0:8a3d::3 (gateway), seq=1 time=28.5ms +35 bytes from fdaa:0:8a3d::3 (gateway), seq=2 time=29.3ms +... +``` + +¡entonces no hay problemas de conexión! + +Cada vez que realices cambios en la aplicación, puedes llevar la nueva versión a producción con el siguiente comando: + +```bash +fly deploy +``` + +#### Render + +Lo siguiente asume que ya has iniciado sesión [aquí](https://dashboard.render.com/) con una cuenta de GitHub. + +Después de iniciar sesión, creemos un nuevo "web service": + +![Imagen que muestra la opción de crear un nuevo servicio web](../../images/3/r1.png) + +Luego, el repositorio de la aplicación se conecta a Render: + +![Imagen que muestra el repositorio de la aplicación en Render](../../images/3/r2.png) + +La conexión parece requerir que el repositorio de la aplicación sea público. + +A continuación, definiremos las configuraciones básicas. Si la aplicación no está en la raíz del repositorio, se debe proporcionar un valor adecuado para el Root directory: + +![Imagen que muestra el campo de directorio raíz como opcional](../../images/3/r3.png) + +Después de esto, la aplicación se inicia en Render. El panel nos muestra el estado de la aplicación y la URL donde se está ejecutando: + +![La esquina superior izquierda de la imagen muestra el estado de la aplicación y su URL](../../images/3/r4.png) + +Según la [documentación](https://render.com/docs/deploys), cada commit en GitHub debería volver a desplegar la aplicación. Por alguna razón, esto no siempre funciona. + +Afortunadamente, también es posible volver a desplegar manualmente la aplicación: + +![Menú con la opción para desplegar el último commit resaltado](../../images/3/r5.png) + +Además, los registros de la aplicación se pueden ver en el panel: + +![Imagen con la pestaña de registros resaltada en la esquina izquierda. En el lado derecho, los registros de la aplicación](../../images/3/r7.png) + +Observamos ahora desde los registros que la aplicación se ha iniciado en el puerto 10000. El código de la aplicación obtiene el puerto correcto a través de la variable de entorno PORT, por lo que es esencial que el archivo index.js se haya actualizado en el backend de la siguiente manera: + +```js +const PORT = process.env.PORT || 3001 // highlight-line +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +``` + +### Frontend production build + +Hasta ahora hemos estado ejecutando el código de React en modo de desarrollo. En el modo de desarrollo, la aplicación está configurada para dar mensajes de error claros, mostrar inmediatamente los cambios de código en el navegador, etc. + +Cuando se despliega la aplicación, debemos crear un [production build](https://es.vitejs.dev/guide/build) (compilación de producción) o una versión de la aplicación que esté optimizada para producción. + +Una compilación de producción para aplicaciones creadas con Vite puede crearse con el comando [npm run build](https://es.vitejs.dev/guide/build). + +Ejecutemos este comando desde la raíz del proyecto frontend que desarrollamos en la [Parte 2](/es/part2). + +Esto crea un directorio llamado dist (que contiene el único archivo HTML de nuestra aplicación, index.html) y el directorio assets. Se generará una versión [Minified]()(reducida) del código JavaScript de nuestra aplicación en el directorio dist. Aunque el código de la aplicación está en varios archivos, todo el JavaScript se reducirá en un solo archivo. En realidad, todo el código de todas las dependencias de la aplicación también se reducirá en este único archivo. + +El código reducido no es muy legible. El comienzo del código se ve así: + +```js +!function(e){function r(r){for(var n,f,i=r[0],l=r[1],a=r[2],c=0,s=[];cdist) a la raíz del repositorio del backend y configurar el backend para que muestre la página principal del frontend (el archivo dist/index.html) como su página principal. + +Comenzamos copiando la compilación de producción del frontend a la raíz del backend. Con una computadora Mac o Linux, la copia se puede hacer desde el directorio frontend con el comando + +```bash +cp -r dist ../backend +``` + +Si estás usando una computadora con Windows, puedes usar el comando [copy](https://www.windows-commandline.com/windows-copy-command-syntax-examples/) o [xcopy](https://www.windows-commandline.com/xcopy-command-syntax-examples/) en su lugar. De lo contrario, simplemente copia y pega. + +El directorio de backend ahora debería verse así: + +![comando ls de bash mostrando directorio dist](../../images/3/27v.png) + +Para hacer que Express muestre contenido estático, la página index.html y el JavaScript, etc., necesitamos un middleware integrado de Express llamado [static](http://expressjs.com/en/starter/static-files.html). + +Cuando agregamos lo siguiente en medio de las declaraciones de middlewares + +```js +app.use(express.static('dist')) +``` + +siempre que Express recibe una solicitud HTTP GET, primero verificará si el directorio dist contiene un archivo correspondiente a la dirección de la solicitud. Si se encuentra un archivo correcto, Express lo devolverá. + +Ahora las solicitudes HTTP GET a la dirección www.serversaddress.com/index.html o www.serversaddress.com mostrarán el frontend de React. Las solicitudes GET a la dirección www.serversaddress.com/api/notes serán manejadas por el código del backend. + +Debido a nuestra situación, tanto el frontend como el backend están en la misma dirección, podemos declarar _baseUrl_ como una URL [relativa](https://www.w3.org/TR/WD-html40-970917/htmlweb.html#h-5.1.2). Esto significa que podemos omitir la parte que declara el servidor. + +```js +import axios from 'axios' +const baseUrl = '/api/notes' // highlight-line + +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} + +// ... +``` + +Después del cambio, tenemos que crear una nueva compilación de producción y copiarla en la raíz del repositorio de backend. + +La aplicación ahora se puede utilizar desde la dirección de backend : + +![Aplicación Notes en localhost:3001](../../images/3/28new.png) + +Nuestra aplicación ahora funciona exactamente como la aplicación de ejemplo de [una sola pagina](/es/part0/fundamentos_de_las_aplicaciones_web#aplicacion-de-una-sola-pagina) que estudiamos en la parte 0. + +Cuando usamos un navegador para ir a la dirección , el servidor devuelve el archivo index.html del directorio dist. El contenido del archivo es el siguiente: + +```html + + + + + + + Vite + React + + + + +
    + + + + +``` + +El archivo contiene instrucciones para obtener una hoja de estilo CSS que define los estilos de la aplicación y una etiqueta script que indica al navegador que obtenga el código JavaScript de la aplicación, es decir, la aplicación React. + +El código de React obtiene notas de la dirección del servidor y las muestra en la pantalla. Las comunicaciones entre el servidor y el navegador se pueden ver en la pestaña Network de la consola del desarrollador: + +![pestaña Network de aplicación de notas en el backend](../../images/3/29new.png) + +La configuración que está lista para un despliegue en producción se ve así: + +![diagrama de la aplicación React lista para despliegue](../../images/3/101.png) + +A diferencia de cuando se ejecuta la aplicación en un entorno de desarrollo, todo está ahora en el mismo backend de node/express que se ejecuta en localhost:3001. Cuando el navegador accede a la página, se renderiza el archivo index.html. Esto hace que el navegador obtenga la versión de producción de la aplicación React. Una vez que comienza a ejecutarse, obtiene los datos en formato JSON desde la dirección localhost:3001/api/notes. + +### La aplicación completa en Internet + +Después de asegurarte de que la versión de producción de la aplicación funcione localmente, haz un commit de la compilación de producción del frontend en el repositorio de backend y envía el código a GitHub nuevamente. + +**NB:** Si usas Render, asegúrate de que el directorio dist no esté ignorado por Git en el backend. + +Si estás utilizando Render, un envío a GitHub podría ser suficiente. Si el despliegue automático no funciona, selecciona "manual deploy (despliegue manual)" desde el panel de Render. + +En el caso de Fly.io, el nuevo despliegue se realiza con el comando + +```bash +fly deploy +``` + +La aplicación funciona perfectamente, excepto que aún no hemos agregado la funcionalidad para cambiar la importancia de una nota en el backend. + +NOTA: Al usar Fly.io, ten en cuenta que el archivo _.dockerignore_ en tu directorio de proyecto enumera los archivos que no se suben durante el despliegue. El directorio dist se incluye por defecto. Para desplegar este directorio, elimina su referencia del archivo .dockerignore, asegurando que tu aplicación se despliegue correctamente. + +![captura de pantalla de la aplicación de notas](../../images/3/30new.png) + +**NOTA:** el cambio de la importancia TODAVÍA NO funciona ya que el backend aún no lo tiene implementado. + +Nuestra aplicación guarda las notas en una variable. Si la aplicación se bloquea o se reinicia, todos los datos desaparecerán. + +La aplicación necesita una base de datos. Antes de introducir una, repasemos algunas cosas. + +La configuración ahora se ve así: + +![diagrama de la aplicación React en fly.io](../../images/3/102.png) + +El backend de node/express ahora reside en el servidor de Fly.io/Render. Cuando se accede a la dirección raíz, el navegador carga y ejecuta la aplicación React que obtiene los datos JSON del servidor de Fly.io/Render. + +### Optimizando el despliegue del frontend + +Para crear una nueva compilación de producción del frontend sin trabajo manual adicional, agreguemos algunos scripts npm al package.json del repositorio de backend. + +#### Fly.io script + +Los scripts se ven así: + +```json +{ + "scripts": { + // ... + "build:ui": "rm -rf dist && cd ../notes-frontend/ && npm run build && cp -r dist ../notes-backend", + "deploy": "fly deploy", + "deploy:full": "npm run build:ui && npm run deploy", + "logs:prod": "fly logs" + } +} +``` + +##### Para usuarios de Windows + +Ten en cuenta que los comandos de shell estándar en `build:ui` no funcionan de forma nativa en Windows. En Powershell de Windows se puede escribir el script como + +```json +"build:ui": "@powershell Remove-Item -Recurse -Force dist && cd ../frontend && npm run build && @powershell Copy-Item dist -Recurse ../backend", +``` + +Si el script no funciona en Windows, confirma que estás utilizando Powershell y no el Command Prompt. Si has instalado Git Bash u otro terminal similar a Linux, es posible que puedas ejecutar comandos similares a Linux también en Windows. + +El script _npm run build:ui_ construye el frontend y copia la versión de producción bajo el repositorio del backend. El script _npm run deploy_ despliega el backend actual en Fly.io. + +_npm run deploy:full_ combina estos dos scripts, es decir, _npm run build:ui_ y _npm run deploy_. + +También hay un script _npm run logs:prod_ para mostrar los logs de Fly.io. + +Ten en cuenta que las rutas de directorio en el script build:ui dependen de la ubicación de los repositorios en el sistema de archivos. + +#### Render + +Nota: Cuando intentes desplegar tu backend en Render, asegúrate de tener un repositorio separado para el backend y despliega ese repositorio de GitHub a través de Render. Intentar desplegar a través de tu repositorio Fullstackopen a menudo arrojará "ERR path ....package.json". + +En el caso de Render, los scripts se ven así: + +```json +{ + "scripts": { + //... + "build:ui": "rm -rf dist && cd ../frontend && npm run build && cp -r dist ../backend", + "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push" + } +} +``` + +El script _npm run build:ui_ construye el frontend y copia la versión de producción en el repositorio del backend. _npm run deploy:full_ también contiene los comandos git necesarios para actualizar el repositorio del backend. + +Ten en cuenta que las rutas de directorio en el script build:ui dependen de la ubicación de los repositorios en el sistema de archivos. + +>**NB** En Windows, los scripts de npm se ejecutan en cmd.exe como la shell predeterminada, que no admite comandos bash. Para que funcionen los comandos bash anteriores, puedes cambiar la shell predeterminada a Bash (en la instalación estándar de Git para Windows) de la siguiente manera: + +```md +npm config set script-shell "C:\\Program Files\\git\\bin\\bash.exe" +``` + +Otra opción es el uso de [shx](https://www.npmjs.com/package/shx). + +### Proxy + +Los cambios en el frontend han hecho que ya no funcione en el modo de desarrollo (cuando se inicia con el comando _npm run dev), ya que la conexión con el backend no funciona. + +![pestaña Network mostrando un 404 al solicitar las notas](../../images/3/32new.png) + +Esto se debe al cambio de la dirección de backend a una URL relativa: + +```js +const baseUrl = '/api/notes' +``` + +Debido a que en el modo de desarrollo el frontend está en la dirección localhost:5173, las solicitudes al backend van a la dirección incorrecta localhost:5173/api/notes. El backend está en localhost:3001. + +Si el proyecto se creó con Vite, este problema es fácil de resolver. Es suficiente agregar la siguiente declaración al archivo vite.config.js del repositorio de frontend. + +```bash +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + // highlight-start + server: { + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + } + }, + // highlight-end +}) + +``` + +Después de reiniciar, el entorno de desarrollo de React funcionará como un [proxy](https://es.vitejs.dev/config/server-options#server-proxy). Si el código de React realiza una solicitud HTTP a una dirección de servidor en http://localhost:5173 no administrada por la aplicación React en sí (es decir, cuando las solicitudes no tratan de obtener el CSS o JavaScript de la aplicación), la solicitud se redirigirá a el servidor en http://localhost:3001. + +Ten en cuenta que con la configuración de Vite mostrada anteriormente, solo las solicitudes realizadas a rutas que comienzan con /api se redirigen al servidor. + +Ahora el frontend funciona bien, trabajando con el servidor tanto en el modo de desarrollo como en el de producción. + +Un aspecto negativo de nuestro enfoque es lo complicado que resulta implementar el frontend. Desplegar una nueva versión requiere generar una nueva compilación de producción del frontend y copiarla al repositorio del backend. Esto dificulta la creación de un [pipeline de despliegue](https://martinfowler.com/bliki/DeploymentPipeline.html) automatizado. Un pipeline de despliegue es una forma automatizada y controlada de mover el código desde la computadora del desarrollador a través de diferentes pruebas y controles de calidad hasta el entorno de producción. La construcción de un pipeline de despliegue es el tema de la [parte 11](/en/part11) de este curso. Hay varias formas de lograr esto, por ejemplo, colocar tanto el código del backend como del frontend en el mismo repositorio, pero no profundizaremos en eso por ahora. + +En algunas situaciones, puede tener sentido implementar el código del frontend como su propia aplicación. + +El código actual del backend se puede encontrar en [Github](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-3), en la rama part3-3. Los cambios en el código del frontend están en la rama part3-1 del [repositorio del frontend](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part3-1). + +
    + +
    + +### Ejercicios 3.9.-3.11. + +Los siguientes ejercicios no requieren muchas líneas de código. Sin embargo, pueden ser un desafío, porque debes comprender exactamente qué está sucediendo y dónde, y las configuraciones deben ser las correctas. + +#### 3.9 Backend de la Agenda Telefónica, paso 9 + +Haz que el backend funcione con el frontend de la agenda telefónica de los ejercicios de la parte anterior. No implementes todavía la funcionalidad para realizar cambios en los números de teléfono, que se implementará en el ejercicio 3.17. + +Probablemente tendrás que hacer algunos pequeños cambios en el frontend, al menos en las URL del backend. Recuerda mantener abierta la consola del desarrollador en tu navegador. Si algunas solicitudes HTTP fallan, debes verificar en la pestaña Network qué está sucediendo. Vigila también la consola del backend. Si no hiciste el ejercicio anterior, vale la pena imprimir los datos de la solicitud o request.body en la consola en el controlador de eventos responsable de las solicitudes POST. + +#### 3.10 Backend de la Agenda Telefónica, paso 10 + +Despliega el backend en Internet, por ejemplo en Fly.io o Render. + +Prueba el backend desplegado con un navegador y el REST client de VS Code o con Postman para asegurarte de que funcione. + +**PRO TIP:** Cuando despliegues tu aplicación en Internet, vale la pena al menos al principio estar atento a los logs de la aplicación **EN TODO MOMENTO**. + +Crea un README.md en la raíz de tu repositorio y agrega un enlace a tu aplicación en línea. + +**NOTA**: como se mencionó, debes desplegar el BACKEND al servicio en la nube. Si estás utilizando Fly.io, los comandos deben ejecutarse en el directorio raíz del backend (es decir, en el mismo directorio donde se encuentra el package.json del backend). En caso de usar Render, el backend debe estar en la raíz de tu repositorio. + +NO deberás desplegar el frontend directamente en ninguna etapa de esta parte. Solo se desplegara el repositorio del backend en todo este proceso, nada más. + +#### 3.11 Agenda Telefónica Full Stack + +Genera un build de producción de tu frontend y agrégalo a la aplicación en Internet utilizando el método introducido en esta parte. + +**NB:** Si usas Render, asegúrate de que el directorio dist no esté ignorado por Git en el backend. + +También, asegúrate de que el frontend aún funcione localmente (en modo de desarrollo cuando se inicia con el comando _npm run dev_). + +Si encuentras problemas para que la aplicación funcione, asegúrate de que tu estructura de directorios coincida con [la aplicación de ejemplo](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-3). + +
    diff --git a/src/content/3/es/part3c.md b/src/content/3/es/part3c.md new file mode 100644 index 00000000000..6029ee2701f --- /dev/null +++ b/src/content/3/es/part3c.md @@ -0,0 +1,959 @@ +--- +mainImage: ../../../images/part-3.svg +part: 3 +letter: c +lang: es +--- + +
    + +Antes de pasar al tema principal de persistir datos en una base de datos, veremos algunas formas diferentes de depurar aplicaciones de Node. + +### Depuración en aplicaciones de Node + +Depurar (debugging) aplicaciones de Node es un poco más difícil que depurar JavaScript que se ejecuta en el navegador. Imprimir en la consola es un método probado y confiable, siempre vale la pena hacerlo. Hay personas que piensan que se deberían utilizar métodos más sofisticados en su lugar, pero no estoy de acuerdo. Incluso los desarrolladores de código abierto de élite del mundo [utilizan](https://tenderlovemaking.com/2016/02/05/i-am-a-puts-debuggerer.html) este [método](https://swizec.com/blog/javascript-debugging-slightly-beyond-consolelog/). + +#### Visual Studio Code + +El depurador de Visual Studio Code puede ser útil en algunas situaciones. Puedes iniciar la aplicación en modo de depuración de la siguiente manera (en esta y en las próximas imágenes, las notas tienen un campo _date_ que se ha eliminado de la versión actual de la aplicación): + +![captura de pantalla mostrando como ejecutar el depurador de vscode](../../images/3/35x.png) + +Ten en cuenta que la aplicación no debería ejecutarse en otra consola, de lo contrario, el puerto ya estará en uso. + +__NB__ Una versión más reciente de Visual Studio Code puede tener _Run_ en lugar de _Debug_. Además, es posible que debas configurar tu archivo _launch.json_ para comenzar a depurar. Esto se puede hacer eligiendo _Add Configuration..._ en el menú desplegable, que se encuentra junto al botón de reproducción verde y arriba del menú _VARIABLES_, y seleccionando _Run "npm start" in a debug terminal_. Para obtener instrucciones de configuración más detalladas, visita la [documentación de depuración](https://code.visualstudio.com/docs/editor/debugging) de Visual Studio Code. + +A continuación, puedes ver una captura de pantalla donde la ejecución del código se ha detenido a medio camino de guardar una nueva nota: + +![captura de pantalla de la ejecución de un breakpoint en vscode](../../images/3/36x.png) + +La ejecución se ha detenido en el breakpoint (punto de interrupción) de la línea 69. En la consola puedes ver el valor de la variable de note. En la ventana superior izquierda puede ver otras cosas relacionadas con el estado de la aplicación. + +Las flechas en la parte superior se pueden utilizar para controlar el flujo del depurador. + +Por alguna razón, no uso mucho el debugger de Visual Studio Code. + +#### Chrome dev tools + +La depuración también es posible con la consola de desarrollo de Chrome iniciando su aplicación con el comando: + +```bash +node --inspect index.js +``` + +También puedes pasar la bandera `--inspect` a `nodemon` + +```bash +nodemon --inspect index.js +``` + +Puedes acceder al depurador haciendo clic en el icono verde - el logotipo de node - que aparece en la consola de desarrollo de Chrome: + +![herramientas de desarrolladores con logotipo verde de node](../../images/3/37.png) + +La vista de depuración funciona de la misma manera que con las aplicaciones React. La pestaña Sources se puede usar para establecer breakpoints donde se pausará la ejecución del código. + +![pestaña "Sources" de las herramientas de desarrollo con breakpoint y variables de observación](../../images/3/38eb.png) + +Todos los mensajes console.log de la aplicación aparecerán en la pestaña Console del depurador. También puedes inspeccionar valores de variables y ejecutar tu propio código JavaScript. + +![pestaña "Consola" de las herramientas de desarrollo mostrando el objeto de nota escrito](../../images/3/39ea.png) + +#### Cuestionar todo + +Depurar aplicaciones Full Stack puede parecer complicado al principio. Pronto nuestra aplicación también tendrá una base de datos además del frontend y el backend, y habrá muchas áreas con potenciales errores en la aplicación. + +Cuando la aplicación "no funciona", primero tenemos que averiguar dónde ocurre realmente el problema. Es muy común que el problema exista en un lugar donde no lo esperabas, y pueden pasar minutos, horas o incluso días antes de que encuentres la fuente del problema. + +La clave es ser sistemático. Dado que el problema puede estar en cualquier lugar, debes cuestionarlo todo y eliminar todas las posibilidades una por una. El registro en la consola, Postman, los depuradores y la experiencia te ayudarán. + +Cuando ocurren errores, la peor de todas las estrategias posibles es continuar escribiendo código. Garantizará que tu código pronto tendrá aún más errores, y depurarlos será aún más difícil. El principio [Jidoka](https://blog.toyota-forklifts.es/jidoka-que-es) (detenerse y reparar) de Toyota Production Systems también es muy eficaz en esta situación. + +### MongoDB + +Para almacenar nuestras notas guardadas indefinidamente, necesitamos una base de datos. La mayoría de los cursos que se imparten en la Universidad de Helsinki utilizan bases de datos relacionales. En este curso usaremos [MongoDB](https://www.mongodb.com/), que es una [base de datos de documentos](https://es.wikipedia.org/wiki/Base_de_datos_documental). + +La razón para usar Mongo como la base de datos es su menor complejidad en comparación con una base de datos relacional. [La parte 13](/es/part13) del curso muestra cómo construir backends de Node.js que utilizan una base de datos relacional. + +Las bases de datos de documentos difieren de las bases de datos relacionales en cómo organizan los datos, así como en los lenguajes de consulta que admiten. Las bases de datos de documentos generalmente se clasifican bajo el término general [NoSQL](https://es.wikipedia.org/wiki/NoSQL). + +Puedes leer más sobre bases de datos de documentos y NoSQL en el material del curso de la [semana 7](https://tikape-s18.mooc.fi/part7/) del curso Introducción a las bases de datos. Lamentablemente, el material actualmente solo está disponible en finlandés. + +Lee ahora los capítulos sobre [colecciones](https://docs.mongodb.com/manual/core/databases-and-collections/) y [documentos](https://docs.mongodb.com/manual/core/document/) del manual de MongoDB para tener una idea básica de cómo una base de datos de documentos almacena datos. + +Naturalmente, puedes instalar y ejecutar MongoDB en tu propia computadora. Sin embargo, Internet también está lleno de servicios de base de datos de Mongo que puedes utilizar. Nuestro proveedor preferido de MongoDB en este curso será [MongoDB Atlas](https://www.mongodb.com/cloud/atlas). + +Una vez que hayas creado y accedido a tu cuenta, comencemos seleccionando la opción gratuita: + +![mongodb deploy a cloud database free shared](../../images/3/mongo1.png) + +Elige el proveedor de la nube y la ubicación, y crea el clúster: + +![mongodb picking shared, aws and region](../../images/3/mongo2.png) + +Esperemos a que el clúster esté listo para su uso. Esto puede llevar algunos minutos. + +**NB** No continúes antes de que el clúster esté listo. + +Usemos la pestaña security para crear credenciales de usuario para la base de datos. Ten en cuenta que estas no son las mismas credenciales que utilizas para iniciar sesión en MongoDB Atlas. Estas se usarán para que tu aplicación se conecte a la base de datos. + +![mongodb security quickstart](../../images/3/mongo3.png) + +A continuación, debemos definir las direcciones IP que tienen permitido el acceso a la base de datos. Por simplicidad, permitiremos el acceso desde todas las direcciones IP: + +![mongodb network access/add ip access list](../../images/3/mongo4.png) + +Nota: En caso de que el menú modal sea diferente para ti, según la documentación de MongoDB, agregar 0.0.0.0 como una IP permite el acceso desde cualquier lugar. + +Finalmente, estamos listos para conectarnos a nuestra base de datos. Comienza haciendo clic en connect: + +![mongodb database deployment connect](../../images/3/mongo5.png) + +y elige: Connect to your application: + +![mongodb connect application](../../images/3/mongo6.png) + +La vista muestra el MongoDB URI, que es la dirección de la base de datos que proporcionaremos a la librearía de cliente de MongoDB que agregaremos a nuestra aplicación. + +La dirección se ve así: + +```js +mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/?retryWrites=true&w=majority +``` + +Ahora estamos listos para usar la base de datos. + +Podríamos usar la base de datos directamente desde nuestro código JavaScript con la librería de [controladores oficial MongoDb Node.js](https://mongodb.github.io/node-mongodb-native/), pero es bastante engorroso de usar. En su lugar, usaremos la librería [Mongoose](http://mongoosejs.com/index.html) que ofrece una API de nivel superior. + +Mongoose podría describirse como un object document mapper (ODM) o mapeador de objetos a documentos en castellano, guardar objetos JavaScript como documentos en Mongo es sencillo con esta libería. + +Instalemos Mongoose: + +```bash +npm install mongoose +``` + +No agreguemos ningún código relacionado con Mongo a nuestro backend por el momento. En cambio, hagamos una aplicación de práctica creando un nuevo archivo, mongo.js en la raíz del backend de la aplicación de notas: + +```js +const mongoose = require('mongoose') + +if (process.argv.length<3) { + console.log('give password as argument') + process.exit(1) +} + +const password = process.argv[2] + +const url = + `mongodb+srv://fullstack:${password}@cluster0.o1opl.mongodb.net/?retryWrites=true&w=majority` + +mongoose.set('strictQuery',false) + +mongoose.connect(url) + +const noteSchema = new mongoose.Schema({ + content: String, + important: Boolean, +}) + +const Note = mongoose.model('Note', noteSchema) + +const note = new Note({ + content: 'HTML is easy', + important: true, +}) + +note.save().then(result => { + console.log('note saved!') + mongoose.connection.close() +}) +``` + +**NB:** Dependiendo de la región que seleccionaste al crear tu clúster, el MongoDB URI puede ser diferente al del ejemplo proporcionado anteriormente. Debes verificar y usar el URI correcto que se generó a partir de MongoDB Atlas. + +El código también asume que se le pasará la contraseña de las credenciales que creamos en MongoDB Atlas, como un parámetro de línea de comando. Podemos acceder al parámetro de la línea de comandos así: + +```js +const password = process.argv[2] +``` + +Cuando el código se ejecuta con el comando node mongo.js yourPassword, Mongo agregará un nuevo documento a la base de datos. + +**NB:** Ten en cuenta que la contraseña es la contraseña creada para el usuario de la base de datos, no su contraseña de MongoDB Atlas. Además, si creaste una contraseña con caracteres especiales, deberas [codificar esa contraseña en la URL](https://docs.atlas.mongodb.com/troubleshoot-connection/#special-characters-in-connection-string-password). + +Podemos ver el estado actual de la base de datos en MongoDB Atlas desde Browse collections, en la pestaña Database. + +![Botón para explorar colecciones en las bases de datos de MongoDB](../../images/3/mongo7.png) + +Según la vista, el documento que coincide con la nota se ha añadido a la colección notes en la base de datos myFirstDatabase. + +![Pestaña de colecciones de MongoDB en la base de datos myfirst app notes](../../images/3/mongo8new.png) + +Destruyamos la base de datos predeterminada test y cambiemos el nombre de la base de datos referenciada en nuestra cadena de conexión a noteApp, modificando la URI: + +```js +const url = + `mongodb+srv://fullstack:${password}@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority` +``` + +Ejecutemos nuestro código de nuevo. + +![Pestaña de colecciones de MongoDB en la base de datos noteApp con la colección notes](../../images/3/mongo9.png) + +Los datos ahora se almacenan en la base de datos correcta. La vista también ofrece la función de create database, que se puede utilizar para crear nuevas bases de datos desde el sitio web. No es necesario crear la base de datos de esta manera, ya que MongoDB Atlas crea automáticamente una nueva base de datos cuando una aplicación intenta conectarse a una base de datos que aún no existe. + +### Schema + +Después de establecer la conexión a la base de datos, definimos el [esquema](http://mongoosejs.com/docs/guide.html) para una nota y el [modelo](http://mongoosejs.com/docs/models.html) correspondiente: + +```js +const noteSchema = new mongoose.Schema({ + content: String, + important: Boolean, +}) + +const Note = mongoose.model('Note', noteSchema) +``` + +Primero definimos el [esquema](http://mongoosejs.com/docs/guide.html) de una nota que se almacena en la variable _noteSchema_. El esquema le dice a Mongoose cómo se almacenarán los objetos de nota en la base de datos. + +En la definición del modelo _Note_, el primer parámetro de "Note" es el nombre singular del modelo. El nombre de la colección será el plural notes en minúsculas, porque la [convención de Mongoose](http://mongoosejs.com/docs/models.html) es nombrar automáticamente las colecciones como el plural (por ejemplo, notes) cuando el esquema se refiere a ellas en singular (por ejemplo, Note). + +Las bases de datos de documentos como Mongo no tienen esquema, lo que significa que la base de datos en sí no se preocupa por la estructura de los datos que se almacenan en la base de datos. Es posible almacenar documentos con campos completamente diferentes en la misma colección. + +La idea detrás de Mongoose es que los datos almacenados en la base de datos reciben un esquema al nivel de la aplicación que define la forma de los documentos almacenados en una colección determinada. + +### Crear y guardar objetos + +A continuación, la aplicación crea un nuevo objeto de nota con la ayuda del [modelo](http://mongoosejs.com/docs/models.html) Note: + +```js +const note = new Note({ + content: 'HTML is Easy', + important: false, +}) +``` + +Los modelos son funciones constructoras que crean nuevos objetos JavaScript basados ​​en los parámetros proporcionados. Dado que los objetos se crean con la función constructora del modelo, tienen todas las propiedades del modelo, que incluyen métodos para guardar el objeto en la base de datos. + +Guardar el objeto en la base de datos ocurre con el método _save_, que se puede proporcionar con un controlador de eventos con el método _then_: + +```js +note.save().then(result => { + console.log('note saved!') + mongoose.connection.close() +}) +``` + +Cuando el objeto se guarda en la base de datos, el controlador de eventos proporcionado a _then_ se invoca. El controlador de eventos cierra la conexión de la base de datos con el comando mongoose.connection.close(). Si la conexión no se cierra, el programa nunca terminará su ejecución. + +El resultado de la operación de guardar está en el parámetro _result_ del controlador de eventos. El resultado no es tan interesante cuando almacenamos un objeto en la base de datos. Puedes imprimir el objeto en la consola si deseas verlo más de cerca mientras implementas tu aplicación o durante la depuración. + +Guardemos también algunas notas más modificando los datos en el código y ejecutando el programa nuevamente. + +**NB:** Desafortunadamente, la documentación de Mongoose no es muy consistente, con partes de ella usando callbacks en sus ejemplos y otras partes, otros estilos, por lo que no se recomienda copiar y pegar código directamente desde allí. No se recomienda mezclar promesas con callbacks de la vieja escuela en el mismo código. + +### Obteniendo objetos de la base de datos + +Comentemos el código para generar nuevas notas y reemplázalo con lo siguiente: + +```js +Note.find({}).then(result => { + result.forEach(note => { + console.log(note) + }) + mongoose.connection.close() +}) +``` + +Cuando se ejecuta el código, el programa imprime todas las notas almacenadas en la base de datos: + +![salida de notes como JSON al ejecutar el comando node mongo.js](../../images/3/70new.png) + +Los objetos se recuperan de la base de datos con el método [find](https://mongoosejs.com/docs/api.html#model_Model.find) del modelo _Note_. El parámetro del método es un objeto que expresa condiciones de búsqueda. Dado que el parámetro es un objeto vacío {}, obtenemos todas las notas almacenadas en la colección _notes_. + +Las condiciones de búsqueda se adhieren a la [sintaxis](https://docs.mongodb.com/manual/reference/operator/) de consulta de búsqueda de Mongo. + +Podríamos restringir nuestra búsqueda para incluir solo notas importantes de la siguiente manera: + +```js +Note.find({ important: true }).then(result => { + // ... +}) +``` + +
    + +
    + +### Ejercicio 3.12. + +#### 3.12: Base de datos de línea de comandos + +Crea una base de datos MongoDB basada en la nube para la aplicación de agenda telefónica con MongoDB Atlas. + +Crea un archivo mongo.js en el directorio del proyecto, que se puede usar para agregar entradas a la agenda y para enumerar todas las entradas existentes en la agenda. + +**NB:** ¡No incluyas la contraseña en el archivo que subes a GitHub! + +La aplicación debería funcionar de la siguiente manera. Utiliza el programa pasando tres argumentos de línea de comando (el primero es la contraseña), por ejemplo: + +```bash +node mongo.js yourpassword Anna 040-1234556 +``` + +Como resultado, la aplicación imprimirá: + +```bash +added Anna number 040-1234556 to phonebook +``` + +La nueva entrada a la agenda telefónica se guardará en la base de datos. Ten en cuenta que si el nombre contiene espacios en blanco, debe ir entre comillas: + +```bash +node mongo.js yourpassword "Arto Vihavainen" 045-1232456 +``` + +Si la contraseña es el único parámetro dado al programa, lo que significa que se invoca así: + +```bash +node mongo.js yourpassword +``` + +Entonces el programa debería mostrar todas las entradas en la agenda: + +``` +phonebook: +Anna 040-1234556 +Arto Vihavainen 045-1232456 +Ada Lovelace 040-1231236 +``` + +Puedes obtener los parámetros de la línea de comandos de la variable [process.argv](https://nodejs.org/docs/latest-v18.x/api/process.html#process_process_argv). + +**NB: no cierres la conexión en el lugar incorrecto**. Por ejemplo, el siguiente código no funcionará: + +```js +Person + .find({}) + .then(persons=> { + // ... + }) + +mongoose.connection.close() +``` + +En el código anterior, el comando mongoose.connection.close() se ejecutará inmediatamente después de que se inicie la operación Person.find. Esto significa que la conexión a la base de datos se cerrará inmediatamente y la ejecución nunca llegará al punto en el que finalice la operación Person.find y se llame a la función callback. + +El lugar correcto para cerrar la conexión de la base de datos es al final de la función callback: + +```js +Person + .find({}) + .then(persons=> { + // ... + mongoose.connection.close() + }) +``` + +**NB:** Si defines un modelo con el nombre Person, mongoose nombrará automáticamente la colección asociada como people. + +
    + +
    + +### Backend conectado a una base de datos + +Ahora tenemos suficiente conocimiento para comenzar a usar Mongo en nuestra aplicación. + +Comencemos rápidamente copiando y pegando las definiciones de Mongoose en el archivo index.js: + +```js +const mongoose = require('mongoose') + +const password = process.argv[2] + +// DO NOT SAVE YOUR PASSWORD TO GITHUB!! +const url = + `mongodb+srv://fullstack:${password}@cluster0.o1opl.mongodb.net/?retryWrites=true&w=majority` + +mongoose.set('strictQuery',false) +mongoose.connect(url) + +const noteSchema = new mongoose.Schema({ + content: String, + important: Boolean, +}) + +const Note = mongoose.model('Note', noteSchema) +``` + +Cambiemos el controlador para obtener todas las notas al siguiente formato: + +```js +app.get('/api/notes', (request, response) => { + Note.find({}).then(notes => { + response.json(notes) + }) +}) +``` + +Podemos verificar en el navegador que el backend funciona para mostrar todos los documentos: + +![api/notes en el navegador muestra notas en JSON](../../images/3/44ea.png) + +La aplicación funciona casi a la perfección. El frontend asume que cada objeto tiene un id único en el campo de id. Tampoco queremos retornar el campo de control de versiones de mongo \_\_v al frontend. + +Una forma de formatear los objetos devueltos por Mongoose es [modificar](https://stackoverflow.com/questions/7034848/mongodb-output-id-instead-of-id) el método _toJSON_ del esquema, que se utiliza en todas las instancias de los modelos producidos con ese esquema. + +Para modificar el método, necesitamos cambiar las opciones configurables del esquema. Las opciones se pueden cambiar utilizando el método set del esquema. Consulta aquí para obtener más información sobre este método: https://mongoosejs.com/docs/guide.html#options. Consulta y para obtener más información sobre la opción _toJSON_. + +Consulta para obtener más información sobre la función _transform_. + +```js +noteSchema.set('toJSON', { + transform: (document, returnedObject) => { + returnedObject.id = returnedObject._id.toString() + delete returnedObject._id + delete returnedObject.__v + } +}) +``` + +Aunque la propiedad \_id de los objetos Mongoose parece un string, de hecho es un objeto. El método _toJSON_ que definimos lo transforma en un string solo para estar seguros. Si no hiciéramos este cambio, nos causaría más daño en el futuro una vez que comencemos a escribir pruebas. + +No es necesario hacer cambios en el controlador: + +```js +app.get('/api/notes', (request, response) => { + Note.find({}).then(notes => { + response.json(notes) + }) +}) +``` + +El código utiliza automáticamente el _toJSON_ definido al formatear las notas para la respuesta. + +### Moviendo la configuración de la base de datos a su propio módulo + +Antes de refactorizar el resto del backend para usar la base de datos, extraigamos el código específico de Mongoose en su propio módulo. + +Creemos un nuevo directorio para el módulo llamado models y agreguemos un archivo llamado note.js: + +```js +const mongoose = require('mongoose') + +mongoose.set('strictQuery', false) + +const url = process.env.MONGODB_URI // highlight-line + +console.log('connecting to', url) // highlight-line + +mongoose.connect(url) +// highlight-start + .then(result => { + console.log('connected to MongoDB') + }) + .catch(error => { + console.log('error connecting to MongoDB:', error.message) + }) +// highlight-end + +const noteSchema = new mongoose.Schema({ + content: String, + important: Boolean, +}) + +noteSchema.set('toJSON', { + transform: (document, returnedObject) => { + returnedObject.id = returnedObject._id.toString() + delete returnedObject._id + delete returnedObject.__v + } +}) + +module.exports = mongoose.model('Note', noteSchema) // highlight-line +``` + +La definición de [módulos](https://nodejs.org/docs/latest-v18.x/api/modules.html) de Node difiere ligeramente de la forma de definir [módulos ES6](/es/part2/renderizando_una_coleccion_modulos#refactorizando-modulos) en la parte 2. + +La interfaz pública del módulo se define estableciendo un valor en la variable _module.exports_. Estableceremos el valor para que sea el modelo Note. Las otras cosas definidas dentro del módulo, como las variables _mongoose_ y _url_, no serán accesibles ni visibles para los usuarios del módulo. + +La importación del módulo ocurre agregando la siguiente línea a index.js : + +```js +const Note = require('./models/note') +``` + +De esta forma la variable _Note_ se asignará al mismo objeto que defina el módulo. + +La forma en que se realiza la conexión ha cambiado ligeramente: + +```js +const url = process.env.MONGODB_URI + +console.log('connecting to', url) + +mongoose.connect(url) + .then(result => { + console.log('connected to MongoDB') + }) + .catch(error => { + console.log('error connecting to MongoDB:', error.message) + }) +``` + +No es una buena idea codificar la dirección de la base de datos en el código, por lo que la dirección de la base de datos se pasa a la aplicación a través de la variable de entorno MONGODB_URI. + +El método para establecer la conexión ahora tiene funciones para lidiar con un intento de conexión exitoso y no exitoso. Ambas funciones simplemente registran un mensaje en la consola sobre el estado de éxito: + +![salida de node cuando se pasa username/password erroneo](../../images/3/45e.png) + +Hay muchas formas de definir el valor de una variable de entorno. Una forma sería definirlo cuando se inicia la aplicación: + +```bash +MONGODB_URI=address_here npm run dev +``` + +Una forma más sofisticada es utilizar la librería [dotenv](https://github.com/motdotla/dotenv#readme). Puedes instalar la librería con el comando: + +```bash +npm install dotenv +``` + +Para usar la librería, creamos un archivo .env en la raíz del proyecto. Las variables de entorno se definen dentro del archivo y pueden verse así: + +```bash +MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority +PORT=3001 +``` + +También agregamos el puerto codificado del servidor en la variable de entorno PORT. + +**El archivo .env debe ignorarse de inmediato en .gitignore, ¡ya que no queremos publicar ninguna información confidencial públicamente!** + +![.gitignore en vscode con .env añadido](../../images/3/45ae.png) + +Las variables de entorno definidas en el archivo .env se pueden utilizar con la expresión require('dotenv').config() y puedes referenciarlas en tu código como lo harías con las variables de entorno normales, con la sintaxis process.env.MONGODB_URI. + +Cambiemos el archivo index.js de la siguiente manera: + +```js +require('dotenv').config() // highlight-line +const express = require('express') +const app = express() +const Note = require('./models/note') // highlight-line + +// .. + +const PORT = process.env.PORT // highlight-line +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +``` + +Es importante que dotenv se importe antes de importar el modelo note. Esto asegura que las variables de entorno del archivo .env estén disponibles globalmente antes de que se importe el código de los otros módulos. + +### Nota importante para usuarios de Fly.io + +Debido a que GitHub no se utiliza con Fly.io, el archivo .env también se copia a los servidores de Fly.io cuando se despliega la aplicación. Debido a esto, las variables de entorno definidas en el archivo estarán disponibles allí. + +Sin embargo, una [mejor opción](https://community.fly.io/t/clarification-on-environment-variables/6309) es evitar que .env se copie a Fly.io creando en la raíz del proyecto el archivo _.dockerignore_, con el siguiente contenido: + +```bash +.env +``` + +y estableciendo el valor de la variable de entorno desde la línea de comandos con el comando: + +```bash +fly secrets set MONGODB_URI="mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority" +``` + +Dado que PORT también está definido en nuestro archivo .env, es esencial ignorar el archivo en Fly.io, ya que de lo contrario, la aplicación se inicia en el puerto incorrecto. + +Al utilizar Render, la URL de la base de datos se proporciona definiendo la variable de entorno adecuada en el panel de control: + +![navegador mostrando variables de entorno de Render](../../images/3/render-env.png) + +Solo establece a la URL comenzando con mongodb+srv://... al campo _value_. + +### Usando la base de datos en los controladores de ruta + +A continuación, cambiemos el resto de la funcionalidad del backend para usar la base de datos. + +La creación de una nueva nota se logra así: + +```js +app.post('/api/notes', (request, response) => { + const body = request.body + + if (body.content === undefined) { + return response.status(400).json({ error: 'content missing' }) + } + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + + note.save().then(savedNote => { + response.json(savedNote) + }) +}) +``` + +Los objetos de nota se crean con la función de constructor _Note_. La respuesta se envía dentro de la función callback para la operación _save_. Esto asegura que la respuesta se envíe solo si la operación se realizó correctamente. Discutiremos el manejo de errores un poco más adelante. + +El parámetro _savedNote_ en la función callback es la nota guardada y recién creada. Los datos devueltos en la respuesta son la versión formateada creada con el método _toJSON_ : + +```js +response.json(savedNote) +``` + +Usando el método [findById](https://mongoosejs.com/docs/api/model.html#model_Model-findById) de Mongoose, la obtención de una nota individual se cambia a lo siguiente: + +```js +app.get('/api/notes/:id', (request, response) => { + Note.findById(request.params.id).then(note => { + response.json(note) + }) +}) +``` + +### Verificación de la integración de frontend y backend + +Cuando el backend se expande, es una buena idea probar el backend primero con **el navegador, Postman o el cliente REST de VS Code**. A continuación, intentemos crear una nueva nota después de utilizar la base de datos: + +![VS code cliente rest haciendo un post](../../images/3/46new.png) + +Solo una vez que se haya verificado que todo funciona en el backend, es una buena idea probar que el frontend funciona con el backend. Es muy ineficiente probar cosas exclusivamente a través del frontend. + +Probablemente sea una buena idea integrar el frontend y el backend una funcionalidad a la vez. Primero, podríamos implementar la búsqueda de todas las notas de la base de datos y probarlas a través del endpoint de backend en el navegador. Después de esto, podríamos verificar que el frontend funciona con el nuevo backend. Una vez que todo parezca funcionar, pasaríamos a la siguiente funcionalidad. + +Una vez que introducimos una base de datos en la mezcla, es útil inspeccionar el estado persistente en la base de datos, por ejemplo, desde el panel de control en MongoDB Atlas. Muy a menudo, los pequeños programas auxiliares de Node como el programa mongo.js que escribimos anteriormente pueden ser muy útiles durante el desarrollo. + +Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part3-4 de [este repositorio de GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-4). + +
    + +
    + +### Ejercicios 3.13.-3.14. + +Los siguientes ejercicios son bastante sencillos, pero si tu frontend deja de funcionar con el backend, entonces encontrar y corregir los errores puede ser bastante interesante. + +#### 3.13: Base de datos de la Agenda Telefónica, paso 1 + +Cambia la búsqueda de todas las entradas de la agenda telefónica para que los datos se obtengan desde la base de datos. + +Verifica que el frontend funcione después de que se hayan realizado los cambios. + +En los siguientes ejercicios, escribe todo el código específico de Mongoose en su propio módulo, como hicimos en el capítulo [Configuración de la base de datos en su propio módulo](/es/part3/guardando_datos_en_mongo_db#moviendo-la-configuracion-de-la-base-de-datos-a-su-propio-modulo). + +#### 3.14: Base de datos de la Agenda Telefónica, paso 2 + +Cambia el backend para que los nuevos números se guarden en la base de datos. Verifica que tu frontend aún funcione después de los cambios. + +En esta etapa, puedes ignorar si ya existe una persona en la base de datos con el mismo nombre que la persona que estás agregando. + +
    + +
    + +### Manejo de errores + +Si intentamos visitar la URL de una nota con un id que en realidad no existe, por ejemplo, donde 5c41c90e84d891c15dfa3431 no es un id almacenado en la base de datos, entonces la respuesta será _null_. + +Cambiemos este comportamiento para que si la nota con la identificación dada no existe, el servidor responderá a la solicitud con el código de estado HTTP 404 not found. Además, implementemos un bloque catch sencillo para manejar los casos en los que la promesa devuelta por el método findById es rechazada: + +```js +app.get('/api/notes/:id', (request, response) => { + Note.findById(request.params.id) + .then(note => { + // highlight-start + if (note) { + response.json(note) + } else { + response.status(404).end() + } + // highlight-end + }) + // highlight-start + .catch(error => { + console.log(error) + response.status(500).end() + }) + // highlight-end +}) +``` + +Si no se encuentra ningún objeto coincidente en la base de datos, el valor de _note_ será _null_ y se ejecutará el bloque _else_. Esto da como resultado una respuesta con el código de estado 404 not found. Si se rechaza la promesa retornada por el método findById, la respuesta tendrá el código de estado 500 internal server error. La consola muestra información más detallada sobre el error. + +Además de la nota que no existe, hay una situación de error más que debe manejarse. En esta situación, estamos intentando obtener una nota con un tipo de _id_ incorrecto , es decir, un _id_ que no coincide con el formato del identificador de Mongo. + +Si realizamos la siguiente solicitud, obtendremos el mensaje de error que se muestra a continuación: + +``` +Method: GET +Path: /api/notes/someInvalidId +Body: {} +--- +{ CastError: Cast to ObjectId failed for value "someInvalidId" at path "_id" + at CastError (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/error/cast.js:27:11) + at ObjectId.cast (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/schema/objectid.js:158:13) + ... +``` + +Dado un ID mal formado como argumento, el método findById arrojará un error que provocará el rechazo de la promesa retornada. Esto hará que se llame a la función callback definida en el bloque catch. + +Hagamos algunos pequeños ajustes a la respuesta en el bloque catch: + +```js +app.get('/api/notes/:id', (request, response) => { + Note.findById(request.params.id) + .then(note => { + if (note) { + response.json(note) + } else { + response.status(404).end() + } + }) + .catch(error => { + console.log(error) + response.status(400).send({ error: 'malformatted id' }) // highlight-line + }) +}) +``` + +Si el formato del id es incorrecto, terminaremos en el controlador de errores definido en el bloque _catch_. El código de estado apropiado para la situación es [400 Bad Request](https://www.rfc-editor.org/rfc/rfc9110.html#name-400-bad-request), porque la situación se ajusta perfectamente a la descripción: + +> El código de estado 400 (Solicitud incorrecta) indica que el servidor no puede o no procesará la solicitud debido a algo que se percibe como un error del cliente (por ejemplo, sintaxis de solicitud incorrecta, formato de mensaje de solicitud inválido o enrutamiento de solicitud engañoso). + +También hemos agregado algunos datos a la respuesta para arrojar algo de luz sobre la causa del error. + +Cuando se trata de Promesas, casi siempre es una buena idea agregar el manejo de errores y excepciones, porque de lo contrario te encontraras lidiando con errores extraños. + +Nunca es una mala idea imprimir el objeto que causó la excepción a la consola en el controlador de errores: + +```js +.catch(error => { + console.log(error) // highlight-line + response.status(400).send({ error: 'malformatted id' }) +}) +``` + +La razón por la que se llama al controlador de errores puede ser algo completamente diferente de lo que habías anticipado. Si registras el error en la consola, puedes ahorrarte largas y frustrantes sesiones de depuración. Además, la mayoría de los servicios modernos en los que despliegas tu aplicación admiten algún tipo de sistema de registro que puedes usar para verificar estos registros. Como se mencionó, Fly.io es uno. + +Cada vez que trabajas en un proyecto con un backend, es fundamental estar atento a la salida de la consola del backend. Si estás trabajando en una pantalla pequeña, basta con ver una pequeña porción de la salida en segundo plano. Cualquier mensaje de error llamará tu atención incluso cuando la consola esté muy atrás en segundo plano: + +![captura de pantalla mostrando trozo pequeño de salida de consola](../../images/3/15b.png) + +### Mover el manejo de errores al middleware + +Hemos escrito el código para el controlador de errores entre el resto de nuestro código. Esta puede ser una solución razonable a veces, pero hay casos en los que es mejor implementar todo el manejo de errores en un solo lugar. Esto puede ser particularmente útil si más adelante queremos reportar datos relacionados con errores a un sistema de seguimiento de errores externo como [Sentry](https://sentry.io/welcome/). + +Cambiemos el controlador de la ruta /api/notes/:id, para que pase el error hacia adelante con la función next. La función next se pasa al controlador como tercer parámetro: + +```js +app.get('/api/notes/:id', (request, response, next) => { // highlight-line + Note.findById(request.params.id) + .then(note => { + if (note) { + response.json(note) + } else { + response.status(404).end() + } + }) + .catch(error => next(error)) // highlight-line +}) +``` + +El error que se pasa hacia adelante es dado a la función next como parámetro. Si se llamó a next sin un argumento, entonces la ejecución simplemente pasaría a la siguiente ruta o middleware. Si se llama a la función next con un argumento, la ejecución continuará en el middleware del controlador de errores. + +Los [controladores de errores](https://expressjs.com/en/guide/error-handling.html) de Express son middleware que se definen con una función que acepta cuatro parámetros. Nuestro controlador de errores se ve así: + +```js +const errorHandler = (error, request, response, next) => { + console.error(error.message) + + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } + + next(error) +} + +// este debe ser el último middleware cargado, ¡también todas las rutas deben ser registrada antes que esto! +app.use(errorHandler) +``` + +El controlador de errores comprueba si el error es una excepción CastError, en cuyo caso sabemos que el error fue causado por un ID de objeto no válido para Mongo. En esta situación, el controlador de errores enviará una respuesta al navegador con el objeto de respuesta pasado como parámetro. En todas las demás situaciones de error, el middleware pasa el error al controlador de errores Express predeterminado. + +Ten en cuenta que el middleware de manejo de errores debe ser el último middleware cargado, también todas las rutas deben registrarse antes que el error-handler! + +### El orden de carga del middleware + +El orden de ejecución del middleware es el mismo que el orden en el que se cargan en Express con la función _app.use_. Por esta razón, es importante tener cuidado al definir el middleware. + +El orden correcto es el siguiente: + +```js +app.use(express.static('build')) +app.use(express.json()) +app.use(logger) + +app.post('/api/notes', (request, response) => { + const body = request.body + // ... +}) + +const unknownEndpoint = (request, response) => { + response.status(404).send({ error: 'unknown endpoint' }) +} + +// controlador de solicitudes con endpoint desconocido +app.use(unknownEndpoint) + +const errorHandler = (error, request, response, next) => { + // ... +} + +// controlador de solicitudes que resulten en errores +app.use(errorHandler) +``` + +El middleware json-parser debería estar entre los primeros middleware cargados en Express. Si el orden fuera el siguiente: + +```js +app.use(logger) // request.body es undefined! + +app.post('/api/notes', (request, response) => { + // request.body es undefined! + const body = request.body + // ... +}) + +app.use(express.json()) +``` + +Entonces, los datos JSON enviados con las solicitudes HTTP no estarían disponibles para el middleware del registrador o el controlador de ruta POST, ya que _request.body_ estaría _undefined_ en ese punto. + +También es importante que el middleware para manejar rutas no admitidas esté junto al último middleware que se cargó en Express, justo antes del controlador de errores. + +Por ejemplo, el siguiente orden de carga causaría un problema: + +```js +const unknownEndpoint = (request, response) => { + response.status(404).send({ error: 'unknown endpoint' }) +} + +// controlador de solicitudes con endpoint desconocido +app.use(unknownEndpoint) + +app.get('/api/notes', (request, response) => { + // ... +}) +``` + +Ahora, el manejo de los endpoints desconocidos se ordena antes que el controlador de solicitudes HTTP. Dado que el controlador de endpoint desconocido responde a todas las solicitudes con 404 unknown endpoint, no se llamará a ninguna ruta o middleware después de que el middleware de endpoint desconocido haya enviado la respuesta. La única excepción a esto es el controlador de errores que debe estar al final, después del controlador de endpoints desconocido. + +### Otras operaciones + +Agreguemos algunas funcionalidades que faltan a nuestra aplicación, incluida la eliminación y actualización de una nota individual. + +La forma más fácil de eliminar una nota de la base de datos es con el método [findByIdAndDelete](https://mongoosejs.com/docs/api/model.html#Model.findByIdAndDelete()): + +```js +app.delete('/api/notes/:id', (request, response, next) => { + Note.findByIdAndDelete(request.params.id) + .then(result => { + response.status(204).end() + }) + .catch(error => next(error)) +}) +``` + +En los dos casos "exitosos" de eliminar un recurso, el backend responde con el código de estado 204 no content. Los dos casos diferentes son eliminar una nota que existe y eliminar una nota que no existe en la base de datos. El parámetro callback _result_ podría usarse para verificar si un recurso realmente se eliminó, y podríamos usar esa información para devolver códigos de estado diferentes para los dos casos si lo consideramos necesario. Cualquier excepción que ocurra se pasa al controlador de errores. + +El cambio de la importancia de una nota se puede lograr fácilmente con el método [findByIdAndUpdate](https://mongoosejs.com/docs/api/model.html#model_Model-findByIdAndUpdate). + +```js +app.put('/api/notes/:id', (request, response, next) => { + const body = request.body + + const note = { + content: body.content, + important: body.important, + } + + Note.findByIdAndUpdate(request.params.id, note, { new: true }) + .then(updatedNote => { + response.json(updatedNote) + }) + .catch(error => next(error)) +}) +``` + +En el código anterior, también permitimos que se edite el contenido de la nota. + +Observa que el método findByIdAndUpdate recibe un objeto JavaScript normal como argumento, y no un nuevo objeto de nota creado con la función constructora Note. + +Hay un detalle importante con respecto al uso del método findByIdAndUpdate. De forma predeterminada, el parámetro updatedNote del controlador de eventos recibe el documento original [sin las modificaciones](https://mongoosejs.com/docs/api/model.html#model_Model-findByIdAndUpdate). Agregamos el parámetro opcional { new: true }, que hará que nuestro controlador de eventos sea llamado con el nuevo documento modificado en lugar del original. + +Después de probar el backend directamente con Postman o el cliente REST de VS Code, podemos verificar que parece funcionar. El frontend también parece funcionar con el backend usando la base de datos. + +Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part3-5 de [este repositorio de GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-5). + +### Un verdadero juramento de desarrollador full stack + +Una vez más, es tiempo para los ejercicios. La complejidad de nuestra aplicación ha dado otro paso, ya que ahora, además del frontend y el backend, también tenemos una base de datos. +Realmente hay muchas fuentes potenciales de errores. + +Así que debemos extender una vez más nuestro juramento: + +El desarrollo full stack es extremadamente difícil, por eso utilizaré todos los medios posibles para facilitarlo. + +- Mantendré la consola de desarrollador del navegador abierta todo el tiempo. +- Utilizaré la pestaña de red de las herramientas de desarrollo del navegador para asegurarme de que el frontend y el backend estén comunicándose como espero. +- Vigilaré constantemente el estado del servidor para asegurarme de que los datos enviados por el frontend se guarden allí como espero. +- Observaré la base de datos: ¿guarda el backend los datos allí en el formato correcto? +- Progresaré con pequeños pasos. +- Escribiré muchas declaraciones de _console.log_ para asegurarme de entender cómo se comporta el código y ayudar a señalar problemas. +- Si mi código no funciona, no escribiré más código. En su lugar, comenzaré a eliminar código hasta que funcione o simplemente regresaré a un estado en el que todo aún funcionaba. +- Cuando pida ayuda en el canal de Discord del curso o en cualquier otro lugar, formularé mis preguntas correctamente, consulta [aquí](https://fullstackopen.com/en/part0/general_info#how-to-get-help-in-discord) cómo pedir ayuda. + +
    + +
    + +### Ejercicios 3.15.-3.18. + +#### 3.15: Base de datos de la Agenda Telefónica, paso 3 + +Cambia el backend para que la eliminación de entradas de la agenda telefónica se refleje en la base de datos. + +Verifica que el frontend aún funcione después de realizar los cambios. + +#### 3.16: Base de datos de la Agenda Telefónica, paso 4 + +Mueve el manejo de errores de la aplicación a un nuevo middleware de manejo de errores. + +#### 3.17*: Base de datos de la Agenda Telefónica, paso 5 + +Si el usuario intenta crear una nueva entrada en la agenda para una persona cuyo nombre ya está en la agenda, el frontend intentará actualizar el número de teléfono de la entrada existente realizando una solicitud HTTP PUT a la URL única de la entrada. + +Modifica el backend para admitir esta solicitud. + +Verifica que el frontend funcione después de realizar los cambios. + +#### 3.18*: Base de datos de la Agenda Telefónica, paso 6 + +También actualiza el manejo de las rutas api/persons/:id e info para usar la base de datos, y verifica que funcionen directamente con el navegador, Postman o el cliente REST de VS Code. + +La inspección de una entrada individual de la agenda telefónica desde el navegador debería verse así: + +![navegador mostrando los datos de una persona en la ruta api/persons/id](../../images/3/49.png) + +
    diff --git a/src/content/3/es/part3d.md b/src/content/3/es/part3d.md new file mode 100644 index 00000000000..c4b59b68bf9 --- /dev/null +++ b/src/content/3/es/part3d.md @@ -0,0 +1,409 @@ +--- +mainImage: ../../../images/part-3.svg +part: 3 +letter: d +lang: es +--- + +
    + +Por lo general, existen restricciones que queremos aplicar a los datos que se almacenan en la base de datos de nuestra aplicación. Nuestra aplicación no debe aceptar notas que tengan una propiedad content vacía o faltante. La validez de la nota se comprueba en el controlador de ruta: + +```js +app.post('/api/notes', (request, response) => { + const body = request.body + // highlight-start + if (body.content === undefined) { + return response.status(400).json({ error: 'content missing' }) + } + // highlight-end + + // ... +}) +``` + +Si la nota no tiene la propiedad content, respondemos a la solicitud con el código de estado 400 bad request. + +Una forma más inteligente de validar el formato de los datos antes de que se almacenen en la base de datos es utilizar la funcionalidad de [validación](https://mongoosejs.com/docs/validation.html) disponible en Mongoose. + +Podemos definir reglas de validación específicas para cada campo en el esquema: + +```js +const noteSchema = new mongoose.Schema({ + // highlight-start + content: { + type: String, + minLength: 5, + required: true + }, + // highlight-end + important: Boolean +}) +``` + +El campo content ahora requiere tener al menos cinco caracteres de longitud y esta definido como required, lo que significa que no puede faltar. No hemos agregado ninguna restricción al campo important, por lo que su definición en el esquema no ha cambiado. + +Los validadores minlength y required están [integrados](https://mongoosejs.com/docs/validation.html#built-in-validators) y proporcionados por Mongoose. La funcionalidad del [validador personalizado](https://mongoosejs.com/docs/validation.html#custom-validators) de Mongoose nos permite crear nuevos validadores, si ninguno de los integrados cubre nuestras necesidades. + +Si intentamos almacenar un objeto en la base de datos que rompe una de las restricciones, la operación lanzará una excepción. Cambiemos nuestro controlador para crear una nueva nota para que pase las posibles excepciones al middleware del controlador de errores: + +```js +app.post('/api/notes', (request, response, next) => { // highlight-line + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + + note.save() + .then(savedNote => { + response.json(savedNote) + }) + .catch(error => next(error)) // highlight-line +}) +``` + +Expandamos el controlador de errores para tratar estos errores de validación: + +```js +const errorHandler = (error, request, response, next) => { + console.error(error.message) + + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } else if (error.name === 'ValidationError') { // highlight-line + return response.status(400).json({ error: error.message }) // highlight-line + } + + next(error) +} +``` + +Cuando falla la validación de un objeto, devolvemos el siguiente mensaje de error predeterminado de Mongoose: + +![postman mostrando mensaje de error](../../images/3/50.png) + +Notamos que el backend ahora tiene un problema: las validaciones no se realizan al editar una nota. +La [documentación](https://mongoosejs.com/docs/validation.html#update-validators) aborda el problema explicando que las validaciones no se ejecutan por defecto cuando se utilizan los métodos findOneAndUpdate y métodos relacionados. + +La solución es sencilla. También reformulemos un poco el código de la ruta: + +```js +app.put('/api/notes/:id', (request, response, next) => { + const { content, important } = request.body // highlight-line + + Note.findByIdAndUpdate( + request.params.id, + { content, important }, // highlight-line + { new: true, runValidators: true, context: 'query' } // highlight-line + ) + .then(updatedNote => { + response.json(updatedNote) + }) + .catch(error => next(error)) +}) +``` + +### Desplegando el backend con base de datos a producción + +La aplicación debería funcionar casi tal como está en Fly.io/Render. No necesitamos generar una nueva versión de producción del frontend, ya que los cambios realizados hasta ahora solo afectan a nuestro backend. + +Las variables de entorno definidas en dotenv solo se usarán cuando el backend no esté en modo de producción, es decir, en Fly.io o Render. + +Para producción, debemos establecer la URL de la base de datos en el servicio que está alojando nuestra aplicación. + +En Fly.io se hace con _fly secrets set_: + +```bash +fly secrets set MONGODB_URI='mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority' +``` + +Cuando la aplicación está en desarrollo, es muy probable que algo falle. Por ejemplo, cuando desplegué mi aplicación por primera vez con la base de datos, no se veía ni una sola nota: + +![navegador sin notas](../../images/3/fly-problem1.png) + +La pestaña de red de la consola del navegador reveló que la obtención de las notas no tuvo éxito; la solicitud permaneció en estado _pendiente_ durante mucho tiempo hasta que falló con un código de estado 502. + +¡La consola del navegador debe estar abierta todo el tiempo! + +También es vital seguir continuamente los registros del servidor. El problema se hizo evidente cuando se abrieron los registros con _fly logs_: + +![registro del servidor fly.io mostrando conexión a undefined](../../images/3/fly-problem3.png) + +La URL de la base de datos era _undefined_, por lo que el comando *fly secrets set MONGODB\_URI* no fue utilizado. + +También necesitarás agregar a la whitelist (lista blanca) la dirección IP de la aplicación de fly.io en MongoDB Atlas. Si no lo haces, MongoDB rechazará la conexión. + +Lamentablemente, fly.io no te proporciona una dirección IPv4 dedicada para tu aplicación, por lo que necesitarás permitir todas las direcciones IP en MongoDB Atlas. + +Cuando se utiliza Render, la URL de la base de datos se proporciona definiendo la variable de entorno adecuada en el panel de control: + +![Render Dashboard mostrando la variable de entorno MONGODB_URI](../../images/3/render-env.png) + +El panel de control de Render muestra los registros del servidor: + +![Render Dashboard con flecha apuntando al servidor en ejecución en el puerto 10000](../../images/3/r7.png) + +Puedes encontrar el código de nuestra aplicación actual en su totalidad en la rama part3-6 de [este repositorio de GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-6). + +
    + +
    + +### Ejercicios 3.19.-3.21. + +#### 3.19*: Base de datos de la Agenda Telefónica, paso 7 + +Amplía la validación para que el nombre almacenado en la base de datos tenga al menos tres caracteres de longitud. + +Expande el frontend para que muestre algún tipo de mensaje de error cuando ocurra un error de validación. El manejo de errores se puede implementar agregando un bloque catch como se muestra a continuación: + +```js +personService + .create({ ... }) + .then(createdPerson => { + // ... + }) + .catch(error => { + // está es la forma de acceder al mensaje de error + console.log(error.response.data.error) + }) +``` + +Puedes mostrar el mensaje de error predeterminado devuelto por Mongoose, aunque no son muy legibles: + +![captura de pantalla de la agenda telefónica que muestra el fallo de validación de la persona](../../images/3/56e.png) + +**NB:** En las operaciones de actualización, los validadores de mongoose están desactivados por defecto. [Lee la documentación](https://mongoosejs.com/docs/validation.html) para ver cómo habilitarlos. + +#### 3.20*: Base de datos de la Agenda Telefónica, paso 8 + +Agrega validación a tu aplicación de agenda telefónica para asegurarte de que los números de teléfono tengan el formato correcto. Un número de teléfono debe: + +- Tener una longitud de 8 o más caracteres. +- Estar formado por dos partes separadas por -, la primera parte tiene dos o tres números y la segunda parte también consiste en números. + - Por ejemplo, 09-1234556 y 040-22334455 son números de teléfono válidos. + - Por ejemplo, 1234556, 1-22334455 y 10-22-334455 son inválidos. + +Utiliza un [validador personalizado](https://mongoosejs.com/docs/validation.html#custom-validators) para implementar la segunda parte de la validación. + +Si una solicitud HTTP POST intenta agregar una persona con un número de teléfono no válido, el servidor debería responder con un código de estado apropiado y un mensaje de error. + +#### 3.21 Desplegando el backend con base de datos en producción + +Genera una nueva versión "full stack" de la aplicación creando una nueva compilación de producción del frontend y copiándola al repositorio del backend. Verifica que todo funcione localmente utilizando la aplicación completa desde la dirección . + +Lleva la versión más reciente a Fly.io/Render y verifica que todo funcione allí también. + +**NOTA**: debes desplegar el BACKEND en el servicio en la nube. Si estás utilizando Fly.io, los comandos deben ejecutarse en el directorio raíz del backend (es decir, en el mismo directorio donde se encuentra el package.json del backend). En caso de usar Render, el backend debe estar en la raíz de tu repositorio. + +NO debes desplegar el frontend directamente en ninguna etapa de esta parte. Es solo el repositorio del backend que se despliega en toda esta sección, nada más. + +
    + +
    + +### Lint + +Antes de pasar a la siguiente parte, veremos una herramienta importante llamada [lint](). Wikipedia dice lo siguiente sobre lint: + +> Genéricamente, lint o linter es cualquier herramienta que detecta y marca errores en los lenguajes de programación, incluidos los errores de estilo. El término comportamiento lint-like a veces se aplica al proceso de marcar el uso de lenguaje sospechoso. Las herramientas de tipo lint generalmente realizan análisis estáticos del código fuente. + +En lenguajes compilados de tipado estático como Java, los IDE como NetBeans pueden señalar errores en el código, incluso aquellos que son más que simples errores de compilación. Se pueden utilizar herramientas adicionales para realizar [análisis estáticos](https://es.wikipedia.org/wiki/An%C3%A1lisis_est%C3%A1tico_de_software), como [checkstyle](https://checkstyle.sourceforge.io), para ampliar las capacidades del IDE y señalar también problemas relacionados con el estilo, como la indentación. + +En el universo de JavaScript, la herramienta líder actual para el análisis estático (también conocida como "linting") es [ESlint](https://eslint.org/). + +Instalemos ESlint como una dependencia de desarrollo del proyecto de backend con el comando: + +```bash +npm install eslint --save-dev +``` + +Después de esto, podemos inicializar una configuración predeterminada de ESlint con el comando: + +```bash +npx eslint --init +``` + +Responderemos todas las preguntas: + +![salida del terminal de ESlint init](../../images/3/52q.png) + +La configuración se guardará en el archivo _eslint.config.mjs_. Cambiaremos _browser_ a _node_ en la configuración de _files_: + +```js +import globals from "globals"; +import { defineConfig } from "eslint/config"; + +export default defineConfig([ + { + files: ["**/*.{js,mjs,cjs}"], languageOptions: { globals: globals.node } // highlight-line + } +]); + +``` + +Cambiemos un poco la configuración. Instala un [plugin](https://eslint.style/packages/js) que define un conjunto de reglas relacionadas al estilo: + +``` +npm install --save-dev @stylistic/eslint-plugin +``` + +Habilita el plugin y agrega una definición de "extends": + +```js +import js from '@eslint/js' // highlight-line +import globals from "globals"; +import stylistic from '@stylistic/eslint-plugin' // highlight-line +import { defineConfig } from "eslint/config"; + +export default defineConfig([ + { + files: ["**/*.{js,mjs,cjs}"], languageOptions: { globals: globals.node }, + plugins: { js, stylistic }, // highlight-line + extends: ["js/recommended"] // highlight-line + } +]); +``` + +Extends _["js/recommended"]_ añade un [conjunto](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions) de reglas recomendadas al proyecto. Además, se han añadido reglas para la indentación, saltos de línea, guiones y puntos y comas. + +Inspeccionar y validar un archivo como _index.js_ se puede hacer con el siguiente comando: + +```bash +npx eslint index.js +``` + +Es recomendable crear un _script npm_ separado para linting: + +```json +{ + // ... + "scripts": { + "start": "node index.js", + "dev": "nodemon index.js", + // ... + "lint": "eslint ." // highlight-line + }, + // ... +} +``` + +Ahora, el comando _npm run lint_ comprobará todos los archivos del proyecto. + +Además, los archivos del directorio dist se comprueban cuando se ejecuta el comando. No queremos que esto suceda, y podemos lograrlo creando agregando [globalIgnores](https://eslint.org/docs/latest/use/configure/ignore) en el archivo _eslint.config.mjs_: + +```js +import js from '@eslint/js' +import globals from 'globals' +import stylistic from '@stylistic/eslint-plugin' +import { defineConfig, globalIgnores } from 'eslint/config' // highlight-line + +export default defineConfig([ + { + files: ["**/*.{js,mjs,cjs}"], languageOptions: { globals: globals.node }, + plugins: { js, stylistic }, + extends: ['js/recommended'] + }, + globalIgnores(['./dist/']) // highlight-line +]) + +``` + +Esto hace que el directorio dist no sea comprobado por ESlint. + +Lint tiene mucho que decir sobre nuestro código: + +![salida de consola con errores de ESlint](../../images/3/53ea.png) + +No solucionemos estos problemas todavía. + +Una mejor alternativa a ejecutar el linter desde la línea de comandos es configurar un eslint-plugin en el editor, que ejecuta el linter continuamente. Al usar el plugin, verá errores en su código de inmediato. Puede encontrar más información sobre el plugin Visual Studio ESLint [aquí](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint). + +El plugin de VS Code ESlint subrayará las violaciones de estilo con una línea roja: + +![plugin de ESlint mostrando errores en el código](../../images/3/54a.png) + +Esto hace que los errores sean fáciles de detectar y puedan ser corregidos de inmediato. + +ESlint tiene una amplia gama de [reglas](https://eslint.org/docs/rules/) que son fáciles de usar al editar el archivo .eslintrc.js. + +Agreguemos la regla [eqeqeq](https://eslint.org/docs/rules/eqeqeq) que nos alerta si la igualdad se verifica con algo que no sea el operador de triple igual. La regla se agrega bajo el campo rules en el archivo de configuración. + +```js +{ + // ... + 'rules': { + // ... + 'eqeqeq': 'error', + }, +} +``` + +Ya que estamos en eso, hagamos algunos otros cambios en las reglas. + +Evitemos los [espacios finales innecesarios](https://eslint.org/docs/rules/no-trailing-spaces) al final de las líneas, exijamos que [siempre haya un espacio antes y después de las llaves](https://eslint.org/docs/rules/object-curly-spacing), y exijamos también un uso consistente de espacios en blanco en los parámetros de función de las funciones de flecha. + +```js +{ + // ... + 'rules': { + // ... + 'eqeqeq': 'error', + 'no-trailing-spaces': 'error', + 'object-curly-spacing': ['error', 'always'], + 'arrow-spacing': [ + 'error', { 'before': true, 'after': true } + ], + }, +} +``` + +Nuestra configuración predeterminada utiliza un montón de reglas predeterminadas de eslint:recommended: + +```bash +extends: 'js/recommended', +``` + +Esto incluye una regla que advierte sobre los comandos _console.log_. La [desactivación](https://eslint.org/docs/latest/use/configure/rules) de una regla se puede lograr definiendo su "valor" como 0 en el archivo de configuración. Mientras tanto, hagamos esto para la regla no-console. + +```js +{ + // ... + 'rules': { + // ... + 'eqeqeq': 'error', + 'no-trailing-spaces': 'error', + 'object-curly-spacing': ['error', 'always'], + 'arrow-spacing': [ + 'error', { 'before': true, 'after': true } + ], + 'no-console': 0 // highlight-line + }, +} +``` + +**NB** cuando realizas cambios en el archivo eslint.config.mjs, se recomienda ejecutar el linter desde la línea de comandos. Esto verificará que el archivo de configuración esté formateado correctamente: + +![salida de terminal del comando npm run lint](../../images/3/55.png) + +Si hay algún problema en tu archivo de configuración, el plugin lint puede comportarse de manera bastante errática. + +Muchas empresas definen estándares de codificación que se aplican en toda la organización a través del archivo de configuración de ESlint. No se recomienda seguir reinventando la rueda una y otra vez, y puede ser una buena idea adoptar una configuración ya hecha del proyecto de otra persona en el tuyo. Recientemente, muchos proyectos han adoptado la [guía de estilo Javascript](https://github.com/airbnb/javascript) de Airbnb al utilizar la configuración [ESlint](https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb) de Airbnb. + +Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part3-7 de [este repositorio de GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-7). + +
    + +
    + +### Ejercicio 3.22. + +#### 3.22: Configuración de Lint + +Agrega ESlint a tu aplicación y corrige todas las advertencias. + +Este fue el último ejercicio de esta parte del curso. Es hora de enviar tu código a GitHub y marcar todos tus ejercicios terminados en el [sistema de envío de ejercicios](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    diff --git a/src/content/3/fi/osa3.md b/src/content/3/fi/osa3.md index b7b7a6498c9..8333aa44d0a 100644 --- a/src/content/3/fi/osa3.md +++ b/src/content/3/fi/osa3.md @@ -6,6 +6,11 @@ lang: fi
    -Tässä osassa fokus siirtyy backendin, eli palvelimen toiminnallisuuden toteuttamiseen. Toteutamme Node.js:n Express-kirjastoa hyödyntäen yksinkertaisen REST-apin, joka tallettaa dataa MongoDB-tietokantaan. Viemme myös sovelluksemme internettiin. +Tässä osassa fokus siirtyy backendin, eli palvelimen toiminnallisuuden toteuttamiseen. Toteutamme Node.js:n Express-kirjastoa hyödyntäen yksinkertaisen REST-apin, joka tallettaa dataa MongoDB-tietokantaan. Viemme myös sovelluksemme internetiin. + +Osa päivitetty 16.3.2025 +- Node päivitetty versioon v22.3.0 +- Nodemon korvattu node --watch -komennolla +- MondoDB-ohjeita päivitetty ja muotoiltu uudelleen
    diff --git a/src/content/3/fi/osa3a.md b/src/content/3/fi/osa3a.md index 1867aa24ef6..9c8f4762d27 100644 --- a/src/content/3/fi/osa3a.md +++ b/src/content/3/fi/osa3a.md @@ -7,21 +7,21 @@ lang: fi
    -Siirrämme tässä osassa fokuksen backendiin, eli palvelimella olevaan toiminnallisuuteen. +Siirrämme tässä osassa fokuksen backendiin eli palvelimella olevaan toiminnallisuuteen. -Backendin toteutusympäristönä käytämme [Node.js](https://nodejs.org/en/):ää, joka on melkein missä vaan, erityisesti palvelimilla ja omalla koneellasikin toimiva, Googlen [chrome V8](https://developers.google.com/v8/) -Javascriptmoottoriin perustuva Javascriptin suoritusympäristö. +Backendin toteutusympäristönä käytämme [Node.js](https://nodejs.org/en/):ää, joka on melkein missä vaan, erityisesti palvelimilla ja omalla koneellasikin toimiva Googlen [V8](https://developers.google.com/v8/)-JavaScript-moottoriin perustuva JavaScriptin suoritusympäristö. -Kurssimateriaalia tehtäessä on ollut käytössä Node.js:n versio v10.18.0. Huolehdi että omasi on vähintään yhtä tuore (ks. komentoriviltä _node -v_). +Kurssimateriaalia tehtäessä on ollut käytössä Node.js:n versio v22.3.0. Suosittelen, että omasi on vähintään yhtä tuore (ks. komentoriviltä _node -v_). -Kuten [osassa 1](/osa1/javascriptia) todettiin, selaimet eivät vielä osaa kaikkia uusimpia Javascriptin ominaisuuksia ja siksi selainpuolen koodi täytyy kääntää eli transpiloida esim [babel](https://babeljs.io/):illa. Backendissa tilanne on kuitenkin toinen, uusin Node hallitsee riittävissä määrin myös Javascriptin uusia versioita, joten suoritamme Nodella suoraan kirjoittamaamme koodia ilman transpilointivaihetta. +Kuten [osassa 1](/osa1/java_scriptia) todettiin, selaimet eivät vielä osaa kaikkia uusimpia JavaScriptin ominaisuuksia, ja siksi selainpuolen koodi täytyy kääntää eli transpiloida esim [Babel](https://babeljs.io/):illa. Backendissa tilanne on kuitenkin toinen, koska uusin Node hallitsee riittävissä määrin myös JavaScriptin uusia versioita, joten suoritamme Nodella kirjoittamaamme koodia suoraan ilman transpilointivaihetta. -Tavoitteenamme on tehdä [osan 2](/osa2) muistiinpanosovellukseen sopiva backend. Aloitetaan kuitenkin ensin perusteiden läpikäyminen toteuttamalla perinteinen "hello world"-sovellus. +Tavoitteenamme on tehdä [osan 2](/osa2) muistiinpanosovellukseen sopiva backend. Aloitetaan kuitenkin ensin perusteiden läpikäyminen toteuttamalla perinteinen "hello world" ‑sovellus. -**Huomaa**, että kaikki tässä osassa ja sen tehtävissä luotavat sovellukset eivät ole Reactia, eli emme käytä create-react-app-sovellusta tämän osan sovellusten rungon alustamiseen. +**Huomaa**, että tässä osassa ja sen tehtävissä luotavat sovellukset eivät ole Reactia, eli emme käytä viteä tämän osan sovellusten rungon alustamiseen. -Osassa 2 oli jo puhe [npm](/osa2#npm):stä, eli Javascript-projektien hallintaan liittyvästä, alunperin Node-ekosysteemistä kotoisin olevasta työkalusta. +Osassa 2 oli jo puhe [npm](/osa2/palvelimella_olevan_datan_hakeminen#npm):stä, eli JavaScript-projektien hallintaan liittyvästä, alun perin Node-ekosysteemistä kotoisin olevasta työkalusta. -Mennään sopivaan hakemistoon ja luodaan projektimme runko komennolla _npm init_. Vastaillaan kysymyksiin sopivasti ja tuloksena on hakemiston juureen sijoitettu projektin tietoja kuvaava tiedosto package.json +Mennään sopivaan hakemistoon ja luodaan projektimme runko komennolla _npm init_. Vastaillaan kysymyksiin sopivasti, ja tuloksena on hakemiston juureen sijoitettu projektin tietoja kuvaava tiedosto package.json: ```json { @@ -37,7 +37,7 @@ Mennään sopivaan hakemistoon ja luodaan projektimme runko komennolla _npm init } ``` -Tiedosto määrittelee mm. että ohjelmamme käynnistyspiste on tiedosto index.js. +Tiedosto määrittelee mm., että ohjelmamme käynnistyspiste on tiedosto index.js. Tehdään kenttään scripts pieni lisäys: @@ -52,7 +52,7 @@ Tehdään kenttään scripts pieni lisäys: } ``` -Luodaan sitten sovelluksen ensimmäinen versio, eli projektin juureen sijoitettava tiedosto index.js ja sille seuraava sisältö: +Luodaan sitten sovelluksen ensimmäinen versio eli projektin juureen sijoitettava tiedosto index.js ja sille seuraava sisältö: ```js console.log('hello world') @@ -64,13 +64,13 @@ Voimme suorittaa ohjelman joko "suoraan" nodella, komentorivillä node index.js ``` -tai [npm scriptinä](https://docs.npmjs.com/misc/scripts) +tai [npm-skriptinä](https://docs.npmjs.com/misc/scripts) ```bash npm start ``` -npm-skripti start toimii koska määrittelimme sen tiedostoon package.json +npm-skripti start toimii koska määrittelimme sen tiedostoon package.json: ```bash { @@ -85,7 +85,7 @@ npm-skripti start toimii koska määrittelimme sen tiedostoon package Vaikka esim. projektin suorittaminen onnistuukin suoraan käyttämällä komentoa _node index.js_, on npm-projekteille suoritettavat operaatiot yleensä tapana määritellä nimenomaan npm-skripteinä. -Oletusarvoinen package.json määrittelee valmiiksi myös toisen yleisesti käytetyn npm-scriptin eli _npm test_. Koska projektissamme ei ole vielä testikirjastoa, ei _npm test_ kuitenkaan tee vielä muuta kuin suorittaa komennon +Oletusarvoinen package.json määrittelee valmiiksi myös toisen yleisesti käytetyn npm-skriptin eli _npm test_. Koska projektissamme ei ole vielä testikirjastoa, ei _npm test_ kuitenkaan tee vielä muuta kuin suorittaa komennon ```bash echo "Error: no test specified" && exit 1 @@ -93,22 +93,22 @@ echo "Error: no test specified" && exit 1 ### Yksinkertainen web-palvelin -Muutetaan sovellus web-palvelimeksi: +Muutetaan sovellus web-palvelimeksi asettamalla _index.js_-tiedoston sisällöksi seuraava koodi: ```js const http = require('http') -const app = http.createServer((req, res) => { - res.writeHead(200, { 'Content-Type': 'text/plain' }) - res.end('Hello World') +const app = http.createServer((request, response) => { + response.writeHead(200, { 'Content-Type': 'text/plain' }) + response.end('Hello World') }) -const port = 3001 -app.listen(port) -console.log(`Server running on port ${port}`) +const PORT = 3001 +app.listen(PORT) +console.log(`Server running on port ${PORT}`) ``` -Kun sovellus käynnistuu, konsoliin tulostuu +Kun sovellus käynnistyy, konsoliin tulostuu ```bash Server running on port 3001 @@ -116,11 +116,11 @@ Server running on port 3001 Voimme avata selaimella osoitteessa olevan vaatimattoman sovelluksemme: -![](../../images/3/1.png) +![selaimessa näkyy teksti Hello World](../../images/3/1.png) -Palvelin toimii itseasiassa täsmälleen samalla tavalla riippumatta urlin loppuosasta, eli myös sivun sisältö on sama. +Palvelin toimii samalla tavalla riippumatta urlin loppuosasta, eli myös sivun sisältö on sama. -**HUOM** jos koneesi portti 3001 on jo jonkun sovelluksen käytössä, aiheuttaa käynnistäminen virheen: +**HUOM:** jos koneesi portti 3001 on jo jonkun sovelluksen käytössä, aiheuttaa käynnistäminen virheen: ```bash > notes-backend@1.0.0 start /Users/mluukkai/opetus/_koodi_fs/3/luento/notes-backend @@ -136,7 +136,7 @@ Error: listen EADDRINUSE: address already in use :::3001 at listenInCluster (net.js:1378:12) ``` -Sammuta portissa 3001 oleva sovellus (edellisessä osassa json-server käynnistettiin porttiin 3001) tai määrittele sovellukselle jokin toinen portti. +Sulje portissa 3001 oleva sovellus (edellisessä osassa json-server käynnistettiin porttiin 3001) tai määrittele sovellukselle jokin toinen portti. Tarkastellaan koodia hiukan. Ensimmäinen rivi @@ -144,17 +144,17 @@ Tarkastellaan koodia hiukan. Ensimmäinen rivi const http = require('http') ``` -ottaa käyttöön Noden sisäänrakennetun [web-palvelimen](https://nodejs.org/docs/latest-v8.x/api/http.html) määrittelevän moduulin. Kyse on käytännössä samasta asiasta, mihin olemme selainpuolen koodissa tottuneet hieman syntaksiltaan erilaisessa muodossa: +ottaa käyttöön Noden sisäänrakennetun [web-palvelimen](https://nodejs.org/docs/latest-v8.x/api/http.html) määrittelevän moduulin. Kyse on käytännössä samasta asiasta kuin mihin olemme selainpuolen koodissa tottuneet, mutta syntaksiltaan hieman erilaisessa muodossa: ```js import http from 'http' ``` -Selaimen puolella käytetään (nykyään) ES6:n moduuleita, eli moduulit määritellään [exportilla](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export) ja otetaan käyttöön [importilla](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import). +Selaimen puolella käytetään nykyään ES6:n moduuleita, eli moduulit määritellään [exportilla](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export) ja otetaan käyttöön [importilla](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import). -Node.js kuitenkin käyttää ns. [CommonJS](https://en.wikipedia.org/wiki/CommonJS)-moduuleja. Syy tälle on siinä, että Node-ekosysteemillä oli tarve moduuleihin jo kauan ennen kuin Javascript tuki kielen tasolla moduuleja. Node ei toistaiseksi tue ES-moduuleja, mutta tuki on todennäköisesti jossain vaiheessa [tulossa](https://nodejs.org/api/esm.html). +Node.js käyttää oletusarvoisesti ns. [CommonJS](https://en.wikipedia.org/wiki/CommonJS)-moduuleja. Syy tälle on siinä, että Node-ekosysteemillä oli tarve moduuleihin jo kauan ennen kuin JavaScript tuki moduuleja kielen tasolla. Nykyään Node tukee myös ES-moduuleja, mutta koska tuki ei ole vielä kaikilta osin täydellinen, pitäydymme CommonJS-moduuleissa. -CommonJS-moduulit toimivat kohtuullisessa määrin samaan tapaan kuin ES6-moduulit, ainakin tämän kurssin tarpeiden puitteissa. +CommonJS-moduulit toimivat melko samaan tapaan kuin ES6-moduulit, ainakin tämän kurssin tarpeiden puitteissa. Koodi jatkuu seuraavasti: @@ -165,7 +165,7 @@ const app = http.createServer((request, response) => { }) ``` -koodi luo [http](https://nodejs.org/docs/latest-v8.x/api/http.html)-palvelimen metodilla _createServer_ web-palvelimen, jolle se rekisteröi tapahtumankäsittelijän, joka suoritetaan jokaisen osoitteen http:/localhost:3001 alle tulevan HTTP-pyynnön yhteydessä. +Koodi luo [http](https://nodejs.org/docs/latest-v8.x/api/http.html)-moduulin metodilla _createServer_ web-palvelimen, jolle se rekisteröi tapahtumankäsittelijän, joka suoritetaan jokaisen osoitteen http://localhost:3001 alle tulevan HTTP-pyynnön yhteydessä. Pyyntöön vastataan statuskoodilla 200, asettamalla Content-Type-headerille arvo text/plain ja asettamalla palautettavan sivun sisällöksi merkkijono Hello World. @@ -177,7 +177,7 @@ app.listen(PORT) console.log(`Server running on port ${PORT}`) ``` -Koska tällä kurssilla palvelimen rooli on pääasiassa tarjota frontille JSON-muotoista "raakadataa", muutetaan heti palvelinta siten, että se palauttaa kovakoodatun listallisen JSON-muotoisia muistiinpanoja: +Koska tällä kurssilla palvelimen rooli on pääasiassa tarjota frontille JSON-muotoista "raakadataa", muutetaan palvelinta siten, että se palauttaa kovakoodatun listan JSON-muotoisia muistiinpanoja: ```js const http = require('http') @@ -185,21 +185,18 @@ const http = require('http') // highlight-start let notes = [ { - id: 1, + id: "1", content: "HTML is easy", - date: "2020-01-10T17:30:31.098Z", important: true }, { - id: 2, - content: "Browser can execute only Javascript", - date: "2020-01-10T18:39:34.091Z", + id: "2", + content: "Browser can execute only JavaScript", important: false }, { - id: 3, + id: "3", content: "GET and POST are the most important methods of HTTP protocol", - date: "2020-01-10T19:20:14.298Z", important: true } ] @@ -210,29 +207,29 @@ const app = http.createServer((request, response) => { }) // highlight-end -const port = 3001 -app.listen(port) -console.log(`Server running on port ${port}`) +const PORT = 3001 +app.listen(PORT) +console.log(`Server running on port ${PORT}`) ``` -Käynnistetään palvelin uudelleen (palvelin sammutetaan painamalla _ctrl_ ja _c_ yhtä aikaa konsolissa) ja refreshataan selain. +Käynnistetään palvelin uudelleen (palvelin suljetaan painamalla konsolissa yhtä aikaa _ctrl_ + _c_) ja refreshataan selain. -Headerin Content-Type arvolla application/json kerrotaan, että kyse on JSON-muotoisesta datasta. Muuttujassa _notes_ oleva taulukko muutetaan jsoniksi metodilla JSON.stringify(notes). +Headerin Content-Type arvolla application/json kerrotaan, että kyse on JSON-muotoisesta datasta. Muuttujassa _notes_ oleva taulukko muutetaan JSON-muotoon metodilla JSON.stringify(notes). -Kun avaamme selaimen, on tulostusasu sama kuin [osassa 2](/osa2#datan-haku-palvelimelta) käytetyn [json-serverin](https://github.com/typicode/json-server) tarjoamalla muistiinpanojen listalla: +Kun avaamme selaimen, on tulostusasu sama kuin [osassa 2](/osa2/palvelimella_olevan_datan_hakeminen) käytetyn [json-serverin](https://github.com/typicode/json-server) tarjoamalla muistiinpanojen listalla: -![](../../images/3/2e.png) +![Selain renderöi json-muotoisen datan](../../images/3/2new.png) ### Express -Palvelimen koodin tekeminen suoraan Noden sisäänrakennetun web-palvelimen [http](https://nodejs.org/docs/latest-v8.x/api/http.html):n päälle on mahdollista, mutta työlästä, erityisesti jos sovellus kasvaa hieman isommaksi. +Palvelimen koodin tekeminen suoraan Noden sisäänrakennetun web-palvelimen [http](https://nodejs.org/docs/latest-v8.x/api/http.html):n päälle on mahdollista. Se on kuitenkin työlästä, erityisesti jos sovellus kasvaa hieman isommaksi. -Nodella tapahtuvaa web-sovellusten ohjelmointia helpottamaan onkin kehitelty useita _http_:tä miellyttävämmän ohjelmointirajapinnan tarjoamia kirjastoja. Näistä ylivoimaisesti suosituin on [express](http://expressjs.com). +Nodella tapahtuvaa web-sovellusten ohjelmointia helpottamaan onkin kehitelty useita _http_:tä miellyttävämmän ohjelmointirajapinnan tarjoavia kirjastoja. Näistä ylivoimaisesti suosituin on [Express](https://expressjs.com). -Otetaan express käyttöön määrittelemällä se projektimme riippuvuudeksi komennolla +Otetaan Express käyttöön määrittelemällä se projektimme riippuvuudeksi komennolla ```bash -npm install express --save +npm install express ``` Riippuvuus tulee nyt määritellyksi tiedostoon package.json: @@ -241,27 +238,25 @@ Riippuvuus tulee nyt määritellyksi tiedostoon package.json: { // ... "dependencies": { - "express": "^4.17.1" + "express": "^5.1.0" } } ``` -Riippuvuuden koodi asentuu kaikkien projektin riippuvuuksien tapaan projektin juuressa olevaan hakemistoon node\_modules. Hakemistosta löytyy expressin lisäksi suuri määrä muutakin tavaraa +Riippuvuuden koodi asentuu kaikkien projektin riippuvuuksien tapaan projektin juuressa olevaan hakemistoon node\_modules. Hakemistosta löytyy Expressin lisäksi suuri määrä muutakin tavaraa: -![](../../images/3/4.png) +![komennon ls tulostama suuri määrä kirjastoja vastaavia hakemistoja](../../images/3/4.png) -Kyseessä ovat expressin riippuvuudet ja niiden riippuvuudet ym... eli projektimme [transitiiviset riippuvuudet](https://lexi-lambda.github.io/blog/2016/08/24/understanding-the-npm-dependency-model/). +Kyseessä ovat Expressin riippuvuudet ja niiden riippuvuudet jne. eli projektimme [transitiiviset riippuvuudet](https://lexi-lambda.github.io/blog/2016/08/24/understanding-the-npm-dependency-model/). -Projektiin asentui expressin versio 4.17.1. Mitä tarkoittaa package.json:issa versiomerkinnän edessä oleva väkänen, eli miksi muoto on +Projektiin asentui Expressin versio 5.1.0. package.json:issa versiomerkinnän edessä on väkänen, eli muoto on ```json -"express": "^4.17.1" +"express": "^5.1.0" ``` - -npm:n yhteydessä käytetään ns. [semanttista versiointia](https://docs.npmjs.com/getting-started/semantic-versioning). - -Merkintä ^4.17.1 tarkoittaa, että jos/kun projektin riippuvuudet päivitetään, asennetaan expressistä versio, joka on vähintään 4.17.1, mutta asennetuksi voi tulla versio, jonka patch eli viimeinen numero tai minor eli keskimmäinen numero voi olla suurempi. Pääversio eli major täytyy kuitenkin olla edelleen sama. + +npm:n yhteydessä käytetään ns. [semanttista versiointia](https://docs.npmjs.com/getting-started/semantic-versioning). Merkintä ^5.1.0 tarkoittaa, että jos projektin riippuvuudet päivitetään, asennetaan Expressistä versio, joka on vähintään 5.1.0, mutta asennetuksi voi tulla versio, jonka patch eli viimeinen numero tai minor eli keskimmäinen numero voi olla suurempi. Pääversio eli major täytyy kuitenkin olla edelleen sama. Voimme päivittää projektin riippuvuudet komennolla @@ -269,17 +264,17 @@ Voimme päivittää projektin riippuvuudet komennolla npm update ``` -Vastaavasti jos aloitamme projektin koodaamisen toisella koneella, saamme haettua ajantasaiset, package.json:in määrittelyn kanssa yhteensopivat riippuvuudet komennolla +Jos aloitamme projektin koodaamisen toisella koneella, saamme haettua ajantasaiset, package.json:in määrittelyn kanssa yhteensopivat riippuvuudet komennolla ```bash npm install ``` -Jos riippuvuuden major-versionumero ei muutu, uudempien versioiden pitäisi olla [taaksepäin yhteensopivia](https://en.wikipedia.org/wiki/Backward_compatibility), eli jos ohjelmamme käyttäisi tulevaisuudessa esim. expressin versiota 4.99.175, tässä osassa tehtävän koodin pitäisi edelleen toimia ilman muutoksia. Sen sijaan tulevaisuudessa joskus julkaistava express 5.0.0. [voi sisältää](https://expressjs.com/en/guide/migrating-5.html) sellaisia muutoksia, että koodimme ei enää toimisi. +Jos riippuvuuden major-versionumero ei muutu, uudempien versioiden pitäisi olla [taaksepäin yhteensopivia](https://en.wikipedia.org/wiki/Backward_compatibility), eli jos ohjelmamme käyttäisi tulevaisuudessa esim. Expressin versiota 5.99.175, tässä osassa tehtävän koodin pitäisi edelleen toimia ilman muutoksia. Sen sijaan tulevaisuudessa joskus julkaistava Express 6.0.0 voi sisältää sellaisia muutoksia, että koodimme ei enää toimisi. -### Web ja express +### Web ja Express -Palataan taas sovelluksen ääreen ja muutetaan se muotoon: +Palataan taas sovelluksen ääreen ja muutetaan se muotoon ```js const express = require('express') @@ -289,12 +284,12 @@ let notes = [ ... ] -app.get('/', (req, res) => { - res.send('

    Hello World!

    ') +app.get('/', (request, response) => { + response.send('

    Hello World!

    ') }) -app.get('/api/notes', (req, res) => { - res.json(notes) +app.get('/api/notes', (request, response) => { + response.json(notes) }) const PORT = 3001 @@ -303,16 +298,16 @@ app.listen(PORT, () => { }) ``` -Jotta sovelluksen uusi versio saadaan käyttöön, on sovellus uudelleenkäynnistettävä. +Jotta sovelluksen uusi versio saadaan käyttöön, on sovellus käynnistettävä uudelleen. -Sovellus ei muutu paljoa. Heti alussa otetaan käyttöön _express_, joka on tällä kertaa funktio, jota kutsumalla luodaan muuttujaan _app_ sijoitettava express-sovellusta vastaava olio: +Sovellus ei muutu paljoa. Heti alussa otetaan käyttöön _express_, joka on tällä kertaa funktio, jota kutsumalla luodaan muuttujaan _app_ sijoitettava Express-sovellusta vastaava olio: ```js const express = require('express') const app = express() ``` -Seuraavaksi määritellään sovellukselle kaksi routea. Näistä ensimmäinen määrittelee tapahtumankäsittelijän, joka hoitaa sovelluksen juureen eli polkuun / tulevia HTTP GET -pyyntöjä: +Seuraavaksi määritellään sovellukselle kaksi routea. Näistä ensimmäinen määrittelee tapahtumankäsittelijän, joka hoitaa sovelluksen juureen eli polkuun / tulevia HTTP GET ‑pyyntöjä: ```js app.get('/', (request, response) => { @@ -322,14 +317,13 @@ app.get('/', (request, response) => { Tapahtumankäsittelijäfunktiolla on kaksi parametria. Näistä ensimmäinen eli [request](http://expressjs.com/en/4x/api.html#req) sisältää kaikki HTTP-pyynnön tiedot ja toisen parametrin [response](http://expressjs.com/en/4x/api.html#res):n avulla määritellään, miten pyyntöön vastataan. -Koodissa pyyntöön vastataan käyttäen _response_-olion metodia [send](http://expressjs.com/en/4x/api.html#res.send), jonka kutsumisen seurauksena palvelin vastaa HTTP-pyyntöön lähettämällä selaimelle vastaukseksi _send_:in parametrina olevan merkkijonon \

    Hello World!\

    . Koska parametri on merkkijono, asettaa express vastauksessa content-type-headerin arvoksi text/html, statuskoodiksi tulee oletusarvoisesti 200. +Koodissa pyyntöön vastataan käyttäen _response_-olion metodia [send](http://expressjs.com/en/4x/api.html#res.send), jonka kutsumisen seurauksena palvelin vastaa HTTP-pyyntöön lähettämällä selaimelle vastaukseksi _send_:in parametrina olevan merkkijonon \

    Hello World!\

    . Koska parametri on merkkijono, asettaa Express vastauksessa Content-Type-headerin arvoksi text/html. Statuskoodiksi tulee oletusarvoisesti 200. -Asian voi varmistaa konsolin välilehdeltä -Network +Asian voi varmistaa konsolin välilehdeltä Network: -![](../../images/3/5.png) +![Avattu network-tabi näyttää että palvelin vastaa statuskoodilla 200](../../images/3/5.png) -Routeista toinen määrittelee tapahtumankäsittelijän, joka hoitaa sovelluksen polkuun /api/notes tulevia HTTP GET -pyyntöjä: +Routeista toinen määrittelee tapahtumankäsittelijän, joka hoitaa sovelluksen polkuun /api/notes tulevia HTTP GET ‑pyyntöjä: ```js app.get('/api/notes', (request, response) => { @@ -337,13 +331,13 @@ app.get('/api/notes', (request, response) => { }) ``` -Pyyntöön vastataan _response_-olion metodilla [json](http://expressjs.com/en/4x/api.html#res.json), joka lähettää HTTP-pyynnön vastaukseksi parametrina olevaa Javascript-olioa eli taulukkoa _notes_ vastaavan JSON-muotoisen merkkijonon. Express asettaa headerin Content-type arvoksi application/json. +Pyyntöön vastataan _response_-olion metodilla [json](http://expressjs.com/en/4x/api.html#res.json), joka lähettää HTTP-pyynnön vastaukseksi parametrina olevaa JavaScript-olioa eli taulukkoa _notes_ vastaavan JSON-muotoisen merkkijonon. Express asettaa headerin Content-Type arvoksi application/json. -![](../../images/3/6ea.png) +![Selain renderöi json-muotoiset muistiinpanot](../../images/3/6new.png) Pieni huomio JSON-muodossa palautettavasta datasta. -Aiemmassa, pelkkää Nodea käyttämässä versiossa, jouduimme muuttamaan palautettavan datan json-muotoon metodilla _JSON.stringify_: +Aiemmassa, pelkkää Nodea käyttävässä versiossa, jouduimme muuttamaan palautettavan datan JSON-muotoon metodilla _JSON.stringify_: ```js response.end(JSON.stringify(notes)) @@ -351,80 +345,47 @@ response.end(JSON.stringify(notes)) Expressiä käytettäessä tämä ei ole tarpeen, sillä muunnos tapahtuu automaattisesti. -Kannattaa huomata, että [JSON](https://en.wikipedia.org/wiki/JSON) on merkkijono, eikä Javascript-olio kuten muuttuja _notes_. +Kannattaa huomata, että [JSON](https://en.wikipedia.org/wiki/JSON) on tiedostomuoto, jota kuvataan usein merkkijonona. Se ei ole JavaScript-olio kuten muuttuja _notes_. Seuraava interaktiivisessa [node-repl](https://nodejs.org/docs/latest-v8.x/api/repl.html):issä suoritettu kokeilu havainnollistaa asiaa: -![](../../assets/3/5.png) - -Saat käynnistettyä interaktiivisen node-repl:in kirjoittamalla komentoriville _node_. Esim. joidenkin komentojen toimivuutta on koodatessa kätevä tarkastaa konsolissa, suosittelen! - -### nodemon - -Jos muutamme sovelluksen koodia, joudumme uudelleenkäynnistämään sovelluksen (eli ensin sammuttamaan konsolista _ctrl_ ja _c_ ja sitten käynnistämään uudelleen), jotta muutokset tulisivat voimaan. Verrattuna Reactin mukavaan workflowhun, missä selain päivittyi automaattisesti koodin muuttuessa tuntuu uudelleenkäynnistely kömpelöltä. - -Ongelmaan ratkaisu on [nodemon](https://github.com/remy/nodemon): +![js-objekti muuttuu string-tyyppiseksi JSON.stringify-operaation seurauksena](../../assets/3/5.png) -> nodemon will watch the files in the directory in which nodemon was started, and if any files change, nodemon will automatically restart your node application. +Saat käynnistettyä interaktiivisen node-repl:in kirjoittamalla komentoriville _node_. Komentojen toimivuutta on koodatessa kätevä kokeilla konsolissa, suosittelen! -Asennetaan nodemon määrittelemällä se kehitysaikaiseksi riippuvuudeksi (development dependency) komennolla: +### Muutoksien automaattinen seuraaminen -```bash -npm install --save-dev nodemon -``` - -Tiedoston package.json sisältö muuttuu seuraavasti: - -```json -{ - //... - "dependencies": { - "express": "^4.17.1", - }, - "devDependencies": { - "nodemon": "^2.0.2" - } -} -``` - -Jos nodemon-riippuvuus kuitenkin meni sovelluksessasi normaaliin "dependencies"-ryhmään, päivitä package.json manuaalisesti vastaamaan yllä näkyvää (versiot kuitenkin säilyttäen). - -Kehitysaikaisilla riippuvuuksilla tarkoitetaan työkaluja, joita tarvitaan ainoastaan sovellusta kehitettäessä, esim. testaukseen tai sovelluksen automaattiseen uudelleenkäynnistykseen kuten nodemon. +Jos muutamme sovelluksen koodia, joudumme ensin sulkemaan sovelluksen konsolista (_ctrl_ + _c_) ja sitten käynnistämään sovelluksen uudelleen, jotta muutokset tulevat voimaan. Uudelleenkäynnistely tuntuu kömpelöltä verrattuna Reactin mukavaan workflow'hun, jossa selain päivittyi automaattisesti koodin muuttuessa. -Kun sovellusta suoritetaan tuotantomoodissa, eli samoin kuin sitä tullaan suorittamaan tuotantopalvelimella (esim. Herokussa, mihin tulemme kohta siirtämään sovelluksemme), ei kehitysaikaisia riippuvuuksia tarvita. - -Voimme käynnistää ohjelman nodemonilla seuraavasti: +Palvelimen saa seuraamaan tekemiämme muutoksia, kun sen käynnistää käyttäen _--watch_-optiota: ```bash -node_modules/.bin/nodemon index.js +node --watch index.js ``` -Sovelluksen koodin muutokset aiheuttavat nyt automaattisen palvelimen uudelleenkäynnistymisen. Kannattaa huomata, että vaikka palvelin uudelleenkäynnistyy automaattisesti, selain täytyy kuitenkin refreshata, sillä toisin kuin Reactin yhteydessä, meillä ei nyt ole eikä tässä skenaariossa (missä palautamme JSON-muotoista dataa) edes voisikaan olla selainta päivittävää [hot reload](https://gaearon.github.io/react-hot-loader/getstarted/) -toiminnallisuutta. +Sovelluksen koodin muutokset aiheuttavat nyt automaattisen palvelimen uudelleenkäynnistymisen. Kannattaa huomata, että vaikka palvelin uudelleenkäynnistyy automaattisesti, selain täytyy kuitenkin refreshata, sillä toisin kuin Reactin yhteydessä, meillä ei nyt ole eikä tässä skenaariossa (jossa palautamme JSON-muotoista dataa) edes voisikaan olla selainta päivittävää [hot reload](https://gaearon.github.io/react-hot-loader/getstarted/) ‑toiminnallisuutta. -Komento on ikävä, joten määritellään sitä varten npm-skripti tiedostoon package.json: +Määritellään kehityspalvelimen käynnistämistä varten oma npm-skripti tiedostoon package.json: ```bash { // .. "scripts": { "start": "node index.js", - "dev": "nodemon index.js", + "dev": "node --watch index.js", // highlight-line "test": "echo \"Error: no test specified\" && exit 1" }, // .. } ``` -Skriptissä ei ole tarvetta käyttää nodemonin polusta sen täydellistä muotoa node\_modules/.bin/nodemon sillä _npm_ osaa etsiä automaattisesti suoritettavaa tiedostoa kyseisestä hakemistosta. - Voimme nyt käynnistää palvelimen sovelluskehitysmoodissa komennolla ```bash npm run dev ``` -Toisin kuin skriptejä start tai test suoritettaessa, joudumme sanomaan myös run. - +Toisin kuin skriptejä start tai test suoritettaessa, komennon tulee sisältää myös run. ### REST @@ -432,7 +393,7 @@ Laajennetaan sovellusta siten, että se toteuttaa samanlaisen RESTful-periaattee Representational State Transfer eli REST on Roy Fieldingin vuonna 2000 ilmestyneessä [väitöskirjassa](https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm) määritelty skaalautuvien web-sovellusten rakentamiseksi tarkoitettu arkkitehtuurityyli. -Emme nyt rupea määrittelemään REST:iä Fieldingiläisittäin tai rupea väittämään mitä REST on tai mitä se ei ole vaan otamme hieman [kapeamman näkökulman](https://en.wikipedia.org/wiki/Representational_state_transfer#Applied_to_Web_services), miten REST tai RESTful API:t yleensä tulkitaan Web-sovelluksissa. Alkuperäinen REST-periaate ei edes sinänsä rajoitu Web-sovelluksiin. +Emme nyt rupea määrittelemään REST:iä fieldingiläisittäin tai rupea väittelemään siitä mitä REST on tai mitä se ei ole. Otamme hieman [kapeamman näkökulman](https://en.wikipedia.org/wiki/Representational_state_transfer#Applied_to_web_services), jonka mukaan REST tai RESTful API:t yleensä tulkitaan web-sovelluksissa. Alkuperäinen REST-periaate ei sinänsä rajoitu web-sovelluksiin. Mainitsimme jo [edellisessä osassa](/osa2/palvelimella_olevan_datan_muokkaaminen#rest), että yksittäisiä asioita, meidän tapauksessamme muistiinpanoja kutsutaan RESTful-ajattelussa resursseiksi. Jokaisella resurssilla on URL eli sen yksilöivä osoite. @@ -456,9 +417,9 @@ Resursseille voi suorittaa erilaisia operaatiota. Suoritettavan operaation mää | notes/10 | PATCH | korvaa yksilöidyn resurssin osan pyynnön mukana olevalla datalla | | | | | -Näin määrittyy suurin piirtein asia, mitä REST kutsuu nimellä [uniform interface](https://en.wikipedia.org/wiki/Representational_state_transfer#Architectural_constraints), eli jossain määrin yhtenäinen tapa määritellä rajapintoja, jotka mahdollistavat (tietyin tarkennuksin) järjestelmien yhteiskäytön. +Näin määrittyy suurin piirtein asia, jota REST kutsuu nimellä [uniform interface](https://en.wikipedia.org/wiki/Representational_state_transfer#Architectural_constraints), eli jossain määrin yhtenäinen tapa määritellä rajapintoja, jotka mahdollistavat (tietyin tarkennuksin) järjestelmien yhteiskäytön. -Tämänkaltaista tapaa tulkita REST:iä on nimitetty kolmiportaisella asteikolla [kypsyystason 2](https://martinfowler.com/articles/richardsonMaturityModel.html) REST:iksi. REST:in kehittäjän Roy Fieldingin mukaan tällöin kyseessä ei vielä ole ollenkaan asia, jota tulisi kutsua [REST-apiksi](http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven). Maailman "REST"-apeista valtaosa ei täytäkään puhdasverisen Fieldingiläisen REST-apin määritelmää. +Tämänkaltaista tapaa tulkita REST:iä on nimitetty kolmiportaisella asteikolla [kypsyystason 2](https://martinfowler.com/articles/richardsonMaturityModel.html) REST:iksi. REST:in kehittäjän Roy Fieldingin mukaan tällöin kyseessä ei vielä ole ollenkaan asia, jota tulisi kutsua [REST API:ksi](http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven). Valtaosa maailman "REST" API ‑rajapinnoista ei täytäkään puhdasverisen fieldingiläisen REST API:n määritelmää. Joissain yhteyksissä (ks. esim. [Richardson, Ruby: RESTful Web Services](http://shop.oreilly.com/product/9780596529260.do)) edellä esitellyn kaltaista suoraviivaisehkoa resurssien [CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete)-tyylisen manipuloinnin mahdollistavaa API:a nimitetään REST:in sijaan [resurssipohjaiseksi](https://en.wikipedia.org/wiki/Resource-oriented_architecture) arkkitehtuurityyliksi. Emme nyt kuitenkaan takerru liian tarkasti määritelmällisiin asioihin vaan jatkamme sovelluksen parissa. @@ -466,9 +427,9 @@ Joissain yhteyksissä (ks. esim. [Richardson, Ruby: RESTful Web Services](http:/ Laajennetaan nyt sovellusta siten, että se tarjoaa muistiinpanojen operointiin REST-rajapinnan. Tehdään ensin [route](http://expressjs.com/en/guide/routing.html) yksittäisen resurssin katsomista varten. -Yksittäisen muistiinpanon identifioi URL, joka on muotoa /api/notes/10, missä lopussa oleva numero vastaa resurssin muistiinpanon id:tä. +Yksittäisen muistiinpanon identifioi URL, joka on muotoa /api/notes/10. Lopussa oleva luku vastaa resurssin muistiinpanon id:tä. -Voimme määritellä expressin routejen poluille [parametreja](http://expressjs.com/en/guide/routing.html) käyttämällä kaksoispistesyntaksia: +Voimme määritellä Expressin routejen poluille [parametreja](http://expressjs.com/en/guide/routing.html) käyttämällä kaksoispistesyntaksia: ```js app.get('/api/notes/:id', (request, response) => { @@ -478,7 +439,7 @@ app.get('/api/notes/:id', (request, response) => { }) ``` -Nyt app.get('/api/notes/:id', ...) käsittelee kaikki HTTP GET -pyynnöt, jotka ovat muotoa /api/notes/JOTAIN, missä JOTAIN on mielivaltainen merkkijono. +Nyt app.get('/api/notes/:id', ...) käsittelee kaikki HTTP GET ‑pyynnöt, jotka ovat muotoa /api/notes/JOTAIN, jossa JOTAIN on mielivaltainen merkkijono. Polun parametrin id arvoon päästään käsiksi pyynnön tiedot kertovan olion [request](http://expressjs.com/en/api.html#req) kautta: @@ -488,79 +449,23 @@ const id = request.params.id Jo tutuksi tulleella taulukon _find_-metodilla haetaan taulukosta parametria vastaava muistiinpano ja palautetaan se pyynnön tekijälle. -Kun sovellusta testataan menemällä selaimella osoitteeseen , havaitaan että se ei toimi, selain näyttää tyhjältä. Tämä on tietenkin softadevaajan arkipäivää, ja on ruvettava debuggaamaan. - -Vanha hyvä keino on alkaa lisäillä koodiin _console.log_-komentoja: - -```js -app.get('/api/notes/:id', (request, response) => { - const id = request.params.id - console.log(id) - const note = notes.find(note => note.id === id) - console.log(note) - response.json(note) -}) -``` - -Kun selaimella mennään jälleen osoitteeseen konsoliin, eli siihen terminaaliin, mihin sovellus on käynnistetty tulostuu - -![](../../images/3/8.png) - -eli halutun muistiinpanon id välittyy sovellukseen aivan oikein, mutta _find_ komento ei löydä mitään. - -Päätetään tulostella konsoliin myös _find_-komennon sisällä olevasta vertailijafunktiosta, joka onnistuu helposti kun tiiviissä muodossa oleva funktio note => note.id === id kirjoitetaan eksplisiittisen returnin sisältävässä muodossa: - -```js -app.get('/api/notes/:id', (request, response) => { - const id = request.params.id - const note = notes.find(note => { - console.log(note.id, typeof note.id, id, typeof id, note.id === id) - return note.id === id - }) - console.log(note) - response.json(note) -}) -``` - -Vierailtaessa jälleen yksittäisen muistiinpanon sivulla jokaisesta vertailufunktion kutsusta tulostetaan nyt monta asiaa. Konsolin tulostus on seuraava: +Testataan kokeilemalla selaimella osoittetta : -
    -1 'number' '1' 'string' false
    -2 'number' '1' 'string' false
    -3 'number' '1' 'string' false
    -
    +![Yksittäistä muistiinpanoa vastaava json renderöityy](../../images/3/9new.png) -ongelman syy selviää: muuttujassa _id_ on tallennettuna merkkijono '1' kun taas muistiinpanojen id:t ovat numeroita. Javascriptissä === vertailu katsoo kaikki eri tyyppiset arvot oletusarvoisesti erisuuriksi, joten 1 ei ole '1'. +Toiminnallisuuteen jää kuitenkin pieni ongelma. Jos haemme muistiinpanoa sellaisella indeksillä, jota vastaavaa muistiinpanoa ei ole olemassa, vastaa palvelin seuraavasti: -Korjataan ongelma, muuttamalla parametrina oleva merkkijonomuotoinen id [numeroksi](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number): - -```js -app.get('/api/notes/:id', (request, response) => { - const id = Number(request.params.id) - const note = notes.find(note => note.id === id) - response.json(note) -}) -``` - -ja nyt yksittäisen resurssin hakeminen toimii. - -![](../../images/3/9ea.png) - -Toiminnallisuuteen jää kuitenkin pieni ongelma. - -Jos haemme muistiinpanoa sellaisella indeksillä, mitä vastaavaa muistiinpanoa ei ole olemassa, vastaa palvelin seuraavasti - -![](../../images/3/10ea.png) +![Selaimeen ei renderöidy mitään, network-tab paljastaa että palvelin vastaa statuskoodilla 200](../../images/3/10ea.png) HTTP-statuskoodi on onnistumisesta kertova 200. Vastaukseen ei liity dataa, sillä headerin content-length arvo on 0, ja samaa todistaa selain: mitään ei näy. -Syynä tälle käyttäytymiselle on se, että muuttujan _note_ arvoksi tulee _undefined_ jos muistiinpanoa ei löydy. Tilanne tulisi käsitellä palvelimella järkevämmin, eli statuskoodin 200 sijaan tulee vastata statuskoodilla [404 not found](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.5). +Syynä tälle käyttäytymiselle on se, että muuttujan _note_ arvoksi tulee _undefined_ jos muistiinpanoa ei löydy. Tilanne tulee käsitellä palvelimella järkevämmin, eli statuskoodin 200 sijaan tulee vastata statuskoodilla [404 not found](https://www.rfc-editor.org/rfc/rfc9110.html#name-404-not-found). -Tehdään koodiin muutos +Tehdään koodiin muutos: ```js app.get('/api/notes/:id', (request, response) => { - const id = Number(request.params.id) + const id = request.params.id const note = notes.find(note => note.id === id) // highlight-start @@ -573,146 +478,146 @@ app.get('/api/notes/:id', (request, response) => { }) ``` -Koska vastaukseen ei nyt liity mitään dataa käytetään statuskoodin asettavan metodin [status](http://expressjs.com/en/4x/api.html#res.status) lisäksi metodia [end](http://expressjs.com/en/4x/api.html#res.end) ilmoittamaan siitä, että pyyntöön tulee vastata ilman dataa. +Koska vastaukseen ei nyt liity mitään dataa, käytetään statuskoodin asettavan metodin [status](http://expressjs.com/en/4x/api.html#res.status) lisäksi metodia [end](http://expressjs.com/en/4x/api.html#res.end) ilmoittamaan siitä, että pyyntöön tulee vastata ilman dataa. -Koodin haarautumisessa hyväksikäytetään sitä, että mikä tahansa Javascript-olio on [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy), eli katsotaan todeksi vertailuoperaatiossa. undefined taas on [falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy) eli epätosi. +Koodin haarautumisessa hyväksikäytetään sitä, että mikä tahansa JavaScript-olio on [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy), eli katsotaan todeksi vertailuoperaatiossa. _undefined_ taas on [falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy) eli epätosi. -Nyt sovellus toimii, eli palauttaa oikean virhekoodin. Sovellus ei kuitenkaan palauta mitään käyttäjälle näytettävää kuten web-sovellukset yleensä tekevät jos mennään osoitteeseen jota ei ole olemassa. Emme kuitenkaan tarvitse nyt mitään näytettävää, sillä REST API:t ovat ohjelmalliseen käyttöön tarkoitettuja rajapintoja ja pyyntöön liitetty virheestä kertova statuskoodi on riittävä. +Nyt sovellus palauttaa oikean virhekoodin. Sovellus ei kuitenkaan palauta mitään käyttäjälle näytettävää kuten web-sovellukset yleensä tekevät jos mennään osoitteeseen, jota ei ole olemassa. Emme kuitenkaan tarvitse nyt mitään näytettävää, sillä REST API:t ovat ohjelmalliseen käyttöön tarkoitettuja rajapintoja, ja pyyntöön liitetty virheestä kertova statuskoodi on riittävä. ### Resurssin poisto -Toteutetaan seuraavaksi resurssin poistava route. Poisto tapahtuu tekemällä HTTP DELETE -pyyntö resurssin urliin: +Toteutetaan seuraavaksi resurssin poistava route. Poisto tapahtuu tekemällä HTTP DELETE ‑pyyntö resurssin urliin: ```js app.delete('/api/notes/:id', (request, response) => { - const id = Number(request.params.id) + const id = request.params.id notes = notes.filter(note => note.id !== id) response.status(204).end() }) ``` -Jos poisto onnistuu, eli poistettava muistiinpano on olemassa, vastataan statuskoodilla [204 no content](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.2.5) sillä mukaan ei lähetetä mitään dataa. +Jos poisto onnistuu eli poistettava muistiinpano on olemassa, vastataan statuskoodilla [204 no content](https://www.rfc-editor.org/rfc/rfc9110.html#name-204-no-content) sillä mukaan ei lähetetä mitään dataa. -Ei ole täyttä yksimielisyyttä siitä mikä statuskoodi DELETE-pyynnöstä pitäisi palauttaa jos poistettavaa resurssia ei ole olemassa. Vaihtoehtoja ovat lähinnä 204 ja 404. Yksinkertaisuuden vuoksi sovellus palauttaa nyt molemmissa tilanteissa statuskoodin 204. +Ei ole täyttä yksimielisyyttä siitä, mikä statuskoodi DELETE-pyynnöstä pitäisi palauttaa jos poistettavaa resurssia ei ole olemassa. Vaihtoehtoja ovat lähinnä 204 ja 404. Yksinkertaisuuden vuoksi sovellus palauttaa nyt molemmissa tilanteissa statuskoodin 204. ### Postman -Herää kysymys miten voimme testata poisto-operaatiota? HTTP GET -pyyntöjä on helppo testata selaimessa. Voisimme toki kirjoittaa Javascript-koodin, joka testaa deletointia, mutta jokaiseen mahdolliseen tilanteeseen testikoodinkaan tekeminen ei ole aina paras ratkaisu. - -On olemassa useita backendin testaamista helpottavia työkaluja, eräs näistä on edellisessä osassa nopeasti mainittu komentorivityökalu [curl](https://curl.haxx.se). +HTTP GET ‑pyyntöjä on helppo testata selaimessa, mutta miten voimme testata poisto-operaatioita? Voisimme toki kirjoittaa JavaScript-koodin, joka testaa deletointia, mutta jokaiseen mahdolliseen tilanteeseen testikoodinkaan tekeminen ei ole aina paras ratkaisu. -Käytetään nyt kuitenkin [postman](https://www.getpostman.com/)-nimistä sovellusta. +On olemassa useita backendin testaamista helpottavia työkaluja, eräs näistä on [Postman](https://www.postman.com/), jota käytämme tällä kurssilla. -Asennetaan postman ja kokeillaan +Asennetaan Postmanin desktop sovellus [täältä](https://www.postman.com/downloads/) ja kokeillaan: -![](../../images/3/11ea.png) +![tehdään postmanilla operaatio DELETE http://localhost:3001/api/notes/2, huomataan että vastauksessa statuskoodi 204 no content](../../images/3/11x.png) Postmanin käyttö on tässä tilanteessa suhteellisen yksinkertaista, riittää määritellä url ja valita oikea pyyntötyyppi. -Palvelin näyttää vastaavan oikein. Tekemällä HTTP GET osoitteeseen selviää että poisto-operaatio oli onnistunut, muistiinpanoa, jonka id on 2 ei ole enää listalla. +Palvelin näyttää vastaavan oikein. Tekemällä HTTP GET osoitteeseen selviää, että poisto-operaatio onnistui. Muistiinpanoa, jonka id on 2 ei ole enää listalla. Koska muistiinpanot on talletettu palvelimen muistiin, uudelleenkäynnistys palauttaa tilanteen ennalleen. ### Visual Studio Coden REST client -Jos käytät Visual Studio Codea, voit postmanin sijaan käyttää VS Coden -[REST client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) -pluginia. +Jos käytät Visual Studio Codea, voit Postmanin sijaan käyttää VS Coden +[REST client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) ‑pluginia. -Kun plugin on asennettu, on sen käyttö erittäin helppoa. Tehdään projektin juureen hakemisto requests, jonka sisään talletetaan REST Client -pyynnöt .rest-päätteisinä tiedostoina. +Kun plugin on asennettu, on sen käyttö erittäin helppoa. Tehdään projektin juureen hakemisto requests, jonka sisään talletetaan REST Client ‑pyynnöt .rest-päätteisinä tiedostoina. -Luodaan kaikki muistiinpanot hakevan pyynnön määrittelevä tiedosto get\_all\_notes.rest +Luodaan kaikki muistiinpanot hakevan pyynnön määrittelevä tiedosto get\_all\_notes.rest: -![](../../images/3/12ea.png) +![Luodaan tiedosto jonka sisältlö GET http://localhost:3001/api/notes](../../images/3/12ea.png) -Klikkaamalla tekstiä Send Request, REST client suorittaa määritellyn HTTP-pyynnön ja palvelimen vastaus avautuu editoriin: +Klikkaamalla tekstiä Send Request, REST client suorittaa määritellyn HTTP-pyynnön, ja palvelimen vastaus avautuu editoriin: -![](../../images/3/13ea.png) +![VS codeen avautuu näkymä missä palvelimen palauttama json-muotoinen taulukko muistiinpanoja sekä operaatioon vastattu statuskoodi ja palautetut headerit](../../images/3/13new.png) ### Datan vastaanottaminen -Toteutetaan seuraavana uusien muistiinpanojen lisäys, joka siis tapahtuu tekemällä HTTP POST -pyyntö osoitteeseen http://localhost:3001/api/notes ja liittämällä pyynnön mukaan eli [bodyyn](https://www.w3.org/Protocols/rfc2616/rfc2616-sec7.html#sec7) luotavan muistiinpanon tiedot JSON-muodossa. +Toteutetaan seuraavana uusien muistiinpanojen lisäys, joka siis tapahtuu tekemällä HTTP POST ‑pyyntö osoitteeseen http://localhost:3001/api/notes ja liittämällä pyynnön [bodyyn](https://fastapi.tiangolo.com/tutorial/body/) luotavan muistiinpanon tiedot JSON-muodossa. -Jotta pääsisimme pyynnön mukana lähetettyyn dataan helposti käsiksi, tarvitsemme expressin tarjoaman [json-parserin](https://expressjs.com/en/api.html) apua. Tämä tapahtuu lisäämällä koodiin komento _app.use(express.json())_. +Jotta pääsisimme pyynnön mukana lähetettyyn dataan helposti käsiksi, tarvitsemme Expressin tarjoaman [json-parserin](https://expressjs.com/en/api.html) apua. Tämä tapahtuu lisäämällä koodiin komento _app.use(express.json())_. -Otetaan json-parseri käyttöön ja luodaan alustava määrittely HTTP POST -pyynnön käsittelyyn: +Otetaan json-parseri käyttöön ja luodaan alustava määrittely HTTP POST ‑pyynnön käsittelyyn: ```js const express = require('express') const app = express() -app.use(express.json()) +app.use(express.json()) // highlight-line //... +// highlight-start app.post('/api/notes', (request, response) => { const note = request.body console.log(note) response.json(note) }) +// highlight-end ``` Tapahtumankäsittelijäfunktio pääsee dataan käsiksi olion _request_ kentän body avulla. -Ilman json-parserin lisäämistä eli komentoa _app.use(express.json())_ pyynnön kentän body arvo olisi ollut määrittelemätön. json-parserin toimintaperiaatteena on, että se ottaa pyynnön mukana olevan JSON-muotoisen datan, muuttaa sen Javascript-olioksi ja sijoittaa _request_-olion kenttään body ennen kuin routen käsittelijää kutsutaan. +Ilman json-parserin lisäämistä eli komentoa _app.use(express.json())_ pyynnön kentän body arvo olisi ollut määrittelemätön. Json-parserin toimintaperiaatteena on, että se ottaa pyynnön mukana olevan JSON-muotoisen datan, muuttaa sen JavaScript-olioksi ja sijoittaa _request_-olion kenttään body ennen kuin routen käsittelijää kutsutaan. Toistaiseksi sovellus ei vielä tee vastaanotetulle datalle mitään muuta kuin tulostaa sen konsoliin ja palauttaa sen pyynnön vastauksessa. -Ennen toimintalogiikan viimeistelyä varmistetaan ensin postmanilla, että lähetetty tieto menee varmasti perille. Pyyntötyypin ja urlin lisäksi on määriteltävä myös pyynnön mukana menevä data eli body: +Ennen toimintalogiikan viimeistelyä varmistetaan ensin Postmanilla, että lähetetty tieto menee varmasti perille. Pyyntötyypin ja urlin lisäksi on määriteltävä myös pyynnön mukana menevä data eli body: -![](../../images/3/14ea.png) +![Valitaan postmanissa JSON body-datan tyypiksi](../../images/3/14new.png) Sovellus tulostaa lähetetyn vastaanottamansa datan terminaaliin: -![](../../images/3/15e.png) +![Konsoliin tulostuu palvelimen vastaanottama json-objekti](../../images/3/15c.png) -**HUOM** kun ohjelmoit backendia, pidä sovellusta suorittava konsoli koko ajan näkyvillä. Nodemonin ansiosta sovellus käynnistyy uudelleen jos koodiin tehdään muutoksia. Jos seuraat konsolia, huomaat välittömästi jos sovelluksen koodiin tulee joku perustavanlaatuinen virhe: +**HUOM:** Kun ohjelmoit backendia, pidä sovellusta suorittava konsoli koko ajan näkyvillä. Kehityspalvelin käynnistyy uudelleen, jos koodiin tehdään muutoksia, joten jos seuraat konsolia, huomaat välittömästi jos sovelluksen koodiin tulee virhe: -![](../../images/3/16.png) +![konsoliin tulostuu epävalidista javascriptistä johtuva parse error ‑virheilmoitus](../../images/3/16_25.png) -Vastaavasti konsolista kannattaa seurata reagoiko backend odotetulla tavalla, esim. kun sovellukselle lähetetään dataa metodilla HTTP POST. Backendiin kannattaa luonnollisesti lisäillä runsaat määrät console.log-komentoja kun sovellus on kehitysvaiheessa. +Konsolista kannattaa seurata myös, reagoiko backend odotetulla tavalla esim. kun sovellukselle lähetetään dataa metodilla HTTP POST. Backendiin kannattaa luonnollisesti lisäillä runsaat määrät console.log-komentoja kun sovellus on kehitysvaiheessa. -Eräs potentiaalinen ongelmanlähde on se, että dataa lähettäessä, sen headerille Content-Type ei aseteta oikeaa arvoa. Näin tapahtuu esim. jos Postmanissa bodyn tyyppiä ei määritellä oikein: +Eräs ongelmanlähde on se, että dataa lähettäessä headerille Content-Type ei aseteta oikeaa arvoa. Näin tapahtuu esim. jos Postmanissa bodyn tyyppiä ei määritellä oikein: -![](../../images/3/17e.png) +![Valitaan postmanissa text body-datan tyypiksi](../../images/3/17new.png) -headerin Content-Type arvoksi asettuu text/plain +Headerin Content-Type arvoksi asettuu text/plain: -![](../../images/3/18e.png) +![Nähdään postmanin headers-välilehdeltä että content-type on text/plain](../../images/3/18new.png) -Palvelin näyttää vastaanottavan ainoastaan tyhjän olion +Palvelin näyttää vastaanottavan ainoastaan tyhjän olion: -![](../../images/3/19.png) +![Konsoliin tulostuu tyhjä json](../../images/3/19_25.png) Ilman oikeaa headerin arvoa palvelin ei osaa parsia dataa oikeaan muotoon. Se ei edes yritä arvailla missä muodossa data on, sillä potentiaalisia datan siirtomuotoja eli Content-Typejä on olemassa [suuri määrä](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types). -Jos käytät VS Codea niin edellisessä luvussa esitelty REST client kannattaa asentaa viimeistään nyt. POST-pyyntö tehdään REST clientillä seuraavasti: +Jos käytät VS Codea, edellisessä luvussa esitelty REST client kannattaa asentaa viimeistään nyt. POST-pyyntö tehdään REST clientillä seuraavasti: -![](../../images/3/20eb.png) +![VS codeen avautuu näkymä joka näyttää palvelimen palauttaman, luodun json-objektin, sekä siihen liittyvät headerit ja statuskoodin 200](../../images/3/20new.png) -Eli pyyntöä varten on luotu oma tiedosto create\_note.rest. Pyyntö on muotoiltu [dokumentaation ohjetta](https://github.com/Huachao/vscode-restclient/blob/master/README.md#usage) noudatellen. +Pyyntöä varten on siis luotu oma tiedosto create\_note.rest. Pyyntö on muotoiltu [dokumentaation ohjetta](https://github.com/Huachao/vscode-restclient/blob/master/README.md#usage) noudatellen. -REST clientin eräs suuri etu Postmaniin verrattuna on se, että pyynnöt saa kätevästi talletettua projektin repositorioon ja tällöin ne ovat helposti koko kehitystiimin käytössä. Postmanillakin on mahdollista tallettaa pyyntöjä, mutta tilanne menee helposti kaaoottiseksi etenkin jos työn alla on useita toisistaan riippumattomia projekteja. +REST clientin eräs suuri etu Postmaniin verrattuna on se, että pyynnöt saa kätevästi talletettua projektin repositorioon ja tällöin ne ovat helposti koko kehitystiimin käytössä. Postmanillakin on mahdollista tallettaa pyyntöjä, mutta tilanne menee helposti kaoottiseksi etenkin jos työn alla on useita toisistaan riippumattomia projekteja. > **Tärkeä sivuhuomio** > > Välillä debugatessa tulee vastaan tilanteita, joissa backendissä on tarve selvittää, mitä headereja HTTP-pyynnöille on asetettu. Eräs menetelmä tähän on _request_-olion melko kehnosti nimetty metodi [get](http://expressjs.com/en/4x/api.html#req.get), jonka avulla voi selvittää yksittäisen headerin arvon. _request_-oliolla on myös kenttä headers, jonka arvona ovat kaikki pyyntöön liittyvät headerit. > -> Ongelmia voi esim. syntyä, jos jätät vahingossa VS Coden REST-clientillä ylimmän rivin ja headerit määrittelevien rivien väliin tyhjän rivin. Tällöin REST-client tulkitsee, että millekään headerille ei aseteta arvoa ja näin backend ei osaa tulkita pyynnön mukana olevaa dataa JSON:iksi. +> Ongelmia voi syntyä esim., jos jätät vahingossa VS Coden REST clientillä ylimmän rivin ja headerit määrittelevien rivien väliin tyhjän rivin. Tällöin REST client tulkitsee, että millekään headerille ei aseteta arvoa ja näin backend ei osaa tulkita pyynnön mukana olevaa dataa JSON:iksi. > -> Puuttuvan content-type-headerin ongelma selviää, kun backendissa tulostaa pyynnön headerit esim. komennolla _console.log(request.headers)_. +> Puuttuvan Content-Type-headerin ongelma selviää, kun backendissa tulostaa pyynnön headerit esim. komennolla _console.log(request.headers)_. Palataan taas sovelluksen pariin. Kun tiedämme, että sovellus vastaanottaa tiedon oikein, voimme viimeistellä sovelluslogiikan: ```js app.post('/api/notes', (request, response) => { const maxId = notes.length > 0 - ? Math.max(...notes.map(n => n.id)) + ? Math.max(...notes.map(n => Number(n.id))) : 0 const note = request.body - note.id = maxId + 1 + note.id = String(maxId + 1) notes = notes.concat(note) @@ -720,16 +625,16 @@ app.post('/api/notes', (request, response) => { }) ``` -Uudelle muistiinpanolle tarvitaan uniikki id. Ensin selvitetään olemassaolevista id:istä suurin muuttujaan _maxId_. Uuden muistiinpanon id:ksi asetetaan sitten _maxId + 1_. Tämä tapa ei ole itse asiassa kovin hyvä, mutta emme nyt välitä siitä, sillä tulemme pian korvaamaan tavan, jolla muistiinpanot talletetaan. +Uudelle muistiinpanolle tarvitaan uniikki id. Ensin selvitetään olemassa olevista id:istä suurin muuttujaan _maxId_. Uuden muistiinpanon id:ksi asetetaan sitten _maxId + 1_ merkkijonomuodossa. Tämä tapa ei ole kovin hyvä, mutta emme nyt välitä siitä, sillä tulemme pian korvaamaan tavan, jolla muistiinpanot talletetaan. -Tämänhetkisessä versiossa on vielä se ongelma, että voimme HTTP POST -pyynnöllä lisätä mitä tahansa kenttiä sisältäviä olioita. Parannellaan sovellusta siten, että kenttä content ei voi olla tyhjä. Kentille important ja date asetetaan oletusarvot. Kaikki muut kentät hylätään: +Tämänhetkisessä versiossa on vielä se ongelma, että voimme HTTP POST ‑pyynnöllä lisätä mitä tahansa kenttiä sisältäviä olioita. Parannellaan sovellusta siten, että kenttä content ei saa olla tyhjä. Kentälle important asetetaan oletusarvo false jos sen arvoa ei ole määritelty. Kaikki muut kentät hylätään: ```js const generateId = () => { const maxId = notes.length > 0 - ? Math.max(...notes.map(n => n.id)) + ? Math.max(...notes.map(n => Number(n.id))) : 0 - return maxId + 1 + return String(maxId + 1) } app.post('/api/notes', (request, response) => { @@ -744,7 +649,6 @@ app.post('/api/notes', (request, response) => { const note = { content: body.content, important: body.important || false, - date: new Date(), id: generateId(), } @@ -756,7 +660,7 @@ app.post('/api/notes', (request, response) => { Tunnisteena toimivan id-kentän arvon generointilogiikka on eriytetty funktioon _generateId_. -Jos vastaanotetulta datalta puuttuu sisältö kentästä content, vastataan statuskoodilla [400 bad request](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1): +Jos vastaanotetulta datalta puuttuu sisältö kentästä content, vastataan statuskoodilla [400 bad request](https://www.rfc-editor.org/rfc/rfc9110.html#name-400-bad-request): ```js if (!body.content) { @@ -766,11 +670,9 @@ if (!body.content) { } ``` -Huomaa, että returnin kutsuminen on tärkeää. Jos sitä ei tapahdu, koodi jatkaa suoritusta metodin loppuun asti, ja virheellinen muistiinpano tallettuu! - -Jos content-kentällä on arvo, luodaan muistiinpano syötteen perusteella. Kuten edellisessä osassa mainitsimme, aikaleimoja ei kannata luoda selaimen koodissa, sillä käyttäjän koneen kellon aikaan ei voi luottaa. Aikaleiman eli kentän date arvon generointi tapahtuukin nyt palvelimen toimesta. +Huomaa, että returnin kutsuminen on tärkeää. Ilman kutsua koodi jatkaisi suoritusta metodin loppuun asti, ja virheellinen muistiinpano tallettuisi! -Jos kenttä important puuttuu, asetetaan sille oletusarvo false. Oletusarvo generoidaan nyt hieman erikoisella tavalla: +Jos content-kentällä on arvo, luodaan muistiinpano syötteen perusteella. Jos kenttä important puuttuu, asetetaan sille oletusarvo false. Oletusarvo generoidaan nyt hieman erikoisella tavalla: ```js important: body.important || false, @@ -780,32 +682,32 @@ Jos sovelluksen vastaanottamassa muuttujaan _body_ talletetussa datassa on kentt > Jos ollaan tarkkoja, niin kentän important arvon ollessa false, tulee lausekkeen body.important || false arvoksi oikean puoleinen false... -Sovelluksen tämän hetkinen koodi on kokonaisuudessaan [Githubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-1). +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-1). -Huomaa, että repositorion master-haarassa on myöhemmän vaiheen koodi. Tämän hetken koodi on branchissa [part3-1](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-1): +Tämän hetken koodi on branchissa [part3-1](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-1): -![](../../images/3/21.png) +![Kuva havainnollistaa miten branchi löydetään githubista](../../images/3/21.png) -Jos kloonaat projektin itsellesi, suorita komento _npm install_ ennen käynnistämistä eli komentoa _npm start_ tai _npm run dev_. +Jos kloonaat projektin itsellesi, suorita komento _npm install_ ennen käynnistämistä eli ennen komentoa _npm start_ tai _npm run dev_. -Vielä pieni huomio ennen tehtäviä. Uuden id:n generoiva funktio näyttää seuraavalta +Vielä pieni huomio ennen tehtäviä. Uuden id:n generoiva funktio näyttää seuraavalta: ```js const generateId = () => { const maxId = notes.length > 0 - ? Math.max(...notes.map(n => n.id)) + ? Math.max(...notes.map(n => Number(n.id))) : 0 - return maxId + 1 + return String(maxId + 1) } ``` -Koodi sisältää hieman erikoisen näköisen rivin +Koodi sisältää hieman erikoisen näköisen rivin: ```js -Math.max(...notes.map(n => n.id)) +Math.max(...notes.map(n => Number(n.id))) ``` -Mitä rivillä tapahtuu? notes.map(n => n.id) muodostaa taulukon, joka koostuu muistiinpanojen id-kentisstä. [Math.max](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/max) palauttaa maksimin sille parametrina annetuista luvuista. notes.map(n => n.id) on kuitenkin taulukko, joten se ei kelpaa parametriksi komennolle _Math.max_. Taulukko voidaan muuttaa yksittäisiksi luvuiksi käyttäen taulukon [spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax)-syntaksia, eli kolmea pistettä ...taulukko. +Mitä rivillä tapahtuu? notes.map(n => Number(n.id)) muodostaa taulukon, joka koostuu muistiinpanojen id-kenttiä vastaavasta numeroarvosta. [Math.max](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/max) palauttaa maksimin sille parametrina annetuista luvuista. notes.map(n => Number(n.id))) on kuitenkin taulukko, joten se ei kelpaa parametriksi komennolle _Math.max_. Taulukko voidaan muuttaa yksittäisiksi luvuiksi käyttäen taulukon [spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax)-syntaksia, eli kolmea pistettä ...taulukko.
    @@ -813,9 +715,7 @@ Mitä rivillä tapahtuu? notes.map(n => n.id) muodostaa taulukon, joka ### Tehtävät 3.1.-3.6. -**HUOM:** tämän osan tehtäväsarja kannattaa tehdä omaan git-repositorioon, suoraan repositorion juureen! Jos et tee näin, joudut ongelmiin tehtävässä 3.10 - -**HUOM2:** Koska nyt ei ole kyse frontendista ja Reactista, sovellusta ei luoda create-react-app:illa vaan komennolla npm init, kuten ylempänä tämän osan materiaalissa. +**HUOM:** Koska nyt ei ole kyse frontendista ja Reactista, sovellusta ei luoda Vitellä vaan komennolla npm init, kuten ylempänä tämän osan materiaalissa. **Vahva suositus:** kun teet backendin koodia, pidä koko ajan silmällä, mitä palvelimen koodia suorittavassa konsolissa tapahtuu. @@ -824,9 +724,7 @@ Mitä rivillä tapahtuu? notes.map(n => n.id) muodostaa taulukon, joka Tee Node-sovellus, joka tarjoaa osoitteessa kovakoodatun taulukon puhelinnumerotietoja: -![](../../images/3/22e.png) - -Huomaa, että Noden routejen määrittelyssä merkkijonon api/persons vinoviiva käyttäytyy kuten mikä tahansa muu merkki. +![Selain renderöi taulukollisen json-muotoisia objekteja joilla kentät id, name ja number](../../images/3/22e.png) Sovellus pitää pystyä käynnistämään komennolla _npm start_. @@ -836,9 +734,9 @@ Komennolla _npm run dev_ käynnistettäessa sovelluksen tulee käynnistyä uudel Tee sovelluksen osoitteeseen suunnilleen seuraavanlainen sivu -![](../../images/3/23ea.png) +![Selin renderöi kutsuhetken kellonajan sekä tekstin 'Phonebook has info for 2 people'](../../images/3/23x.png) -eli sivu kertoo pyynnön tekohetken sekä sen, kuinka monta puhelinluettelotietoa sovelluksen muistissa olevassa taulukossa on. +Sivun tulee siis kertoa pyynnön tekohetki sekä se, kuinka monta puhelinluettelotietoa sovelluksen muistissa olevassa taulukossa on. #### 3.3: puhelinluettelon backend step3 @@ -848,13 +746,13 @@ Jos id:tä vastaavaa puhelinnumerotietoa ei ole, tulee palvelimen vastata asianm #### 3.4: puhelinluettelon backend step4 -Toteuta toiminnallisuus, jonka avulla puhelinnumerotieto on mahdollista poistaa numerotiedon yksilöivään URL:iin tehtävällä HTTP DELETE -pyynnöllä. +Toteuta toiminnallisuus, jonka avulla puhelinnumerotieto on mahdollista poistaa numerotiedon yksilöivään URL:iin tehtävällä HTTP DELETE ‑pyynnöllä. -Testaa toiminnallisuus Postmanilla tai Visual Studio Coden REST-clientillä. +Testaa toiminnallisuus Postmanilla tai Visual Studio Coden REST clientillä. #### 3.5: puhelinluettelon backend step5 -Laajenna backendia siten, että uusia puhelintietoja on mahdollista lisätä osoitteeseen tapahtuvalla HTTP POST -pyynnöllä. +Laajenna backendia siten, että uusia puhelintietoja on mahdollista lisätä osoitteeseen tapahtuvalla HTTP POST ‑pyynnöllä. Generoi uuden puhelintiedon tunniste funktiolla [Math.random](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random). Käytä riittävän isoa arvoväliä, jotta arvottu id on riittävän suurella todennäköisyydellä sellainen, joka ei ole jo käytössä. @@ -864,7 +762,7 @@ Tee uuden numeron lisäykseen virheiden käsittely. Pyyntö ei saa onnistua, jos - nimi tai numero puuttuu - lisättävä nimi on jo luettelossa -Vastaa asiaankuuluvalla statuskoodilla ja liitä vastaukseen mukaan myös tieto, joka kertoo virheen syyn, esim: +Vastaa asiaankuuluvalla statuskoodilla ja liitä vastaukseen mukaan myös tieto, joka kertoo virheen syyn, esim.: ```js { error: 'name must be unique' } @@ -882,7 +780,7 @@ HTTP-pyynnöistä GET:in tulisi olla safe: > In particular, the convention has been established that the GET and HEAD methods SHOULD NOT have the significance of taking an action other than retrieval. These methods ought to be considered "safe". -Safety siis tarkoittaa, että pyynnön suorittaminen ei saa aiheuttaa palvelimelle sivuvaikutuksia eli esim. muuttaa palvelimen tietokannan tilaa. Pyynnön tulee ainoastaan palauttaa palvelimella olevaa dataa. +Safety tarkoittaa siis, että pyynnön suorittaminen ei saa aiheuttaa palvelimelle sivuvaikutuksia eli esim. muuttaa palvelimen tietokannan tilaa. Pyynnön tulee ainoastaan palauttaa palvelimella olevaa dataa. Mikään ei automaattisesti takaa, että GET-pyynnöt olisivat luonteeltaan safe. Kyseessä onkin HTTP-standardin suositus palvelimien toteuttajille. RESTful-periaatetta noudattaessa GET-pyyntöjä käytetäänkin aina siten, että ne ovat safe. @@ -894,19 +792,19 @@ HTTP-pyynnöistä muiden paitsi POST:in tulisi olla idempotentteja: Eli jos pyynnöllä on sivuvaikutuksia, lopputulos on sama suoritettaessa pyyntö yhden tai useamman kerran. -Esim. jos tehdään HTTP PUT -pyyntö osoitteeseen /api/notes/10 ja pyynnön mukana on { content: "ei sivuvaikutuksia", important: true }, on lopputulos sama riippumatta siitä, kuinka monta kertaa pyyntö suoritetaan. +Esim. jos tehdään HTTP PUT ‑pyyntö osoitteeseen /api/notes/10 ja pyynnön mukana on { content: "ei sivuvaikutuksia", important: true }, on lopputulos sama riippumatta siitä, kuinka monta kertaa pyyntö suoritetaan. Kuten metodin GET safety myös idempotence on HTTP-standardin suositus palvelimien toteuttajille. RESTful-periaatetta noudattaessa GET-, HEAD-, PUT- ja DELETE-pyyntöjä käytetäänkin aina siten, että ne ovat idempotentteja. -HTTP-pyyntötyypeistä POST on ainoa, joka ei ole safe eikä idempotent. Jos tehdään 5 kertaa HTTP POST -pyyntö osoitteeseen /api/notes siten että pyynnön mukana on { content: "monta samaa", important: true }, tulee palvelimelle 5 saman sisältöistä muistiinpanoa. +HTTP-pyyntötyypeistä POST on ainoa, joka ei ole safe eikä idempotent. Jos tehdään viisi kertaa HTTP POST ‑pyyntö osoitteeseen /api/notes siten että pyynnön mukana on { content: "monta samaa", important: true }, tulee palvelimelle viisi saman sisältöistä muistiinpanoa. ### Middlewaret -Äsken käyttöönottamamme expressin [json-parseri](https://expressjs.com/en/api.html) on terminologiassa niin sanottu [middleware](http://expressjs.com/en/guide/using-middleware.html). +Äsken käyttöönottamamme Expressin [json-parseri](https://expressjs.com/en/api.html) on terminologiassa niin sanottu [middleware](http://expressjs.com/en/guide/using-middleware.html). Middlewaret ovat funktioita, joiden avulla voidaan käsitellä _request_- ja _response_-olioita. -Esim. json-parseri ottaa pyynnön mukana tulevan raakadatan _request_-oliosta, parsii sen Javascript-olioksi ja sijoittaa olion _request_:in kenttään body +Esim. json-parseri ottaa pyynnön mukana tulevan raakadatan _request_-oliosta, parsii sen JavaScript-olioksi ja sijoittaa olion _request_:in kenttään body Middlewareja voi olla käytössä useita, jolloin ne suoritetaan peräkkäin siinä järjestyksessä, kuin ne on otettu koodissa käyttöön. @@ -946,7 +844,7 @@ const unknownEndpoint = (request, response) => { app.use(unknownEndpoint) ``` -Sovelluksen tämän hetkinen koodi on kokonaisuudessaan [Githubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-2), branchissa part3-2. +Sovelluksen tämän hetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-2), branchissa part3-2.
    @@ -958,19 +856,19 @@ Sovelluksen tämän hetkinen koodi on kokonaisuudessaan [Githubissa](https://git Lisää sovellukseesi loggausta tekevä middleware [morgan](https://github.com/expressjs/morgan). Konfiguroi se logaamaan konsoliin tiny-konfiguraation mukaisesti. -Morganin ohjeet eivät ole ehkä kaikkein selvimmät, ja joudut kenties miettimään hiukan. Toisaalta juuri koskaan dokumentaatio ei ole aivan itsestäänselvää, joten kryptisempiäkin asioita on hyvä oppia tulkitsemaan. +Morganin ohjeet eivät ole ehkä kaikkein selvimmät, ja joudut kenties miettimään hiukan. Toisaalta dokumentaatio ei ole juuri koskaan aivan itsestäänselvää, joten kryptisempiäkin asioita on hyvä oppia tulkitsemaan. Morgan asennetaan kuten muutkin kirjastot, eli komennolla _npm install_ ja sen käyttöönotto tapahtuu kaikkien middlewarejen tapaan komennolla _app.use_ #### 3.8*: puhelinluettelon backend step8 -Konfiguroi morgania siten, että se näyttää myös HTTP POST -pyyntöjen mukana tulevan datan: +Konfiguroi morgania siten, että se näyttää myös HTTP POST ‑pyyntöjen mukana tulevan datan: -![](../../images/3/24.png) +![Konsoliin tulostuu HTTP-pyntötyyppi, kutsuttu polku, paluuarvon statuskoodi, operaation viemä aika millisekunteina (tämä on morganin defaultina näyttämä) sekä pyynnön mukana mahdollisesti lähetetty data](../../images/3/24.png) -Tämä tehtävä on kohtuullisen haastava, vaikka koodia ei tarvitakkaan paljoa. +Tämä tehtävä on kohtuullisen haastava, vaikka koodia ei tarvitakaan paljoa. -Tehtävän voi tehdä muutamallakin tavalla. Eräs näistä onnistuu hyödyntämällä seuraavia +Tehtävän voi tehdä muutamallakin tavalla. Eräs näistä onnistuu hyödyntämällä seuraavia: - [creating new tokens](https://github.com/expressjs/morgan#creating-new-tokens) - [JSON.stringify](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) diff --git a/src/content/3/fi/osa3b.md b/src/content/3/fi/osa3b.md index ae43094cd15..48fb935d851 100644 --- a/src/content/3/fi/osa3b.md +++ b/src/content/3/fi/osa3b.md @@ -9,7 +9,7 @@ lang: fi Yhdistetään seuraavaksi [osassa 2](/osa2) tekemämme frontend omaan backendiimme. -Edellisessä osassa backendinä toiminut json-server tarjosi muistiinpanojen listan osoitteessa http://localhost:3001/notes frontendin käyttöön. Backendimme urlien rakenne on hieman erilainen, muistiinpanot löytyvät osoitteesta http://localhost:3001/api/notes, eli muutetaan frontendin tiedostossa src/services/notes.js määriteltyä muuttujaa _baseUrl_ seuraavasti: +Edellisessä osassa backendinä toiminut JSON Server tarjosi muistiinpanojen listan osoitteessa http://localhost:3001/notes frontendin käyttöön. Backendimme urlien rakenne on hieman erilainen (muistiinpanot ovat osoitteessa http://localhost:3001/api/notes) eli muutetaan frontendin tiedostossa src/services/notes.js määriteltyä muuttujaa _baseUrl_ seuraavasti: ```js import axios from 'axios' @@ -27,7 +27,7 @@ export default { getAll, create, update } Frontendin tekemä GET-pyyntö osoitteeseen ei jostain syystä toimi: -![](../../images/3/3ae.png) +![Konsolissa näkyy virhe 'Access ... blocked by CORS policy'](../../images/3/3ae.png) Mistä on kyse? Backend toimii kuitenkin selaimesta ja postmanista käytettäessä ilman ongelmaa. @@ -37,19 +37,19 @@ Kyse on asiasta nimeltään CORS eli Cross-origin resource sharing. [Wikipedian] > Cross-origin resource sharing (CORS) is a mechanism that allows restricted resources (e.g. fonts) on a web page to be requested from another domain outside the domain from which the first resource was served. A web page may freely embed cross-origin images, stylesheets, scripts, iframes, and videos. Certain "cross-domain" requests, notably Ajax requests, are forbidden by default by the same-origin security policy. -Lyhyesti sanottuna meidän kontekstissa kyse on seuraavasta: websovelluksen selaimessa suoritettava Javascript-koodi saa oletusarvoisesti kommunikoida vain samassa [originissa](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy) olevan palvelimen kanssa. Koska palvelin on localhostin portissa 3001 ja frontend localhostin portissa 3000, niiden origin ei ole sama. +Lyhyesti sanottuna meidän kontekstissa kyse on seuraavasta: web-sovelluksen selaimessa suoritettava JavaScript-koodi saa oletusarvoisesti kommunikoida vain samassa [originissa](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy) olevan palvelimen kanssa. Koska palvelin on localhostin portissa 3001 ja frontend localhostin portissa 5173, niiden origin ei ole sama. -Korostetaan vielä, että [same origin policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy) ja CORS eivät ole mitenkään React- tai Node-spesifisiä asioita, vaan yleismaailmallisia periaatteita Web-sovellusten toiminnasta. +Korostetaan vielä, että [same origin policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy) ja CORS eivät ole mitenkään React- tai Node-spesifisiä asioita, vaan yleismaailmallisia periaatteita web-sovellusten toiminnasta. Voimme sallia muista origineista tulevat pyynnöt käyttämällä Noden [cors](https://github.com/expressjs/cors)-middlewarea. Asennetaan backendiin cors komennolla ```bash -npm install cors --save +npm install cors ``` -Otetaan middleware käyttöön toistaiseksi sellaisella konfiguraatiolla joka sallii kaikista origineista tulevat pyynnöt kaikkiin backendin express routeihin: +Otetaan middleware käyttöön toistaiseksi sellaisella konfiguraatiolla, joka sallii kaikista origineista tulevat pyynnöt kaikkiin backendin Express routeihin: ```js const cors = require('cors') @@ -57,23 +57,36 @@ const cors = require('cors') app.use(cors()) ``` -Nyt frontend toimii! Tosin muistiinpanojen tärkeäksi muuttavaa toiminnallisuutta backendissa ei vielä ole. +Nyt frontend toimii muuten, mutta muistiinpanojen tärkeäksi muuttaminen ei vielä onnistu, sillä toiminnallisuutta backendissa ei vielä ole. CORS:ista voi lukea tarkemmin esim. [Mozillan sivuilta](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS). -### Sovellus internettiin +Sovelluksen suoritusympäristö näyttää nyt seuraavalta: -Kun koko "stäkki" on saatu vihdoin kuntoon, siirretään sovellus internettiin. Käytetään seuraavassa vanhaa kunnon [Herokua](https://www.heroku.com). +![Kuvassa localhost:5173 toimiva Vite dev server ja localhost:3001 toimiva node backend, jotka molemmat käyttävät lokaalilla levylä olevia fs-tiedostoja. Kuvassa myös selaimessa oleva react-sovellus. joka yhteydessä dev-serveriin (mistä se saa js-tiedoston) sekä node-backendiin jonka reitilt /app/notes sen saa json-muotoisen datan](../../images/3/100_25.png) -> Jos et ole koskaan käyttänyt herokua, löydät käyttöohjeita kurssin [Tietokantasovellus](https://materiaalit.github.io/tsoha-18/viikko1/)-materiaalista ja Googlaamalla... +Selaimessa toimiva frontendin koodi siis hakee datan osoitteessa localhost:3001 olevalta Express-palvelimelta. -Lisätään backendin projektin juureen tiedosto Procfile, joka kertoo Herokulle, miten sovellus käynnistetään +### Sovellus Internetiin -```bash -web: node index.js -``` +Kun koko "stäkki" on saatu vihdoin kuntoon, siirretään sovellus Internetiin. + +Sovellusten hostaamiseen, eli "internettiin laittamiseen" on olemassa lukematon määrä erilaisia ratkaisuja. Helpoimpia näistä sovelluskehittäjän kannalta ovat ns PaaS (eli Platform as a Service) ‑palvelut, jotka huolehtivat sovelluskehittäjän puolesta tietokannan ja suoritusympäristön asentamisen. + +Kymmenen vuoden ajan PaaS-ratkaisujen ykkönen on ollut [Heroku](http://heroku.com). Elokuun 2022 lopussa Heroku ilmoitti että 27.11.2022 alkaen alustan maksuttomat palvelut loppuvat. Jos olet valmis maksamaan hiukan, on Heroku edelleen varteenotettava vaihtoehto. + +Esittelemme seuraavassa kaksi hieman uudempaa palvelua [Fly.io:n](https://fly.io/):n sekä [Renderin](https://render.com/). Fly.io tarjoaa palveluna enemmän joustavuutta, mutta myös se on muuttunut hiljattain maksulliseksi. Renderissä on tarjolla jonkin verran ilmaista laskenta-aikaa, joten jos haluat suorittaa kurssin ilman kuluja, valitse Render. Renderin käyttöönotto saattaa myös olla jossain tapauksissa helpompaa, sillä Render ei edellytä mitään asennuksia omalle koneelle. + +On olemassa myös muita palveluja, jotka saattavat toimia hyvin tämän kurssin kanssa, ainakin kaikissa muissa osissa paitsi osassa 11 (CI/CD), jossa saattaa olla yksi hankala harjoitus muille alustoille. + +Jotkut kurssin osallistujat ovat käyttäneet seuraavia palveluita: -Muutetaan tiedoston index.js lopussa olevaa sovelluksen käyttämän portin määrittelyä seuraavasti: +- [Replit](https://replit.com) +- [CodeSandBox](https://codesandbox.io) + +Jos tiedät helppokäyttöisiä ja ilmaisia palveluita NodeJS:n hostaamiseen, kerro siitä meille! + +Molempia ratkaisuja varten muutetaan tiedoston index.js lopussa olevaa sovelluksen käyttämän portin määrittelyä seuraavasti: ```js const PORT = process.env.PORT || 3001 // highlight-line @@ -82,40 +95,122 @@ app.listen(PORT, () => { }) ``` -Nyt käyttöön tulee [ympäristömuuttujassa](https://en.wikipedia.org/wiki/Environment_variable) _PORT_ määritelty portti tai 3001, jos ympäristömuuttuja _PORT_ ei ole määritelty. Heroku konfiguroi sovelluksen portin ympäristömuuttujan avulla. +Nyt käyttöön tulee [ympäristömuuttujassa](https://en.wikipedia.org/wiki/Environment_variable) _PORT_ määritelty portti tai 3001, jos ympäristömuuttuja _PORT_ ei ole määritelty. Sekä Fly.io:ssa että Renderissä sovelluksen portti on mahdollista konfiguroida ympäristömuuttujan avulla. + +#### Fly.io +Huomaa, että sinun tulee mahdollisesti syöttää Fly.io:hon maksukorttisi numero, ja palvelun käytöstä veloitetaan hinnaston mukaisesti, jos erillistä ilmaista kokeilua ei ole tarjolla. + +Jos päätät käyttää [Fly.io](https://fly.io/):ta, aloita asentamalla Fly.io [tämän](https://fly.io/docs/hands-on/install-flyctl/) ohjeen mukaan ja luomalla itsellesi [tunnus](https://fly.io/docs/hands-on/sign-up/) palveluun. -Tehdään projektihakemistosta git-repositorio ja lisätään .gitignore, jolla on seuraava sisältö +Aloita [kirjautumalla](https://fly.io/docs/hands-on/sign-in/) komentoriviltä palveluun komennolla ```bash -node_modules +fly auth login ``` -Luodaan heroku-sovellus komennolla _heroku create_, tehdään sovelluksen hakemistosta git-repositorio, commitoidaan koodi ja siirretään se Herokuun komennolla _git push heroku master_. +*HUOM* jos komento fly ei toimi, kokeile toimiiko sen pidempi muoto _flyctl_. Esim. Macissa toimivat komennon molemmat muodot. -Jos kaikki meni hyvin, sovellus toimii: +Sovelluksen alustus tapahtuu seuraavasti. Mene sovelluksen juurihakemistoon ja anna komento -![](../../images/3/25ea.png) +```bash +fly launch --no-deploy +``` -Jos ei, vikaa voi selvittää herokun lokeja lukemalla, eli komennolla _heroku logs_. +Anna sovellukselle nimi, tai anna Fly.io:n generoida automaattinen nimi, valitse "region" eli alue, jonka konesalissa sovelluksesi toimii. Älä luo sovellukselle postgres- sekä Upstash Redis-tietokantaa. Lopuksi vielä kysytään "Would you like to deploy now?" eli haluatko että sovellus myös viedään tuotantoympäristöön. Valitse "Ei". -> **HUOM** ainakin alussa on järkevää tarkkailla Herokussa olevan sovelluksen lokeja koko ajan. Parhaiten tämä onnistuu antamalla komento _heroku logs -t_, jolloin logit tulevat konsoliin sitä mukaan kun palvelimella tapahtuu jotain. +Fly.io luo hakemistoosi tiedoston fly.toml, joka sisältää sovelluksen tuotantoympäristön konfiguraation. -Myös frontend toimii Herokussa olevan backendin avulla. Voit varmistaa asian muuttamalla frontendiin määritellyn backendin osoitteen viittaamaan http://localhost:3001:n sijaan Herokussa olevaan backendiin. +Jotta sovellus saadaan konfiguroitua oikein, tulee tiedostoon konfiguraatioon tehdä pieni lisäys osiin [env] -Seuraavaksi herää kysymys: miten saamme myös frontendin internettiin? Vaihtoehtoja on useita, mutta käydään seuraavaksi läpi yksi niistä. +```bash +[build] + +[env] + PORT = "3001" # add this + +[http_service] + internal_port = 3001 # ensure that this is same as PORT + force_https = true + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 0 + processes = ["app"] +``` + +Osaan [env] lisätään tarvittaessa (jos se ei jo siellä valmiiksi ole) määritelmä ympäristömuuttujalle PORT, jotta sovellus osaa käynnistää itsensä samaan samaan porttiin, missä Fly olettaa (määrittely kohdassa [services]) sen olevan käynnissä. + +Sovellus voidaan nyt käynnistää komennolla + +```bash +fly deploy +``` + +Jos kaikki menee hyvin, sovellus käynnistyy ja saat sen avattua selaimeen komennolla + +```bash +fly apps open +``` + +Tämän jälkeen aina kun teet muutoksia sovellukseen, saat vietyä uuden version tuotantoon komennolla + +```bash +fly deploy +``` + +Erittäin tärkeä komento on myös _fly logs_ jonka avulla voit seurata tuotantopalvelimen konsoliin tulostuvia logeja. Logit on viisainta pitää koko ajan näkyvillä. + +#### Render + +Huomaa, että saatat joutua syöttämään pankkikorttisi tiedot palveluun, vaikka käyttäisit vain ilmaisia ominaisuuksia. Ohje olettaa, että Renderiin on [kirjauduttu](https://dashboard.render.com/) GitHub-tunnuksen avulla. + +Kirjautumisen jälkeen luodaan uusi "web service": + +![](../../images/3/r1.png) + +Yhdistetään sovelluksen repositorio Renderiin: + +![](../../images/3/r2.png) + +Yhdistäminen onnistuu ainakin jos repositorio on julkinen. + +Määritellään sovelluksen tiedot. Jos sovellus ei ole repositorion juuressa, tulee oikea hakemisto määritellä kohtaan Root directory: + +![](../../images/3/r3.png) + +Tämän jälkeen sovellus käynnistyy Renderiin. Sovelluksen sivu kertoo sovelluksen tilan ja osoitteen, mihin sovellus on käynnistynyt: + +![](../../images/3/r4.png) + +Oletusarvoisella konfiguraatiolla sovelluksen pitäisi [uudelleenkäynnistyä](https://render.com/docs/deploys) jokaisen GitHubiin pushatun commitin myötä automaattisesti uudelleen. Jostain syystä tämä ei kuitenkaan toimi aina. + +Sovelluksen saa myös uudelleenkäynnistettyä manuaalisesti: + +![](../../images/3/r5.png) + +Dashboardin kautta on myös mahdollista seurata sovelluksen lokia: + +![](../../images/3/r7.png) + +Huomaamme nyt lokista, että sovellus on käynnistynyt porttiin 10000. Sovellus saa käynnistysportin ympäristömuuttujan PORT kautta, eli on äärimmäisen tärkeää että tiedoston index.js loppu on päivitetty muotoon: + +```js +const PORT = process.env.PORT || 3001 // highlight-line +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +``` ### Frontendin tuotantoversio -Olemme toistaiseksi suorittaneet React-koodia sovelluskehitysmoodissa, missä sovellus on konfiguroitu antamaan havainnollisia virheilmoituksia, päivittämään koodiin tehdyt muutokset automaattisesti selaimeen ym. +Olemme toistaiseksi suorittaneet React-koodia sovelluskehitysmoodissa, jossa sovellus on konfiguroitu antamaan havainnollisia virheilmoituksia, päivittämään koodiin tehdyt muutokset automaattisesti selaimeen ym. -Kun sovellus viedään tuotantoon, täytyy siitä tehdä [production build](https://reactjs.org/docs/optimizing-performance.html#use-the-production-build) -eli tuotantoa varten optimoitu versio. +Kun sovellus viedään tuotantoon, täytyy siitä tehdä [production build](https://vitejs.dev/guide/build.html) eli tuotantoa varten optimoitu versio. -create-react-app:in avulla tehdyistä sovelluksista saadaan muodostettua tuotantoversio komennolla [_npm run build_](https://github.com/facebookincubator/create-react-app#npm-run-build-or-yarn-build). +Viten avulla tehdyistä sovelluksista saadaan tehtyä tuotantoversio komennolla [_npm run build_](https://vitejs.dev/guide/build.html). Suoritetaan nyt komento frontendin projektin juuressa. -Komennon seurauksena syntyy hakemiston build (joka sisältää jo sovelluksen ainoan html-tiedoston index.html) sisään hakemisto static, minkä alle generoituu sovelluksen Javascript-koodin [minifioitu]() versio. Vaikka sovelluksen koodi on kirjoitettu useaan tiedostoon, generoituu kaikki Javascript yhteen tiedostoon, samaan tiedostoon tulee itseasiassa myös kaikkien sovelluksen koodin tarvitsemien riippuvuuksien koodi. +Komennon seurauksena syntyy hakemiston dist (joka sisältää jo sovelluksen ainoan html-tiedoston index.html) sisään hakemisto assets, jonka alle generoituu sovelluksen JavaScript-koodin [minifioitu]() versio. Vaikka sovelluksen koodi on kirjoitettu useaan tiedostoon, generoituu kaikki JavaScript yhteen tiedostoon. Samaan tiedostoon tulee myös kaikkien sovelluksen koodin tarvitsemien riippuvuuksien koodi. Minifioitu koodi ei ole miellyttävää luettavaa. Koodin alku näyttää seuraavalta: @@ -125,31 +220,31 @@ Minifioitu koodi ei ole miellyttävää luettavaa. Koodin alku näyttää seuraa ### Staattisten tiedostojen tarjoaminen backendistä -Eräs mahdollisuus frontendin tuotantoon viemiseen on kopioida tuotantokoodi, eli hakemisto build backendin repositorion juureen ja määritellä backend näyttämään pääsivunaan frontendin pääsivu, eli tiedosto build/index.html. +Eräs mahdollisuus frontendin tuotantoon viemiseen on kopioida tuotantokoodi eli hakemisto dist backendin hakemiston juureen ja määritellä backend näyttämään pääsivunaan frontendin pääsivu eli tiedosto dist/index.html. Aloitetaan kopioimalla frontendin tuotantokoodi backendin alle, projektin juureen. Omalla koneellani kopiointi tapahtuu frontendin hakemistosta käsin komennolla ```bash -cp -r build ../../../3/luento/notes-backend +cp -r dist ../backend ``` Backendin sisältävän hakemiston tulee nyt näyttää seuraavalta: -![](../../images/3/27ea.png) +![ls-komento näyttää tiedostot index.js, Procfile, package.json, package-lock.json sekä hakemistot dist ja node_modules](../../images/3/27v.png) -Jotta saamme expressin näyttämään staattista sisältöä eli sivun index.html ja sen lataaman Javascriptin ym. tarvitsemme expressiin sisäänrakennettua middlewarea [static](http://expressjs.com/en/starter/static-files.html). +Jotta saamme Expressin näyttämään staattista sisältöä eli sivun index.html ja sen lataaman JavaScriptin ym. tarvitsemme Expressiin sisäänrakennettua middlewarea [static](http://expressjs.com/en/starter/static-files.html). Kun lisäämme muiden middlewarejen määrittelyn yhteyteen seuraavan ```js -app.use(express.static('build')) +app.use(express.static('dist')) ``` -tarkastaa Express GET-tyyppisten HTTP-pyyntöjen yhteydessä ensin löytyykö pyynnön polkua vastaavan nimistä tiedostoa hakemistosta build. Jos löytyy, palauttaa express tiedoston. +tarkastaa Express GET-tyyppisten HTTP-pyyntöjen yhteydessä ensin löytyykö pyynnön polkua vastaavan nimistä tiedostoa hakemistosta dist. Jos löytyy, palauttaa Express tiedoston. -Nyt HTTP GET -pyyntö osoitteeseen www.palvelimenosoite.com/index.html tai www.palvelimenosoite.com näyttää Reactilla tehdyn frontendin. GET-pyynnön esim. osoitteeseen www.palvelimenosoite.com/notes hoitaa backendin koodi. +Nyt HTTP GET ‑pyyntö osoitteeseen www.palvelimenosoite.com/index.html tai www.palvelimenosoite.com näyttää Reactilla tehdyn frontendin. GET-pyynnön esim. osoitteeseen www.palvelimenosoite.com/api/notes hoitaa backendin koodi. -Koska tässä tapauksessa sekä frontend että backend toimivat samassa osoitteessa, voidaan React-sovelluksessa eli frontendin koodissa oleva palvelimen _baseUrl_ määritellä [suhteellisena](https://www.w3.org/TR/WD-html40-970917/htmlweb.html#h-5.1.2) URL:ina, eli ilman palvelinta yksilöivää osaa: +Koska tässä tapauksessa sekä frontend että backend toimivat samassa osoitteessa, voidaan React-sovelluksessa eli frontendin koodissa oleva palvelimen _baseUrl_ määritellä [suhteellisena](https://www.w3.org/TR/WD-html40-970917/htmlweb.html#h-5.1.2) URL:ina eli ilman palvelinta yksilöivää osaa: ```js import axios from 'axios' @@ -163,71 +258,134 @@ const getAll = () => { // ... ``` -Muutoksen jälkeen frontendistä on luotava uusi production build ja kopioitava se backendin repositorion juureen. +Muutoksen jälkeen frontendistä on luotava uusi production build ja kopioitava se backendin projektin juureen. Sovellusta voidaan käyttää nyt backendin osoitteesta : -![](../../images/3/28e.png) +![Mentäessä osoitteeseen localhost:3001 selain renderöi react-sovelluksen, joka listaa muistiinpanot. Jokaisen muistiinpanon yhteydessä on sen tärkeyden muuttava nappi 'make important' tai 'make not important', näkymässä on myös lomake uuden muistiinpanon luomiseen. Tärkeyttä ei lomakkeella tarvitse voida asettaa, ainoastaan muistiinpanon sisältö.](../../images/3/28new.png) -Sovelluksemme toiminta vastaa nyt täysin osan 0 luvussa [Single page app](/osa0/#single-page-app) läpikäydyn esimerkkisovelluksen toimintaa. +Sovelluksemme toiminta vastaa nyt täysin osan 0 luvussa [Single page app](/osa0/web_sovelluksen_toimintaperiaatteita#single-page-app) läpikäydyn esimerkkisovelluksen toimintaa. -Kun mennään selaimella osoitteeseen palauttaa palvelin hakemistossa build olevan tiedoston index.html, jonka sisältö hieman tiivistettynä on seuraava: +Kun mennään selaimella osoitteeseen palauttaa palvelin hakemistossa dist olevan tiedoston index.html, jonka sisältö on seuraava: ```html - - - React App - - - -
    - - - + + + + + + + Vite + React + + + + +
    + + ``` -Sivu sisältää ohjeen ladata sovelluksen tyylit määrittelevän CSS-tiedoston, sekä kaksi script-tagia, joiden ansiosta selain lataa sovelluksen Javascript-koodin, eli varsinaisen React-sovelluksen. +Sivu sisältää ohjeen ladata sovelluksen tyylit määrittelevän CSS-tiedoston, sekä kaksi script-tagia, joiden ansiosta selain lataa sovelluksen JavaScript-koodin, eli varsinaisen React-sovelluksen. -React-koodi hakee palvelimelta muistiinpanot osoitteesta ja renderöi ne ruudulle. Selaimen ja palvelimen kommunikaatio selviää tuttuun tapaan konsolin välilehdeltä Network: +React-koodi hakee palvelimelta muistiinpanot osoitteesta ja renderöi ne ruudulle. Selaimen ja palvelimen kommunikaatio selviää tuttuun tapaan konsolin välilehdeltä Network: -![](../../images/3/29ea.png) +![Välilehti Network kertoo että on tehty pyyntö GET localhost:3001/api/notes](../../images/3/29new.png) -Kun sovelluksen "internettiin vietävä" tuotantoversio todetaan toimivan paikallisesti, commitoidaan frontendin tuotantoversio backendin repositorioon ja pushataan koodi uudelleen herokuun. +Tuotantoa varten tehty suoritusympäristö näyttää siis seuraavalta: -[Sovellus](https://vast-oasis-81447.herokuapp.com/) toimii moitteettomasti lukuunottamatta vielä backendiin toteuttamatonta muistiinpanon tärkeyden muuttamista: +![Selain hakee json-muotoisen datan localhost:3001/api/notes reitiltä ja suoritettavan react-sovelluksen js-koodin sekä index.html-tiedoston osoitteesta localhost:3001. Backend hakee tarvitsemansa js-tiedostot ja index.html:n paikalliselta levyltä.](../../images/3/101.png) -![](../../images/3/30ea.png) +Toisin kuin sovelluskehitysympäristössä, kaikki sovelluksen tarvitsema löytyy nyt node/express-palvelimelta osoitteesta localhost:3001. Kun osoitteeseen mennään, renderöi selain pääsivun index.html mikä taas aiheuttaa sen, että React-sovelluksen tuotantoversio haetaan palvelimelta ja selain alkaa suorittamaan sitä. Tämä taas saa aikaan sen, että ruudulla näytettävä JSON-muotoinen data haetaan osoitteesta localhost:3001/api/notes. + +### Koko sovellus Internetiin + +Kun sovelluksen "Internetiin vietävä" tuotantoversio todetaan toimivaksi paikallisesti, voidaan koko sovellus siirtää suoritettavaksi valittuun palveluun. + +Fly.io:n tapauksessa sovelluksen uusi versio käynnistyy komennolla + +```bash +fly deploy +``` + +HUOM: Projektin juureen luotu _.dockerignore_-tiedosto listaa ne tiedostot, joita ei oteta mukaan deploymentiin, ja dist-kansio saattaa olla siellä mukana oletuksena. Jos näin on, poista viittaus dist/ _.dockerignore_-tiedostosta, jotta sovelluksesi otetaan käyttöön oikein. + +Renderin tapauksessa commitoidaan tehdyt muutokset ja pushataan koodi GitHubiin. Automaattinen uudelleenkäynnistys saattaa toimia, mutta jos näin ei ole, käynnistä uusi versio itse dashboardin kautta tekemällä "manual deployment". + +Sovellus toimii moitteettomasti lukuun ottamatta vielä backendiin toteuttamatonta muistiinpanon tärkeyden muuttamista: + +![Selain renderöi sovelluksen frontendin (joka näyttää palvelimella olevan datan) mentäessä sovelluksen urlin juureen](../../images/3/30new.png) Sovelluksemme tallettama tieto ei ole ikuisesti pysyvää, sillä sovellus tallettaa muistiinpanot muuttujaan. Jos sovellus kaatuu tai se uudelleenkäynnistetään, kaikki tiedot katoavat. Tarvitsemme sovelluksellemme tietokannan. Ennen tietokannan käyttöönottoa katsotaan kuitenkin vielä muutamaa asiaa. -### Frontendin deployauksen suoraviivaistus +Tuotannossa oleva sovellus näyttää seuraavalta: + +![Selain hakee json-muotoisen datan nameoftheapp.herokuapp.com/api/notes osoitteesta ja suoritettavan react-sovelluksen js-koodin sekä index.html-tiedoston osoitteesta nameoftheapp.herokuapp.com. Backend hakee tarvitsemansa js-tiedostot ja index.html:n herokun palvelimen levyltä.](../../images/3/102.png) + +Nyt siis node/express-backend sijaitsee Fly.io:n/Renderin palvelimella. Kun selaimella mennään sovelluksen "juuriosoitteeseen", alkaa selain suorittamaan React-koodia, joka taas hakee JSON-muotoisen datan Fly.io:sta/Renderistä. + +### Frontendin deployauksen suoraviivaistus + +Jotta uuden frontendin version generointi onnistuisi jatkossa ilman turhia manuaalisia askelia, lisätään uusia skriptejä backendin package.json-tiedostoon. + +#### Fly.io + +Fly.io:n tapauksessa skriptit näyttävät seuraavalta: -Jotta uuden frontendin version generointi onnistuisi jatkossa ilman turhia manuaalisia askelia, lisätään uusia skriptejä backendin package.json-tiedostoon ```json { "scripts": { // ... - "build:ui": "rm -rf build && cd ../../osa2/materiaali/notes-new && npm run build --prod && cp -r build ../../../osa3/notes-backend/", - "deploy": "git push heroku master", - "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy", - "logs:prod": "heroku logs --tail" + "build:ui": "rm -rf dist && cd ../frontend/ && npm run build && cp -r dist ../backend", + "deploy": "fly deploy", + "deploy:full": "npm run build:ui && npm run deploy", + "logs:prod": "fly logs" } } ``` -Skripteistä _npm run build:ui_ kääntää ui:n tuotantoversioksi ja kopioi sen. _npm run deploy_ julkaisee herokuun. -_npm run deploy:full_ yhdistää nuo molemmat sekä lisää vaadittavat git-komennot versionhallinnan päivittämistä varten. Lisätään lisäksi oma skripti _npm run logs:prod_ lokien lukemiseen, jolloin käytännössä kaikki toimii npm-skriptein. +Skripteistä _npm run build:ui_ kääntää ui:n tuotantoversioksi ja kopioi sen. _npm run deploy_ julkaisee Fly.io:n. + +_npm run deploy:full_ yhdistää molemmat edellä mainitut komennot. Lisätään lisäksi oma skripti _npm run logs:prod_ lokien lukemiseen, jolloin käytännössä kaikki toimii npm-skriptein. + +Huomaa, että skriptissä build:ui olevat polut riippuvat frontendin ja backendin hakemistojen sijainnista. + +##### Huomautus Windows-käyttäjille +Huomaa, että näistä _build:ui_:n käyttämät shell-komennot eivät toimi natiivisti Windowsilla, jonka powershell käyttää eri komentoja. Tällöin skripti olisi +```json +"build:ui": "@powershell Remove-Item -Recurse -Force dist && cd ../frontend && npm run build && @powershell Copy-Item dist -Recurse ../backend", +``` + +Mikäli skripti ei toimi Windowsilla, tarkista, että terminaalisi sovelluskehitysympäristössäsi on Powershell eikä esimerkiksi Command Prompt. Jos olet asentanut Git Bash ‑terminaalin, tai muun Linuxia matkivan terminaalin tai ympäristön, saatat pystyä ajamaan Linuxin kaltaisia komentoja myös Windowsilla. + -Huomaa, että skriptissä build:ui olevat polut riippuvat repositorioiden sijainnista. +#### Render + +Renderin tapauksessa skriptit näyttävät seuraavalta: + +```json +{ + "scripts": { + // ... + "build:ui": "rm -rf dist && cd ../frontend && npm run build && cp -r dist ../backend", + "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push" + } +} +``` + +Skripteistä _npm run build:ui_ kääntää ui:n tuotantoversioksi ja kopioi sen. + +_npm run deploy:full_ sisältää edellisen lisäksi vaadittavat git-komennot versionhallinnan päivittämistä varten. + +Huomaa, että skriptissä build:ui olevat polut riippuvat frontendin ja backendin hakemistojen sijainnista. ### Proxy -Frontendiin tehtyjen muutosten seurauksena on nyt se, että kun suoritamme frontendiä sovelluskehitysmoodissa, eli käynnistämällä sen komennolla _npm start_, yhteys backendiin ei toimi: +Frontendiin tehtyjen muutosten seurauksena on nyt se, että kun suoritamme frontendiä sovelluskehitysmoodissa eli käynnistämällä sen komennolla _npm run dev_, yhteys backendiin ei toimi: -![](../../images/3/32ea.png) +![Network-tabi kertoo että pyyntöön localhost:3000/api/notes vastataan statuskoodilla 404](../../images/3/32new.png) Syynä tälle on se, että backendin osoite muutettiin suhteellisesti määritellyksi: @@ -235,35 +393,44 @@ Syynä tälle on se, että backendin osoite muutettiin suhteellisesti määritel const baseUrl = '/api/notes' ``` -Koska frontend toimii osoitteessa localhost:3000, menevät backendiin tehtävät pyynnöt väärään osoitteeseen localhost:3000/api/notes. Backend toimii kuitenkin osoitteessa localhost:3001. +Koska frontend toimii osoitteessa localhost:5173, menevät backendiin tehtävät pyynnöt väärään osoitteeseen localhost:5173/api/notes. Backend toimii kuitenkin osoitteessa localhost:3001. -create-react-app:illa luoduissa projekteissa ongelma on helppo ratkaista. Riittää, että frontendin repositorion tiedostoon package.json lisätään seuraava määritelmä: +Vitellä luoduissa projekteissa ongelma on helppo ratkaista. Riittää, että frontendin tiedostoon vite.config.js lisätään seuraava määritelmä: ```bash -{ - "dependencies": { - // ... - }, - "scripts": { - // ... +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + // highlight-start + server: { + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + } }, - "proxy": "http://localhost:3001" // highlight-line -} -``` + // highlight-end +}) -Uudelleenkäynnistyksen jälkeen Reactin sovelluskehitysympäristö toimii [proxynä](https://facebook.github.io/create-react-app/docs/proxying-api-requests-in-development). Jos React-koodi tekee HTTP-pyynnön palvelimen http://localhost:3000 johonkin osoitteeseen, joka ei ole React-sovelluksen vastuulla (eli kyse ei ole esim. sovelluksen Javascript-koodin tai CSS:n lataamisesta), lähetetään pyyntö edelleen osoitteessa http://localhost:3001 olevalle palvelimelle. +``` -Nyt myös frontend on kunnossa. Se toimii sekä sovelluskehitysmoodissa että tuotannossa yhdessä palvelimen kanssa. +Uudelleenkäynnistyksen jälkeen Reactin sovelluskehitysympäristö toimii välityspalvelimena eli [proxynä](https://vitejs.dev/config/server-options.html#server-proxy). Jos React-koodi tekee HTTP-pyynnön http://localhost:5173/api-alkuiseen polkuun, lähetetään pyyntö edelleen osoitteessa http://localhost:3001 olevalle palvelimelle. Muihin polkuihin tulevat pyynnöt kehityspalvelin käsittelee normaalisti. -Eräs negatiivinen puoli käyttämässämme lähestymistavassa on se, että sovelluksen uuden version tuotantoon vieminen edellyttää erillisessä repositoriossa olevan frontendin koodin tuotantoversion generoimista. Tämä taas hankaloittaa automatisoidun [deployment pipelinen](https://martinfowler.com/bliki/DeploymentPipeline.html) toteuttamista. Deployment pipelinellä tarkoitetaan automatisoitua ja hallittua tapaa viedä koodi sovelluskehittäjän koneelta erilaisten testien ja laadunhallinnallisten vaiheiden kautta tuotantoympäristöön. +Nyt myös frontend on kunnossa. Se toimii sekä sovelluskehitysmoodissa että tuotannossa yhdessä palvelimen kanssa. Koska frontendin näkökulmasta kaikki pyynnöt suuntautuvat osoitteeseen http://localhost:5173 eli yhteen ja samaan originiin, ei tarvetta backendin cors-middlewarelle enää ole. Poistetaan siis viittaukset cors-kirjastoon backendin index.js-tiedostosta, ja poistetaan cors projektin riippuvuuksista: -Tähänkin on useita erilaisia ratkaisuja (esim. sekä frontendin että backendin [sijoittaminen samaan repositorioon](https://github.com/mars/heroku-cra-node)), emme kuitenkaan nyt mene niihin. +```bash +npm remove cors +``` -Myös frontendin koodin deployaaminen omana sovelluksenaan voi joissain tilanteissa olla järkevää. _create-react-app_:in avulla luotujen sovellusten osalta se on [suoraviivaista](https://github.com/mars/create-react-app-buildpack). +Olemme nyt vieneet koko sovelluksen onnistuneesti internetiin. Deploymenttien toteuttamiseen on olemassa monenlaisia muitakin tapoja. Esimerkiksi frontendin koodin deployaaminen omana sovelluksenaan voi joissain tilanteissa olla järkevää, sillä se voi helpottaa automatisoidun [deployment pipelinen](https://martinfowler.com/bliki/DeploymentPipeline.html) toteuttamista. Deployment pipelinellä tarkoitetaan automatisoitua ja hallittua tapaa viedä koodi sovelluskehittäjän koneelta erilaisten testien ja laadunhallinnallisten vaiheiden kautta tuotantoympäristöön. Aiheeseen tutustutaan kurssin [osassa 11](/osa11). -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [Githubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-3), branchissa part3-3. +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-3), branchissa part3-3. -Frontendin koodiin tehdyt muutokset ovat the [frontendin repositorion](https://github.com/fullstack-hy2020/part2-notes/tree/part3-1) branchissa part3-1. +Frontendin koodiin tehdyt muutokset ovat [frontendin repositorion](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part3-1) branchissa part3-1. @@ -275,38 +442,28 @@ Seuraavissa tehtävissä koodia ei tarvita montaa riviä. Tehtävät ovat kuiten #### 3.9 puhelinluettelon backend step9 -Laita backend toimimaan edellisessä osassa tehdyn puhelinluettelon frontendin kanssa muilta osin, paitsi mahdollisen puhelinnumeron muutoksen osalta, jonka vastaava toiminnallisuus toteutetaan backendiin vasta tehtävässä 3.17. +Laita backend toimimaan edellisessä osassa tehdyn puhelinluettelon frontendin kanssa muilta osin paitsi mahdollisen puhelinnumeron muutoksen osalta, jonka vastaava toiminnallisuus toteutetaan backendiin vasta tehtävässä 3.17. Joudut todennäköisesti tekemään frontendiin erinäisiä pieniä muutoksia ainakin backendin oletettujen urlien osalta. Muista pitää selaimen konsoli koko ajan auki. Jos jotkut HTTP-pyynnöt epäonnistuvat, kannattaa katsoa Network-välilehdeltä, mitä tapahtuu. Pidä myös silmällä, mitä palvelimen konsolissa tapahtuu. Jos et tehnyt edellistä tehtävää, kannattaa POST-pyyntöä käsittelevässä tapahtumankäsittelijässä tulostaa konsoliin mukana tuleva data eli request.body. #### 3.10 puhelinluettelon backend step10 -Vie sovelluksen backend internetiin, esim. Herokuun. - -**Huom.** komento _heroku_ toimii laitoksen koneilla ja fuksikannettavilla. Jos et jostain syystä saa [asennettua](https://devcenter.heroku.com/articles/heroku-cli) herokua koneellesi, voit käyttää komentoa [npx heroku-cli](https://www.npmjs.com/package/heroku-cli). - -Testaa selaimen ja postmanin tai VS Code REST-clientin avulla, että internetissä oleva backend toimii. - -**PRO TIP:** kun deployaat sovelluksen Herokuun, kannattaa ainakin alkuvaiheissa pitää **KOKO AJAN** näkyvillä Herokussa olevan sovelluksen loki antamalla komento heroku logs -t. +Vie sovelluksen backend Internetiin, esim. Fly.io:n tai Renderiin. Jos käytät Fly.io:ta, komennot tulee suorittaa backendin juurihakemistossa (eli samassa kansiossa, jossa backendin package.json-tiedosto sijaitsee). -Seuraavassa loki eräästä tyypillisestä ongelmatilanteesta, jossa Heroku ei löydä sovelluksen riippuvuutena olevaa moduulia express: +**PRO TIP:** kun deployaat sovelluksen Fly.io:n tai Renderiin, kannattaa ainakin alkuvaiheissa pitää **KOKO AJAN** näkyvillä sovelluksen loki. -![](../../images/3/33.png) +Testaa selaimen ja Postmanin tai VS Coden REST-clientin avulla, että Internetissä oleva backend toimii. -Syynä ongelmalle on se, että expressiä asennettaessa oli unohtunut antaa optio --save, joka tallentaa tiedon riippuvuudesta tiedostoon package.json. - -Toinen tyypillinen ongelma on se, että sovellusta ei ole konfiguroitu käyttämään ympäristömuuttujana PORT määriteltyä porttia: - -![](../../images/3/34.png) - -Tee repositorion juureen tiedosto README.md ja lisää siihen linkki internetissä olevaan sovellukseesi. +Tee repositorion juureen tiedosto README.md ja lisää siihen linkki Internetissä olevaan sovellukseesi. #### 3.11 puhelinluettelo full stack -Generoi frontendistä tuotantoversio ja lisää se internetissä olevaan sovellukseesi tässä osassa esiteltyä menetelmää noudattaen. - -**Huom.** eihän hakemisto build ole gitignoroituna projektissasi? +Generoi frontendistä tuotantoversio ja lisää se Internetissä olevaan sovellukseesi tässä osassa esiteltyä menetelmää noudattaen. Huolehdi myös, että frontend toimii edelleen myös paikallisesti. +Jos käytät Renderiä, varmista, että hakemisto dist ole gitignoroituna projektissasi. + +**HUOM:** Frontendiä ei julkaista suoraan missään vaiheessa tämän osan aikana. Vain backend-repositorio viedään Internetiin. Frontendin tuotantoversio lisätään backend-repositorioon, ja backend näyttää sen pääsivunaan kuten kohdassa [Staattisten tiedostojen tarjoaminen backendistä](/osa3/sovellus_internetiin#staattisten-tiedostojen-tarjoaminen-backendista) on kuvattu. + diff --git a/src/content/3/fi/osa3c.md b/src/content/3/fi/osa3c.md index 6f6c5470cda..64c76ed98fa 100644 --- a/src/content/3/fi/osa3c.md +++ b/src/content/3/fi/osa3c.md @@ -7,169 +7,154 @@ lang: fi
    -Ennen kuin siirrymme osan varsinaiseen aiheeseen, eli tiedon tallettamiseen tietokantaan, tarkastellaan muutamaa tapaa Node-sovellusten debuggaamiseen. +Ennen kuin siirrymme osan varsinaiseen aiheeseen eli tiedon tallettamiseen tietokantaan, tarkastellaan muutamaa tapaa Node-sovellusten debuggaamiseen. ### Node-sovellusten debuggaaminen -Nodella tehtyjen sovellusten debuggaaminen on jossain määrin hankalampaa kuin selaimessa toimivan Javascriptin. Vanha hyvä keino on tietysti konsoliin tulostelu. Se kannattaa aina. On mielipiteitä, joiden mukaan konsoliin tulostelun sijaan olisi syytä suosia jotain kehittyneempää menetelmää, mutta en ole ollenkaan samaa mieltä. Jopa maailman aivan eliittiin kuuluvat open source -kehittäjät [käyttävät](https://tenderlovemaking.com/2016/02/05/i-am-a-puts-debuggerer.html) tätä [menetelmää](https://swizec.com/blog/javascript-debugging-slightly-beyond-console-log/swizec/6633). +Nodella tehtyjen sovellusten debuggaaminen on jossain määrin hankalampaa kuin selaimessa toimivan JavaScriptin. Vanha hyvä keino on tietysti konsoliin tulostelu. Se kannattaa aina. On mielipiteitä, joiden mukaan konsoliin tulostelun sijaan olisi syytä suosia jotain kehittyneempää menetelmää, mutta se ei ole koko totuus. Jopa maailman aivan eliittiin kuuluvat open source ‑kehittäjät [käyttävät](https://tenderlovemaking.com/2016/02/05/i-am-a-puts-debuggerer.html) tätä [menetelmää](https://swizec.com/blog/javascript-debugging-slightly-beyond-consolelog/). #### Visual Studio Code -Visual Studio Coden debuggeri voi olla hyödyksi joissain tapauksissa. Saat käynnistettyä sovelluksen debuggaustilassa seuraavasti +Visual Studio Coden debuggeri voi olla hyödyksi joissain tapauksissa. Saat käynnistettyä sovelluksen debuggaustilassa seuraavasti (tässä ja muutamassa seuraavassa kuvassa muistiinpanoilla on kenttä _date_ joka on poistunut sovelluksen nykyisestä versiosta): -![](../../images/3/35.png) +![Avataan run-tabi ja sieltä valinta start debugging](../../images/3/35x.png) Huomaa, että sovellus ei saa olla samalla käynnissä "normaalisti" konsolista, sillä tällöin sovelluksen käyttämä portti on varattu. -Seuraavassa screenshot, missä koodi on pysäytetty kesken uuden muistiinpanon lisäyksen +Seuraavassa screenshot, jossa koodi on pysäytetty kesken uuden muistiinpanon lisäyksen: -![](../../images/3/36e.png) +![Koodiin on lisätty breakpoint, johon suoritus pysähtyy. Vasemman puolen tabissa näkyvät muuttujien arvot, alhaalla Debugging console, jossa on mahdollista evaluoida koodia](../../images/3/36x.png) -Koodi on pysähtynyt rivillä 63 olevan breakpointin kohdalle ja konsoliin on evaluoitu muuttujan note arvo. Vasemmalla olevassa ikkunassa on nähtävillä myös kaikki ohjelman muuttujien arvot. +Koodi on pysähtynyt rivillä 69 olevan breakpointin kohdalle ja konsoliin on evaluoitu muuttujan note arvo. Vasemmalla olevassa ikkunassa on nähtävillä myös kaikki ohjelman muuttujien arvot. Ylhäällä olevista nuolista yms. voidaan kontrolloida debuggauksen etenemistä. - + Itse en jostain syystä juurikaan käytä Visual Studio Coden debuggeria. -#### Chromen dev tools +#### Chromen DevTools -Debuggaus onnistuu myös Chromen developer-konsolilla, käynnistämällä sovellus komennolla: +Debuggaus onnistuu myös Chromen developer-konsolilla käynnistämällä sovellus komennolla: ```bash node --inspect index.js ``` -Debuggeriin pääsee käsiksi klikkaamalla chromen developer-konsoliin ilmestyneestä vihreästä ikonista +Debuggeriin pääsee käsiksi klikkaamalla Chromen developer-konsoliin ilmestyneestä vihreästä ikonista: -![](../../images/3/37.png) +![Developer-konsolitabille on ilmestynyt uusi valinta, joka on Elements-valinnan vasemmalla puolella oleva tekstitön laatikkosymboli](../../images/3/37.png) -Debuggausnäkymä toimii kuten React-koodia debugattaessa, Sources-välilehdelle voidaan esim. asettaa breakpointeja, eli kohtia joihin suoritus pysähtyy: +Debuggausnäkymä toimii kuten React-koodia debugattaessa. Sources-välilehdelle voidaan esim. asettaa breakpointeja eli kohtia joihin suoritus pysähtyy: -![](../../images/3/38eb.png) +![Developer-konsolista valittu source-tabi, ja näkyville tulleeseen koodiin on asetettu breakpoint. Suorituksen pysähtyessä aukeaa Watch-näkymä, joka kertoo muuttujien arvot](../../images/3/38eb.png) Ohjelman muuttujien arvoja voi evaluoida oikealla olevaan watch-ikkunaan. -Kaikki sovelluksen console.log-tulostukset tulevat debuggerin Console-välilehdelle. Voit myös tutkia siellä muuttujien arvoja ja suorittaa mielivaltaista Javascript-koodia: +Kaikki sovelluksen console.log-tulostukset tulevat debuggerin Console-välilehdelle. Voit tutkia siellä myös muuttujien arvoja ja suorittaa mielivaltaista JavaScript-koodia: -![](../../images/3/39ea.png) +![Console-tabille on mahdollista evaluoida mielivaltaista js-koodia, pysäytetyn koodin muuttujat ovat käytettävissä](../../images/3/39ea.png) #### Epäile kaikkea -Full Stack -sovellusten debuggaaminen vaikuttaa alussa erittäin hankalalta. Kun kohta kuvaan tulee myös tietokanta ja frontend on yhdistetty backendiin, on potentiaalisia virhelähteitä todella paljon. +Full Stack ‑sovellusten debuggaaminen vaikuttaa alussa erittäin hankalalta. Kun kohta kuvaan tulee myös tietokanta, ja frontend on yhdistetty backendiin, on potentiaalisia virhelähteitä todella paljon. -Kun sovellus "ei toimi", onkin selvitettävä missä vika on. On erittäin yleistä, että vika on sellaisessa paikassa, mitä ei osaa ollenkaan epäillä, ja menee minuutti-, tunti- tai jopa päiväkausia ennen kuin oikea ongelmien lähde löytyy. +Kun sovellus "ei toimi", onkin selvitettävä missä vika on. On erittäin yleistä, että vika on sellaisessa paikassa, jota ei osaa ollenkaan epäillä, ja menee minuutti-, tunti- tai jopa päiväkausia ennen kuin oikea ongelmien lähde löytyy. -Avainasemassa onkin systemaattisuus. Koska virhe voi olla melkein missä vain, kaikkea pitää epäillä, ja tulee pyrkiä poissulkemaan ne osat tarkastelusta, missä virhe ei ainakaan ole. Konsoliin kirjoitus, Postman, debuggeri ja kokemus auttavat. +Avainasemassa onkin systemaattisuus. Koska virhe voi olla melkein missä vain, kaikkea pitää epäillä, ja tulee pyrkiä poissulkemaan ne osat, joissa virhe ei ainakaan ole. Konsoliin kirjoitus, Postman, debuggeri ja kokemus auttavat. -Virheiden ilmaantuessa ylivoimaisesti huonoin strategia on jatkaa koodin kirjoittamista. Se on tae siitä, että koodissa on pian kymmenen ongelmaa lisää ja niiden syyn selvittäminen on entistäkin vaikeampaa. Toyota Production Systemin periaate [Stop and fix](http://gettingtolean.com/toyota-principle-5-build-culture-stopping-fix/#.Wjv9axP1WCQ) toimii tässäkin yhteydessä paremmin kuin hyvin. +Virheiden ilmaantuessa ylivoimaisesti huonoin strategia on jatkaa koodin kirjoittamista. Se on tae siitä, että koodissa on pian kymmenen ongelmaa lisää ja niiden syyn selvittäminen on entistäkin vaikeampaa. Toyota Production Systemin periaate [Stop and fix](https://leanscape.io/principles-of-lean-13-jidoka/) toimii tässäkin yhteydessä paremmin kuin hyvin. ### MongoDB -Jotta saisimme talletettua muistiinpanot pysyvästi, tarvitsemme tietokannan. Useimmilla laitoksen kursseilla on käytetty relaatiotietokantoja. Tällä kurssilla käytämme [MongoDB](https://www.mongodb.com/):tä, joka on ns. [dokumenttitietokanta](https://en.wikipedia.org/wiki/Document-oriented_database). - -Dokumenttitietokannat poikkeavat jossain määrin relaatiotietokannoista niin datan organisointitapansa kuin kyselykielensäkin suhteen. Dokumenttitietokantojen ajatellaan kuuluvan sateenvarjotermin [NoSQL](https://en.wikipedia.org/wiki/NoSQL) alle. Lyhyt johdanto dokumenttitietokannoihin lyöytyy [täällä](https://github.com/fullstack-hy2020/misc/blob/master/dokumenttitietokannat.MD). - -Lue nyt linkitetty [johdanto](https://github.com/fullstack-hy2020/misc/blob/master/dokumenttitietokannat.MD). Jatkossa oletetaan, että hallitset käsitteet dokumentti ja kokoelma (collection). - -MongoDB:n voi luonnollisesti asentaa omalle koneelle. Internetistä löytyy kuitenkin myös palveluna toimivia Mongoja, joista tämän hetken paras valinta on [MongoDB Atlas](https://www.mongodb.com/cloud/atlas). - -Kun käyttäjätili on luotu ja kirjauduttu, Atlas kehoittaa luomaan klusterin: - -![](../../images/3/57.png) +Jotta saisimme talletettua muistiinpanot pysyvästi, tarvitsemme tietokannan. Useimmilla Tietojenkäsittelytieteen osaston kursseilla on käytetty relaatiotietokantoja. Melkein kaikissa tämän kurssin osissa käytämme [MongoDB](https://www.mongodb.com/):tä, joka on ns. [dokumenttitietokanta](https://en.wikipedia.org/wiki/Document-oriented_database). -Valitaan AWS ja Frankfurt ja luodaan klusteri. +Tärkein syy Mongon käytölle kurssilla on se, että Mongo on tietokantanoviiseille helpompikäyttöisempi kuin relaatiotietokannat. Kurssin [osassa 13](https://fullstackopen.com/osa13) tutustutaan relaatiotietokantoja käyttävien Node-sovellusten tekemiseen. -![](../../images/3/58.png) +Mongon valinta tämän kurssin alkuun on siis tehty enimmäkseen pedagogisista perusteista. Itse suosittelen useimpiin sovelluksiin lähtökohtaisesti relaatiotietokantaa. Eli suosittelen lämpimästi tekemään myös tämän kurssin [Osan 13](https://fullstackopen.com/osa13). -Odotetaan että klusteri on valmiina, tähän menee noin 10 minuuttia. +Dokumenttitietokannat poikkeavat jossain määrin relaatiotietokannoista niin datan organisointitapansa kuin kyselykielensäkin suhteen. Dokumenttitietokantojen ajatellaan kuuluvan sateenvarjotermin [NoSQL](https://en.wikipedia.org/wiki/NoSQL) alle. Lyhyt johdanto dokumenttitietokantoihin on [täällä](https://github.com/fullstack-hy2020/misc/blob/master/dokumenttitietokannat.MD). -**HUOM** älä jatka eteenpäin ennen kun klusteri on valmis! - -Luodaan security välilehdeltä tietokantakäyttäjätunnus joka on siis eri tunnus kuin se, jonka avulla kirjaudutaan MongoDB Atlasiin: +Lue nyt linkitetty [johdanto](https://github.com/fullstack-hy2020/misc/blob/master/dokumenttitietokannat.MD). Jatkossa oletetaan, että hallitset käsitteet dokumentti ja kokoelma (collection). -![](../../images/3/59.png) +MongoDB:n voi asentaa paikallisesti omalle koneelle. Internetistä löytyy kuitenkin myös palveluna toimivia Mongoja, joista tämän hetken paras valinta on [MongoDB Atlas](https://www.mongodb.com/atlas/database). -annetaan käyttäjälle luku- ja kirjoitusoikeus kaikkiin tietokantoihin +Kun käyttäjätili on luotu ja kirjauduttu, luodaan käyttöömme uusi klusteri etusivulla näkyvästä painikkeesta. Avautuvasta näkymästä valitaan kokeiluihin sopiva ilmainen vaihtoehto sekä pilvipalvelu ja konesali, ja luodaan klusteri: -![](../../images/3/60.png) +![Valitaan esim AWS Stockholm ja klikataan Create cluster](../../images/3/mongo2.png) -**HUOM** muutamissa tapauksissa uusi käyttäjä ei ole toiminut heti luomisen jälkeen. On saattanut kestää jopa useita minuutteja ennen kuin käyttäjätunnus on ruvennut toimimaan. +Provideriksi on valittu _AWS_ ja Regioniksi _Stockholm (eu-north-1)_. Huomaa, että jos valitset näihin jotakin muuta, tulee tietokannan yhteysosoitteesi olemaan hieman erilainen kuin tässä esimerkissä. Odotetaan että klusteri on valmiina, mihin menee joitakin minuutteja. -Seuraavaksi tulee määritellä ne ip-osoitteet, joista tietokantaan pääsee käsiksi +**HUOM:** Älä jatka eteenpäin ennen kun klusteri on valmis! -![](../../images/3/61ea.png) +Luodaan security-välilehdeltä tietokantakäyttäjätunnus joka on siis eri tunnus kuin se, jonka avulla kirjaudutaan MongoDB Atlasiin: -Sallitaan yksinkertaisuuden vuoksi yhteydet kaikkialta: +![Valitaan Security-välilehdeltä 'Username and password' ja luodaan käyttäjätunnus](../../images/3/mongo3.png) -![](../../images/3/62.png) +Seuraavaksi tulee määritellä ne IP-osoitteet, joista tietokantaan pääsee käsiksi ja sallitaan yksinkertaisuuden vuoksi yhteydet kaikkialta: -Lopultakin ollaan valmiina ottamaan tietokantayhteyden. Klikataan connect +![Valitaan Network access ‑välilehdeltä 'Allow access from anywhere'](../../images/3/mongo4.png) -![](../../images/3/63ea.png) +Lopulta ollaan valmiina ottamaan tietokantayhteys. Yhteyden muodostamiseksi tarvitaan tietokannan yhteysosoite, joka löytyy esimerkiksi valitsemalla connect ja sen jälkeisestä näkymästä Connect your application-osiosta kohta Drivers: -Valitaan Connect your application: +![Valitaan Databases-välilehdeltä 'Connect'](../../images/3/mongo5.png) -![](../../images/3/64ea.png) +Näkymä kertoo MongoDB URI:n eli osoitteen, jonka avulla sovelluksemme käyttämä MongoDB-kirjasto saa yhteyden kantaan: -Näkymä kertoo MongoDB URI:n eli osoitteen, jonka avulla sovelluksemme käyttämä MongoDB-kirjasto saa yhteyden kantaan. +![näin avautuvasta näkymästä pitäisi löytyä connect-url](../../images/3/mongo6new.png) Osoite näyttää seuraavalta: ```bash -mongodb+srv://fullstack:@cluster0-ostce.mongodb.net/test?retryWrites=true&w=majority +mongodb+srv://fullstack:thepasswordishere@cluster0.a5qfl.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0 ``` Olemme nyt valmiina kannan käyttöön. -Voisimme käyttää kantaa Javascript-koodista suoraan Mongon virallisen -[MongoDB Node.js driver](https://mongodb.github.io/node-mongodb-native/) -kirjaston avulla, mutta se on ikävän työlästä. Käytämmekin hieman korkeammalla tasolla toimivaa [Mongoose](http://mongoosejs.com/index.html)-kirjastoa. +Voisimme käyttää kantaa JavaScript-koodista suoraan Mongon virallisen [MongoDB Node.js driver](https://mongodb.github.io/node-mongodb-native/) ‑kirjaston avulla, mutta se on ikävän työlästä. Käytämmekin hieman korkeammalla tasolla toimivaa [Mongoose](http://mongoosejs.com/index.html)-kirjastoa. -Mongoosesta voisi käyttää luonnehdintaa object document mapper (ODM), ja sen avulla Javascript-olioiden tallettaminen mongon dokumenteiksi on suoraviivaista. +Mongoosesta voisi käyttää luonnehdintaa object document mapper (ODM), ja sen avulla JavaScript-olioiden tallettaminen MongoDB:n dokumenteiksi on suoraviivaista. Asennetaan Mongoose: ```bash -npm install mongoose --save +npm install mongoose ``` -Ei lisätä mongoa käsittelevää koodia heti backendin koodin sekaan, vaan tehdään erillinen kokeilusovellus tiedostoon mongo.js: +Ei lisätä MongoDB:tä käsittelevää koodia heti backendin koodin sekaan, vaan tehdään erillinen kokeilusovellus tiedostoon mongo.js: ```js const mongoose = require('mongoose') -if (process.argv.length<3) { +if (process.argv.length < 3) { console.log('give password as argument') process.exit(1) } const password = process.argv[2] -const url = - `mongodb+srv://fullstack:${password}@cluster0-ostce.mongodb.net/test?retryWrites=true` +const url = `mongodb+srv://fullstack:${password}@cluster0.a5qfl.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0` -mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) +mongoose.set('strictQuery', false) +mongoose.connect(url) const noteSchema = new mongoose.Schema({ content: String, - date: Date, important: Boolean, }) const Note = mongoose.model('Note', noteSchema) const note = new Note({ - content: 'HTML is Easy', - date: new Date(), + content: 'HTML is easy', important: true, }) -note.save().then(response => { +note.save().then(result => { console.log('note saved!') mongoose.connection.close() }) ``` -Koodi siis olettaa, että sille annetaan parametrina MongoDB Atlasissa luodulle käyttäjälle määritelty salasana. Komentoriviparametriin se pääsee käsiksi seuraavasti +Koodi siis olettaa, että sille annetaan parametrina MongoDB Atlasissa luodulle käyttäjälle määritelty salasana. Komentoriviparametriin se pääsee käsiksi seuraavasti: ```js const password = process.argv[2] @@ -177,25 +162,25 @@ const password = process.argv[2] Kun koodi suoritetaan komennolla node mongo.js salasana lisää Mongoose tietokantaan uuden dokumentin. -Voimme tarkastella tietokannan tilaa MongoDB Atlasin hallintanäkymän collections-osasta +Voimme tarkastella tietokannan tilaa MongoDB Atlasin hallintanäkymän Browse collections-osasta: -![](../../images/3/65.png) +![Valitaan Databases-välilehdeltä 'Browse collections'](../../images/3/mongo7.png) -Kuten näkymä kertoo, on muistiinpanoa vastaava dokumentti lisätty tietokannan test kokoelmaan (collection) nimeltään notes. +Kuten näkymä kertoo, on muistiinpanoa vastaava dokumentti lisätty tietokannan test kokoelmaan (collection) nimeltään notes: -![](../../images/3/66a.png) +![Näkymä kertoo luodun tietokannan ja siihen sisältyvän kokoelman](../../images/3/mongo8new.png) -Tuhotaan kanta test. Päätetään käyttää tietokannasta nimeä note-app muutetaan siis tietokanta-URI muotoon +Tuhotaan oletusarvoisen nimen saanut kanta test. Päätetään käyttää tietokannasta nimeä noteApp, joten muutetaan tietokanta-URI muotoon -```bash -mongodb+srv://fullstack:@cluster0-ostce.mongodb.net/note-app?retryWrites=true +```js +const url = `mongodb+srv://fullstack:${password}@cluster0.a5qfl.mongodb.net/noteApp?retryWrites=true&w=majority&appName=Cluster0` ``` -Suoritetaan ohjelma uudelleen. +Suoritetaan ohjelma uudelleen: -![](../../images/3/68.png) +![Näkymä 'Browse collections' näyttää nyt halutun nimen noteApp sisältävän tietokannan](../../images/3/mongo9.png) -Data on nyt oikeassa kannassa. Hallintanäkymä sisältää myös toiminnon create database, joka mahdollistaa uusien tietokantojenluomisen hallintanäkymän kautta. Kannan luominen etukäteen hallintanäkymässä ei kuitenkaan ole tarpeen, sillä MongoDB Atlas osaa luoda kannan automaattisesti, jos sovellus yrittää yhdistää kantaan, jota ei ole vielä olemassa. +Data on nyt oikeassa kannassa. Hallintanäkymä sisältää myös toiminnon create database, joka mahdollistaa uusien tietokantojen luomisen hallintanäkymän kautta. Kannan luominen etukäteen hallintanäkymässä ei kuitenkaan ole tarpeen, sillä MongoDB Atlas osaa luoda kannan automaattisesti jos sovellus yrittää yhdistää kantaan, jota ei ole vielä olemassa. ### Skeema @@ -204,7 +189,6 @@ Yhteyden avaamisen jälkeen määritellään muistiinpanon [skeema](http://mongo ```js const noteSchema = new mongoose.Schema({ content: String, - date: Date, important: Boolean, }) @@ -213,9 +197,9 @@ const Note = mongoose.model('Note', noteSchema) Ensin muuttujaan _noteSchema_ määritellään muistiinpanon [skeema](http://mongoosejs.com/docs/guide.html), joka kertoo Mongooselle, miten muistiinpano-oliot tulee tallettaa tietokantaan. -Modelin _Note_ määrittelyssä ensimmäisenä parametrina oleva merkkijono Note määrittelee, että mongoose tallettaa muistiinpanoa vastaavat oliot kokoelmaan nimeltään notes, sillä [Mongoosen konventiona](http://mongoosejs.com/docs/models.html) on määritellä kokoelmien nimet monikossa (esim. notes), kun niihin viitataan skeeman määrittelyssä yksikkömuodossa (esim. Note). +Modelin _Note_ määrittelyssä ensimmäisenä parametrina oleva merkkijono Note määrittelee, että Mongoose tallettaa muistiinpanoa vastaavat oliot kokoelmaan nimeltään notes, sillä [Mongoosen konventiona](http://mongoosejs.com/docs/models.html) on määritellä kokoelmien nimet monikossa (esim. notes), kun niihin viitataan skeeman määrittelyssä yksikkömuodossa (esim. Note). -Dokumenttikannat, kuten Mongo ovat skeemattomia, eli tietokanta itsessään ei välitä mitään sinne talletettavan tiedon muodosta. Samaan kokoelmaankin on mahdollista tallettaa olioita joilla on täysin eri kentät. +Dokumenttikannat, kuten MongoDB ovat skeemattomia, eli tietokanta itsessään ei välitä mitään sinne talletettavan tiedon muodosta. Jopa samaan kokoelmaan on mahdollista tallettaa olioita, joilla on täysin eri kentät. Mongoosea käytettäessä periaatteena on kuitenkin se, että tietokantaan talletettavalle tiedolle määritellään sovelluksen koodin tasolla skeema, joka määrittelee minkä muotoisia olioita kannan eri kokoelmiin talletetaan. @@ -226,12 +210,11 @@ Seuraavaksi tiedoston mongo.js sovellus luo muistiinpanoa vastaavan [mode ```js const note = new Note({ content: 'HTML is Easy', - date: new Date(), important: false, }) ``` -Modelit ovat ns. konstruktorifunktioita, jotka luovat parametrien perusteella Javascript-olioita. Koska oliot on luotu modelien konstruktorifunktiolla, niillä on kaikki modelien ominaisuudet, eli joukko metodeja, joiden avulla olioita voidaan mm. tallettaa tietokantaan. +Modelit ovat ns. konstruktorifunktioita, jotka luovat parametrien perusteella JavaScript-olioita. Koska oliot on luotu modelien konstruktorifunktiolla, niillä on kaikki modelien ominaisuudet eli joukko metodeja, joiden avulla olioita voidaan mm. tallettaa tietokantaan. Tallettaminen tapahtuu metodilla _save_. Metodi palauttaa promisen, jolle voidaan rekisteröidä _then_-metodin avulla tapahtumankäsittelijä: @@ -244,15 +227,15 @@ note.save().then(result => { Kun olio on tallennettu kantaan, kutsutaan _then_:in parametrina olevaa tapahtumankäsittelijää, joka sulkee tietokantayhteyden komennolla mongoose.connection.close(). Ilman yhteyden sulkemista ohjelman suoritus ei pääty. -Tallennusoperaation tulos on takaisinkutsun parametrissa _result_. Yhtä olioa tallentaessamme tulos ei ole kovin mielenkiintoinen, olion sisällön voi esim. tulostaa konsoliin, jos haluaa tutkia sitä tarkemmin sovelluslogiikassa tai esim. debugatessa. +Tallennusoperaation tulos on takaisinkutsun parametrissa _result_. Yhtä olioa tallentaessamme tulos ei ole kovin mielenkiintoinen. Olion sisällön voi esim. tulostaa konsoliin, jos haluaa tutkia sitä tarkemmin sovelluslogiikassa tai esim. debugatessa. Talletetaan kantaan myös pari muuta muistiinpanoa muokkaamalla dataa koodista ja suorittamalla ohjelma uudelleen. -**HUOM:** Valitettavasti Mongoosen dokumentaatiossa käytetään joka paikassa promisejen _then_-metodien sijaan takaisinkutsufunktioita, joten sieltä ei kannata suoraan copypasteta koodia, sillä promisejen ja vanhanaikaisten callbackien sotkeminen samaan koodiin ei ole kovin järkevää. +**HUOM:** Valitettavasti Mongoosen dokumentaatiossa käytetään joka paikassa promisejen _then_-metodien sijaan takaisinkutsufunktioita, joten sieltä ei kannata suoraan copy-pasteta koodia, sillä promisejen ja vanhanaikaisten callbackien sotkeminen samaan koodiin ei ole kovin järkevää. ### Olioiden hakeminen tietokannasta -Kommentoidaan koodista uusia muistiinpanoja generoiva osa, ja korvataan se seuraavalla: +Kommentoidaan koodista uusia muistiinpanoja generoiva osa ja korvataan se seuraavalla: ```js Note.find({}).then(result => { @@ -265,11 +248,11 @@ Note.find({}).then(result => { Kun koodi suoritetaan, kantaan talletetut muistiinpanot tulostuvat: -![](../../images/3/70ea.png) +![Mongoon tallennetut muistiinpanot tulostuvat konsoliin, muistiinpanoilla on myös kenttä _id jonka Mongo on luonut](../../images/3/70new.png) -Oliot haetaan kannasta _Note_-modelin metodilla [find](http://mongoosejs.com/docs/api.html#find_find). Metodin parametrina on hakuehto. Koska hakuehtona on tyhjä olio {}, saimme kannasta kaikki _notes_-kokoelmaan talletetut oliot. +Oliot haetaan kannasta _Note_-modelin metodilla [find](https://mongoosejs.com/docs/api.html#model_Model.find). Metodin parametrina on hakuehto. Koska hakuehtona on tyhjä olio {}, saimme kannasta kaikki _notes_-kokoelmaan talletetut oliot. -Hakuehdot noudattavat mongon [syntaksia](https://docs.mongodb.com/manual/reference/operator/). +Hakuehdot noudattavat MongoDB:n [syntaksia](https://docs.mongodb.com/manual/reference/operator/). Voisimme hakea esim. ainoastaan tärkeät muistiinpanot seuraavasti: @@ -287,19 +270,19 @@ Note.find({ important: true }).then(result => { #### 3.12: tietokanta komentoriviltä -Luo puhelinluettelo-sovellukselle pilvessä oleva mongo Mongo DB Atlaksen avulla. +Luo puhelinluettelosovellukselle pilvessä oleva MongoDB-tietokanta Mongo DB Atlaksen avulla. Tee projektihakemistoon tiedosto mongo.js, jonka avulla voit lisätä tietokantaan puhelinnumeroja sekä listata kaikki kannassa olevat numerot. -**HUOM.** Jos/kun laitat tiedoston Githubiin, älä laita tietokannan salasanaa mukaan! +**HUOM:** Jos/kun laitat tiedoston GitHubiin, älä laita tietokannan salasanaa mukaan! -Ohjelma toimii siten, että jos sille annetaan käynnistäessä kolme komentoriviparametria (joista ensimmäinen on salasana), esim: +Ohjelma toimii siten, että jos sille annetaan käynnistettäessä kolme komentoriviparametria (joista ensimmäinen on salasana), esim.: ```bash node mongo.js yourpassword Anna 040-1234556 ``` -Ohjelma tulostaa +niin ohjelma tulostaa ```bash added Anna number 040-1234556 to phonebook @@ -311,7 +294,7 @@ ja lisää uuden yhteystiedon tietokantaan. Huomaa, että jos nimi sisältää v node mongo.js yourpassword "Arto Vihavainen" 040-1234556 ``` -Jos komentoriviparametreina ei ole muuta kuin salasana, eli ohjelma suoritetaan komennolla +Jos komentoriviparametreina ei ole muuta kuin salasana eli ohjelma suoritetaan komennolla ```bash node mongo.js yourpassword @@ -319,16 +302,16 @@ node mongo.js yourpassword tulostaa ohjelma tietokannassa olevat numerotiedot: -
    +```
     phonebook:
     Anna 040-1234556
     Arto Vihavainen 045-1232456
     Ada Lovelace 040-1231236
    -
    +``` -Saat selville ohjelman komentoriviparametrit muuttujasta [process.argv](https://nodejs.org/docs/latest-v8.x/api/process.html#process_process_argv) +Saat selville ohjelman komentoriviparametrit muuttujasta [process.argv](https://nodejs.org/docs/latest-v8.x/api/process.html#process_process_argv). -**HUOM. Älä sulje tietokantayhteyttä väärässä kohdassa**. Esim. seuraava koodi ei toimi +**HUOM: Älä sulje tietokantayhteyttä väärässä kohdassa**. Esim. seuraava koodi ei toimi: ```js Person @@ -353,7 +336,7 @@ Person }) ``` -**HUOM.** Jos määrittelet modelin nimeksi Person, muuttaa mongoose sen monikkomuotoon people, jota se käyttää vastaavan kokoelman nimenä. +**HUOM:** Jos määrittelet modelin nimeksi Person, muuttaa Mongoose sen monikkomuotoon people, jota se käyttää vastaavan kokoelman nimenä.
    @@ -361,29 +344,30 @@ Person ### Tietokantaa käyttävä backend -Nyt meillä on periaatteessa hallussamme riittävä tietämys ottaa mongo käyttöön sovelluksessamme. +Nyt meillä on periaatteessa hallussamme riittävä tietämys ottaa MongoDB käyttöön sovelluksessamme. -Aloitetaan nopean kaavan mukaan, copypastetaan tiedostoon index.js Mongoosen määrittelyt, eli +Aloitetaan nopean kaavan mukaan, copy-pastetaan tiedostoon index.js Mongoosen määrittelyt eli ```js const mongoose = require('mongoose') -// ÄLÄ KOSKAAN TALLETA SALASANOJA githubiin! -const url = - 'mongodb+srv://fullstack:sekred@cluster0-ostce.mongodb.net/note-app?retryWrites=true' -mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) +// ÄLÄ KOSKAAN TALLETA SALASANOJA GitHubiin! +const password = process.argv[2] +const url = `mongodb+srv://fullstack:${password}@cluster0.a5qfl.mongodb.net/noteApp?retryWrites=true&w=majority&appName=Cluster0` + +mongoose.set('strictQuery',false) +mongoose.connect(url) const noteSchema = new mongoose.Schema({ content: String, - date: Date, important: Boolean, }) const Note = mongoose.model('Note', noteSchema) ``` -ja muutetaan kaikkien muistiinpanojen hakemisesta vastaava käsittelijä seuraavaan muotoon +ja muutetaan kaikkien muistiinpanojen hakemisesta vastaava käsittelijä seuraavaan muotoon: ```js app.get('/api/notes', (request, response) => { @@ -393,11 +377,11 @@ app.get('/api/notes', (request, response) => { }) ``` -Voimme todeta selaimella, että backend toimii kaikkien dokumenttien näyttämisen osalta: +Käynnistetään nyt backend komennolla node --watch index.js yourpassword, jotta voimme varmistua koodin toimivuudesta. Voimme todeta selaimella, että backend toimii kaikkien dokumenttien näyttämisen osalta: -![](../../images/3/44ea.png) +![Mongoon tallennetut muistiinpanot renderöityvät selaimeen JSON-muodossa](../../images/3/44ea.png) -Toiminnallisuus on muuten kunnossa, mutta frontend olettaa, että olioiden yksikäsitteinen tunniste on kentässä id. Emme myöskään halua näyttää frontendille mongon versiointiin käyttämää kenttää \_\_v. +Toiminnallisuus on muuten kunnossa, mutta frontend olettaa, että olioiden yksikäsitteinen tunniste on kentässä id. Emme myöskään halua näyttää frontendille MongoDB:n versiointiin käyttämää kenttää \_\_v. Eräs tapa muotoilla Mongoosen palauttamat oliot haluttuun muotoon on [muokata](https://stackoverflow.com/questions/7034848/mongodb-output-id-instead-of-id) kannasta haettavilla olioilla olevan _toJSON_-metodin palauttamaa muotoa. Metodin muokkaus onnistuu seuraavasti: @@ -413,31 +397,33 @@ noteSchema.set('toJSON', { Vaikka Mongoose-olioiden kenttä \_id näyttääkin merkkijonolta, se on todellisuudessa olio. Määrittelemämme metodi _toJSON_ muuttaa sen merkkijonoksi kaiken varalta. Jos emme tekisi muutosta, siitä aiheutuisi ylimääräistä harmia testien yhteydessä. -Palautetaan HTTP-pyynnön vastauksena _toJSON_-metodin avulla muotoiltuja oliota: +Muistiinpanojen hakemisesta vastaavaan käsittelijään ei tarvitse tehdä muutoksia eli seuraava koodi ```js app.get('/api/notes', (request, response) => { Note.find({}).then(notes => { - response.json(notes.map(note => note.toJSON())) + response.json(notes) }) }) ``` -Nyt siis muuttujassa _notes_ on taulukollinen mongon palauttamia olioita. Kun suoritamme operaation notes.map(note => note.toJSON()) seurauksena on uusi taulukko, missä on jokaista alkuperäisen taulukon alkiota vastaava metodin _toJSON_ avulla muodostettu alkio. +kutsuu jokaiselle tietokannasta luettavalle muistiinpanolle automaattisesti metodia _toJSON_ muodostaessaan vastausta. ### Tietokantamäärittelyjen eriyttäminen moduuliksi -Ennen kuin täydennämme backendin muutkin osat käyttämään tietokantaa, eriytetään Mongoose-spesifinen koodi omaan moduuliin. +Ennen kuin täydennämme backendin muutkin osat käyttämään tietokantaa, eriytetään Mongoose-spesifinen koodi omaan moduuliinsa. Tehdään moduulia varten hakemisto models ja sinne tiedosto note.js: ```js const mongoose = require('mongoose') +mongoose.set('strictQuery', false) + const url = process.env.MONGODB_URI // highlight-line -console.log('connecting to', url) // highlight-line -mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) +console.log('connecting to', url) +mongoose.connect(url) // highlight-start .then(result => { console.log('connected to MongoDB') @@ -449,7 +435,6 @@ mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) const noteSchema = new mongoose.Schema({ content: String, - date: Date, important: Boolean, }) @@ -464,26 +449,24 @@ noteSchema.set('toJSON', { module.exports = mongoose.model('Note', noteSchema) // highlight-line ``` -Noden [moduulien](https://nodejs.org/docs/latest-v8.x/api/modules.html) määrittely poikkeaa hiukan osassa 2 määrittelemistämme frontendin käyttämistä [ES6-moduuleista](/osa2/kokoelmien_renderointi_ja_moduulit#refaktorointia-moduulit). +Koodissa on jonkin verran muutoksia aiempaan. Tietokannan yhteysosoite välitetään sovellukselle nyt MONGODB_URI ympäristömuuttujan kautta, koska sen kovakoodaaminen sovellukseen ei ole järkevää: -Moduulin ulos näkyvä osa määritellään asettamalla arvo muuttujalle _module.exports_. Asetamme arvoksi modelin Note. Muut moduulin sisällä määritellyt asiat, esim. muuttujat _mongoose_ ja _url_ eivät näy moduulin käyttäjälle. +```js +const url = process.env.MONGODB_URI +``` -Moduulin käyttöönotto tapahtuu lisäämällä tiedostoon index.js seuraava rivi +On useita tapoja määritellä ympäristömuuttujan arvo. Voimme esim. antaa sen ohjelman käynnistyksen yhteydessä seuraavasti: -```js -const Note = require('./models/note') +```bash +MONGODB_URI="osoite_tahan" npm run dev ``` -Näin muuttuja _Note_ saa arvokseen saman olion, jonka moduuli määrittelee. +Opettelemme pian kehittyneemmän tavan määritellä ympäristömuuttujia. Yhteyden muodostustavassa on pieni muutos aiempaan: ```js -const url = process.env.MONGODB_URI - -console.log('connecting to', url) - -mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) +mongoose.connect(url) .then(result => { console.log('connected to MongoDB') }) @@ -492,48 +475,55 @@ mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) }) ``` -Tietokannan osoitetta ei kannata kirjoittaa koodiin, joten osoite annetaan sovellukselle ympäristömuuttujan MONGODB_URI välityksellä. +Yhteyden muodostavalle metodille on nyt rekisteröity onnistuneen ja epäonnistuneen yhteydenmuodostuksen käsittelevät funktiot, jotka tulostavat konsoliin tiedon siitä, onnistuiko yhteyden muodostaminen: -Yhteyden muodostavalle metodille on nyt rekisteröity onnistuneen ja epäonnistuneen yhteydenmuodostuksen käsittelevät funktiot, jotka tulostavat konsoliin tiedon siitä, onnistuuko yhteyden muodostaminen: +![Konsoliin tulostuu virheilmoitus 'error connecting to Mongo, bad auth'](../../images/3/45e.png) -![](../../images/3/45e.png) +Noden [moduulien](https://nodejs.org/docs/latest-v8.x/api/modules.html) määrittely poikkeaa hiukan osassa 2 määrittelemistämme frontendin käyttämistä [ES6-moduuleista](/osa2/kokoelmien_renderointi_ja_moduulit#refaktorointia-moduulit). -On useita tapoja määritellä ympäristömuuttujan arvo, voimme esim. antaa sen ohjelman käynnistyksen yhteydessä seuraavasti +Moduulin ulos näkyvä osa määritellään asettamalla arvo muuttujalle _module.exports_. Asetamme arvoksi modelin Note. Muut moduulin sisällä määritellyt asiat, esim. muuttujat _mongoose_ ja _url_ eivät näy moduulin käyttäjälle. -```bash -MONGODB_URI=osoite_tahan npm run watch +Moduulin käyttöönotto tapahtuu lisäämällä tiedostoon index.js seuraava rivi: + +```js +const Note = require('./models/note') ``` -Eräs kehittyneempi tapa on käyttää [dotenv](https://github.com/motdotla/dotenv#readme)-kirjastoa. Asennetaan kirjasto komennolla +Näin muuttuja _Note_ saa arvokseen saman olion, jonka moduuli määrittelee. + +### Ympäristömuuttujien määritteleminen käyttäen dotenv-kirjastoa + +Eräs kehittyneempi tapa ympäristömuuttujien määrittelemiseen on käyttää [dotenv](https://github.com/motdotla/dotenv#readme)-kirjastoa. Asennetaan kirjasto komennolla ```bash -npm install dotenv --save +npm install dotenv ``` -Sovelluksen juurihakemistoon tehdään sitten tiedosto nimeltään .env, minne tarvittavien ympäristömuuttujien arvot määritellään. Tiedosto näyttää seuraavalta +Luodaan sitten sovelluksen juurihakemistoon tiedosto nimeltään .env, jonne tarvittavien ympäristömuuttujien arvot määritellään. Tiedosto näyttää seuraavalta: ```bash -MONGODB_URI=mongodb+srv://fullstack:sekred@cluster0-ostce.mongodb.net/note-app?retryWrites=true +MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.a5qfl.mongodb.net/noteApp?retryWrites=true&w=majority&appName=Cluster0 PORT=3001 ``` +Huomaa, että sinun tulee sisällyttää salasanasi osaksi url:ia, thepasswordishere tilalle. + Määrittelimme samalla aiemmin kovakoodaamamme sovelluksen käyttämän portin eli ympäristömuuttujan PORT. **Tiedosto .env tulee heti gitignorata, sillä emme halua julkaista tiedoston sisältöä verkkoon!** -![](../../images/3/45ae.png) +![Kuva havainnollistaa sitä että .env on lisätty tiedostoon .gitignore](../../images/3/45ae.png) -dotenvissä määritellyt ympäristömuuttujat otetaan koodissa käyttöön komennolla -require('dotenv').config() ja niihin viitataan Nodessa kuten "normaaleihin" ympäristömuuttujiin syntaksilla process.env.MONGODB_URI. +dotenvissä määritellyt ympäristömuuttujat otetaan koodissa käyttöön komennolla require('dotenv').config() ja niihin viitataan Nodessa kuten "normaaleihin" ympäristömuuttujiin syntaksilla process.env.MONGODB_URI. -Muutetaan nyt tiedostoa index.js seuraavasti +Ladataan ympäristömuuttujat käyttöön heti index.js-tiedoston alussa, jolloin ne tulevat käyttöön koko sovellukselle. Muutetaan nyt tiedostoa index.js seuraavasti: ```js require('dotenv').config() // highlight-line const express = require('express') -const app = express() const Note = require('./models/note') // highlight-line +const app = express() // .. const PORT = process.env.PORT // highlight-line @@ -542,7 +532,27 @@ app.listen(PORT, () => { }) ``` -On tärkeää, että dotenv otetaan käyttöön ennen modelin note importtaamista, tällöin varmistutaan siitä, että tiedostossa .env olevat ympäristömuuttujat ovat alustettuja kun moduulin koodia importoidaan. +On tärkeää, että dotenv otetaan käyttöön ennen modelin note importtaamista. Tällöin varmistutaan siitä, että tiedostossa .env olevat ympäristömuuttujat ovat alustettuja kun moduulin koodia importoidaan. + +#### Tärkeä huomio ympäristömuuttujien määrittelemisestä Fly.io:ssa ja Renderissä + +**Fly.io:n käyttäjät:** Koska Fly.io ei hyödynnä gitiä, menee myös .env-tiedosto Fly.io:n palvelimelle, ja ympäristömuuttujien arvo välittyy myös sinne. + +[Tietoturvallisempi vaihtoehto](https://community.fly.io/t/clarification-on-environment-variables/6309) on kuitenkin estää tiedoston .env siirtyminen Fly.io:n tekemällä hakemiston juureen tiedosto _.dockerignore_, jolla on sisältö + +```bash +.env +``` + +ja asettaa ympäristömuuttujan arvo komennolla: + +``` +fly secrets set MONGODB_URI='mongodb+srv://fullstack:thepasswordishere@cluster0.a5qfl.mongodb.net/noteApp?retryWrites=true&w=majority&appName=Cluster0' +``` + +**Renderin käyttäjät:** Renderiä käytettäessä tietokannan osoitteen kertova ympäristömuuttuja määritellään dashboardista käsin: + +![](../../images/3/render-env.png) ### Tietokannan käyttö reittien käsittelijöissä @@ -554,28 +564,27 @@ Uuden muistiinpanon luominen tapahtuu seuraavasti: app.post('/api/notes', (request, response) => { const body = request.body - if (body.content === undefined) { + if (!body.content) { return response.status(400).json({ error: 'content missing' }) } const note = new Note({ content: body.content, important: body.important || false, - date: new Date(), }) note.save().then(savedNote => { - response.json(savedNote.toJSON()) + response.json(savedNote) }) }) ``` -Muistiinpano-oliot siis luodaan _Note_-konstruktorifunktiolla. Pyyntöön vastataan _save_-operaation takaisinkutsufunktion sisällä. Näin varmistutaan, että operaation vastaus tapahtuu vain jos operaatio on onnistunut. Palaamme virheiden käsittelyyn myöhemmin. +Muistiinpano-oliot siis luodaan _Note_-konstruktorifunktiolla. Pyyntöön vastataan metodin _save_ takaisinkutsufunktion sisällä. Näin varmistutaan, että operaation vastaus tapahtuu vain, jos operaatio on onnistunut. Palaamme virheiden käsittelyyn myöhemmin. -Takaisinkutsufunktion parametrina oleva _savedNote_ on talletettu muistiinpano. HTTP-pyyntöön palautetaan kuitenkin siitä metodilla _toJSON_ formatoitu muoto: +Takaisinkutsufunktion parametrina oleva _savedNote_ on talletettu muistiinpano. HTTP-pyyntöön palautetaan kuitenkin automaattisesti siitä metodilla _toJSON_ formatoitu muoto: ```js -response.json(savedNote.toJSON()) +response.json(savedNote) ``` Yksittäisen muistiinpanon tarkastelu muuttuu muotoon @@ -583,24 +592,39 @@ Yksittäisen muistiinpanon tarkastelu muuttuu muotoon ```js app.get('/api/notes/:id', (request, response) => { Note.findById(request.params.id).then(note => { - response.json(note.toJSON()) + response.json(note) }) }) ``` ### Frontendin ja backendin yhteistoiminnallisuuden varmistaminen -Kun backendia laajennetaan, kannattaa sitä testailla aluksi **ehdottomasti selaimella, postmanilla tai VS Coden REST clientillä**. Seuraavassa kokeillaan uuden muistiinpanon luomista tietokannan käyttöönoton jälkeen: +Kun backendia laajennetaan, kannattaa sitä testailla aluksi **ehdottomasti selaimella, Postmanilla tai VS Coden REST Clientillä**. Seuraavassa kokeillaan uuden muistiinpanon luomista tietokannan käyttöönoton jälkeen: -![](../../images/3/46e.png) +![Luotaessa VS coden rest clientillä muistiinpano, avautuva näkymä havainnollistaa HTTP-kutsuun saatua vastausta](../../images/3/46new.png) Vasta kun kaikki on todettu toimivaksi, kannattaa siirtyä testailemaan, että muutosten jälkeinen backend toimii yhdessä myös frontendin kanssa. Kaikkien kokeilujen tekeminen ainoastaan frontendin kautta on todennäköisesti varsin tehotonta. -Todennäköisesti voi olla kannattavaa edetä frontin ja backin integroinnissa toiminnallisuus kerrallaan, eli ensin voidaan toteuttaa esim. kaikkien muistiinpanojen näyttäminen backendiin ja testata että toiminnallisuus toimii selaimella. Tämän jälkeen varmistetaan, että frontend toimii yhteen muutetun backendin kanssa. Kun kaikki on todettu olevan kunnossa, siirrytään seuraavan ominaisuuden toteuttamiseen. +Todennäköisesti voi olla kannattavaa edetä frontin ja backin integroinnissa toiminnallisuus kerrallaan, eli ensin voidaan toteuttaa esim. kaikkien muistiinpanojen näyttäminen backendiin ja testata, että toiminnallisuus toimii selaimella. Tämän jälkeen varmistetaan, että frontend toimii yhteen muutetun backendin kanssa. Kun kaiken on todettu olevan kunnossa, siirrytään seuraavan ominaisuuden toteuttamiseen. + +Kun kuvioissa on mukana tietokanta, on tietokannan tilan tarkastelu MongoDB Atlasin hallintanäkymästä varsin hyödyllistä. Usein myös suoraan tietokantaa käyttävät Node-apuohjelmat, kuten tiedostoon mongo.js kirjoittamamme koodi auttavat sovelluskehityksen edetessä. -Kun kuvioissa on mukana tietokanta, on tietokannan tilan tarkastelu MongoDB Atlasin hallintanäkymästä varsin hyödyllistä, usein myös suoraan tietokantaa käyttävät Node-apuohjelmat, kuten tiedostoon mongo.js kirjoittamamme koodi auttavat sovelluskehityksen edetessä. +Sovelluksen tämän hetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-4), branchissa part3-4. -Sovelluksen tämän hetkinen koodi on kokonaisuudessaan [Githubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-4), branchissa part3-4. +### Todellisen full stack ‑sovelluskehittäjän vala + +Sovelluksemme koostuu nyt frontendin ja backendin lisäksi myös tietokannasta. Virhelähteiden määrä siis kasvaa ja päivitämme full stack ‑kehittäjän valaa seuraavasti: + +Full stack ‑ohjelmointi on todella hankalaa, ja sen takia lupaan hyödyntää kaikkia ohjelmointia helpottavia keinoja: + +- pidän selaimen konsolin koko ajan auki +- tarkkailen säännöllisesti selaimen network-välilehdeltä, että frontendin ja backendin välinen kommunikaatio tapahtuu oletusteni mukaan +- tarkkailen säännöllisesti palvelimella olevan datan tilaa, ja varmistan että frontendin lähettämä data siirtyy sinne kuten oletin +- pidän silmällä tietokannan tilaa: varmistan että backend tallentaa datan sinne oikeaan muotoon +- etenen pienin askelin +- käytän koodissa runsaasti _console.log_-komentoja varmistamaan sen, että varmasti ymmärrän jokaisen kirjoittamani koodirivin, sekä etsiessäni koodista mahdollisia bugin aiheuttajia +- jos koodini ei toimi, en kirjoita enää yhtään lisää koodia, vaan alan poistamaan toiminnan rikkoneita rivejä tai palaan suosiolla tilanteeseen, missä koodi vielä toimi +- kun kysyn apua kurssin Discord-kanavalla, tai muualla internetissä, muotoilen kysymyksen järkevästi, esim. [täällä](/en/part0/general_info#how-to-get-help-in-discord) esiteltyyn tapaan @@ -608,22 +632,21 @@ Sovelluksen tämän hetkinen koodi on kokonaisuudessaan [Githubissa](https://git ### Tehtävät 3.13.-3.14. -Seuraavat tehtävät saattavat olla melko suoraviivaisia, tosin jos frontend-koodissasi sattuu olemaan bugeja tai epäyhteensopivuutta backendin kanssa, voi seurauksena olla myös mielenkiintoisia bugeja. +Seuraavat tehtävät saattavat olla melko suoraviivaisia, mutta jos frontend-koodissasi sattuu olemaan bugeja tai epäyhteensopivuutta backendin kanssa, voi seurauksena olla myös mielenkiintoisia bugeja. #### 3.13: puhelinluettelo ja tietokanta, step1 -Muuta backendin kaikkien puhelintietojen näyttämistä siten, että se hakee näytettävät puhelintiedot tietokannasta. +Muuta backendin kaikkien puhelintietojen näyttämistä siten, että backend hakee näytettävät puhelintiedot tietokannasta. Varmista, että frontend toimii muutosten jälkeen. -Tee tässä ja seuraavissa tehtävissä Mongoose-spesifinen koodi omaan moduuliin samaan tapaan kuin luvussa [Tietokantamäärittelyjen eriyttäminen moduuliksi](/osa3/tietojen_tallettaminen_mongo_db_tietokantaan#tietokantamaarittelyjen-eriyttaminen-moduuliksi). +Tee tässä ja seuraavissa tehtävissä Mongoose-spesifinen koodi omaan moduuliinsa samaan tapaan kuin kohdassa [Tietokantamäärittelyjen eriyttäminen moduuliksi](/osa3/tietojen_tallettaminen_mongo_db_tietokantaan#tietokantamaarittelyjen-eriyttaminen-moduuliksi). #### 3.14: puhelinluettelo ja tietokanta, step2 -Muuta backendiä siten, että uudet numerot tallennetaan tietokantaan. -Varmista, että frontend toimii muutosten jälkeen. +Muuta backendiä siten, että uudet numerot tallennetaan tietokantaan. Varmista, että frontend toimii muutosten jälkeen. -**Tässä vaiheessa voit olla välittämättä siitä, onko tietokannassa jo henkilöä jolla on sama nimi kuin lisättävällä.** +**Tässä vaiheessa voit olla välittämättä siitä, onko tietokannassa jo henkilöä, jolla on sama nimi kuin lisättävällä.** @@ -631,36 +654,38 @@ Varmista, että frontend toimii muutosten jälkeen. ### Virheiden käsittely -Jos yritämme mennä selaimella sellaisen yksittäisen muistiinpanon sivulle, jota ei ole olemassa, eli esim. urliin missä 5c41c90e84d891c15dfa3431 ei ole minkään tietokannassa olevan muistiinpanon tunniste, jää selain "jumiin" sillä palvelin ei vastaa pyyntöön koskaan. - -Palvelimen konsolissa näkyykin virheilmoitus: - -![](../../images/3/47.png) +Jos yritämme hakea selaimella sellaista yksittäistä muistiinpanoa, jota ei ole olemassa (eli esim. urliin , jossa 5c41c90e84d891c15dfa3431 ei ole minkään tietokannassa olevan muistiinpanon tunniste, on palvelimelta saatu vastaus null. -Kysely on epäonnistunut ja kyselyä vastaava promise mennyt tilaan rejected. Koska emme käsittele promisen epäonnistumista, ei pyyntöön vastata koskaan. Osassa 2 tutustuimme jo [promisejen virhetilanteiden käsittelyyn](/osa2/palvelimella_olevan_datan_muokkaaminen#promise-ja-virheet). - -Lisätään tilanteeseen yksinkertainen virheidenkäsittelijä: +Muutetaan koodia niin, että tapauksessa, jossa muistiinpanoa ei ole olemassa, lähetään vastauksena HTTP-statuskoodi 404 Not Found. Toteutetaan lisäksi yksinkertainen catch-lohko, jossa käsitellään tapaukset, joissa findById-metodin palauttama promise päätyy rejected-tilaan: ```js app.get('/api/notes/:id', (request, response) => { Note.findById(request.params.id) .then(note => { - response.json(note.toJSON()) + // highlight-start + if (note) { + response.json(note) + } else { + response.status(404).end() + } + // highlight-end }) + // highlight-start .catch(error => { console.log(error) - response.status(404).end() + response.status(500).end() }) + // highlight-end }) ``` -Kaikissa virheeseen päättyvissä tilanteissa HTTP-pyyntöön vastataan statuskoodilla 404 not found. Konsoliin tulostetaan tarkempi tieto virheestä. +Jos kannasta ei löydy haettua olioa, muuttujan _note_ arvo on _null_ ja koodi ajautuu _else_-haaraan. Siellä vastataan kyselyyn statuskoodilla 404 Not Found. Jos findById-metodin palauttama promise päätyy rejected-tilaan, kyselyyn vastataan statuskoodilla 500 Internal Server Error. Konsoliin tulostetaan tarkempi tieto virheestä. -Tapauksessamme on itseasiassa olemassa kaksi erityyppistä virhetilannetta. Toinen vastaa sitä, että yritetään hakea muistiinpanoa virheellisen muotoisella _id_:llä, eli sellaisella mikä ei vastaa mongon id:iden muotoa. +Olemattoman muistiinpanon lisäksi koodista löytyy myös toinen virhetilanne, joka täytyy käsitellä. Tässä virhetilanteessa muistiinpanoa yritetään hakea virheellisen muotoisella _id_:llä eli sellaisella, joka ei vastaa MongoDB:n id:iden muotoa. -Jos teemme näin tulostuu konsoliin: +Jos teemme näin, tulostuu konsoliin: -
    +```
     Method: GET
     Path:   /api/notes/5a3b7c3c31d61cb9f8a0343
     Body:   {}
    @@ -669,35 +694,21 @@ Body:   {}
         at CastError (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/error/cast.js:27:11)
         at ObjectId.cast (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/schema/objectid.js:158:13)
         ...
    -
    - -Toinen virhetilanne taas vastaa tilannetta, missä haettavan muistiinpanon id on periaatteessa oikeassa formaatissa, mutta tietokannasta ei löydy indeksillä mitään: - -
    -Method: GET
    -Path:   /api/notes/5a3b7c3c31d61cbd9f8a0343
    -Body:   {}
    ----
    -TypeError: Cannot read property 'toJSON' of null
    -    at Note.findById.then.note (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/index.js:27:24)
    -    at process._tickCallback (internal/process/next_tick.js:178:7)
    -
    +``` -Nämä tilanteet on syytä erottaa toisistaan, ja itseasiassa jälkimmäinen poikkeus on oman koodimme aiheuttama. +Kun findById-metodi saa argumentikseen väärässä muodossa olevan id:n, se heittää virheen. Tästä seuraa se, että metodin palauttama promise päätyy rejected-tilaan, jonka seurauksena catch-lohkossa määriteltyä funktiota kutsutaan. -Muutetaan koodia seuraavasti: +Tehdään pieniä muutoksia koodin catch-lohkoon: ```js app.get('/api/notes/:id', (request, response) => { Note.findById(request.params.id) .then(note => { - // highlight-start if (note) { - response.json(note.toJSON()) + response.json(note) } else { response.status(404).end() } - // highlight-end }) .catch(error => { console.log(error) @@ -706,44 +717,41 @@ app.get('/api/notes/:id', (request, response) => { }) ``` -Jos kannasta ei löydy haettua olioa, muuttujan _note_ arvo on _undefined_ ja koodi ajautuu _else_-haaraan. Siellä vastataan kyselyyn 404 not found - -Jos id ei ole hyväksyttävässä muodossa, ajaudutaan _catch_:in avulla määriteltyyn virheidenkäsittelijään. Sopiva statuskoodi on [400 bad request](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1) koska kyse on juuri siitä: +Jos id ei ole hyväksyttävässä muodossa, ajaudutaan _catch_:in avulla määriteltyyn virheidenkäsittelijään. Sopiva statuskoodi on [400 Bad Request](https://www.rfc-editor.org/rfc/rfc9110.html#name-400-bad-request) koska kyse on juuri siitä: > The request could not be understood by the server due to malformed syntax. The client SHOULD NOT repeat the request without modifications. Vastaukseen on lisätty myös hieman dataa kertomaan virheen syystä. -Promisejen yhteydessä kannattaa melkeinpä aina lisätä koodiin myös virhetilainteiden käsittely, muuten seurauksena on usein hämmentäviä vikoja. +Promisejen yhteydessä kannattaa melkeinpä aina lisätä koodiin myös virhetilanteiden käsittely, muuten seurauksena on usein hämmentäviä vikoja. Ei ole koskaan huono idea tulostaa poikkeuksen aiheuttanutta olioa konsoliin virheenkäsittelijässä: ```js .catch(error => { - console.log(error) + console.log(error) // highlight-line response.status(400).send({ error: 'malformatted id' }) }) ``` -Virheenkäsittelijään joutumisen syy voi olla joku ihan muu kuin mitä on tullut alunperin ajatelleeksi. Jos virheen tulostaa konsoliin, voi säästyä pitkiltä ja turhauttavilta väärää asiaa debuggaavilta sessioilta. +Virheenkäsittelijään joutumisen syy voi olla joku ihan muu kuin mitä on tullut alun perin ajatelleeksi. Jos virheen tulostaa konsoliin, voi säästyä pitkiltä ja turhauttavilta väärää asiaa debuggaavilta sessioilta. -Aina kun ohjelmoit ja projektissa on mukana backend **tulee ehdottomasti koko ajan pitää silmällä backendin konsolin tulostuksia**. Jos työskentelet pienellä näytöllä, riittää että konsolista on näkyvissä edes pieni kaistale: +Aina kun ohjelmoit ja projektissa on mukana backend, **tulee ehdottomasti koko ajan pitää silmällä backendin konsolin tulostuksia**. Jos työskentelet pienellä näytöllä, riittää että konsolista on näkyvissä edes pieni kaistale: -![](../../images/3/15b.png) +![Kuva havainnollistaa sitä miten sovelluskehittäjän tulee pitää koko ajan esillä editorin lisäksi konsolia johon backend on käynnistetty](../../images/3/15b.png) ### Virheidenkäsittelyn keskittäminen middlewareen Olemme kirjoittaneet poikkeuksen aiheuttavan virhetilanteen käsittelevän koodin muun koodin sekaan. Se on välillä ihan toimiva ratkaisu, mutta on myös tilanteita, joissa on järkevämpää keskittää virheiden käsittely yhteen paikkaan. Tästä on huomattava etu esim. jos virhetilanteiden yhteydessä virheen aiheuttaneen pyynnön tiedot logataan tai lähetetään johonkin virhediagnostiikkajärjestelmään, esim. [Sentryyn](https://sentry.io/welcome/). -Muutetaan routen /api/notes/:id käsittelijää siten, että se siirtää virhetilanteen käsittelyn eteenpäin funktiolla next, jonka se saa kolmantena parametrina: +Muutetaan routen /api/notes/:id käsittelijää siten, että se siirtää virhetilanteen käsittelyn eteenpäin funktiolla next, jonka se saa kolmantena parametrinaan: ```js -app.get('/api/notes/:id', (request, response, next) => { - // highlight-line +app.get('/api/notes/:id', (request, response, next) => { // highlight-line Note.findById(request.params.id) .then(note => { if (note) { - response.json(note.toJSON()) + response.json(note) } else { response.status(404).end() } @@ -767,21 +775,24 @@ const errorHandler = (error, request, response, next) => { next(error) } +// tämä tulee kaikkien muiden middlewarejen ja routejen rekisteröinnin jälkeen! app.use(errorHandler) ``` -Virhekäsittelijä tarkastaa onko kyse CastError-poikkeuksesta, eli virheellisestä olioid:stä, jos on, se lähettää pyynnön tehneelle selaimelle vastauksen käsittelijän parametrina olevan response-olion avulla. Muussa tapauksessa se siirtää funktiolla next virheen käsittelyn Expressin oletusarvoisen virheidenkäsittelijän hoidettavavksi. +Virheenkäsittelijä tarkastaa, onko kyse CastError-poikkeuksesta eli virheellisestä olio-id:stä. Jos on, käsittelijä lähettää pyynnön tehneelle selaimelle vastauksen käsittelijän parametrina olevan response-olion avulla. Muussa tapauksessa se siirtää funktiolla next virheen käsittelyn Expressin oletusarvoisen virheidenkäsittelijän hoidettavaksi. + +Huomaa, että virheidenkäsittelijämiddleware tulee rekisteröidä muiden middlewarejen sekä routejen rekisteröinnin jälkeen. ### Middlewarejen käyttöönottojärjestys -Koska middlewaret suoritetaan siinä järjestyksessä, missä ne on otettu käyttöön funktiolla _app.use_ on niiden määrittelyn kanssa oltava tarkkana. +Koska middlewaret suoritetaan siinä järjestyksessä, missä ne on otettu käyttöön funktiolla _app.use_, on niiden määrittelyn kanssa oltava tarkkana. -Oikeaoppinen järjestys seuraavassa: +Oikeaoppinen järjestys on tämä: ```js -app.use(express.static('build')) +app.use(express.static('dist')) app.use(express.json()) -app.use(logger) +app.use(requestLogger) app.post('/api/notes', (request, response) => { const body = request.body @@ -803,10 +814,10 @@ const errorHandler = (error, request, response, next) => { app.use(errorHandler) ``` -Json-parseri on syytä ottaa käyttöön melkeimpä ensimmäisenä. Jos järjestys olisi seuraava +JSON-parseri on syytä ottaa käyttöön melkeinpä ensimmäisenä. Jos järjestys olisi seuraava ```js -app.use(logger) // request.body on tyhjä +app.use(requestLogger) // request.body on tyhjä app.post('/api/notes', (request, response) => { // request.body on tyhjä @@ -817,11 +828,9 @@ app.post('/api/notes', (request, response) => { app.use(express.json()) ``` -ei HTTP-pyynnön mukana oleva data olisi loggerin eikä POST-pyynnön käsittelyn aikana käytettävissä, kentässä _request.body_ olisi tyhjä olio. - -Tärkeää on myös ottaa käyttöön olemattomien osoitteiden käsittely viimeisenä. +ei HTTP-pyynnön mukana oleva data olisi loggerin eikä POST-pyynnön käsittelyn aikana käytettävissä, vaan kentässä _request.body_ olisi tyhjä olio. -Myös seuraava järjestys aiheuttaisi ongelman +Tärkeää on myös ottaa olemattomat osoitteet käsittelevä middleware käyttöön vasta kaikkien endpointtien määrittelyn jälkeen, juuri ennen virheenkäsittelijää. Seuraava järjestys aiheuttaisi ongelman: ```js const unknownEndpoint = (request, response) => { @@ -836,17 +845,17 @@ app.get('/api/notes', (request, response) => { }) ``` -Nyt olemattomien osoitteiden käsittely on sijoitettu ennen HTTP GET -pyynnön käsittelyä. Koska olemattomien osoitteiden käsittelijä vastaa kaikkiin pyyntöihin 404 unknown endpoint, ei mihinkään sen jälkeen määriteltyyn reittiin tai middlewareen (poikkeuksena virheenkäsittelijä) enää mennä. +Nyt olemattomien osoitteiden käsittely on sijoitettu ennen HTTP GET ‑pyynnön käsittelyä. Koska olemattomien osoitteiden käsittelijä vastaa kaikkiin pyyntöihin 404 Unknown Endpoint, ei mihinkään sen jälkeen määriteltyyn reittiin tai middlewareen (poikkeuksena virheenkäsittelijä) enää mennä. ### Muut operaatiot Toteutetaan vielä jäljellä olevat operaatiot, eli yksittäisen muistiinpanon poisto ja muokkaus. -Poisto onnistuu helpoiten metodilla [findByIdAndRemove](https://mongoosejs.com/docs/api.html#model_Model.findByIdAndRemove): +Poisto onnistuu helpoiten metodilla [findByIdAndDelete](https://mongoosejs.com/docs/api/model.html#Model.findByIdAndDelete): ```js app.delete('/api/notes/:id', (request, response, next) => { - Note.findByIdAndRemove(request.params.id) + Note.findByIdAndDelete(request.params.id) .then(result => { response.status(204).end() }) @@ -854,52 +863,58 @@ app.delete('/api/notes/:id', (request, response, next) => { }) ``` -Vastauksena on statuskoodi 204 no content molemmissa "onnistuneissa" tapauksissa, eli jos olio poistettiin tai olioa ei ollut mutta id oli periaatteessa oikea. Takaisinkutsun parametrin _result_ perusteella olisi mahdollisuus haarautua ja palauttaa tilanteissa eri statuskoodi, jos sille on tarvetta. Mahdollinen poikkeus siirretään jälleen virheenkäsittelijälle. +Vastauksena on molemmissa "onnistuneissa" tapauksissa statuskoodi 204 No Content eli jos olio poistettiin tai olioa ei ollut mutta id oli periaatteessa oikea. Takaisinkutsun parametrin _result_ perusteella olisi mahdollisuus haarautua ja palauttaa tilanteissa eri statuskoodi, jos sille on tarvetta. Mahdollinen poikkeus siirretään jälleen virheenkäsittelijälle. -Muistiinpanon tärkeyden muuttamisen mahdollistava olemassaolevan muistiinpanon päivitys onnistuu helposti metodilla [findByIdAndUpdate](https://mongoosejs.com/docs/api.html#model_Model.findByIdAndUpdate). +Toteutetaan vielä yksittäisen muistiinpanon muokkaustoiminto, jotta muistiinpanon tärkeyden muuttaminen mahdollistuu. Muistiinpanon muokkaus tapahtuu seuraavasti: ```js app.put('/api/notes/:id', (request, response, next) => { - const body = request.body + const { content, important } = request.body - const note = { - content: body.content, - important: body.important, - } + Note.findById(request.params.id) + .then(note => { + if (!note) { + return response.status(404).end() + } + + note.content = content + note.important = important - Note.findByIdAndUpdate(request.params.id, note, { new: true }) - .then(updatedNote => { - response.json(updatedNote.toJSON()) + return note.save().then((updatedNote) => { + response.json(updatedNote) + }) }) .catch(error => next(error)) }) ``` -Operaatio mahdollistaa myös muistiinpanon sisällön editoinnin. Päivämäärän muuttaminen ei ole mahdollista. +Muokattava muistiinpano haetaan ensin tietokannasta metodilla _findById_. Jos kannasta ei löydy oliota annetulla id:llä, muuttujan _note_ arvo on _null_, ja kyselyyn vastataan statuskoodilla 404 Not Found. -Huomaa, että metodin findByIdAndUpdate parametrina tulee antaa normaali Javascript-olio, eikä uuden olion luomisessa käytettävä Note-konstruktorifunktiolla luotu olio. +Jos annettua id:tä vastaava olio löytyy, päivitetään sen _content_- ja _important_-kentät pyynnön mukana tulleella datalla ja tallennetaan muokattu muistiinpano tietokantaan metodilla _save()_. HTTP-pyyntöön vastataan lähettämällä vastauksen mukana päivitetty muistiinpano. -Pieni, mutta tärkeä detalji liittyen operaatioon findByIdAndUpdate. Oletusarvoisesti tapahtumankäsittelijä saa parametrikseen updatedNote päivitetyn olion [ennen muutosta](https://mongoosejs.com/docs/api.html#model_Model.findByIdAndUpdate) olleen tilan. Lisäsimme operaatioon parametrin { new: true }, jotta saamme muuttuneen olion palautetuksi kutsujalle. - -Backend vaikuttaa toimivan postmanista ja VS Coden REST-clientistä tehtyjen kokeilujen perusteella, ja myös frontend toimii moitteettomasti tietokantaa käyttävän backendin kanssa. - -Kun muutamme muistiinpanon tärkeyttä, tulostuu backendin konsoliin ikävä varoitus - -![](../../images/3/48.png) - -Googlaamalla virheilmoitusta löytyy [ohje](https://stackoverflow.com/questions/52572852/deprecationwarning-collection-findandmodify-is-deprecated-use-findoneandupdate) ongelman korjaamiseen. Eli kuten [mongoosen dokumentaatio kehottaa](https://mongoosejs.com/docs/deprecations.html) lisätään tiedostoon note.js yksi rivi: +Eräs huomionarvoinen seikka on se, että koodissa on nyt ns. sisäkkäiset promiset, eli ulomman _.then_-metodin sisällä on määritelty toinen [promise-ketju](https://javascript.info/promise-chaining): ```js -const mongoose = require('mongoose') - -mongoose.set('useFindAndModify', false) // highlight-line + .then(note => { + if (!note) { + return response.status(404).end() + } -// ... + note.content = content + note.important = important -module.exports = mongoose.model('Note', noteSchema) + // highlight-start + return note.save().then((updatedNote) => { + response.json(updatedNote) + }) + // highlight-end ``` -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-5), branchissa part3-5. +Yleensä tällaista ei suositella, koska se voi tehdä koodista vaikealukuista. Tässä tapauksessa ratkaisu kuitenkin toimii, sillä näin voimme varmistua siitä, että _.save()_-metodin jälkeiseen _.then_-lohkoon mennään vain, jos id:tä vastaava muistiinpano on löytynyt kannasta ja _save()_-metodia on kutsuttu. Tutustumme kurssin neljännessä osassa async/await-syntaksiin, joka tarjoaa helpomman ja selkeämmän kirjoitustavan tämänkaltaisiin tilanteisiin. + +Backend vaikuttaa toimivan Postmanista ja VS Coden REST Clientistä tehtyjen kokeilujen perusteella. Myös frontend toimii moitteettomasti tietokantaa käyttävän backendin kanssa. + +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-5), branchissa part3-5. @@ -921,7 +936,7 @@ Muista, että virheitä heittävät routejen metodit tarvitsevat myös kolmannen #### 3.17*: puhelinluettelo ja tietokanta, step5 -Jos frontendissä annetaan numero henkilölle, joka on jo olemassa, päivittää frontend tehtävässä 2.18 tehdyn toteutuksen ansiosta tiedot uudella numerolla tekemällä HTTP PUT -pyynnön henkilön tietoja vastaavaan url:iin. +Jos frontendissä annetaan numero henkilölle, joka on jo olemassa, päivittää frontend tehtävässä 2.18 tehdyn toteutuksen ansiosta tiedot uudella numerolla tekemällä HTTP PUT ‑pyynnön henkilön tietoja vastaavaan url:iin. Laajenna backendisi käsittelemään tämä tilanne. @@ -929,10 +944,10 @@ Varmista, että frontend toimii muutosten jälkeen. #### 3.18*: puhelinluettelo ja tietokanta, step6 -Päivitä myös polkujen api/persons/:id ja info käsittely, ja varmista niiden toimivuus suoraan selaimella, postmanilla tai VS Coden REST clientillä. +Päivitä myös polkujen api/persons/:id ja info käsittely ja varmista niiden toimivuus suoraan selaimella, Postmanilla tai VS Coden REST Clientillä. Selaimella tarkastellen yksittäisen numerotiedon tulisi näyttää seuraavalta: -![](../../images/3/49.png) +![Mentäessä yksittäisen henkilön urliin, renderöi selain JSON:in missä kenttinä nimi, puhelinnumero sekä id](../../images/3/49.png) diff --git a/src/content/3/fi/osa3d.md b/src/content/3/fi/osa3d.md index 29973b5929e..4eaf81c448b 100644 --- a/src/content/3/fi/osa3d.md +++ b/src/content/3/fi/osa3d.md @@ -7,13 +7,13 @@ lang: fi
    -Sovelluksen tietokantaan tallettamalle datan muodolle on usein tarve asettaa joitain ehtoja. Sovelluksemme ei esim. hyväksy muistiinpanoja, joiden sisältö eli content kenttä puuttuu. Muistiinpanon oikeellisuus tallennetaan sen luovassa metodissa: +Sovelluksen tietokantaan tallettamalle datan muodolle on usein tarve asettaa joitain ehtoja. Sovelluksemme ei hyväksy esim. muistiinpanoja, joiden sisältö eli content-kenttä puuttuu. Muistiinpanon oikeellisuus tarkastetaan sen luovassa metodissa: ```js app.post('/api/notes', (request, response) => { const body = request.body // highlight-start - if (body.content === undefined) { + if (!body.content) { return response.status(400).json({ error: 'content missing' }) } // highlight-end @@ -22,7 +22,7 @@ app.post('/api/notes', (request, response) => { }) ``` -Eli jos muistiinpanolla ei ole kenttää content, vastataan pyyntöön statuskoodilla 400 bad request. +Eli jos muistiinpanolla ei ole kenttää content, vastataan pyyntöön statuskoodilla 400 Bad Request. Routejen tapahtumakäsittelijöissä tehtävää tarkastusta järkevämpi tapa tietokantaan talletettavan tiedon oikean muodon määrittelylle ja tarkastamiselle on Mongoosen [validointitoiminnallisuuden](https://mongoosejs.com/docs/validation.html) käyttö. @@ -35,19 +35,15 @@ const noteSchema = new mongoose.Schema({ type: String, minlength: 5, required: true - }, - date: {  - type: Date, - required: true }, // highlight-end important: Boolean }) ``` -Kentän content pituuden vaaditaan nyt olevan vähintään 5 merkkiä. Kentälle date taas on asetettu ehdoksi että sillä on oltava joku arvo, eli kenttä ei voi olla tyhjä. Sama ehto on asetettu myös kentälle content, sillä minimipituuden tarkistava ehto ei huomioi tilannetta, missä kentällä ei ole mitään arvoa. Kentälle important ei ole asetettu mitään ehtoa, joten se on määritelty edelleen yksinkertaisemmassa muodossa. +Kentän content pituuden vaaditaan nyt olevan vähintään viisi merkkiä ja kentän arvo ei saa olla tyhjä. Kentälle important ei ole asetettu mitään ehtoa, joten se on määritelty edelleen yksinkertaisemmassa muodossa. -Esimerkissä käytetyt validaattorit minlength ja required ovat Mongooseen [sisäänrakennettuja](https://mongoosejs.com/docs/validation.html#built-in-validators) validointisääntöjä. Mongoosen [custom validator](https://mongoosejs.com/docs/validation.html#custom-validators) -ominaisuus mahdollistaa mielivaltaisten validaattorien toteuttamisen, jos valmiiden joukosta ei löydy tarkoitukseen sopivaa. +Esimerkissä käytetyt validaattorit minlength ja required ovat Mongooseen [sisäänrakennettuja](https://mongoosejs.com/docs/validation.html#built-in-validators) validointisääntöjä. Mongoosen [custom validator](https://mongoosejs.com/docs/validation.html#custom-validators) ‑ominaisuus mahdollistaa mielivaltaisten validaattorien toteuttamisen, jos valmiiden joukosta ei löydy tarkoitukseen sopivaa. Jos tietokantaan yritetään tallettaa validointisäännön rikkova olio, heittää tallennusoperaatio poikkeuksen. Muutetaan uuden muistiinpanon luomisesta huolehtivaa käsittelijää siten, että se välittää mahdollisen poikkeuksen virheenkäsittelijämiddlewaren huolehdittavaksi: @@ -58,12 +54,11 @@ app.post('/api/notes', (request, response, next) => { // highlight-line const note = new Note({ content: body.content, important: body.important || false, - date: new Date(), }) note.save() .then(savedNote => { - response.json(savedNote.toJSON()) + response.json(savedNote) }) .catch(error => next(error)) // highlight-line }) @@ -87,115 +82,55 @@ const errorHandler = (error, request, response, next) => { Validoinnin epäonnistuessa palautetaan validaattorin oletusarvoinen virheviesti: -![](../../images/3/50.png) - -### Promisejen ketjutus - -Useat routejen tapahtumankäsittelijöistä muuttivat palautettavan datan oikeaan formaattiin kutsumalla palautetuille olioille niiden metodia _toJSON_. Esimimerkiksi uuden muistiinpanon luomisessa metodia kutsutaan _then_:in parametrina palauttamalle oliolle: +![Luotaessa muistiinpano jonka kenttä content on liian lyhyt, seurauksena on virheilmoituksen sisältävä JSON](../../images/3/50.png) -```js -app.post('/api/notes', (request, response, next) => { - // ... +### Tietokantaa käyttävän version vieminen tuotantoon - note.save() - .then(savedNote => { - response.json(savedNote.toJSON()) - }) - .catch(error => next(error)) -}) -``` +Sovelluksen pitäisi toimia tuotannossa eli Fly.io:ssa tai Renderissä lähes sellaisenaan. Frontendin muutosten takia on tehtävä siitä uusi tuotantoversio ja kopioitava se backendiin. -Voisimme tehdä saman myös hieman tyylikkäämmin [promiseja ketjuttamalla](https://javascript.info/promise-chaining): +Huomaa, että vaikka määrittelimme sovelluskehitystä varten ympäristömuuttujille arvot tiedostossa .env, tietokantaurlin kertovan ympäristömuuttujan täytyy asettaa Fly.io:n tai Render vielä erikseen. -```js -app.post('/api/notes', (request, response, next) => { - // ... +Fly.io:ssa komennolla _fly secrets set_: - note - .save() - // highlight-start - .then(savedNote => { - return savedNote.toJSON() - }) - .then(savedAndFormattedNote => { - response.json(savedAndFormattedNote) - }) - // highlight-end - .catch(error => next(error)) -}) +``` +fly secrets set MONGODB_URI='mongodb+srv://fullstack:thepasswordishere@cluster0.a5qfl.mongodb.net/noteApp?retryWrites=true&w=majority' ``` -Eli ensimmäisen _then_:in takaisinkutsussa otamme Mongoosen palauttaman olion _savedNote_ ja formatoimme sen. Operaation tulos palautetaan returnilla. Kuten osassa 2 [todettiin](/osa2/palvelimella_olevan_datan_muokkaaminen#palvelimen-kanssa-tapahtuvan-kommunikoinnin-eristaminen-omaan-moduuliin), promisen then-metodi palauttaa myös promisen. Eli kun palautamme _savedNote.toJSON()_:n takaisinkutsufunktiosta, syntyy promise, jonka arvona on formatoitu muistiinpano. Pääsemme käsiksi arvoon rekisteröimällä _then_-kutsulla uuden tapahtumankäsittelijän. +Kun sovellus viedään tuotantoon, on hyvin tavanomaista että kaikki ei toimi odotusten mukaan. Esim. ensimmäinen tuotantoonvientiyritykseni päätyi seuraavaan: -Selviämme vieläkin tiiviimmällä koodilla käyttämällä nuolifunktion lyhempää muotoa: +![](../../images/3/fly-problem1.png) -```js -app.post('/api/notes', (request, response, next) => { - // ... +Sovelluksessa ei toimi mikään. - note - .save() - .then(savedNote => savedNote.toJSON()) // highlight-line - .then(savedAndFormattedNote => { - response.json(savedAndFormattedNote) - }) - .catch(error => next(error)) -}) -``` +Selaimen konsolin network-välilehti paljastaa että yritys muistiinpanojen hakemiseksi ei onnistu, pyyntö jää pitkäksi aikaa tilaan _pending_ ja lopulta epäonnistuu HTTP statuskoodilla 502. -Esimerkkimme tapauksessa promisejen ketjutuksesta ei ole suurta hyötyä. Tilanne alkaa muuttua, jos joudumme tekemään useita peräkkäisiä asynkronisia operaatiota. Emme kuitenkaan mene asiaan sen tarkemmin. Tutustumme seuraavassa osassa Javascriptin async/await-syntaksiin, jota käyttämällä peräkkäisten asynkronisten operaatioiden tekeminen helpottuu oleellisesti. +Selaimen konsolia on siis tarkasteltava koko ajan! -### Tietokantaa käyttävän version vieminen tuotantoon - -Sovelluksen pitäisi toimia tuotannossa, eli Herokussa lähes sellaisenaan. Frontendin muutosten takia on tehtävä siitä uusi tuotantoversio ja kopioitava se backendiin. +Myös palvelimen lokien seuraaminen on elintärkeää. Ongelman syy selviääkin heti kun katsomme komennolla _fly logs_ mitä palvelimella tapahtuu: -Huomaa, että vaikka määrittelimme sovelluskehitystä varten ympäristömuuttujille arvot tiedostossa .env, tietokantaurlin kertovan ympäristömuuttujan arvo asetetaan Herokuun komentorivillä komennolla _heroku config:set_ +![](../../images/3/fly-problem3.png) -```bash -heroku config:set MONGODB_URI=mongodb+srv://fullstack:secretpasswordhere@cluster0-ostce.mongodb.net/note-app?retryWrites=true -``` +Tietokannan osoite on siis _undefined_, eli komento *fly secrets set MONGODB\_URI* oli unohtunut. -HUOM: jos komento antaa virheilmoituksen, anna MONGODB_URI:n arvo hipsuissa - -```bash -heroku config:set MONGODB_URI='mongodb+srv://fullstack:secretpasswordhere@cluster0-ostce.mongodb.net/note-app?retryWrites=true' -``` +Renderiä käytettäessä tietokannan osoitteen kertova ympäristömuuttuja määritellään dashboardista käsin: -Sovelluksen pitäisi nyt toimia. Aina kaikki ei kuitenkaan mene suunnitelmien mukaan. Jos ongelmia ilmenee, heroku logs auttaa. Oma sovellukseni ei toiminut muutoksen jälkeen. Loki kertoi seuraavaa +![](../../images/3/render-env.png) -![](../../images/3/51a.png) +Renderiä käytettäessä sovelluksen lokia on mahdollista tarkastella +Dashboardin kautta: -eli tietokannan osoite olikin jostain syystä määrittelemätön. Komento heroku config paljasti että olin vahingossa määritellyt ympäristömuuttujan MONGO\_URL kun koodi oletti sen olevan nimeltään MONGODB\_URI. +![](../../images/3/r7.png) -Sovelluksen tämän hetkinen koodi on kokonaisuudessaan [Githubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-6), branchissä part3-6. +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-6), branchissä part3-6.
    ### Tehtävät 3.19.-3.21. - #### 3.19: puhelinluettelo ja tietokanta, step7 -Toteuta sovelluksellesi validaatio, joka huolehtii, että backendiin ei voi lisätä nimeä, joka on jo puhelinluettelossa. Frontendin nykyisestä versiosta ei duplikaatteja voi luoda, mutta suoraan Postmanilla tai VS Coden REST-clientillä se onnistuu. - -Mongoose ei tarjoa tilanteeseen sopivaa valmista validaattoria. Käytä npm:llä asennettavaa pakettia -[mongoose-unique-validator](https://github.com/blakehaswell/mongoose-unique-validator#readme). - -Jos HTTP POST -pyyntö yrittää lisätä nimeä, joka on jo puhelinluettelossa, tulee vastata sopivalla statuskoodilla ja lisätä vastaukseen asianmukainen virheilmoitus. - -**Huom:** unique-validatorin käyttö aiheuttaa konsoliin tulostuvan varoituksen - -``` -(node:49251) DeprecationWarning: collection.ensureIndex is deprecated. Use createIndexes instead. -connected to MongoDB -``` - -Mongoosen [dokumentaatio](https://mongoosejs.com/docs/deprecations.html) kertoo, miten saat virheilmoituksen poistettua. - -#### 3.20*: puhelinluettelo ja tietokanta, step8 - -Laajenna validaatiota siten, että tietokantaan talletettavan nimen on oltava pituudeltaan vähintään 3 merkkiä ja puhelinnumeron vähitään 8 merkkiä. +Laajenna validaatiota siten, että tietokantaan talletettavan nimen on oltava pituudeltaan vähintään kolme merkkiä. Laajenna sovelluksen frontendia siten, että se antaa jonkinlaisen virheilmoituksen validoinnin epäonnistuessa. Virheidenkäsittely hoidetaan lisäämällä catch-lohko uuden henkilön lisäämisen yhteyteen: @@ -213,13 +148,27 @@ personService Voit näyttää frontendissa käyttäjälle Mongoosen validoinnin oletusarvoisen virheilmoituksen vaikka ne eivät olekaan luettavuudeltaan parhaat mahdolliset: -![](../../images/3/56e.png) +![Selain renderöi virheilmoituksen 'Person valiation failed: name...'](../../images/3/56e.png) + +#### 3.20*: puhelinluettelo ja tietokanta, step8 + +Toteuta sovelluksellesi validaatio, joka huolehtii, että backendiin voi tallettaa ainoastaan oikeassa muodossa olevia puhelinnumeroita. Puhelinnumeron täytyy olla +- vähintään 8 merkkiä pitkä +- koostua kahdesta väliviivalla erotetusta osasta joissa ensimmäisessä osassa on 2 tai 3 numeroa ja toisessa osassa riittävä määrä numeroita + - esim. 09-1234556 ja 040-22334455 ovat oikeassa muodossa + - esim. 1234556, 1-22334455 ja 10-22-334455 eivät ole kelvollisia + +Toteuta validoinnin toinen osa [Custom validationina](https://mongoosejs.com/docs/validation.html#custom-validators). + +Jos HTTP POST ‑pyyntö yrittää lisätä virheellistä numeroa, tulee vastata sopivalla statuskoodilla ja lisätä vastaukseen asianmukainen virheilmoitus. -#### 3.21 tietokantaa käyttävä versio internettiin +#### 3.21 tietokantaa käyttävä versio Internetiin -Generoi päivitetystä sovelluksesta "full stack"-versio, eli tee frontendista uusi production build ja kopioi se backendin repositorioon. Varmista että kaikki toimii paikallisesti käyttämällä koko sovellusta backendin osoitteesta . +Generoi päivitetystä sovelluksesta "full stack" ‑versio, eli tee frontendista uusi production build ja kopioi se backendin juureen. Varmista, että kaikki toimii paikallisesti käyttämällä koko sovellusta backendin osoitteesta . -Pushaa uusi versio Herokuun ja varmista, että kaikki toimii myös siellä. +Pushaa uusi versio Fly.io:n tai Renderiin ja varmista, että kaikki toimii myös siellä. + +**HUOM:** Frontendiä ei julkaista suoraan missään vaiheessa tämän osan aikana. Vain backend-repositorio viedään Internetiin. Frontendin tuotantoversio lisätään backend-repositorioon, ja backend näyttää sen pääsivunaan kuten kohdassa [Staattisten tiedostojen tarjoaminen backendistä](/osa3/sovellus_internetiin#staattisten-tiedostojen-tarjoaminen-backendista) on kuvattu.
    @@ -231,77 +180,131 @@ Ennen osan lopetusta katsomme vielä nopeasti paitsioon jäänyttä tärkeää t > Generically, lint or a linter is any tool that detects and flags errors in programming languages, including stylistic errors. The term lint-like behavior is sometimes applied to the process of flagging suspicious language usage. Lint-like tools generally perform static analysis of source code. -Staattisesti tyypitetyissä, käännettävissä kielissä esim. Javassa ohjelmointiympäristöt, kuten NetBeans osaavat huomautella monista koodiin liittyvistä asioista, sellaisistakin, jotka eivät ole välttämättä käännösvirheitä. Erilaisten [staattisen analyysin](https://en.wikipedia.org/wiki/Static_program_analysis) lisätyökalujen, kuten [checkstylen](http://checkstyle.sourceforge.net/) avulla voidaan vielä laajentaa Javassa huomautettavien asioiden määrää koskemaan koodin tyylillisiä seikkoja, esim. sisentämistä. +Staattisesti tyypitetyissä, käännettävissä kielissä (esim. Javassa) ohjelmointiympäristöt, kuten NetBeans osaavat huomautella monista koodiin liittyvistä asioista, sellaisistakin, jotka eivät ole välttämättä käännösvirheitä. Erilaisten [staattisen analyysin](https://en.wikipedia.org/wiki/Static_program_analysis) lisätyökalujen, kuten [checkstylen](https://checkstyle.sourceforge.io/) avulla voidaan vielä laajentaa Javassa huomautettavien asioiden määrää koskemaan koodin tyylillisiä seikkoja, esim. sisentämistä. + +JavaScript-maailmassa tämän hetken johtava työkalu staattiseen analyysiin eli "linttaukseen" on [ESLint](https://eslint.org/). -Javascript-maailmassa tämän hetken johtava työkalu staattiseen analyysiin, eli "linttaukseen" on [ESlint](https://eslint.org/). +Lisätään ESLint backendin kehitysaikaiseksi riippuvuudeksi (development dependency). Kehitysaikaisilla riippuvuuksilla tarkoitetaan työkaluja, joita tarvitaan ainoastaan sovellusta kehittäessä. Esimerkiksi testaukseen liittyvät työkalut ovat tällaisia. Kun sovellusta suoritetaan tuotantomoodissa, ei kehitysaikaisia riippuvuuksia tarvita. -Asennetaan ESlint backendiin kehitysaikaiseksi riippuvuudeksi komennolla +Asennetaan ESLint backendiin kehitysaikaiseksi riippuvuudeksi komennolla: ```bash -npm install eslint --save-dev +npm install eslint @eslint/js --save-dev ``` -Tämän jälkeen voidaan muodostaa alustava ESlint-konfiguraatio komennolla +Tiedoston package.json sisältö muuttuu seuraavasti: + +```js +{ + //... + "dependencies": { + "dotenv": "^16.4.7", + "express": "^5.1.0", + "mongoose": "^8.11.0" + }, + "devDependencies": { // highlight-line + "@eslint/js": "^9.22.0", // highlight-line + "eslint": "^9.22.0" // highlight-line + } +} +``` + +Komento lisäsi siis tiedostoon devDependencies-osion ja sinne paketit eslint ja @eslint/js sekä asensi tarvittavat kirjastot node_modules-hakemistoon. + +Tämän jälkeen voidaan muodostaa alustava ESLint-konfiguraatio: ```bash -node_modules/.bin/eslint --init +npx eslint --init ``` Vastaillaan kysymyksiin: -![](../../images/3/52be.png) +![Vastataan kysymyksiin koodin luonteen mukaan, erityisesti että kyse ei ole TypeSriptistä, käytetään ' merkkijonoissa, ei käytetä ; rivien lopussa](../../images/3/lint1.png) + +Konfiguraatiot tallentuvat tiedostoon _eslint.config.mjs_. -Konfiguraatiot tallentuvat tiedostoon _.eslintrc.js_: +### Konfiguraatiotiedoston muotoilu + +Muutetaan tiedoston _eslint.config.mjs_ sisältö seuraavaan muotoon: ```js -module.exports = { - 'env': { - 'commonjs': true, - 'es6': true, - 'node': true - }, - 'extends': 'eslint:recommended', - 'globals': { - 'Atomics': 'readonly', - 'SharedArrayBuffer': 'readonly' - }, - 'parserOptions': { - 'ecmaVersion': 2018 +import globals from 'globals' + +export default [ + { + files: ['**/*.js'], + languageOptions: { + sourceType: 'commonjs', + globals: { ...globals.node }, + ecmaVersion: 'latest', }, - 'rules': { - 'indent': [ - 'error', - 4 - ], - 'linebreak-style': [ - 'error', - 'unix' - ], - 'quotes': [ - 'error', - 'single' - ], - 'semi': [ - 'error', - 'never' - ] - } -} + }, +] ``` -Muutetaan heti konfiguraatioista sisennystä määrittelevä sääntö, siten että sisennystaso on 2 välilyöntiä +_files_ määrittelee, että ESLint tarkkailee projektin JavaScript-tiedostoja. _languageOptions_ alla _sourceType_ kertoo, että projektissa on käytössä commonjs-moduulijärjestelmä, ja _globals.node_ taas määrittelee että projektissa käytetään NodeJS-ympäristön globaaleja muuttujia kuten _process_. Jos kyseessä olisi selaimessa suoritettava koodi, tulisi määritellä _globals.browser_ sallimaan selainkohtaiset globaalit muuttujat, kuten _window_ ja _document_. + +Lopuksi _ecmaVersion_-ominaisuuden arvoksi asetetaan viimeisin JavaScriptin versio. + +Haluamme käyttää [ESLintin suosittelemia](https://eslint.org/docs/latest/use/configure/configuration-files#using-predefined-configurations) asetuksia omien asetustemme ohella. Asentamamme _@eslint/js_ tarjoaa meille ennalta määritetyt asetukset ESLintille. Otetaan nämä käyttöön: ```js -"indent": [ - "error", - 2 -], +import globals from 'globals' +import js from '@eslint/js' // highlight-line +// ... + +export default [ + js.configs.recommended, // highlight-line + { + // ... + }, +] ``` -Esim tiedoston _index.js_ tarkastus tapahtuu komennolla +Rivi _js.configs.recommended_ kannattaa laittaa konfiguraation alkuun ennen mahdollisia itse tehtäviä lisäkonfiguraatioita. + + +Asennetaan seuraavaksi liitännäinen [@stylistic/eslint-plugin-js](https://eslint.style/packages/js) jonka avulla saamme käyttöömme joukon valmiiksi määriteltyjä ESlint-säntöjä: ```bash -node_modules/.bin/eslint index.js +npm install --save-dev @stylistic/eslint-plugin-js +``` + +Otetaan plugin käyttöön ja määritellään projektiin neljä sääntöä: + +```js +import globals from 'globals' +import js from '@eslint/js' +import stylisticJs from '@stylistic/eslint-plugin-js' // highlight-line + +export default [ + { + // ... + // highlight-start + plugins: { + '@stylistic/js': stylisticJs, + }, + rules: { + '@stylistic/js/indent': ['error', 2], + '@stylistic/js/linebreak-style': ['error', 'unix'], + '@stylistic/js/quotes': ['error', 'single'], + '@stylistic/js/semi': ['error', 'never'], + }, + // highlight-end + }, +] +``` + +[Pluginit](https://eslint.org/docs/latest/use/configure/plugins) tarjoavat tavan laajentaa ESLintin toiminnallisuutta lisäämällä määrittelyjä jotka eivät ole mukana ESLint-ydinkirjastossa. Otimme nyt käyttöön pluginin [@stylistic/eslint-plugin-js](https://eslint.style/packages/js), joka tuo käyttöömme joukon JavaScriptin tyylisääntöjä, joista otimme käyttöön sisennystä, rivinvaihtoa, lainausmerkkejä ja puolipisteitä koskevat säännöt. + +**Huomautus Windows-käyttäjille:** Rivinvaihtojen tyypiksi on tyylisäännössä määritelty _unix_. On suositeltavaa käyttää Unix-tyyppisiä rivinvaihtoja (_\n_) riippumatta käyttämästäsi käyttöjärjestelmästä, sillä ne ovat yhteensopivia useimpien modernien käyttöjärjestelmien kanssa ja helpottavat työskentelyä, jos useat eri henkilöt työstävät samoja tiedostoja. Jos käytössäsi on Windows-tyyppiset rivinvaihdot, ESLint antaa seuraavia virheitä: Expected linebreaks to be 'LF' but found 'CRLF'. Konfiguroi tällöin Visual Studio Code käyttämään Unix-tyyppisiä rivinvaihtoja esimerkiksi [tämän ohjeen](https://stackoverflow.com/questions/48692741/how-can-i-make-all-line-endings-eols-in-all-files-in-visual-studio-code-unix) mukaan. + +### Lintterin ajaminen + +Tiedoston _index.js_ tarkastus tapahtuu komennolla: + +```bash +npx eslint index.js ``` Kannattaa ehkä tehdä linttaustakin varten _npm-skripti_: @@ -311,110 +314,163 @@ Kannattaa ehkä tehdä linttaustakin varten _npm-skripti_: // ... "scripts": { "start": "node index.js", - "dev": "nodemon index.js", + "dev": "node --watch index.js", + "test": "echo \"Error: no test specified\" && exit 1", + "lint": "eslint ." // highlight-line // ... - "lint": "eslint ." }, // ... } ``` -Nyt komennot _npm run lint_ suorittaa tarkastukset koko projektille. +Nyt komento _npm run lint_ suorittaa tarkastukset koko projektille. -Myös hakemistossa build oleva frontendin tuotantoversio tulee näin tarkastettua. Sitä emme kuitenkaan halua, eli tehdään projektin juureen tiedosto [.eslintignore](https://eslint.org/docs/user-guide/configuring#ignoring-files-and-directories) ja sille seuraava sisältö +Myös hakemistossa dist oleva frontendin tuotantoversio tulee näin tarkastettua. Sitä emme kuitenkaan halua. Määritelläänkin konfiguraatioon hakemiston sisältö [ignoroitavaksi](https://eslint.org/docs/latest/use/configure/ignore): -```bash -build -``` -Näin koko hakemiston build sisältö jätetään huomioimatta linttauksessa. +```js +// ... +export default [ + js.configs.recommended, + { + files: ['**/*.js'], + // ... + }, + // highlight-start + { + ignores: ['dist/**'], + }, + // highlight-end +] +``` -Lintillä on jonkin verran huomautettavaa koodistamme: -![](../../images/3/53ea.png) +Kun nyt suoritamme linttauksen, löytyy koodistamme jonkin verran huomautettavaa: -Ei kuitenkaan korjata ongelmia vielä. +![Lint kertoo kolmesta virheestä, kaikki muuttujia joille ei ole käyttöä](../../images/3/53ea.png) -Parempi vaihtoehto kuin linttauksen suorittaminen komentoriviltä on konfiguroida editorille eslint-plugin, joka suorittaa linttausta koko ajan. Näin pääset korjaamaan pienet virheet välittömästi. Tietoja esim. Visual Studion ESlint-pluginsta [täällä](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint). +Parempi vaihtoehto linttauksen suorittamiselle komentoriviltä on konfiguroida editorille eslint-plugin, joka suorittaa linttausta koko ajan. Näin pääset korjaamaan pienet virheet välittömästi. Tietoja esim. Visual Studion ESLint-pluginista on [täällä](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint). -VS Coden ESlint-plugin alleviivaa tyylisääntöjä rikkovat kohdat punaisella: +VS Coden ESLint-plugin alleviivaa tyylisääntöjä rikkovat kohdat punaisella: -![](../../images/3/54a.png) +![Havainnollistus siitä miten VS code merkkaa rivit, joilla on eslint-tyylirike](../../images/3/54a.png) Näin ongelmat on helppo korjata koodiin heti. + +Komento _npm run lint -- --fix_ voi olla avuksi, jos koodissa on esim. useampia syntaksivirheitä. -ESlintille on määritelty suuri määrä [sääntöjä](https://eslint.org/docs/rules/), joita on helppo ottaa käyttöön muokkaamalla tiedostoa .eslintrc.js. +### Lisää tyylisääntöjä -Otetaan käyttöön sääntö [eqeqeq](https://eslint.org/docs/rules/eqeqeq) joka varoittaa, jos koodissa yhtäsuuruutta verrataan muuten kuin käyttämällä kolmea = -merkkiä. Sääntö lisätään konfiguraatiotiedostoon kentän rules alle. +ESLintille on määritelty suuri määrä [sääntöjä](https://eslint.org/docs/rules/), joita on helppo ottaa käyttöön muokkaamalla tiedostoa _eslint.config.mjs_. + +Otetaan käyttöön sääntö [eqeqeq](https://eslint.org/docs/rules/eqeqeq) joka varoittaa, jos koodissa yhtäsuuruutta verrataan muuten kuin käyttämällä kolmea = ‑merkkiä. Sääntö lisätään konfiguraatiotiedostoon kentän rules alle. ```js -{ +export default [ // ... - 'rules': { + rules: { // ... - 'eqeqeq': 'error', + eqeqeq: 'error', // highlight-line }, -} + // ... +] ``` + Tehdään samalla muutama muukin muutos tarkastettaviin sääntöihin. Estetään rivien lopussa olevat [turhat välilyönnit](https://eslint.org/docs/rules/no-trailing-spaces), vaaditaan että [aaltosulkeiden edessä/jälkeen on aina välilyönti](https://eslint.org/docs/rules/object-curly-spacing) ja vaaditaan myös konsistenttia välilyöntien käyttöä [nuolifunktioiden parametrien suhteen](https://eslint.org/docs/rules/arrow-spacing): ```js -{ +export default [ // ... -'rules': { + rules: { // ... - 'eqeqeq': 'error', + eqeqeq: 'error', + // highlight-start 'no-trailing-spaces': 'error', - 'object-curly-spacing': [ - 'error', 'always' - ], - 'arrow-spacing': [ - 'error', { 'before': true, 'after': true } - ] + 'object-curly-spacing': ['error', 'always'], + 'arrow-spacing': ['error', { before: true, after: true }], + // highlight-end }, -} +] ``` -Oletusarvoinen konfiguraatiomme ottaa käyttöön joukon valmiiksi määriteltyjä sääntöjä eslint:recommended +Oletusarvoinen konfiguraatiomme ottaa käyttöön joukon valmiiksi määriteltyjä sääntöjä: -```bash -'extends': 'eslint:recommended', +```js +// ... + +export default [ + js.configs.recommended, + // ... +] ``` -Mukana on myös _console.log_-komennoista varoittava sääntö. -Yksittäisen sääntö on helppo kytkeä [pois päältä](https://eslint.org/docs/user-guide/configuring#configuring-rules) määrittelemällä sen "arvoksi" konfiguraatiossa 0. Tehdään toistaiseksi näin säännölle no-console. +Näissä mukana on myös _console.log_-komennoista varoittava sääntö, jota emme halua käyttää. Yksittäinen sääntö on helppo kytkeä [pois päältä](https://eslint.org/docs/user-guide/configuring/rules#configuring-rules) määrittelemällä sen "arvoksi" konfiguraatiossa 0 tai _off_. Tehdään toistaiseksi näin säännölle no-console: ```js -{ - // ... - 'rules': { +[ + { + // ... + rules: { // ... - 'eqeqeq': 'error', + eqeqeq: 'error', 'no-trailing-spaces': 'error', - 'object-curly-spacing': [ - 'error', 'always' - ], - 'arrow-spacing': [ - 'error', { 'before': true, 'after': true } - ] + 'object-curly-spacing': ['error', 'always'], + 'arrow-spacing': ['error', { before: true, after: true }], + 'no-console': 'off', // highlight-line }, - 'no-console': 0 // highlight-line }, -} +] +``` + +Kokonaisuudessaan konfiguraatiotiedosto näyttää seuraavalta: + +```js +import globals from 'globals' +import js from '@eslint/js' +import stylisticJs from '@stylistic/eslint-plugin-js' + +export default [ + js.configs.recommended, + { + files: ['**/*.js'], + languageOptions: { + sourceType: 'commonjs', + globals: { ...globals.node }, + ecmaVersion: 'latest', + }, + plugins: { + '@stylistic/js': stylisticJs, + }, + rules: { + '@stylistic/js/indent': ['error', 2], + '@stylistic/js/linebreak-style': ['error', 'unix'], + '@stylistic/js/quotes': ['error', 'single'], + '@stylistic/js/semi': ['error', 'never'], + eqeqeq: 'error', + 'no-trailing-spaces': 'error', + 'object-curly-spacing': ['error', 'always'], + 'arrow-spacing': ['error', { before: true, after: true }], + 'no-console': 'off', + }, + }, + { + ignores: ['dist/**'], + }, +] ``` -**HUOM** kun teet muutoksia tiedostoon .eslintrc.js, kannattaa muutosten jälkeen suorittaa linttaus komentoriviltä ja varmistaa että konfiguraatio ei ole viallinen: +**HUOM:** Kun teet muutoksia konfiguraatiotiedostoon, kannattaa muutosten jälkeen suorittaa linttaus komentoriviltä ja varmistaa, että konfiguraatio ei ole viallinen: -![](../../images/3/55.png) +![Suoritetaan npm run lint...](../../images/3/lint2.png) Jos konfiguraatiossa on jotain vikaa, voi editorin lint-plugin näyttää mitä sattuu. -Monissa yrityksissä on tapana määritellä yrityksen laajuiset koodausstandardit ja näiden käyttöä valvova ESlint-konfiguraatio. Pyörää ei kannata välttämättä keksiä uudelleen ja voi olla hyvä idea ottaa omaan projektiin käyttöön joku jossain muualla hyväksi havaittu konfiguraatio. Viime aikoina monissa projekteissa on omaksuttu AirBnB:n [Javascript](https://github.com/airbnb/javascript)-tyyliohjeet ottamalla käyttöön firman määrittelemä [ESLint](https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb)-konfiguraatio. +Monissa yrityksissä on tapana määritellä yrityksen laajuiset koodausstandardit ja näiden käyttöä valvova ESLint-konfiguraatio. Pyörää ei kannata välttämättä keksiä uudelleen, ja voi olla hyvä idea ottaa omaan projektiin käyttöön joku jossain muualla hyväksi havaittu konfiguraatio. Viime aikoina monissa projekteissa on omaksuttu AirBnB:n [JavaScript](https://github.com/airbnb/javascript)-tyyliohjeet ottamalla käyttöön firman määrittelemä [ESLint](https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb)-konfiguraatio. -Sovelluksen tämän hetkinen koodi on kokonaisuudessaan [Githubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-7), branchissa part3-7. +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-7), branchissa part3-7. @@ -424,8 +480,8 @@ Sovelluksen tämän hetkinen koodi on kokonaisuudessaan [Githubissa](https://git #### 3.22: lint-konfiguraatio -Ota sovellukseesi käyttöön ESlint, ja korjaa kaikki tyylivirheet. +Ota sovellukseesi käyttöön ESLint, ja korjaa kaikki tyylivirheet. -Tämä oli osan viimeinen tehtävä, joten on aika pushata koodi Githubiin sekä merkata tehdyt tehtävät [palautussovellukseen](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). +Tämä oli osan viimeinen tehtävä, joten on aika pushata koodi GitHubiin sekä merkata tehdyt tehtävät [palautussovellukseen](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). diff --git a/src/content/3/fr/part3.md b/src/content/3/fr/part3.md new file mode 100644 index 00000000000..80374354dd8 --- /dev/null +++ b/src/content/3/fr/part3.md @@ -0,0 +1,10 @@ +--- +mainImage: ../../../images/part-3.svg +part: 3 +lang: fr +--- + +
    + +Dans cette partie, notre attention se porte sur le backend, c'est-à-dire sur l'implémentation de fonctionnalités du côté serveur. Nous implémenterons une API REST simple dans Node.js en utilisant la bibliothèque Express, et les données de l'application seront stockées dans une base de données MongoDB. A la fin de cette partie, nous déploierons notre application sur internet. +
    diff --git a/src/content/3/fr/part3a.md b/src/content/3/fr/part3a.md new file mode 100644 index 00000000000..30afea07eb2 --- /dev/null +++ b/src/content/3/fr/part3a.md @@ -0,0 +1,1174 @@ +--- +mainImage: ../../../images/part-3.svg +part: 3 +letter: a +lang: fr +--- + +
    + + +Dans cette partie, nous nous concentrons sur le backend, c'est-à-dire l'implémentation des fonctionnalités côté serveur. + + +Nous construirons notre backend à base de [NodeJS](https://nodejs.org/en/), qui est un environnement d'exécution basé sur JavaScript et le moteur [Chrome V8](https://developers.google.com/v8/) de Google. + + +Ce matériel de cours a été écrit avec la version 16.13.2 de Node.js. Veuillez vous assurer que votre version de Node est au moins aussi récente que la version utilisée dans le matériel (vous pouvez vérifier la version en exécutant _node -v_ dans la ligne de commande). + + +Comme mentionné dans la [partie 1](/fr/part1/java_script), les navigateurs ne supportent pas encore les dernières fonctionnalités de JavaScript, et c'est pourquoi le code s'exécutant dans le navigateur doit être transpilé avec par exemple [babel](https://babeljs.io/). La situation avec JavaScript s'exécutant dans le backend est différente. La dernière version de Node supporte une grande majorité des dernières fonctionnalités de JavaScript, nous pouvons donc utiliser les dernières fonctionnalités sans avoir à transpiler notre code. + + +Notre objectif est d'implémenter un backend qui fonctionnera avec l'application notes de la [partie 2](/fr/part2/). Cependant, commençons par les bases en implémentant une application classique "hello world". + + +**Notons** que les applications et exercices de cette partie ne sont pas tous des applications React, et nous n'utiliserons pas l'utilitaire create-react-app pour initialiser le projet de cette application. + + +Nous avions déjà mentionné [npm](/fr/part2/getting_data_from_server#npm) dans la partie 2, qui est un outil utilisé pour gérer les paquets JavaScript. En fait, npm est issu de l'écosystème Node. + + +Naviguons vers un répertoire approprié et créons un nouveau modèle pour notre application avec la commande _npm init_. Nous répondrons aux questions présentées par l'utilitaire, et le résultat sera un fichier package.json généré automatiquement à la racine du projet qui contient des informations sur le projet. + +```json +{ + "name": "backend", + "version": "0.0.1", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Matti Luukkainen", + "license": "MIT" +} +``` + + +Ce fichier définit, par exemple, que le point d'entrée de l'application est le fichier index.js. + +Faisons un petit changement à l'objet scripts : + + +```bash +{ + // ... + "scripts": { + "start": "node index.js", // highlight-line + "test": "echo \"Error: no test specified\" && exit 1" + }, + // ... +} +``` + + +Ensuite, créons la première version de notre application en ajoutant un fichier index.js à la racine du projet avec le code suivant : + +```js +console.log('hello world') +``` + + +Nous pouvons exécuter le programme directement avec Node depuis la ligne de commande : + +```bash +node index.js +``` + + +Ou nous pouvons l'exécuter en tant que [script npm](https://docs.npmjs.com/misc/scripts) : + +```bash +npm start +``` + + +Le start npm script fonctionne parce que nous l'avons défini dans le fichier package.json : + +```bash +{ + // ... + "scripts": { + "start": "node index.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + // ... +} +``` + + +Même si l'exécution du projet fonctionne lorsqu'il est lancé en appelant _node index.js_ depuis la ligne de commande, il est habituel pour les projets npm d'exécuter de telles tâches sous forme de scripts npm. + + +Par défaut le fichier package.json définit également un autre script npm couramment utilisé appelé npm test. Comme notre projet n'a pas encore de bibliothèque de test, la commande _npm test_ exécute simplement la commande suivante : + +```bash +echo "Error: no test specified" && exit 1 +``` + + +### Simple serveur web + + +Transformons l'application en serveur web en modifiant les fichiers _index.js_ comme suit : + +```js +const http = require('http') + +const app = http.createServer((request, response) => { + response.writeHead(200, { 'Content-Type': 'text/plain' }) + response.end('Hello World') +}) + +const PORT = 3001 +app.listen(PORT) +console.log(`Server running on port ${PORT}`) +``` + +Une fois l'application lancée, le message suivant est imprimé dans la console : + +```bash +Server running on port 3001 +``` + +Nous pouvons ouvrir notre humble application dans le navigateur en visitant l'adresse : + +![](../../images/3/1.png) + +En fait, le serveur fonctionne de la même manière, quelle que soit la dernière partie de l'URL. Aussi, l'adresse affichera le même contenu. + + +**NB** si le port 3001 est déjà utilisé par une autre application, le démarrage du serveur donnera lieu au message d'erreur suivant : + +```bash +➜ hello npm start + +> hello@1.0.0 start /Users/mluukkai/opetus/_2019fullstack-code/part3/hello +> node index.js + +Server running on port 3001 +events.js:167 + throw er; // Unhandled 'error' event + ^ + +Error: listen EADDRINUSE :::3001 + at Server.setupListenHandle [as _listen2] (net.js:1330:14) + at listenInCluster (net.js:1378:12) +``` + + +Vous avez deux options. Soit vous fermez l'application en utilisant le port 3001 (le serveur json dans la dernière partie du matériel utilisait le port 3001), soit vous utilisez un port différent pour cette application. + +Regardons de plus près la première ligne du code : + +```js +const http = require('http') +``` + +Dans la première ligne, l'application importe le module [web server](https://nodejs.org/docs/latest-v8.x/api/http.html) intégré de Node. C'est pratiquement ce que nous avons déjà fait dans notre code côté navigateur, mais avec une syntaxe légèrement différente : + +```js +import http from 'http' +``` + +De nos jours, le code qui s'exécute dans le navigateur utilise des modules ES6. Les modules sont définis avec un [export](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export) et utilisés avec un [import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import). + +Toutefois, Node.js utilise des modules dits [CommonJS](https://en.wikipedia.org/wiki/CommonJS). La raison en est que l'écosystème Node avait besoin de modules bien avant que JavaScript ne les prenne en charge dans la spécification du langage. Node supporte maintenant aussi l'utilisation des modules ES6, mais puisque le support n'est pas encore [tout à fait parfait](https://nodejs.org/api/esm.html#modules-ecmascript-modules), nous nous en tiendrons aux modules CommonJS. + +Les modules CommonJS fonctionnent presque exactement comme les modules ES6, du moins en ce qui concerne nos besoins dans ce cours. + +La partie suivante de notre code ressemble à ceci : + +```js +const app = http.createServer((request, response) => { + response.writeHead(200, { 'Content-Type': 'text/plain' }) + response.end('Hello World') +}) +``` + +Le code utilise la méthode _createServer_ de [http](https://nodejs.org/docs/latest-v8.x/api/http.html) pour créer un nouveau serveur web. Un gestionnaire d'événements est enregistré sur le serveur qui est appelé à chaque fois qu'une requête HTTP est faite à l'adresse du serveur http://localhost:3001. + + +La requête reçoit une réponse avec le code d'état 200, avec le header Content-Type défini comme text/plain, et le contenu du site à renvoyer défini comme Hello World. + + +Les dernières lignes lient le serveur http assigné à la variable _app_, pour écouter les requêtes HTTP envoyées au port 3001 : + +```js +const PORT = 3001 +app.listen(PORT) +console.log(`Server running on port ${PORT}`) +``` + + +L'objectif principal du serveur backend dans ce cours est d'offrir des données brutes au format JSON au frontend. Pour cette raison, modifions immédiatement notre serveur pour qu'il renvoie une liste de notes codées en dur au format JSON : + +```js +const http = require('http') + +// highlight-start +let notes = [ + { + id: 1, + content: "HTML is easy", + date: "2022-05-30T17:30:31.098Z", + important: true + }, + { + id: 2, + content: "Browser can execute only Javascript", + date: "2022-05-30T18:39:34.091Z", + important: false + }, + { + id: 3, + content: "GET and POST are the most important methods of HTTP protocol", + date: "2022-05-30T19:20:14.298Z", + important: true + } +] + +const app = http.createServer((request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify(notes)) +}) +// highlight-end + +const PORT = 3001 +app.listen(PORT) +console.log(`Server running on port ${PORT}`) +``` + +Redémarrons le serveur (vous pouvez arrêter le serveur en appuyant sur _Ctrl+C_ dans la console) et rafraîchissons le navigateur. + +La valeur application/json dans le header Content-Type informe le récepteur que les données sont au format JSON. Le tableau _notes_ est transformé en JSON avec la méthode JSON.stringify(notes). + +Lorsque nous ouvrons le navigateur, le format affiché est exactement le même que dans la [partie 2](/fr/part2/getting_data_from_server/) où nous avons utilisé [json-server](https://github.com/typicode/json-server) pour servir la liste des notes : + +![](../../images/3/2e.png) + +### Express + +Il est possible d'implémenter notre code serveur directement avec le serveur web intégré de Node [http](https://nodejs.org/docs/latest-v8.x/api/http.html). Cependant, c'est lourd, surtout lorsque la taille de l'application augmente. + +De nombreuses bibliothèques ont été développées pour faciliter le développement côté serveur avec Node, en offrant une interface plus agréable pour travailler avec le module http intégré. Ces bibliothèques visent à fournir une meilleure abstraction pour les cas d'utilisation généraux dont nous avons habituellement besoin pour construire un serveur dorsal. La bibliothèque la plus populaire à cet effet est de loin [express](http://expressjs.com). + +Utilisons express en le définissant comme une dépendance du projet avec la commande : + +```bash +npm install express +``` + +La dépendance est également ajoutée à notre fichier package.json : + +```json +{ + // ... + "dependencies": { + "express": "^4.17.2" + } +} + +``` + + +Le code source de la dépendance est installé dans le répertoire node\_modules situé à la racine du projet. En plus d'express, vous pouvez trouver une grande quantité d'autres dépendances dans ce répertoire : + +![](../../images/3/4.png) + + +Ce sont en fait les dépendances de la bibliothèque express, et les dépendances de toutes ses dépendances, et ainsi de suite. On les appelle les [dépendances transitives](https://lexi-lambda.github.io/blog/2016/08/24/understanding-the-npm-dependency-model/) de notre projet. + + +La version 4.17.2. d'express a été installée dans notre projet. Que signifie le caret devant le numéro de version dans package.json ? + +```json +"express": "^4.17.2" +``` + + +Le modèle de versioning utilisé dans npm est appelé [versioning sémantique](https://docs.npmjs.com/getting-started/semantic-versioning). + + +Le caret devant ^4.17.2 signifie que si et quand les dépendances d'un projet sont mises à jour, la version d'express qui est installée sera au moins 4.17.2. Cependant, la version installée d'express peut aussi avoir un numéro de patch plus grand (le dernier chiffre), ou un numéro mineur plus grand (le chiffre du milieu). La version majeure de la bibliothèque indiquée par le premier numéro majeur doit être la même. + + +Nous pouvons mettre à jour les dépendances du projet avec la commande : + +```bash +npm update +``` + +De même, si nous commençons à travailler sur le projet sur un autre ordinateur, nous pouvons installer toutes les dépendances à jour du projet définies dans package.json avec la commande : + +```bash +npm install +``` + +Si le numéro majeur d'une dépendance ne change pas, alors les versions plus récentes devraient être [rétrocompatibles](https://en.wikipedia.org/wiki/Backward_compatibility). Cela signifie que si notre application venait à utiliser la version 4.99.175 d'express dans le futur, alors tout le code implémenté dans cette partie devrait continuer à fonctionner sans apporter de modifications au code. En revanche, la future version 5.0.0. d'express [pourrait contenir](https://expressjs.com/en/guide/migrating-5.html) des modifications qui feraient que notre application ne fonctionnerait plus. + +### Web et express + +Revenons à notre application et apportons les modifications suivantes : + +```js +const express = require('express') +const app = express() + +let notes = [ + ... +] + +app.get('/', (request, response) => { + response.send('

    Hello World!

    ') +}) + +app.get('/api/notes', (request, response) => { + response.json(notes) +}) + +const PORT = 3001 +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +``` + + +Afin de mettre en service la nouvelle version de notre application, nous devons redémarrer l'application. + + +L'application n'a pas beaucoup changé. Dès le début de notre code, nous importons _express_, qui est cette fois une fonction utilisée pour créer une application express stockée dans la variable _app_ : + +```js +const express = require('express') +const app = express() +``` + + +Ensuite, nous définissons deux routes vers l'application. La première définit un gestionnaire d'événements qui est utilisé pour traiter les requêtes HTTP GET faites à la racine / de l'application : + +```js +app.get('/', (request, response) => { + response.send('

    Hello World!

    ') +}) +``` + + +La fonction de gestion d'événement accepte deux paramètres. Le premier paramètre [request](http://expressjs.com/en/4x/api.html#req) contient toutes les informations de la demande HTTP, et le second paramètre [response](http://expressjs.com/en/4x/api.html#res) est utilisé pour définir la réponse à la demande. + + +Dans notre code, on répond à la requête en utilisant la méthode [send](http://expressjs.com/en/4x/api.html#res.send) de l'objet _response_. L'appel de la méthode fait que le serveur répond à la requête HTTP en envoyant une réponse contenant la chaîne de caractères \

    Hello World!\

    qui a été passée à la méthode _send_. Comme le paramètre est une chaîne de caractères, express définit automatiquement la valeur du header Content-Type comme étant text/html. Le code d'état de la réponse a la valeur 200 par défaut. + + +Nous pouvons le vérifier à partir de l'onglet Network dans les outils de développement : + +![](../../images/3/5.png) + + +La deuxième route définit un gestionnaire d'événements qui gère les requêtes HTTP GET effectuées sur le chemin notes de l'application : + +```js +app.get('/api/notes', (request, response) => { + response.json(notes) +}) +``` + + +La requête est traitée avec la méthode [json](http://expressjs.com/en/4x/api.html#res.json) de l'objet _réponse_. L'appel de cette méthode enverra le tableau __notes__ qui lui a été transmis sous la forme d'une chaîne de caractères formatée en JSON. Express définit automatiquement le header Content-Type avec la valeur appropriée de application/json. + +![](../../images/3/6ea.png) + +Ensuite, jetons un coup d'oeil rapide aux données envoyées au format JSON. + +Dans la version précédente où nous utilisions uniquement Node, nous devions transformer les données au format JSON avec la méthode _JSON.stringify_ : + +```js +response.end(JSON.stringify(notes)) +``` + + +Avec express, ce n'est plus nécessaire, car cette transformation se fait automatiquement. + + +Il convient de noter que [JSON](https://en.wikipedia.org/wiki/JSON) est une chaîne de caractères, et non un objet JavaScript comme la valeur attribuée à _notes_. + + +L'expérience présentée ci-dessous illustre cela : + +![](../../assets/3/5.png) + + +L'expérience ci-dessus a été réalisée dans l'application interactive [node-repl](https://nodejs.org/docs/latest-v8.x/api/repl.html). Vous pouvez lancer le node-repl interactif en tapant _node_ dans la ligne de commande. Le repl est particulièrement utile pour tester le fonctionnement des commandes pendant que vous écrivez le code de l'application. Je le recommande vivement ! + +### nodemon + +Si nous apportons des modifications au code de l'application, nous devons redémarrer l'application afin de voir les changements. Nous redémarrons l'application en l'arrêtant d'abord en tapant _Ctrl+C_ puis en la redémarrant. Comparé au flux de travail pratique de React, où le navigateur se recharge automatiquement après que les changements ont été effectués, cela semble légèrement encombrant. + +La solution à ce problème est [nodemon](https://github.com/remy/nodemon) : + +> nodemon surveillera les fichiers du répertoire dans lequel nodemon a été lancé, et si un fichier change, nodemon redémarrera automatiquement votre application node. + + +Installons nodemon en le définissant comme une dépendance de développement avec la commande : + +```bash +npm install --save-dev nodemon +``` + +Le contenu de package.json a également changé : + +```json +{ + //... + "dependencies": { + "express": "^4.17.2", + }, + "devDependencies": { + "nodemon": "^2.0.15" + } +} +``` + + +Si vous avez accidentellement utilisé la mauvaise commande et que la dépendance nodemon a été ajoutée sous "dependencies" au lieu de "devDependencies", modifiez manuellement le contenu de package.json pour qu'il corresponde à ce qui est indiqué ci-dessus. + + +Par dépendances de développement, nous entendons les outils qui ne sont nécessaires que pendant le développement de l'application, par exemple pour les tests ou le redémarrage automatique de l'application, comme nodemon. + + +Ces dépendances de développement ne sont pas nécessaires lorsque l'application est exécutée en mode production sur le serveur de production (par exemple Heroku). + + +Nous pouvons démarrer notre application avec nodemon comme ceci : + +```bash +node_modules/.bin/nodemon index.js +``` + + +Les modifications apportées au code de l'application entraînent désormais le redémarrage automatique du serveur. Il est intéressant de noter que même si le serveur backend redémarre automatiquement, le navigateur doit toujours être rafraîchi manuellement. En effet, contrairement à ce qui se passe lorsque l'on travaille en React, nous ne disposons pas de la fonctionnalité de [rechargement à chaud](https://gaearon.github.io/react-hot-loader/getstarted/) nécessaire pour recharger automatiquement le navigateur. + + +La commande est longue et assez désagréable, aussi définissons-nous un npm script dédié pour elle dans le fichier package.json : + +```bash +{ + // .. + "scripts": { + "start": "node index.js", + "dev": "nodemon index.js", // highlight-line + "test": "echo \"Error: no test specified\" && exit 1" + }, + // .. +} +``` + + +Dans le script, il n'est pas nécessaire de spécifier le chemin node\_modules/.bin/nodemon vers nodemon, car _npm_ sait automatiquement rechercher le fichier dans ce répertoire. + + +Nous pouvons maintenant démarrer le serveur en mode développement avec la commande : + +```bash +npm run dev +``` + + +Contrairement aux scripts start et test , nous devons également ajouter run à la commande. + + +### REST + + +Développons notre application afin qu'elle fournisse la même API HTTP RESTful que [json-server](https://github.com/typicode/json-server#routes). + + +Le transfert d'état représentationnel, alias REST, a été présenté en 2000 dans la [dissertation](https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm) de Roy Fielding. REST est un style architectural destiné à la création d'applications web évolutives. + + +Nous n'allons pas creuser la définition de REST de Fielding ni passer du temps à réfléchir à ce qui est ou n'est pas RESTful. Nous allons plutôt adopter une [vision plus étroite](https://en.wikipedia.org/wiki/Representational_state_transfer#Applied_to_web_services) en nous intéressant uniquement à la manière dont les API RESTful sont généralement comprises dans les applications web. En fait, la définition originale de REST n'est même pas limitée aux applications web. + + +Nous avons mentionné dans la [partie précédente](/fr/part2/altering_data_in_server#rest) que les choses singulières, comme les notes dans le cas de notre application, sont appelées ressources dans la pensée RESTful. Chaque ressource a une URL associée qui est l'adresse unique de la ressource. + + +Une convention consiste à créer l'adresse unique des ressources en combinant le nom du type de ressource avec l'identifiant unique de la ressource. + + +Supposons que l'URL racine de notre service est www.example.com/api. + + +Si nous définissons le type de ressource de note comme étant notes, alors l'adresse d'une ressource de note avec l'identifiant 10, a l'adresse unique suivante www.example.com/api/notes/10. + + +L'URL de la collection complète de toutes les ressources de notes est www.example.com/api/notes. + + +Nous pouvons exécuter différentes opérations sur les ressources. L'opération à exécuter est définie par les verbes HTTP : + +| URL | verbe | fonctionnalité | +| --------------------- |--------|-------------------------------------------------------------------------------| +| notes/10 | GET | récupère une seule ressource | +| notes | GET | récupère toutes les ressources de la collection | +| notes | POST | crée une nouvelle ressource basée sur les données de la requête | +| notes/10 | DELETE | supprime la ressource identifiée | +| notes/10 | PUT | remplace l'ensemble de la ressource identifiée par les données de la requête | +| notes/10 | PATCH | remplace une partie de la ressource identifiée par les données de la requête | +| | | | + + +C'est ainsi que nous parvenons à définir grossièrement ce que REST appelle une [interface uniforme](https://en.wikipedia.org/wiki/Representational_state_transfer#Architectural_constraints), c'est-à-dire une méthode cohérente de définition des interfaces qui permet aux systèmes de coopérer. + + +Cette façon d'interpréter REST relève du [deuxième niveau de maturité RESTful](https://martinfowler.com/articles/richardsonMaturityModel.html) du modèle de maturité de Richardson. Selon la définition fournie par Roy Fielding, nous n'avons pas réellement défini une [API REST](http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven). En fait, une grande majorité des prétendues API "REST" dans le monde ne répondent pas aux critères initiaux de Fielding décrits dans sa thèse. + + +À certains endroits (voir par exemple [Richardson, Ruby : RESTful Web Services](http://shop.oreilly.com/product/9780596529260.do)), vous verrez que notre modèle d'API [CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete) simple est désigné comme un exemple d'[architecture orientée ressources](https://en.wikipedia.org/wiki/Resource-oriented_architecture) au lieu de REST. Nous éviterons de nous perdre dans des discussions sur la sémantique et retournerons plutôt travailler sur notre application. + + +### Récupération d'une seule ressource + + +Développons notre application de manière à ce qu'elle offre une interface REST pour opérer sur des notes individuelles. Tout d'abord, créons une [route](http://expressjs.com/en/guide/routing.html) pour récupérer une seule ressource. + + +L'adresse unique que nous utiliserons pour une note individuelle est de la forme notes/10, où le nombre à la fin fait référence au numéro d'identification unique de la note. + + +Nous pouvons définir des [paramètres](http://expressjs.com/en/guide/routing.html#route-parameters) pour les routes dans express en utilisant la syntaxe des deux points : + +```js +app.get('/api/notes/:id', (request, response) => { + const id = request.params.id + const note = notes.find(note => note.id === id) + response.json(note) +}) +``` + + +Maintenant, app.get('/api/notes/:id', ...) traitera toutes les requêtes HTTP GET qui sont de la forme /api/notes/SOMETHING, où SOMETHING est une chaîne arbitraire. + + +Le paramètre id dans la route d'une demande, est accessible par l'objet [request](http://expressjs.com/en/api.html#req) : + +```js +const id = request.params.id +``` + +La méthode _find_ des tableaux, désormais bien connue, est utilisée pour trouver la note dont l'identifiant correspond au paramètre. La note est ensuite renvoyée à l'expéditeur de la demande. + + +Lorsque nous testons notre application en allant sur dans notre navigateur, nous remarquons qu'elle ne semble pas fonctionner, car le navigateur affiche une page vide. Ce n'est pas une surprise pour nous, développeurs de logiciels, et il est temps de déboguer. + + +L'ajout de commandes console.log dans notre code est une astuce éprouvée: + +```js +app.get('/api/notes/:id', (request, response) => { + const id = request.params.id + console.log(id) + const note = notes.find(note => note.id === id) + console.log(note) + response.json(note) +}) +``` + + +Lorsque nous visitons à nouveau dans le navigateur, la console qui est le terminal dans ce cas, affichera ce qui suit : + +![](../../images/3/8.png) + + +Le paramètre id de la route est transmis à notre application mais la méthode _find_ ne trouve pas de note correspondante. + + +Pour approfondir notre enquête, nous ajoutons également un journal de la console à l'intérieur de la fonction de comparaison passée à la fonction _find_. Pour ce faire, nous devons nous débarrasser de la syntaxe de la fonction flèche compacte note => note.id === id, et utiliser la syntaxe avec une déclaration de retour explicite: + +```js +app.get('/api/notes/:id', (request, response) => { + const id = request.params.id + const note = notes.find(note => { + console.log(note.id, typeof note.id, id, typeof id, note.id === id) + return note.id === id + }) + console.log(note) + response.json(note) +}) +``` + + +Lorsque nous visitons à nouveau l'URL dans le navigateur, chaque appel à la fonction de comparaison imprime quelques éléments différents dans la console. La sortie de la console est la suivante : + +``` +1 'number' '1' 'string' false +2 'number' '1' 'string' false +3 'number' '1' 'string' false +``` + + +La cause du bogue devient claire. La variable _id_ contient une chaîne de caractères '1', alors que les identifiants des notes sont des entiers. En JavaScript, la comparaison "triple equals" === considère que toutes les valeurs de types différents ne sont pas égales par défaut, ce qui signifie que 1 n'est pas '1'. + + +Résolvons le problème en transformant le paramètre id, qui est une chaîne de charactères, en un [nombre](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number): + +```js +app.get('/api/notes/:id', (request, response) => { + const id = Number(request.params.id) // highlight-line + const note = notes.find(note => note.id === id) + response.json(note) +}) +``` + + +Maintenant, la récupération d'une ressource individuelle fonctionne. + +![](../../images/3/9ea.png) + + +Cependant, il y a un autre problème avec notre application. + + +Si nous recherchons une note avec un id qui n'existe pas, le serveur répond avec : + +![](../../images/3/10ea.png) + + +Le code d'état HTTP renvoyé est 200, ce qui signifie que la réponse a réussi. Aucune donnée n'est renvoyée avec la réponse, puisque la valeur du header content-length est 0, ce qui peut être vérifié à partir du navigateur. + + +La raison de ce comportement est que la variable _note_ prend la valeur _undefined_ si aucune note correspondante n'est trouvée. La situation doit être mieux gérée sur le serveur. Si aucune note n'est trouvée, le serveur devrait répondre avec le code d'état [404 not found](https://www.rfc-editor.org/rfc/rfc9110.html#name-404-not-found) au lieu de 200. + + +Faisons la modification suivante à notre code : + +```js +app.get('/api/notes/:id', (request, response) => { + const id = Number(request.params.id) + const note = notes.find(note => note.id === id) + + // highlight-start + if (note) { + response.json(note) + } else { + response.status(404).end() + } + // highlight-end +}) +``` + + +Comme aucune donnée n'est jointe à la réponse, nous utilisons la méthode [status](http://expressjs.com/en/4x/api.html#res.status) pour définir l'état et la méthode [end](http://expressjs.com/en/4x/api.html#res.end) pour répondre à la demande sans envoyer de données. + + +La condition if exploite le fait que tous les objets JavaScript sont [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy), ce qui signifie qu'ils sont évalués à true dans une opération de comparaison. Cependant, _undefined_ est [falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy), ce qui signifie qu'il sera évalué comme faux. + + +Notre application fonctionne et envoie le code d'état d'erreur si aucune note n'est trouvée. Cependant, l'application ne renvoie rien à montrer à l'utilisateur, comme le font normalement les applications Web lorsque nous visitons une page qui n'existe pas. Nous n'avons pas besoin d'afficher quoi que ce soit dans le navigateur, car les API REST sont des interfaces destinées à une utilisation programmatique, et le code d'état d'erreur est tout ce dont nous avons besoin. + +De toute façon, il est possible de donner un indice sur la raison de l'envoi de l'erreur 404 en [remplaçant le message par défaut NOT FOUND](https://stackoverflow.com/questions/14154337/how-to-send-a-custom-http-status-message-in-node-express/36507614#36507614). + + +### Suppression des ressources + + +Ensuite, nous allons implémenter une route pour la suppression des ressources. La suppression se fait par une requête HTTP DELETE vers l'url de la ressource : + +```js +app.delete('/api/notes/:id', (request, response) => { + const id = Number(request.params.id) + notes = notes.filter(note => note.id !== id) + + response.status(204).end() +}) +``` + +Si la suppression de la ressource est réussie, c'est-à-dire que la note existe et qu'elle est supprimée, nous répondons à la demande avec le code d'état [204 no content](https://www.rfc-editor.org/rfc/rfc9110.html#name-204-no-content) et ne renvoyons aucune donnée avec la réponse. + + +Il n'y a pas de consensus sur le code d'état à renvoyer à une demande DELETE si la ressource n'existe pas. En réalité, les deux seules options sont 204 et 404. Pour des raisons de simplicité, notre application répondra par 204 dans les deux cas. + +### Postman + +Alors comment tester l'opération de suppression ? Les requêtes HTTP GET sont faciles à réaliser à partir du navigateur. Nous pourrions écrire du JavaScript pour tester la suppression, mais écrire du code de test n'est pas toujours la meilleure solution dans toutes les situations. + +De nombreux outils existent pour faciliter le test des backends. L'un d'entre eux est le programme en ligne de commande [curl](https://curl.haxx.se). Cependant, au lieu de curl, nous allons utiliser [Postman](https://www.postman.com) pour tester l'application. + +Installons le client de bureau Postman [depuis ici](https://www.postman.com/downloads/) et essayons-le : + +![](../../images/3/11x.png) + +L'utilisation de Postman est assez simple dans cette situation. Il suffit de définir l'url et de sélectionner le type de requête correct (DELETE). + +Le serveur backend semble répondre correctement. En faisant une demande HTTP GET à nous voyons que la note avec l'id 2 n'est plus dans la liste, ce qui indique que la suppression a réussi. + +Comme les notes de l'application ne sont sauvegardées qu'en mémoire, la liste des notes reviendra à son état initial lorsque nous redémarrerons l'application. + +### Le client REST de Visual Studio Code + +Si vous utilisez Visual Studio Code, vous pouvez utiliser le plugin VS Code [REST client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) à la place de Postman. + +Une fois le plugin installé, son utilisation est très simple. Nous créons un répertoire à la racine de l'application nommé requests. Nous enregistrons toutes les requêtes du client REST dans le répertoire sous forme de fichiers qui se terminent par l'extension .rest. + +Créons un nouveau fichier get\_all\_notes.rest et définissons la requête qui récupère toutes les notes. + +![](../../images/3/12ea.png) + +En cliquant sur le texte Envoyer la demande, le client REST exécutera la demande HTTP et la réponse du serveur est ouverte dans l'éditeur. + +![](../../images/3/13ea.png) + +### Le client HTTP de WebStorm + +Si vous utilisez *IntelliJ WebStorm* à la place, vous pouvez utiliser une procédure similaire avec son client HTTP intégré. Créez un nouveau fichier avec l'extension `.rest` et l'éditeur vous affichera des options pour créer et exécuter vos requêtes. Vous pouvez en savoir plus en suivant [ce guide](https://www.jetbrains.com/help/webstorm/http-client-in-product-code-editor.html). + +### Réception des données + +Ensuite, rendons possible l'ajout de nouvelles notes sur le serveur. L'ajout d'une note se fait par une requête HTTP POST à l'adresse http://localhost:3001/api/notes, et par l'envoi de toutes les informations relatives à la nouvelle note dans la requête [body](https://www.w3.org/Protocols/rfc2616/rfc2616-sec7.html#sec7) au format JSON. + +Afin d'accéder facilement aux données, nous avons besoin de l'aide de l'express [json-parser](https://expressjs.com/en/api.html) qui est utilisé avec la commande _app.use(express.json())_. + +Activons le json-parser et implémentons un gestionnaire initial pour traiter les requêtes HTTP POST : + +```js +const express = require('express') +const app = express() + +app.use(express.json()) // highlight-line + +//... + +// highlight-start +app.post('/api/notes', (request, response) => { + const note = request.body + console.log(note) + + response.json(note) +}) +// highlight-end +``` + + +La fonction de gestion d'événement peut accéder aux données de la propriété body de l'objet _request_. + +Sans le json-parser, la propriété body serait indéfinie. Le json-parser fonctionne de telle sorte qu'il prend les données JSON d'une demande, les transforme en un objet JavaScript et les attache ensuite à la propriété body de l'objet _request_ avant que le gestionnaire de route ne soit appelé. + +Pour l'instant, l'application ne fait rien avec les données reçues, à part les imprimer sur la console et les renvoyer dans la réponse. + +Avant d'implémenter le reste de la logique applicative, vérifions avec Postman que les données sont effectivement reçues par le serveur. En plus de définir l'URL et le type de requête dans Postman, nous devons également définir les données envoyées dans le body: + +![](../../images/3/14x.png) + +L'application imprime les données que nous avons envoyées dans la requête à la console : + +![](../../images/3/15new.png) + +**NB** Gardez le terminal exécutant l'application visible à tout moment lorsque vous travaillez sur le backend. Grâce à Nodemon, toute modification apportée au code redémarre l'application. Si vous prêtez attention à la console, vous serez immédiatement en mesure de repérer les erreurs qui se produisent dans l'application : + +![](../../images/3/16.png) + +De même, il est utile de consulter la console pour s'assurer que le backend se comporte comme nous l'attendons dans différentes situations, comme lorsque nous envoyons des données avec une requête HTTP POST. Naturellement, c'est une bonne idée d'ajouter beaucoup de commandes console.log au code pendant que l'application est encore en cours de développement. + +Une cause potentielle de problèmes est un header Content-Type incorrectement défini dans les requêtes. Cela peut se produire avec Postman si le type de corps n'est pas défini correctement : + +![](../../images/3/17x.png) + +Le header Content-Type est défini comme text/plain : + +![](../../images/3/18x.png) + +Le serveur semble ne recevoir qu'un objet vide : + +![](../../images/3/19.png) + +Le serveur ne sera pas en mesure d'analyser correctement les données sans la valeur correcte dans le header. Il n'essaiera même pas de deviner le format des données, car il y a une [quantité massive](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) de Content-Types potentiels. + +Si vous utilisez VS Code, alors vous devez installer le client REST du chapitre précédent maintenant, si ce n'est pas déjà. La requête POST peut être envoyée avec le client REST comme ceci : + +![](../../images/3/20eb.png) + +Nous avons créé un nouveau fichier create\_note.rest pour la requête. La requête est formatée selon les [instructions de la documentation](https://github.com/Huachao/vscode-restclient/blob/master/README.md#usage). + +L'un des avantages du client REST par rapport à Postman est que les demandes sont facilement disponibles à la racine du dépôt du projet et qu'elles peuvent être distribuées à tous les membres de l'équipe de développement. Vous pouvez également ajouter plusieurs demandes dans le même fichier en utilisant les séparateurs `###` : + +``` +GET http://localhost:3001/api/notes/ + +### +POST http://localhost:3001/api/notes/ HTTP/1.1 +content-type: application/json + +{ + "name": "sample", + "time": "Wed, 21 Oct 2015 18:27:50 GMT" +} +``` + +Postman permet également aux utilisateurs de sauvegarder leurs demandes, mais la situation peut devenir assez chaotique, surtout lorsque vous travaillez sur plusieurs projets sans lien entre eux. + +> **Remarque importante** +> +> Parfois, lors d'un débogage, vous pouvez vouloir savoir quels en-têtes ont été définis dans la requête HTTP.Une façon d'y parvenir est d'utiliser la méthode [get](http://expressjs.com/en/4x/api.html#req.get) de l'objet _request_, qui peut être utilisée pour obtenir la valeur d'un seul header. L'objet _request_ possède également la propriété headers, qui contient tous les en-têtes d'une requête spécifique. +> + +> Des problèmes peuvent survenir avec le client VS REST si vous ajoutez accidentellement une ligne vide entre la ligne supérieure et la ligne spécifiant les en-têtes HTTP. Dans cette situation, le client REST interprète cela comme signifiant que tous les en-têtes sont laissés vides, ce qui conduit le serveur backend à ne pas savoir que les données qu'il a reçues sont au format JSON.> +> + +Vous pourrez repérer ce header Content-Type manquant si, à un moment donné dans votre code, vous imprimez tous les headers de la requête avec la commande _console.log(request.headers)_. + + +Revenons à l'application. Une fois que nous savons que l'application reçoit correctement les données, il est temps de finaliser le traitement de la requête : + +```js +app.post('/api/notes', (request, response) => { + const maxId = notes.length > 0 + ? Math.max(...notes.map(n => n.id)) + : 0 + + const note = request.body + note.id = maxId + 1 + + notes = notes.concat(note) + + response.json(note) +}) +``` + + +Nous avons besoin d'un identifiant unique pour la note. Tout d'abord, nous trouvons le plus grand numéro d'identifiant dans la liste actuelle et nous l'attribuons à la variable _maxId_. L'identifiant de la nouvelle note est alors défini comme _maxId + 1_. Cette méthode n'est en fait pas recommandée, mais nous nous en accommoderons pour l'instant car nous la remplacerons bien assez tôt. + + +La version actuelle présente toujours le problème que la requête HTTP POST peut être utilisée pour ajouter des objets avec des propriétés arbitraires. Améliorons l'application en définissant que la propriété content ne peut pas être vide. Les propriétés important et date auront des valeurs par défaut. Toutes les autres propriétés sont rejetées: + +```js +const generateId = () => { + const maxId = notes.length > 0 + ? Math.max(...notes.map(n => n.id)) + : 0 + return maxId + 1 +} + +app.post('/api/notes', (request, response) => { + const body = request.body + + if (!body.content) { + return response.status(400).json({ + error: 'content missing' + }) + } + + const note = { + content: body.content, + important: body.important || false, + date: new Date(), + id: generateId(), + } + + notes = notes.concat(note) + + response.json(note) +}) +``` + + +La logique de génération du nouveau numéro d'identification des notes a été extraite dans une fonction _generateId_ distincte. + + +Si les données reçues ne contiennent pas de valeur pour la propriété content, le serveur répondra à la demande avec le code d'état [400 bad request](https://www.rfc-editor.org/rfc/rfc9110.html#name-400-bad-request) : + +```js +if (!body.content) { + return response.status(400).json({ + error: 'content missing' + }) +} +``` + + +Notez que l'appel de return est crucial, car sinon le code s'exécutera jusqu'à la fin et la note malformée sera enregistrée dans l'application. + + +Si la propriété content a une valeur, la note sera basée sur les données reçues. Comme mentionné précédemment, il est préférable de générer les horodatages sur le serveur plutôt que dans le navigateur, car nous ne pouvons pas être sûrs que la machine hôte qui exécute le navigateur a son horloge correctement réglée. La génération de la propriété date est maintenant effectuée par le serveur. + + +Si la propriété important est manquante, la valeur par défaut sera false. La valeur par défaut est actuellement générée d'une manière assez étrange : + +```js +important: body.important || false, +``` + + +Si les données enregistrées dans la variable _body_ possèdent la propriété important, l'expression sera évaluée à sa valeur. Si la propriété n'existe pas, alors l'expression sera évaluée à false qui est défini sur le côté droit des lignes verticales. + + +>Pour être exact, lorsque la propriété important est false, alors l'expression body.important || false retournera en fait le false de la partie droite... + + +Vous pouvez trouver le code de notre application actuelle dans son intégralité dans la branche part3-1 de [ce dépôt GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-1). + + +Le code pour l'état actuel de l'application est spécifiquement dans la branche [part3-1](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-1). + +![](../../images/3/21.png) + + +Si vous clonez le projet, exécutez la commande _npm install_ avant de lancer l'application avec _npm start_ ou _npm run dev_. + + +Une dernière chose avant de passer aux exercices. La fonction permettant de générer les IDs ressemble actuellement à ceci : + +```js +const generateId = () => { + const maxId = notes.length > 0 + ? Math.max(...notes.map(n => n.id)) + : 0 + return maxId + 1 +} +``` + + +Le corps de la fonction contient une ligne qui semble un peu intrigante : + +```js +Math.max(...notes.map(n => n.id)) +``` + +Que se passe-t-il exactement dans cette ligne de code ? notes.map(n => n.id) crée un nouveau tableau qui contient tous les ids des notes. [Math.max](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/max) renvoie la valeur maximale des nombres qui lui sont passés. Cependant, notes.map(n => n.id) est un tableau et ne peut donc pas être donné directement comme paramètre à _Math.max_. Le tableau peut être transformé en nombres individuels en utilisant la syntaxe d'étalement "trois points" [spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) .... + +
    + +
    + + +### Exercices 3.1.-3.6. + +**NB:** Il est recommandé de faire tous les exercices de cette partie dans un nouveau dépôt git dédié, et de placer votre code source à la racine du dépôt. Sinon, vous rencontrerez des problèmes dans l'exercice 3.10. + + +**NB:** Comme il ne s'agit pas d'un projet frontend et que nous ne travaillons pas avec React, l'application n'est pas créée avec create-react-app. Vous initialisez ce projet avec la commande npm init qui a été démontrée plus tôt dans cette partie du matériel. + + +**Forte recommandation:** Lorsque vous travaillez sur du code backend, gardez toujours un oeil sur ce qui se passe dans le terminal qui exécute votre application. + + +#### 3.1: Backend du répertoire téléphonique étape 1 + + +Mettez en oeuvre une application Node qui renvoie une liste codée en dur d'entrées de répertoire téléphonique à partir de l'adresse . + + +Données: + +```js +[ + { + "id": 1, + "name": "Arto Hellas", + "number": "040-123456" + }, + { + "id": 2, + "name": "Ada Lovelace", + "number": "39-44-5323523" + }, + { + "id": 3, + "name": "Dan Abramov", + "number": "12-43-234345" + }, + { + "id": 4, + "name": "Mary Poppendieck", + "number": "39-23-6423122" + } +] +``` + +Sortie dans le navigateur après une requête GET: + +![](../../images/3/22e.png) + +Notez que la barre oblique dans la route api/persons n'est pas un caractère spécial, et est comme n'importe quel autre caractère dans la chaîne de caractères. + +L'application doit être lancée avec la commande _npm start_. + +L'application doit également proposer une commande _npm run dev_ qui exécutera l'application et redémarrera le serveur chaque fois que des modifications seront apportées et enregistrées dans un fichier du code source. + +#### 3.2: Backend du répertoire téléphonique étape 2 + +Mettez en place une page à l'adresse qui ressemble à peu près à ceci : + +![](../../images/3/23x.png) + + +La page doit indiquer l'heure de réception de la demande et le nombre d'entrées présentes dans le répertoire au moment du traitement de la requête. + +#### 3.3: Backend du répertoire téléphonique étape 3 + + +Implémenter la fonctionnalité permettant d'afficher les informations d'une seule entrée du répertoire. L'url pour obtenir les données pour une personne avec l'id 5 devrait être . + +Si une entrée pour l'id donné n'est pas trouvée, le serveur doit répondre avec le code d'état approprié. + +#### 3.4: Backend du répertoire téléphonique étape 4 + + +Implémentez une fonctionnalité permettant de supprimer une seule entrée de répertoire en effectuant une requête HTTP DELETE vers l'URL unique de cette entrée de répertoire. + + +Testez que votre fonctionnalité fonctionne avec Postman ou le client REST de Visual Studio Code. + + +#### 3.5: Backend du répertoire téléphonique étape 5 + + +Développez le backend pour que de nouvelles entrées de répertoire puissent être ajoutées en effectuant des requêtes HTTP POST à l'adresse . + + +Générez un nouvel identifiant pour l'entrée du répertoire téléphonique avec la fonction [Math.random](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random). Utilisez un intervalle suffisamment grand pour vos valeurs aléatoires afin que la probabilité de créer des ids en double soit faible. + + +#### 3.6: Backend du répertoire téléphonique étape 6 + + + + +Mettre en oeuvre la gestion des erreurs pour la création de nouvelles entrées. La demande n'est pas autorisée à aboutir, si : +- Le nom ou le numéro est manquant +- Le nom existe déjà dans le répertoire. + + +Répondez aux demandes de ce type avec le code d'état approprié, et renvoyez également des informations expliquant la raison de l'erreur, par exemple : + +```js +{ error: 'name must be unique' } +``` + +
    + +
    + + +### À propos des types de requêtes HTTP + +[La norme HTTP](https://www.rfc-editor.org/rfc/rfc9110.html#name-common-method-properties) parle de deux propriétés liées aux types de demande, la **sécurité** et l'**idempotence**. + +La demande HTTP GET doit être sécurisée : + +> En particulier, la convention a été établie que les méthodes GET et HEAD NE DOIVENT PAS avoir la signification de prendre une action autre que la récupération. Ces méthodes doivent être considérées comme "sûres". + + +La sécurité signifie que l'exécution de la requête ne doit pas provoquer d'effets secondaires sur le serveur. Par effets secondaires, nous entendons que l'état de la base de données ne doit pas changer à la suite de la demande et que la réponse ne doit renvoyer que des données qui existent déjà sur le serveur. + + +Rien ne peut garantir qu'une requête GET est réellement sûre. Il s'agit en fait d'une simple recommandation définie dans la norme HTTP. En adhérant aux principes RESTful dans notre API, les requêtes GET sont en fait toujours utilisées de manière à être sûres. + + +La norme HTTP définit également le type de requête [HEAD](https://www.rfc-editor.org/rfc/rfc9110.html#name-head), qui devrait être sûr. En pratique, HEAD devrait fonctionner exactement comme GET, mais il ne renvoie rien d'autre que le code d'état et les en-têtes de réponse. Le corps de la réponse ne sera pas renvoyé lorsque vous faites une demande HEAD. + + +Toutes les requêtes HTTP, sauf POST, doivent être idempotentes : + +> Les méthodes peuvent également avoir la propriété d'"idempotence" en ce sens que (hormis les problèmes d'erreur ou d'expiration) les effets secondaires de N > 0 demandes identiques sont les mêmes que pour une seule demande. Les méthodes GET, HEAD, PUT et DELETE partagent cette propriété. + + +Cela signifie que si une demande ne génère pas d'effets secondaires, le résultat devrait être le même, quel que soit le nombre de fois où la demande est envoyée. + + +Si nous effectuons une requête HTTP PUT vers l'url /api/notes/10 et que nous envoyons avec cette requête les données { content: "no side effects!", important: true }, le résultat est le même, quel que soit le nombre de fois où la demande est envoyée + + +Comme pour la sécurité de la requête GET, l'idempotence n'est également qu'une recommandation de la norme HTTP et ne peut être garantie simplement sur la base du type de requête. Cependant, lorsque votre API adhère aux principes RESTfull, les requêtes GET, HEAD, PUT et DELETE sont utilisées de telle sorte qu'elles sont idempotentes. + + +POST est le seul type de requête HTTP qui n'est ni sûr ni idempotent. Si nous envoyons 5 requêtes HTTP POST différentes à /api/notes avec un corps de {content: "many same", important: true}, les 5 notes qui en résultent sur le serveur auront toutes le même contenu. + + +### Middleware + +Le [json-parser](https://expressjs.com/en/api.html) d'express que nous avons utilisé précédemment est un [middleware](http://expressjs.com/en/guide/using-middleware.html). + + +Les Middleware sont des fonctions qui peuvent être utilisées pour traiter les objets _requête_ et _réponse_. + +Le json-parser que nous avons utilisé précédemment prend les données brutes des requêtes qui sont stockées dans l'objet _request_, les analyse en un objet JavaScript et les assigne à l'objet _request_ en tant que nouvelle propriété body. + + +En pratique, vous pouvez utiliser plusieurs intergiciels en même temps. Lorsque vous en avez plusieurs, ils sont exécutés un par un dans l'ordre où ils ont été pris en compte dans express. + + +Implémentons notre propre middleware qui imprime des informations sur chaque requête envoyée au serveur. + + +Le middleware est une fonction qui reçoit trois paramètres : + +```js +const requestLogger = (request, response, next) => { + console.log('Method:', request.method) + console.log('Path: ', request.path) + console.log('Body: ', request.body) + console.log('---') + next() +} +``` + +À la fin du corps de la fonction, la fonction _next_ qui a été passée en paramètre est appelée. La fonction _next_ cède le contrôle à l'intergiciel suivant. + +Les intergiciels sont utilisés de la manière suivante : + +```js +app.use(requestLogger) +``` + +Les fonctions middleware sont appelées dans l'ordre où elles sont prises en charge par la méthode _use_ de l'objet serveur express.Notez que json-parser est pris en compte avant le middleware _requestLogger_, car sinon request.body ne sera pas initialisé lorsque le logger sera exécuté ! + + +Les fonctions middleware doivent être prises en compte avant les routes si nous voulons qu'elles soient exécutées avant que les gestionnaires d'événements de la route soient appelés. Il existe également des situations où nous voulons définir des fonctions middleware après les routes. En pratique, cela signifie que nous définissons des fonctions middleware qui ne sont appelées que si aucune route ne traite la requête HTTP. + + +Ajoutons le middleware suivant après nos routes, qui est utilisé pour attraper les requêtes faites vers des routes inexistantes. Pour ces requêtes, l'intergiciel renverra un message d'erreur au format JSON. + +```js +const unknownEndpoint = (request, response) => { + response.status(404).send({ error: 'unknown endpoint' }) +} + +app.use(unknownEndpoint) +``` + + +Vous pouvez trouver le code de notre application actuelle dans son intégralité dans la branche part3-2 de [ce dépôt GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-2). + +
    + +
    + +### Exercices 3.7.-3.8. + +#### 3.7: Backend du répertoire téléphonique étape 7 + +Ajoutez le middleware [morgan](https://github.com/expressjs/morgan) à votre application pour la journalisation. Configurez-le pour qu'il consigne les messages sur votre console en selon la configuration tiny. + +La documentation de Morgan n'est pas la meilleure, et vous devrez peut-être passer un certain temps à comprendre comment le configurer correctement. Cependant, la plupart des documentations dans le monde tombent dans la même catégorie, il est donc bon d'apprendre à déchiffrer et à interpréter une documentation cryptique dans tous les cas. + + +Morgan est installé comme toutes les autres bibliothèques avec la commande _npm install_. La mise en service de Morgan se fait de la même manière que la configuration de tout autre middleware en utilisant la commande _app.use_. + + +#### 3.8*: Backend du répertoire téléphonique step8 + + +Configurez morgan pour qu'il affiche également les données envoyées dans les requêtes HTTP POST : + +![](../../images/3/24.png) + +Notez que l'enregistrement de données, même dans la console, peut être dangereux car il peut contenir des données sensibles et violer la législation locale sur la confidentialité (par exemple, le GDPR dans l'UE) ou les normes commerciales. Dans cet exercice, vous n'avez pas à vous soucier des problèmes de confidentialité, mais en pratique, essayez de ne pas enregistrer de données sensibles. + +Cet exercice peut être assez difficile, même si la solution ne nécessite pas beaucoup de code. + + +Cet exercice peut être réalisé de plusieurs manières différentes. L'une des solutions possibles utilise ces deux techniques : +- [créer de nouveaux tokens](https://github.com/expressjs/morgan#creating-new-tokens) +- [JSON.stringify](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) + +
    diff --git a/src/content/3/fr/part3b.md b/src/content/3/fr/part3b.md new file mode 100644 index 00000000000..a75bc9b3e7d --- /dev/null +++ b/src/content/3/fr/part3b.md @@ -0,0 +1,377 @@ +--- +mainImage: ../../../images/part-3.svg +part: 3 +letter: b +lang: fr +--- + +
    + +Ensuite, nous allons connecter le frontend que nous avons créé dans la [partie 2](/fr/part2) à notre propre backend. + +Dans la partie précédente, le frontend pouvait demander la liste des notes au serveur json que nous avions comme backend, à l'adresse http://localhost:3001/notes. +Notre backend a une structure d'url légèrement différente maintenant, puisque les notes peuvent être trouvées à http://localhost:3001/api/notes. Changeons l'attribut __baseUrl__ dans le src/services/notes.js comme ceci : + + ```js +import axios from 'axios' +const baseUrl = 'http://localhost:3001/api/notes' //highlight-line + +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} + +// ... + +export default { getAll, create, update } +``` + + +Maintenant la requête GET du frontend vers ne fonctionne pas pour une raison quelconque : + + ![](../../images/3/3ae.png) + + +Qu'est-ce qui se passe ici ? Nous pouvons accéder au backend depuis un navigateur et depuis postman sans aucun problème. + +### Même politique d'origine et CORS + +Le problème réside dans une chose appelée CORS, ou Cross-Origin Resource Sharing. + +Selon [Wikipedia](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing): + +> Cross-origin resource sharing (CORS) est un mécanisme qui permet aux ressources restreintes (par exemple, les polices) d'une page Web d'être demandées à partir d'un autre domaine en dehors du domaine à partir duquel la première ressource a été servie. Une page Web peut librement intégrer des images, des feuilles de style, des scripts, des iframes et des vidéos d'origine croisée. Certaines requêtes "cross-domain", notamment les requêtes Ajax, sont interdites par défaut par la politique de sécurité same-origin. + +Dans notre contexte, le problème est que, par défaut, le code JavaScript d'une application qui s'exécute dans un navigateur ne peut communiquer qu'avec un serveur dans la même [origine](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy). +Parce que notre serveur est sur le port 3001 de localhost, et notre frontend sur le port 3000 de localhost, ils n'ont pas la même origine. + +Gardez à l'esprit que la [politique de même origine](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy) et CORS ne sont pas spécifiques à React ou Node. Ce sont en fait des principes universels du fonctionnement des applications web. + +Nous pouvons autoriser les demandes provenant d'autres origines en utilisant le middleware [cors](https://github.com/expressjs/cors) de Node. + +Dans votre dépôt de backend, installez cors avec la commande + +```bash +npm install cors +``` + +prenez le middleware à utiliser et autorisez les requêtes de toutes origines : + +```js +const cors = require('cors') + +app.use(cors()) +``` + +Et le frontend fonctionne ! Cependant, la fonctionnalité permettant de modifier l'importance des notes n'a pas encore été implémentée dans le backend. + +Vous pouvez en savoir plus sur les CORS sur la [page Mozillas](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS). + +La configuration de notre application est maintenant la suivante : + + ![](../../images/3/100.png) + + L'application react qui s'exécute dans le navigateur va maintenant chercher les données dans le node/express-server qui s'exécute sur localhost:3001. +### Application vers l'Internet + +Maintenant que toute la pile est prête, déplaçons notre application sur Internet. Nous allons utiliser le bon vieux [Heroku] (https://www.heroku.com) pour cela. + +> Si vous n'avez jamais utilisé Heroku auparavant, vous pouvez trouver des instructions dans [Heroku documentation](https://devcenter.heroku.com/articles/getting-started-with-nodejs) ou en cherchant sur Google. + +Ajoutez un fichier appelé Procfile à la racine du projet backend pour indiquer à Heroku comment démarrer l'application. + +```bash +web: node index.js +``` + +Changez la définition du port que notre application utilise en bas du fichier index.js comme suit : + +```js +const PORT = process.env.PORT || 3001 // highlight-line +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +``` + +Nous utilisons maintenant le port défini dans la [variable d'environnement](https://en.wikipedia.org/wiki/Environment_variable) _PORT_ ou le port 3001 si la variable d'environnement _PORT_ est indéfinie. +Heroku configure le port de l'application en fonction de la variable d'environnement. + +Créez un dépôt Git dans le répertoire du projet, et ajoutez .gitignore avec le contenu suivant + +```bash +node_modules +``` +Créez un compte Heroku dans https://devcenter.heroku.com/. +Installez le paquet Heroku en utilisant la commande : npm install -g heroku. +Créez une application Heroku avec la commande heroku create, commitez votre code sur le dépôt et déplacez-le sur Heroku avec la commande git push heroku main. + +Si tout s'est bien passé, l'application fonctionne : + +![](../../images/3/25ea.png) + +Si ce n'est pas le cas, le problème peut être trouvé en lisant les logs de heroku avec la commande heroku logs. + +> **NB** Au moins au début, il est bon de garder un oeil sur les logs heroku à tout moment. La meilleure façon de le faire est avec la commande heroku logs -t qui imprime les logs à la console chaque fois que quelque chose se passe sur le serveur. + +> **NB** Si vous déployez depuis un dépôt git où votre code n'est pas sur la branche principale (c'est-à-dire si vous modifiez le [dépôt notes](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-2) de la dernière leçon), vous devrez exécuter _git push heroku HEAD:master_. Si vous avez déjà effectué un push sur heroku, vous devrez peut-être exécuter _git push heroku HEAD:main --force_. + +Le frontend fonctionne aussi avec le backend sur Heroku. Vous pouvez le vérifier en changeant l'adresse du backend sur le frontend pour être l'adresse du backend dans Heroku au lieu de http://localhost:3001. + +La question suivante est de savoir comment déployer le frontend sur Internet. Nous avons plusieurs options. Passons en revue l'une d'entre elles ensuite. + +### Construction de production du frontend + +Jusqu'à présent, nous avons exécuté le code React en mode développement. En mode développement, l'application est configurée pour donner des messages d'erreur clairs, rendre immédiatement les changements de code au navigateur, et ainsi de suite. + +Lorsque l'application est déployée, nous devons créer un [production build](https://reactjs.org/docs/optimizing-performance.html#use-the-production-build) ou une version de l'application qui est optimisée pour la production. + +Une build de production des applications créées avec create-react-app peut être créée avec la commande [npm run build](https://github.com/facebookincubator/create-react-app#npm-run-build-or-yarn-build). + +**NOTE:** au moment de la rédaction (20 janvier 2022) create-react-app avait un bug qui provoque l'erreur suivante _TypeError : MiniCssExtractPlugin n'est pas un constructeur_. + +Un correctif possible est trouvé à partir d'[ici](https://github.com/facebook/create-react-app/issues/11930). Ajoutez ce qui suit au fichier package.json. + +```json +{ + // ... + "resolutions": { + "mini-css-extract-plugin": "2.4.5" + } +} +``` + +et lancez les commandes + +``` +rm -rf package-lock.json +rm -rf node_modules +npm cache clean --force +npm install +``` + +Après ces commandes _npm run build_ devrait fonctionner. + +Exécutons cette commande depuis la racine du projet frontend. + +Cela crée un répertoire appelé build (qui contient le seul fichier HTML de notre application, index.html ) qui contient le répertoire static. La version [minifiée]() du code JavaScript de notre application sera générée dans le répertoire static. Même si le code de l'application se trouve dans plusieurs fichiers, tout le JavaScript sera minifié en un seul fichier. En fait, tout le code de toutes les dépendances de l'application sera également minifié dans ce fichier unique. + +Le code minifié n'est pas très lisible. Le début du code ressemble à ceci : + +```js +!function(e){function r(r){for(var n,f,i=r[0],l=r[1],a=r[2],c=0,s=[];cbuild) à la racine du dépôt du backend et de configurer le backend pour afficher le main page du frontend (le fichier build/index.html) comme page principale. + +Nous commençons par copier la build de production du frontend à la racine du backend. Avec un ordinateur Mac ou Linux, la copie peut être faite depuis le répertoire du frontend avec la commande + +```bash +cp -r build ../notes-backend +``` + +Si vous utilisez un ordinateur Windows, vous pouvez utiliser la commande [copy](https://www.windows-commandline.com/windows-copy-command-syntax-examples/) ou [xcopy](https://www.windows-commandline.com/xcopy-command-syntax-examples/) à la place. Sinon, faites simplement un copier-coller. + +Le répertoire backend devrait maintenant ressembler à ceci : + +![](../../images/3/27ea.png) + +Pour qu'express affiche le contenu statique, la page index.html et le JavaScript, etc., qu'il récupère, nous avons besoin d'un middleware intégré à express appelé [static](http://expressjs.com/en/starter/static-files.html). + +Lorsque nous ajoutons ce qui suit au milieu des déclarations de middlewares +```js +app.use(express.static('build')) +``` + +chaque fois qu'express reçoit une requête HTTP GET, il vérifie d'abord si le répertoire build contient un fichier correspondant à l'adresse de la requête. Si un fichier correct est trouvé, express le retournera. + +Maintenant, les requêtes HTTP GET vers l'adresse www.serversaddress.com/index.html ou www.serversaddress.com afficheront le frontend React. Les requêtes GET vers l'adresse www.serversaddress.com/api/notes seront traitées par le code du backend. + +En raison de notre situation, le frontend et le backend sont tous deux à la même adresse, nous pouvons déclarer _baseUrl_ comme une URL [relative](https://www.w3.org/TR/WD-html40-970917/htmlweb.html#h-5.1.2). Cela signifie que nous pouvons laisser de côté la partie déclarant le serveur. + +```js +import axios from 'axios' +const baseUrl = '/api/notes' // highlight-line + +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} + +// ... +``` + +Après le changement, nous devons créer un nouveau build de production et le copier à la racine du dépôt backend. + +L'application peut maintenant être utilisée depuis l'adresse backend : + +![](../../images/3/28e.png) + +Notre application fonctionne maintenant exactement comme l'application d'exemple [single-page app](/fr/part0/fundamentals_of_web_apps#single-page-app) que nous avons étudiée dans la partie 0. + +Lorsque nous utilisons un navigateur pour aller à l'adresse , le serveur renvoie le fichier index.html à partir du build du dépôt. Le contenu résumé de ce fichier est le suivant : + +```html + + + React App + + + +
    + + + + +``` + +Le fichier contient des instructions pour récupérer une feuille de style CSS définissant les styles de l'application, et deux balises script qui indiquent au navigateur de récupérer le code JavaScript de l'application - l'application React réelle. + +Le code React va chercher les notes à l'adresse du serveur et les rend à l'écran. Les communications entre le serveur et le navigateur peuvent être vues dans l'onglet Réseau de la console du développeur : + +![](../../images/3/29ea.png) + +La configuration prête pour le déploiement du produit se présente comme suit : + +![](../../images/3/101.png) + +Contrairement à ce qui s'est passé lors de l'exécution de l'application dans un environnement de développement, tout est maintenant dans le même node/express-backend qui s'exécute dans localhost:3001. Lorsque le navigateur se rend sur la page, le fichier index.html est rendu. Cela amène le navigateur à récupérer la version produit de l'application React. Une fois qu'elle commence à s'exécuter, elle récupère les données json de l'adresse localhost:3001/api/notes. + +### L'application entière sur internet + +Après vous être assuré que la version de production de l'application fonctionne localement, faites un commit du build de production du frontend dans le dépôt principal et faites à nouveau un push du code à Heroku. + +[L'application](https://obscure-harbor-49797.herokuapp.com/) fonctionne parfaitement, sauf que nous n'avons pas encore ajouté la fonctionnalité pour changer l'importance d'une note au backend. + +![](../../images/3/30ea.png) + +Notre application enregistre les notes dans une variable. Si l'application se plante ou est redémarrée, toutes les données disparaîtront. + +L'application a besoin d'une base de données. Avant d'en introduire une, passons en revue quelques éléments. + +La configuration ressemble maintenant à ce qui suit : + +![](../../images/3/102.png) + +Le node/express-backend réside maintenant dans le serveur Heroku. Lorsque l'on accède à l'adresse racine qui est de la forme https://glacial-ravine-74819.herokuapp.com/, le navigateur charge et exécute l'application React qui récupère les données json du serveur Heroku. + +### Rationalisation du déploiement du front-end + +Pour créer une nouvelle construction de production du frontend sans travail manuel supplémentaire, ajoutons quelques npm-scripts au package.json du dépôt du backend : + +```json +{ + "scripts": { + //... + "build:ui": "rm -rf build && cd ../part2-notes/ && npm run build && cp -r build ../notes-backend", + "deploy": "git push heroku main", + "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && npm run deploy", + "logs:prod": "heroku logs --tail" + } +} +``` + +Le script _npm run build:ui_ construit le frontend et copie la version de production sous le dépôt backend. _npm run deploy_ libère le backend actuel sur Heroku. + +_npm run deploy:full_ combine ces deux-là et contient les commandes git nécessaires pour mettre à jour le dépôt backend. + +Il existe également un script _npm run logs:prod_ pour afficher les logs de heroku. + +Notez que les chemins de répertoire dans le script build:ui dépendent de l'emplacement des dépôts dans le système de fichiers. + +> **NB** Sous Windows, les scripts npm sont exécutés dans cmd.exe comme shell par défaut qui ne supporte pas les commandes bash. Pour que les commandes bash ci-dessus fonctionnent, vous pouvez changer le shell par défaut en Bash (dans l'installation par défaut de Git pour Windows) comme suit : + +```md +npm config set script-shell "C:\\Program Files\\git\\bin\\bash.exe" +``` + +Une autre option est l'utilisation de [shx](https://www.npmjs.com/package/shx). + +### Proxy + +Des modifications apportées au frontend ont fait qu'il ne fonctionne plus en mode développement (lorsqu'il est lancé avec la commande _npm start_), car la connexion au backend ne fonctionne pas. + +![](../../images/3/32ea.png) + +Ceci est dû au changement de l'adresse du backend en une URL relative : + +```js +const baseUrl = '/api/notes' +``` + +Parce qu'en mode développement, le frontend est à l'adresse localhost:3000, les requêtes vers le backend vont à la mauvaise adresse localhost:3000/api/notes. Le backend se trouve à localhost:3001. + +Si le projet a été créé avec create-react-app, ce problème est facile à résoudre. Il suffit d'ajouter la déclaration suivante au fichier package.json du dépôt du frontend. + +```bash +{ + "dependencies": { + // ... + }, + "scripts": { + // ... + }, + "proxy": "http://localhost:3001" // highlight-line +} +``` + +Après un redémarrage, l'environnement de développement React fonctionnera comme un [proxy](https://create-react-app.dev/docs/proxying-api-requests-in-development/). Si le code React fait une requête HTTP vers une adresse de serveur à http://localhost:3000 non gérée par l'application React elle-même (c'est-à-dire lorsque les requêtes ne consistent pas à récupérer le CSS ou le JavaScript de l'application), la requête sera redirigée vers le serveur à http://localhost:3001. + +Maintenant, le frontend est aussi bien, travaillant avec le serveur à la fois en mode développement et en mode production. + +Un aspect négatif de notre approche est la complexité du déploiement du frontend. Le déploiement d'une nouvelle version nécessite de générer un nouveau build de production du frontend et de le copier dans le dépôt du backend. Cela rend la création d'un [pipeline de déploiement] automatisé (https://martinfowler.com/bliki/DeploymentPipeline.html) plus difficile. Un pipeline de déploiement est un moyen automatisé et contrôlé de faire passer le code de l'ordinateur du développeur à l'environnement de production en passant par différents tests et contrôles de qualité. La construction d'un pipeline de déploiement est le sujet de la [partie 11](https://fullstackopen.com/en/part11) de ce cours. + +Il existe de multiples façons d'y parvenir (par exemple en plaçant le code backend et frontend [dans le même dépôt](https://github.com/mars/heroku-cra-node) ) mais nous ne nous y attarderons pas maintenant. + +Dans certaines situations, il peut être judicieux de déployer le code frontal comme une application à part entière. Avec les applications créées avec create-react-app, c'est [simple] (https://github.com/mars/create-react-app-buildpack). + +Le code actuel du backend peut être trouvé sur [Github](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-3), dans la branche part3-3. Les modifications du code du frontend se trouvent dans la branche part3-1 du [dépôt du frontend](https://github.com/fullstack-hy2020/part2-notes/tree/part3-1). + +
    + +
    + +### Exercices 3.9.-3.11. + +Les exercices suivants ne nécessitent pas beaucoup de lignes de code. Ils peuvent cependant constituer un défi, car vous devez comprendre exactement ce qui se passe et où, et les configurations doivent être justes. + +#### 3.9 backend du répertoire téléphonique étape9 + +Faites fonctionner le backend avec le frontend phonebook des exercices de la partie précédente. N'implémentez pas encore la fonctionnalité permettant de modifier les numéros de téléphone, qui sera implémentée dans l'exercice 3.17. + +Vous devrez probablement faire quelques petits changements au frontend, au moins aux URLs pour le backend. N'oubliez pas de garder la console de développement ouverte dans votre navigateur. Si certaines requêtes HTTP échouent, vous devriez vérifier à partir de l'onglet Réseau ce qui se passe. Gardez également un oeil sur la console du backend. Si vous n'avez pas fait l'exercice précédent, il vaut la peine d'imprimer les données de la requête ou request.body à la console dans le gestionnaire d'événements responsable des requêtes POST. + +#### 3.10 backend répertoire téléphonique étape10 + +Déployez le backend sur internet, par exemple sur Heroku. + +**NB** la commande _heroku_ fonctionne sur les ordinateurs du département et les ordinateurs portables des étudiants de première année. Si pour une raison quelconque vous ne pouvez pas [installer](https://devcenter.heroku.com/articles/heroku-cli) Heroku sur votre ordinateur, vous pouvez utiliser la commande [npx heroku](https://www.npmjs.com/package/heroku). + +Testez le backend déployé avec un navigateur et le client REST Postman ou VS Code pour vous assurer qu'il fonctionne. + +**PRO TIP:** Lorsque vous déployez votre application sur Heroku, il vaut la peine, au moins au début, de garder un oeil sur les logs de l'application heroku **TOUT LE TEMPS** avec la commande heroku logs -t. + +Ce qui suit est un log concernant un problème typique. Heroku ne peut pas trouver la dépendance d'application express : + +![](../../images/3/33.png) + +La raison est que le paquet express n'a pas été installé avec la commande npm install express, donc les informations sur la dépendance n'ont pas été enregistrées dans le fichier package.json. + +Un autre problème typique est que l'application n'est pas configurée pour utiliser le port défini dans la variable d'environnement PORT : + +![](../../images/3/34.png) + +Créez un README.md à la racine de votre dépôt, et ajoutez-y un lien vers votre application en ligne. + +#### 3.11 phonebook full stack + +Générez un build de production de votre frontend, et ajoutez-le à l'application internet en utilisant la méthode introduite dans cette partie. + +**NB** Assurez-vous que le répertoire build n'est pas gitignored. + +Assurez-vous également que le frontend fonctionne toujours localement (en mode développement lorsqu'il est lancé avec la commande _npm start_). + +Si vous avez des problèmes pour faire fonctionner l'application, assurez-vous que votre structure de répertoire correspond à celle de [l'application d'exemple](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-3). + +
    diff --git a/src/content/3/fr/part3c.md b/src/content/3/fr/part3c.md new file mode 100644 index 00000000000..f972e65b72e --- /dev/null +++ b/src/content/3/fr/part3c.md @@ -0,0 +1,925 @@ +--- +mainImage: ../../../images/part-3.svg +part: 3 +letter: c +lang: fr +--- + +
    + +Avant de passer au sujet principal de la persistance des données dans une base de données, nous allons jeter un coup d'oeil à quelques façons différentes de déboguer les applications Node. + +### Débogage des applications Node + +Le débogage des applications Node est légèrement plus difficile que le débogage du JavaScript exécuté dans votre navigateur. L'impression à la console est une méthode éprouvée et vraie, et cela vaut toujours la peine de la faire. Il y a des gens qui pensent que des méthodes plus sophistiquées devraient être utilisées à la place, mais je ne suis pas d'accord. Même l'élite mondiale des développeurs open source [utilise](https://tenderlovemaking.com/2016/02/05/i-am-a-puts-debuggerer.html) cette [méthode](https://swizec.com/blog/javascript-debugging-slightly-beyond-consolelog/). + + +#### Visual Studio Code + +Le débogueur de Visual Studio Code peut être utile dans certaines situations. Vous pouvez lancer l'application en mode débogage comme ceci : + +![](../../images/3/35x.png) + +Notez que l'application ne doit pas être exécutée dans une autre console, sinon le port sera déjà utilisé. + +__NB__ Une version plus récente de Visual Studio Code peut avoir _Run_ au lieu de _Debug_. De plus, vous devrez peut-être configurer votre fichier _launch.json_ pour lancer le débogage. Cela peut être fait en choisissant _Add Configuration... _ dans le menu déroulant, qui se trouve à côté du bouton vert de lecture et au-dessus du menu _VARIABLES_, et sélectionnez _Run "npm start" dans un terminal de débogage_. Pour des instructions de configuration plus détaillées, consultez la [documentation sur le débogage] de Visual Studio Code (https://code.visualstudio.com/docs/editor/debugging). + +Vous pouvez voir ci-dessous une capture d'écran où l'exécution du code a été interrompue au milieu de l'enregistrement d'une nouvelle note : + +![](../../images/3/36x.png) + +L'exécution s'est arrêtée au point d'arrêt à la ligne 63. Dans la console, vous pouvez voir la valeur de la variable note. Dans la fenêtre en haut à gauche, vous pouvez voir d'autres choses liées à l'état de l'application. + +Les flèches en haut peuvent être utilisées pour contrôler le flux du débogueur. + +Pour une raison quelconque, je n'utilise pas beaucoup le débogueur de Visual Studio Code. + +#### Outils de développement de Chrome + +Le débogage est également possible avec la console de développement de Chrome en démarrant votre application avec la commande : + +```bash +node --inspect index.js +``` + +Vous pouvez accéder au débogueur en cliquant sur l'icône verte - le logo de node - qui apparaît dans la console du développeur de Chrome : + +![](../../images/3/37.png) + +La vue de débogage fonctionne de la même manière que pour les applications React. L'onglet Sources peut être utilisé pour définir des points d'arrêt où l'exécution du code sera mise en pause. + +![](../../images/3/38eb.png) + +Tous les messages console.log de l'application apparaîtront dans l'onglet Console du débogueur. Vous pouvez également inspecter les valeurs des variables et exécuter votre propre code JavaScript. + +![](../../images/3/39ea.png) + +#### Questionnez tout + +Le débogage des applications Full Stack peut sembler délicat au début. Bientôt, notre application aura également une base de données en plus du frontend et du backend, et il y aura de nombreuses zones potentielles de bugs dans l'application. + +Lorsque l'application "ne fonctionne pas", nous devons d'abord déterminer où le problème se situe réellement. Il est très fréquent que le problème se trouve à un endroit où vous ne vous y attendiez pas, et il peut se passer des minutes, des heures, voire des jours avant que vous ne trouviez la source du problème. + +La clé est d'être systématique. Puisque le problème peut exister n'importe où, vous devez tout remettre en question, et éliminer toutes les possibilités une par une. La journalisation sur la console, Postman, les débogueurs et l'expérience vous aideront. + +Lorsque des bugs surviennent, la pire des stratégies possibles est de continuer à écrire du code. Cela garantira que votre code aura bientôt encore plus de bugs, et que leur débogage sera encore plus difficile. Le principe [stop and fix](http://gettingtolean.com/toyota-principle-5-build-culture-stopping-fix/) du Toyota Production Systems est également très efficace dans cette situation. + +### MongoDB + +Afin de stocker indéfiniment nos notes enregistrées, nous avons besoin d'une base de données. La plupart des cours dispensés à l'Université d'Helsinki utilisent des bases de données relationnelles. Dans la majeure partie de ce cours, nous utiliserons [MongoDB](https://www.mongodb.com/) qui est une base de données documentaire (https://en.wikipedia.org/wiki/Document-oriented_database). + +La raison de l'utilisation de Mongo comme base de données est sa moindre complexité par rapport à une base de données relationnelle. [La partie 13](https://fullstackopen.com/en/part13) du cours montre comment construire des backends node.js qui utilisent une base de données relationnelle. + +Les bases de données documentaires diffèrent des bases de données relationnelles par la manière dont elles organisent les données ainsi que par les langages d'interrogation qu'elles prennent en charge. Les bases de données documentaires sont généralement classées sous le terme générique [NoSQL](https://en.wikipedia.org/wiki/NoSQL). + +Pour en savoir plus sur les bases de données documentaires et les bases de données NoSQL, consultez le support de cours de la [semaine 7](https://tikape-s18.mooc.fi/part7/) du cours Introduction aux bases de données. Malheureusement, ce matériel n'est actuellement disponible qu'en finnois. + +Lisez maintenant les chapitres sur les [collections](https://docs.mongodb.com/manual/core/databases-and-collections/) et les [documents](https://docs.mongodb.com/manual/core/document/) du manuel MongoDB pour avoir une idée de base sur la façon dont une base de données de documents stocke les données. + +Naturellement, vous pouvez installer et exécuter MongoDB sur votre propre ordinateur. Cependant, l'Internet regorge également de services de base de données Mongo que vous pouvez utiliser. Notre fournisseur MongoDB préféré dans ce cours sera [MongoDB Atlas](https://www.mongodb.com/atlas/database). + + + +Une fois que vous avez créé et connecté votre compte, commençons par sélectionner l'option gratuite : + +![](../../images/3/mongo1.png) + +Choisissez le fournisseur de cloud et l'emplacement et créez le cluster : + +![](../../images/3/mongo2.png) + +Attendons que le cluster soit prêt à être utilisé. Cela peut prendre quelques minutes. + +**NB** ne continuez pas avant que le cluster soit prêt. + +Utilisons l'onglet security pour créer les informations d'identification des utilisateurs pour la base de données. Veuillez noter que ce ne sont pas les mêmes informations d'identification que vous utilisez pour vous connecter à MongoDB Atlas. Ils seront utilisés par votre application pour se connecter à la base de données. + +![](../../images/3/mongo3.png) + + Ensuite, nous devons définir les adresses IP qui sont autorisées à accéder à la base de données. Pour des raisons de simplicité, nous allons autoriser l'accès à partir de toutes les adresses IP : + +![](../../images/3/mongo4.png) + +Enfin, nous sommes prêts à nous connecter à notre base de données. Commencez par cliquer sur connect : + +![](../../images/3/mongo5.png) + +et choisissez Connecter votre application : + +![](../../images/3/mongo6.png) + +La vue affiche l'URI MongoDB, qui est l'adresse de la base de données que nous allons fournir à la bibliothèque client MongoDB que nous allons ajouter à notre application. + +L'adresse ressemble à ceci : + +```bash +mongodb+srv://fullstack:$thepasswordishere@cluster0.o1opl.mongodb.net/myFirstDatabase?retryWrites=true&w=majority +``` + +Nous sommes maintenant prêts à utiliser la base de données. + +Nous pourrions utiliser la base de données directement depuis notre code JavaScript avec la bibliothèque [official MongoDb Node.js driver](https://mongodb.github.io/node-mongodb-native/), mais elle est assez lourde à utiliser. Nous utiliserons plutôt la bibliothèque [Mongoose](http://mongoosejs.com/index.html) qui offre une API de plus haut niveau. + +Mongoose pourrait être décrit comme un object document mapper (ODM), et l'enregistrement d'objets JavaScript en tant que documents Mongo est simple avec cette bibliothèque. + +Installons Mongoose : + +```bash +npm install mongoose +``` + + N'ajoutons pas encore de code traitant de Mongo à notre backend. Au lieu de cela, faisons une application d'entraînement en créant un nouveau fichier, mongo.js : + + ```js +const mongoose = require('mongoose') + +if (process.argv.length < 3) { + console.log('Please provide the password as an argument: node mongo.js ') + process.exit(1) +} + +const password = process.argv[2] + +const url = `mongodb+srv://notes-app-full:${password}@cluster1.lvvbt.mongodb.net/?retryWrites=true&w=majority` + +const noteSchema = new mongoose.Schema({ + content: String, + date: Date, + important: Boolean, +}) + +const Note = mongoose.model('Note', noteSchema) + +mongoose + .connect(url) + .then((result) => { + console.log('connected') + + const note = new Note({ + content: 'HTML is Easy', + date: new Date(), + important: true, + }) + + return note.save() + }) + .then(() => { + console.log('note saved!') + return mongoose.connection.close() + }) + .catch((err) => console.log(err)) +``` + +**NB:** Selon la région que vous avez sélectionnée lors de la construction de votre cluster, l'URI MongoDB peut être différent de l'exemple fourni ci-dessus. Vous devez vérifier et utiliser l'URI correct qui a été généré par l'Atlas MongoDB. + +Le code suppose également qu'on lui transmettra le mot de passe des informations d'identification que nous avons créées dans MongoDB Atlas, en tant que paramètre de ligne de commande. Nous pouvons accéder au paramètre de la ligne de commande comme suit : + +```js +const password = process.argv[2] +```` + +Lorsque le code est exécuté avec la commande node mongo.js password, Mongo ajoutera un nouveau document à la base de données. + +**NB:** Veuillez noter que le mot de passe est le mot de passe créé pour l'utilisateur de la base de données, et non votre mot de passe Atlas MongoDB. De plus, si vous avez créé un mot de passe avec des caractères spéciaux, vous devrez [coder l'URL de ce mot de passe](https://docs.atlas.mongodb.com/troubleshoot-connection/#special-characters-in-connection-string-password). + +Nous pouvons voir l'état actuel de la base de données dans l'Atlas MongoDB à partir de Browse collections, dans l'onglet Database. + +![](../../images/3/mongo7.png) + +Comme l'indique la vue, le document correspondant à la note a été ajouté à la collection notes de la base de données myFirstDatabase. + +![](../../images/3/mongo8.png) + +Détruisons la base de données par défaut myFirstDatabase et changeons le nom de la base de données référencée dans notre chaîne de connexion en noteApp à la place, en modifiant l'URI : + +```bash +mongodb+srv://fullstack:$thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority +``` + +Exécutons à nouveau notre code : + +![](../../images/3/mongo9.png) + +Les données sont maintenant stockées dans la bonne base de données. La vue offre également la fonctionnalité créer une base de données, qui peut être utilisée pour créer de nouvelles bases de données à partir du site Web. Créer la base de données comme ceci n'est pas nécessaire, puisque MongoDB Atlas crée automatiquement une nouvelle base de données lorsqu'une application tente de se connecter à une base de données qui n'existe pas encore. + +### Schema + +Après avoir établi la connexion à la base de données, nous définissons le [schéma](http://mongoosejs.com/docs/guide.html) pour une note et le [modèle](http://mongoosejs.com/docs/models.html) correspondant : + +```js +const noteSchema = new mongoose.Schema({ + content: String, + date: Date, + important: Boolean, +}) + +const Note = mongoose.model('Note', noteSchema) +``` + +Tout d'abord, nous définissons le [schéma](http://mongoosejs.com/docs/guide.html) d'une note qui est stocké dans la variable _noteSchema_. Le schéma indique à Mongoose comment les objets note doivent être stockés dans la base de données. + +Dans la définition du modèle _Note_, le premier paramètre "Note" est le nom singulier du modèle. Le nom de la collection sera le pluriel en minuscules notes, car la [convention Mongoose](http://mongoosejs.com/docs/models.html) consiste à nommer automatiquement les collections au pluriel (par exemple notes) lorsque le schéma les désigne au singulier (par exemple Note). + +Les bases de données de documents comme Mongo sont schemaless, ce qui signifie que la base de données elle-même ne se soucie pas de la structure des données qui sont stockées dans la base. Il est possible de stocker des documents avec des champs complètement différents dans la même collection. + +L'idée derrière Mongoose est que les données stockées dans la base de données reçoivent un schéma au niveau de l'application qui définit la forme des documents stockés dans toute collection donnée. + +### Création et sauvegarde d'objets + +Ensuite, l'application crée un nouvel objet note à l'aide du Note [modèle](http://mongoosejs.com/docs/models.html) : + +```js +const note = new Note({ + content: 'HTML is Easy', + date: new Date(), + important: false, +}) +``` + +Les modèles sont des fonctions dites constructrices qui créent de nouveaux objets JavaScript en fonction des paramètres fournis. Puisque les objets sont créés avec la fonction constructeur du modèle, ils ont toutes les propriétés du modèle, qui incluent des méthodes pour enregistrer l'objet dans la base de données. + +L'enregistrement de l'objet dans la base de données s'effectue à l'aide de la méthode _save_, qui peut être associée à un gestionnaire d'événements avec la méthode _then_ : + +```js +note.save().then(result => { + console.log('note saved!') + mongoose.connection.close() +}) +``` + +Lorsque l'objet est enregistré dans la base de données, le gestionnaire d'événements fourni à _then_ est appelé. Le gestionnaire d'événements ferme la connexion à la base de données avec la commande mongoose.connection.close(). Si la connexion n'est pas fermée, le programme ne terminera jamais son exécution. + +Le résultat de l'opération de sauvegarde se trouve dans le paramètre _result_ du gestionnaire d'événements. Le résultat n'est pas très intéressant lorsque nous stockons un seul objet dans la base de données. Vous pouvez imprimer l'objet sur la console si vous souhaitez l'examiner de plus près lors de la mise en oeuvre de votre application ou pendant le débogage. + +Prenons également quelques notes supplémentaires en modifiant les données dans le code et en exécutant à nouveau le programme. + +**NB:** Malheureusement, la documentation de Mongoose n'est pas très cohérente, certaines parties utilisant les callbacks dans leurs exemples et d'autres parties, d'autres styles, il n'est donc pas recommandé de copier-coller du code directement à partir de là. Il n'est pas recommandé de mélanger les promesses avec les callbacks de la vieille école dans le même code. + +### Récupération d'objets dans la base de données + +Mettons en commentaire le code pour générer de nouvelles notes et remplaçons-le par ce qui suit : + +```js +Note.find({}).then(result => { + result.forEach(note => { + console.log(note) + }) + mongoose.connection.close() +}) +``` + +Lorsque le code est exécuté, le programme imprime toutes les notes stockées dans la base de données : + +![](../../images/3/70ea.png) + +Les objets sont récupérés dans la base de données avec la méthode [find](https://mongoosejs.com/docs/api/model.html#model_Model-find) du modèle _Note_. Le paramètre de la méthode est un objet exprimant les conditions de recherche. Comme le paramètre est un objet vide{}, nous obtenons toutes les notes stockées dans la collection _notes_. + +Les conditions de recherche sont conformes à la requête de recherche Mongo [syntaxe](https://docs.mongodb.com/manual/reference/operator/). + +Nous pourrions restreindre notre recherche pour n'inclure que les notes importantes comme ceci : + +```js +Note.find({ important: true }).then(result => { + // ... +}) +``` + +
    + +
    + +### Exercice 3.12. + +#### 3.12 : base de données en ligne de commande + +Créez une base de données MongoDB basée sur le cloud pour l'application de répertoire téléphonique avec MongoDB Atlas. + +Créez un fichier mongo.js dans le répertoire du projet, qui peut être utilisé pour ajouter des entrées au répertoire et pour lister toutes les entrées existantes dans le répertoire. + +**NB:** N'incluez pas le mot de passe dans le fichier que vous commettez et poussez sur GitHub ! + +L'application doit fonctionner comme suit. Vous utilisez le programme en passant trois arguments de ligne de commande (le premier est le mot de passe), par exemple : + +```bash +node mongo.js yourpassword Anna 040-1234556 +``` + + En conséquence, l'application imprimera : + +```bash +added Anna number 040-1234556 to phonebook +``` + + La nouvelle entrée du répertoire sera enregistrée dans la base de données. Notez que si le nom contient des caractères d'espacement, il doit être placé entre guillemets : + +```bash +node mongo.js yourpassword "Arto Vihavainen" 045-1232456 +``` + + Si le mot de passe est le seul paramètre donné au programme, c'est-à-dire qu'il est invoqué comme ceci : + +```bash +node mongo.js yourpassword +``` + + Alors le programme devrait afficher toutes les entrées du répertoire téléphonique : + +``` +phonebook: +Anna 040-1234556 +Arto Vihavainen 045-1232456 +Ada Lovelace 040-1231236 +``` + +Vous pouvez obtenir les paramètres de la ligne de commande à partir de la variable [process.argv](https://nodejs.org/docs/latest-v8.x/api/process.html#process_process_argv). + +**NB : ne pas fermer la connexion au mauvais endroit**. Par exemple, le code suivant ne fonctionnera pas : + +```js +Person + .find({}) + .then(persons=> { + // ... + }) + +mongoose.connection.close() +``` + +Dans le code ci-dessus, la commande mongoose.connection.close() sera exécutée immédiatement après le lancement de l'opération Person.find. Cela signifie que la connexion à la base de données sera fermée immédiatement, et que l'exécution n'arrivera jamais au point où l'opération Person.find se termine et où la fonction callback est appelée. + +L'endroit correct pour fermer la connexion à la base de données est à la fin de la fonction callback : + +```js +Person + .find({}) + .then(persons=> { + // ... + mongoose.connection.close() + }) +``` + +**NB:** Si vous définissez un modèle avec le nom Person, mongoose nommera automatiquement la collection associée comme people. + +
    + +
    + +### Backend connecté à une base de données + +Maintenant, nous avons suffisamment de connaissances pour commencer à utiliser Mongo dans notre application. + +Commençons rapidement en copiant-collant les définitions de Mongoose dans le fichier index.js : + +```js +const mongoose = require('mongoose') + +// Assigns the second command line argument to 'password' + const password = process.argv[2]; + +// DO NOT SAVE YOUR PASSWORD TO GITHUB!! +const url = + `mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority` + +mongoose.connect(url) + +const noteSchema = new mongoose.Schema({ + content: String, + date: Date, + important: Boolean, +}) + +const Note = mongoose.model('Note', noteSchema) +``` + +Modifions le gestionnaire pour récupérer toutes les notes sous la forme suivante : + +```js +app.get('/api/notes', (request, response) => { + Note.find({}).then(notes => { + response.json(notes) + }) +}) +``` + +Nous pouvons vérifier dans le navigateur que le backend fonctionne pour l'affichage de tous les documents : + +![](../../images/3/44ea.png) + +L'application fonctionne presque parfaitement. Le frontend suppose que chaque objet a un id unique dans le champ id. Nous ne voulons pas non plus renvoyer le champ de versioning mongo \_\_v au frontend. + +Une façon de formater les objets retournés par Mongoose est de [modifier](https://stackoverflow.com/questions/7034848/mongodb-output-id-instead-of-id) la méthode _toJSON_ du schéma, qui est utilisée sur toutes les instances des modèles produits avec ce schéma. La modification de la méthode fonctionne comme suit : + +```js +noteSchema.set('toJSON', { + transform: (document, returnedObject) => { + returnedObject.id = returnedObject._id.toString() + delete returnedObject._id + delete returnedObject.__v + } +}) +``` + +Même si la propriété \_id des objets Mongoose ressemble à une chaîne, il s'agit en fait d'un objet. La méthode _toJSON_ que nous avons définie la transforme en chaîne de caractères par mesure de sécurité. Si nous ne faisions pas ce changement, cela nous causerait plus de tort à l'avenir, lorsque nous commencerons à écrire des tests. + +Répondons à la requête HTTP avec une liste d'objets formatés avec la méthode _toJSON_ : + +```js +app.get('/api/notes', (request, response) => { + Note.find({}).then(notes => { + response.json(notes) + }) +}) +``` + +Maintenant la variable _notes_ est assignée à un tableau d'objets retournés par Mongo. Lorsque la réponse est envoyée au format JSON, la méthode _toJSON_ de chaque objet du tableau est appelée automatiquement par la méthode [JSON.stringify](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify). + +### La configuration de la base de données dans son propre module + +Avant de refactoriser le reste du backend pour utiliser la base de données, extrayons le code spécifique à Mongoose dans son propre module. + +Créons un nouveau répertoire pour le module appelé models, et ajoutons un fichier appelé note.js : + +```js +const mongoose = require('mongoose') + +const url = process.env.MONGODB_URI // highlight-line + +console.log('connecting to', url) // highlight-line + +mongoose.connect(url) +// highlight-start + .then(result => { + console.log('connected to MongoDB') + }) + .catch((error) => { + console.log('error connecting to MongoDB:', error.message) + }) +// highlight-end + +const noteSchema = new mongoose.Schema({ + content: String, + date: Date, + important: Boolean, +}) + +noteSchema.set('toJSON', { + transform: (document, returnedObject) => { + returnedObject.id = returnedObject._id.toString() + delete returnedObject._id + delete returnedObject.__v + } +}) + +module.exports = mongoose.model('Note', noteSchema) // highlight-line +``` + +La définition des [modules] de Node (https://nodejs.org/docs/latest-v8.x/api/modules.html) diffère légèrement de la manière de définir les [modules ES6](/fr/part2/rendering_a_collection_modules#refactoring-modules) dans la partie 2. + +L'interface publique du module est définie en attribuant une valeur à la variable _module.exports_. Nous allons définir la valeur comme étant le modèle Note. Les autres choses définies à l'intérieur du module, comme les variables _mongoose_ et _url_ ne seront pas accessibles ou visibles pour les utilisateurs du module. + +L'importation du module se fait en ajoutant la ligne suivante à index.js : + +```js +const Note = require('./models/note') +``` + +De cette façon, la variable _Note_ sera affectée au même objet que celui défini par le module. + +La façon dont la connexion est établie a légèrement changé : + +```js +const url = process.env.MONGODB_URI + +console.log('connecting to', url) + +mongoose.connect(url) + .then(result => { + console.log('connected to MongoDB') + }) + .catch((error) => { + console.log('error connecting to MongoDB:', error.message) + }) +``` + +Ce n'est pas une bonne idée de coder en dur l'adresse de la base de données dans le code, donc à la place l'adresse de la base de données est transmise à l'application via la variable d'environnement MONGODB_URI. + +La méthode d'établissement de la connexion est maintenant dotée de fonctions permettant de traiter une tentative de connexion réussie ou non. Les deux fonctions se contentent de consigner un message dans la console concernant l'état de réussite : + +![](../../images/3/45e.png) + +Il existe de nombreuses façons de définir la valeur d'une variable d'environnement. L'une d'elles consiste à la définir au démarrage de l'application : + +```bash +MONGODB_URI=address_here npm run dev +``` + +Une méthode plus sophistiquée consiste à utiliser la bibliothèque [dotenv](https://github.com/motdotla/dotenv#readme). Vous pouvez installer la bibliothèque avec la commande : + +```bash +npm install dotenv +``` + +Pour utiliser la bibliothèque, nous créons un fichier .env à la racine du projet. Les variables d'environnement sont définies à l'intérieur du fichier, et il peut ressembler à ceci : + +```bash +MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority +PORT=3001 +``` + + Nous avons également ajouté le port en dur du serveur dans la variable d'environnement PORT. + +**Le fichier .env doit être gitignoré tout de suite, car nous ne voulons pas publier d'informations confidentielles publiquement en ligne !** + +![](../../images/3/45ae.png) + +Les variables d'environnement définies dans le fichier .env peuvent être prises en compte avec l'expression require('dotenv').config() et vous pouvez les référencer dans votre code comme vous référeriez des variables d'environnement normales, avec la syntaxe familière process.env.MONGODB_URI. + +Modifions le fichier index.js de la manière suivante : + +```js +require('dotenv').config() // highlight-line +const express = require('express') +const app = express() +const Note = require('./models/note') // highlight-line + +// .. + +const PORT = process.env.PORT // highlight-line +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +``` + +Il est important que dotenv soit importé avant que le modèle note soit importé. Cela garantit que les variables d'environnement du fichier .env sont disponibles globalement avant que le code des autres modules ne soit importé. + +Une fois que le fichier .env a été gitignoré, Heroku ne récupère pas l'url de la base de données à partir du référentiel, vous devez donc le définir vous-même. Cela peut être fait via le tableau de bord Heroku comme suit : + +![](../../images/3/herokuConfig.png) + +ou depuis la ligne de commande avec la commande : + +``` +heroku config:set MONGODB_URI='mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority' +``` + +### Utilisation de la base de données dans les gestionnaires de route + +Ensuite, changeons le reste de la fonctionnalité du backend pour utiliser la base de données. + +La création d'une nouvelle note se fait comme suit : + +```js +app.post('/api/notes', (request, response) => { + const body = request.body + + if (body.content === undefined) { + return response.status(400).json({ error: 'content missing' }) + } + + const note = new Note({ + content: body.content, + important: body.important || false, + date: new Date(), + }) + + note.save().then(savedNote => { + response.json(savedNote) + }) +}) +``` + +Les objets note sont créés avec la fonction constructrice _Note_. La réponse est envoyée dans la fonction de rappel de l'opération _save_. Cela garantit que la réponse n'est envoyée que si l'opération a réussi. Nous aborderons la gestion des erreurs un peu plus tard. + +Le paramètre _savedNote_ de la fonction de rappel est la note sauvegardée et nouvellement créée. Les données renvoyées dans la réponse sont la version formatée créée avec la méthode _toJSON_ : + +```js +response.json(savedNote) +``` + +En utilisant la méthode [findById](https://mongoosejs.com/docs/api/model.html#model_Model-findById) de Mongoose, la récupération d'une note individuelle se transforme en ce qui suit : + +```js +app.get('/api/notes/:id', (request, response) => { + Note.findById(request.params.id).then(note => { + response.json(note) + }) +}) +``` + +### Vérification de l'intégration du front-end et du back-end + +Lorsque le backend est étendu, c'est une bonne idée de tester d'abord le backend avec **le navigateur, Postman ou le client REST de VS Code**. Ensuite, essayons de créer une nouvelle note après avoir pris en compte la base de données : + +![](../../images/3/46e.png) + +Ce n'est qu'une fois que l'on a vérifié que tout fonctionne dans le backend, qu'il est bon de tester que le frontend fonctionne avec le backend. Il est très inefficace de tester les choses exclusivement par le biais du frontend. + +C'est probablement une bonne idée d'intégrer le frontend et le backend une fonctionnalité à la fois. Tout d'abord, nous pourrions implémenter la récupération de toutes les notes de la base de données et la tester via le point de terminaison du backend dans le navigateur. Ensuite, nous pourrions vérifier que le frontend fonctionne avec le nouveau backend. Une fois que tout semble fonctionner, nous pourrions passer à la fonctionnalité suivante. + +Une fois que nous introduisons une base de données dans le mélange, il est utile d'inspecter l'état persistant dans la base de données, par exemple à partir du panneau de contrôle dans MongoDB Atlas. Très souvent, de petits programmes d'aide Node comme le programme mongo.js que nous avons écrit précédemment peuvent être très utiles pendant le développement. + +Vous pouvez trouver le code de notre application actuelle dans son intégralité dans la branche part3-4 de [ce dépôt GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-4). + +
    + +
    + +### Exercices 3.13.-3.14. + +Les exercices suivants sont assez simples, mais si votre frontend cesse de fonctionner avec le backend, alors trouver et corriger les bugs peut être assez intéressant. + +#### 3.13 : Base de données du répertoire téléphonique, étape 1 + +Changez la récupération de toutes les entrées du répertoire téléphonique pour que les données soient retirées de la base de données. + +Vérifiez que le frontend fonctionne après que les changements ont été faits. + +Dans les exercices suivants, écrivez tout le code spécifique à Mongoose dans son propre module, comme nous l'avons fait dans le chapitre [Configuration de la base de données dans son propre module](/fr/part3/saving_data_to_mongo_db#database-configuration-into-its-own-module). + +#### 3.14 : Base de données du répertoire téléphonique, étape 2 + +Changez le backend pour que les nouveaux numéros soient sauvegardés dans la base de données. Vérifiez que votre frontend fonctionne toujours après les changements. + +À ce stade, vous pouvez choisir de simplement autoriser les utilisateurs à créer toutes les entrées du répertoire téléphonique. À ce stade, le répertoire téléphonique peut avoir plusieurs entrées pour une personne ayant le même nom. + +
    + +
    + +### Traitement des erreurs + +Si nous essayons de visiter l'URL d'une note avec un id qui n'existe pas réellement, par exemple 5c41c90e84d891c15dfa3431 n'est pas un id stocké dans la base de données, alors la réponse sera _null_. + +Changeons ce comportement pour que si la note avec l'id donné n'existe pas, le serveur répondra à la requête avec le code de statut HTTP 404 not found. En outre, implémentons un simple bloc catch pour gérer les cas où la promesse retournée par la méthode findById est rejetée : + +```js +app.get('/api/notes/:id', (request, response) => { + Note.findById(request.params.id) + .then(note => { + // highlight-start + if (note) { + response.json(note) + } else { + response.status(404).end() + } + // highlight-end + }) + // highlight-start + .catch(error => { + console.log(error) + response.status(500).end() + }) + // highlight-end +}) +``` + +Si aucun objet correspondant n'est trouvé dans la base de données, la valeur de _note_ sera _null_ et le bloc _else_ sera exécuté. Il en résulte une réponse avec le code d'état 404 not found. Si la promesse renvoyée par la méthode findById est rejetée, la réponse aura le code d'état 500 internal server error. La console affiche des informations plus détaillées sur l'erreur. + +En plus de la note inexistante, il y a une autre situation d'erreur qui doit être traitée. Dans cette situation, nous essayons de récupérer une note avec un mauvais type d'_id_, c'est-à-dire un _id_ qui ne correspond pas au format d'identifiant mongo. + +Si nous effectuons la requête suivante, nous obtiendrons le message d'erreur indiqué ci-dessous : + +``` +Method: GET +Path: /api/notes/someInvalidId +Body: {} +--- +{ CastError: Cast to ObjectId failed for value "someInvalidId" at path "_id" + at CastError (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/error/cast.js:27:11) + at ObjectId.cast (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/schema/objectid.js:158:13) + ... +``` + +Étant donné un id malformé comme argument, la méthode findById lancera une erreur provoquant le rejet de la promesse retournée. Cela provoquera l'appel de la fonction callback définie dans le bloc catch. + +Faisons quelques petits ajustements à la réponse dans le bloc catch : + +```js +app.get('/api/notes/:id', (request, response) => { + Note.findById(request.params.id) + .then(note => { + if (note) { + response.json(note) + } else { + response.status(404).end() + } + }) + .catch(error => { + console.log(error) + response.status(400).send({ error: 'malformatted id' }) // highlight-line + }) +}) +``` + +Si le format de l'identifiant est incorrect, nous nous retrouverons dans le gestionnaire d'erreur défini dans le bloc _catch_. Le code d'état approprié pour cette situation est [400 Bad Request](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1), car la situation correspond parfaitement à la description : + +> La demande n'a pas pu être comprise par le serveur en raison d'une syntaxe malformée. Le client NE DEVRAIT PAS répéter la demande sans modifications. + +Nous avons également ajouté quelques données à la réponse pour faire la lumière sur la cause de l'erreur. + +Lorsque vous traitez avec des Promesses, c'est presque toujours une bonne idée d'ajouter la gestion des erreurs et des exceptions, car sinon vous vous retrouverez à traiter des bugs étranges. + +Ce n'est jamais une mauvaise idée d'imprimer l'objet qui a causé l'exception sur la console dans le gestionnaire d'erreur : + +```js +.catch(error => { + console.log(error) // highlight-line + response.status(400).send({ error: 'malformatted id' }) +}) +``` + +La raison pour laquelle le gestionnaire d'erreurs est appelé peut être complètement différente de ce que vous aviez prévu. Si vous consignez l'erreur dans la console, vous vous épargnerez de longues et frustrantes sessions de débogage. En outre, la plupart des services modernes sur lesquels vous déployez votre application prennent en charge une certaine forme de système de journalisation que vous pouvez utiliser pour vérifier ces journaux. Comme nous l'avons mentionné, Heroku en est un. + +Chaque fois que vous travaillez sur un projet avec un backend, il est essentiel de garder un oeil sur la sortie console du backend. Si vous travaillez sur un petit écran, il suffit de voir une toute petite tranche de la sortie en arrière-plan. Tout message d'erreur attirera votre attention même si la console est loin en arrière-plan : + +![](../../images/3/15b.png) + +### Déplacer la gestion des erreurs dans le middleware + +Nous avons écrit le code pour le gestionnaire d'erreurs parmi le reste de notre code. Cela peut être une solution raisonnable à certains moments, mais il y a des cas où il est préférable d'implémenter toute la gestion des erreurs à un seul endroit. Cela peut s'avérer particulièrement utile si nous souhaitons par la suite transmettre les données relatives aux erreurs à un système externe de suivi des erreurs comme [Sentry](https://sentry.io/welcome/). + +Modifions le gestionnaire de la route /api/notes/:id, afin qu'il transmette l'erreur avec la fonction next. La fonction next est passée au handler comme troisième paramètre : + +```js +app.get('/api/notes/:id', (request, response, next) => { // highlight-line + Note.findById(request.params.id) + .then(note => { + if (note) { + response.json(note) + } else { + response.status(404).end() + } + }) + .catch(error => next(error)) // highlight-line +}) +``` + +L'erreur qui est transmise en amont est donnée à la fonction next en tant que paramètre. Si next était appelée sans paramètre, alors l'exécution passerait simplement à la route ou au middleware suivant. Si la fonction next est appelée avec un paramètre, alors l'exécution se poursuivra jusqu'au milieu de traitement des erreurs. + +Les [error handlers](https://expressjs.com/en/guide/error-handling.html) d'express sont des middlewares qui sont définis avec une fonction qui accepte quatre paramètres. Notre gestionnaire d'erreur ressemble à ceci : + +```js +const errorHandler = (error, request, response, next) => { + console.error(error.message) + + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } + + next(error) +} + +// this has to be the last loaded middleware. +app.use(errorHandler) +``` + +Le gestionnaire d'erreur vérifie si l'erreur est une exception CastError, auquel cas nous savons que l'erreur a été causée par un id d'objet invalide pour Mongo. Dans cette situation, le gestionnaire d'erreur enverra une réponse au navigateur avec l'objet de réponse passé en paramètre. Dans toutes les autres situations d'erreur, le middleware transmet l'erreur au gestionnaire d'erreur Express par défaut. + +Notez que le middleware de gestion des erreurs doit être le dernier middleware chargé ! + +### L'ordre de chargement des middlewares + +L'ordre d'exécution des middlewares est le même que l'ordre dans lequel ils sont chargés dans Express avec la fonction _app.use_. Pour cette raison, il est important d'être prudent lors de la définition des middlewares. + +L'ordre correct est le suivant : + +```js +app.use(express.static('build')) +app.use(express.json()) +app.use(requestLogger) + +app.post('/api/notes', (request, response) => { + const body = request.body + // ... +}) + +const unknownEndpoint = (request, response) => { + response.status(404).send({ error: 'unknown endpoint' }) +} + +// handler of requests with unknown endpoint +app.use(unknownEndpoint) + +const errorHandler = (error, request, response, next) => { + // ... +} + +// handler of requests with result to errors +app.use(errorHandler) +``` + +Le middleware json-parser devrait être parmi les tout premiers middleware chargés dans Express. Si l'ordre était le suivant : + +```js +app.use(requestLogger) // request.body is undefined! + +app.post('/api/notes', (request, response) => { + // request.body is undefined! + const body = request.body + // ... +}) + +app.use(express.json()) +``` + +Les données JSON envoyées avec les requêtes HTTP ne seraient alors pas disponibles pour le middleware du logger ou le gestionnaire de route POST, puisque le _request.body_ serait _undefined_ à ce moment-là. + +Il est également important que le middleware de gestion des routes non prises en charge soit le dernier middleware chargé dans Express, juste avant le gestionnaire d'erreurs. + +Par exemple, l'ordre de chargement suivant causerait un problème : + +```js +const unknownEndpoint = (request, response) => { + response.status(404).send({ error: 'unknown endpoint' }) +} + +// handler of requests with unknown endpoint +app.use(unknownEndpoint) + +app.get('/api/notes', (request, response) => { + // ... +}) +``` + +Maintenant, le traitement des points de terminaison inconnus est ordonné avant le gestionnaire de requête HTTP. Puisque le gestionnaire de points de terminaison inconnus répond à toutes les demandes avec 404 unknown endpoint, aucune route ou middleware ne sera appelée après que la réponse ait été envoyée par le middleware de points de terminaison inconnus. La seule exception à cela est le gestionnaire d'erreur qui doit venir à la toute fin, après le gestionnaire de points de terminaison inconnus. + +### Autres opérations + +Ajoutons quelques fonctionnalités manquantes à notre application, notamment la suppression et la mise à jour d'une note individuelle. + +La façon la plus simple de supprimer une note de la base de données est d'utiliser la méthode [findByIdAndDelete](https://mongoosejs.com/docs/api/model.html#model_Model-findByIdAndDelete) : + +```js +app.delete('/api/notes/:id', (request, response, next) => { + Note.findByIdAndDelete(request.params.id) + .then(result => { + response.status(204).end() + }) + .catch(error => next(error)) +}) +``` + +Dans les deux cas de "réussite" de la suppression d'une ressource, le backend répond avec le code d'état 204 no content. Les deux cas différents sont la suppression d'une note qui existe, et la suppression d'une note qui n'existe pas dans la base de données. Le paramètre de callback _result_ pourrait être utilisé pour vérifier si une ressource a effectivement été supprimée, et nous pourrions utiliser cette information pour renvoyer des codes d'état différents pour les deux cas si nous le jugions nécessaire. Toute exception qui se produit est transmise au gestionnaire d'erreurs. + +Le changement de l'importance d'une note peut être facilement réalisé avec la méthode [findByIdAndUpdate](https://mongoosejs.com/docs/api/model.html#model_Model-findByIdAndUpdate). + +```js +app.put('/api/notes/:id', (request, response, next) => { + const body = request.body + + const note = { + content: body.content, + important: body.important, + } + + Note.findByIdAndUpdate(request.params.id, note, { new: true }) + .then(updatedNote => { + response.json(updatedNote) + }) + .catch(error => next(error)) +}) +``` + +Dans le code ci-dessus, nous permettons également de modifier le contenu de la note. Cependant, nous ne prendrons pas en charge la modification de la date de création pour des raisons évidentes. + +Remarquez que la méthode findByIdAndUpdate reçoit un objet JavaScript ordinaire comme paramètre, et non un nouvel objet note créé avec la fonction constructeur Note. + +Il existe un détail important concernant l'utilisation de la méthode findByIdAndUpdate. Par défaut, le paramètre updatedNote du gestionnaire d'événements reçoit le document original [sans les modifications](https://mongoosejs.com/docs/api/model.html#model_Model-findByIdAndUpdate). Nous avons ajouté le paramètre optionnel { new : true }, qui fera en sorte que notre gestionnaire d'événements soit appelé avec le nouveau document modifié au lieu de l'original. + +Après avoir testé le backend directement avec Postman et le client REST de VS Code, nous pouvons vérifier qu'il semble fonctionner. Le frontend semble également fonctionner avec le backend en utilisant la base de données. + +Vous pouvez trouver le code de notre application actuelle dans son intégralité dans la branche part3-5 de [ce dépôt GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-5). + +
    + +
    + +### Exercices 3.15.-3.18. + +#### 3.15 : Base de données du répertoire téléphonique, étape3 + +Modifiez le backend pour que la suppression des entrées du répertoire téléphonique soit répercutée dans la base de données. + +Vérifiez que le frontend fonctionne toujours après avoir fait les changements. + +#### 3.16 : Base de données du répertoire téléphonique, étape 4 + +Déplacer la gestion des erreurs de l'application vers un nouveau middleware de gestion des erreurs. + +#### 3.17* : Base de données du répertoire téléphonique, étape 5 + +Si l'utilisateur essaie de créer une nouvelle entrée dans le répertoire pour une personne dont le nom est déjà dans le répertoire, le frontend va essayer de mettre à jour le numéro de téléphone de l'entrée existante en faisant une requête HTTP PUT à l'URL unique de l'entrée. + +Modifiez le backend pour supporter cette requête. + +Vérifiez que le frontend fonctionne après avoir fait vos changements. + +#### 3.18* : Base de données du répertoire téléphonique étape6 + +Mettez également à jour la gestion des routes api/persons/:id et info pour utiliser la base de données, et vérifiez qu'elles fonctionnent directement avec le navigateur, Postman, ou le client REST de VS Code. + +L'inspection d'une entrée de répertoire individuelle depuis le navigateur devrait ressembler à ceci : + +![](../../images/3/49.png) + +
    diff --git a/src/content/3/fr/part3d.md b/src/content/3/fr/part3d.md new file mode 100644 index 00000000000..0ade0edcd26 --- /dev/null +++ b/src/content/3/fr/part3d.md @@ -0,0 +1,404 @@ +--- +mainImage: ../../../images/part-3.svg +part: 3 +letter: d +lang: fr +--- + +
    + + +Il y a généralement des contraintes que nous voulons appliquer aux données qui sont stockées dans la base de données de notre application. Notre application ne devrait pas accepter les notes dont la propriété content est manquante ou vide. La validité de la note est vérifiée dans le gestionnaire de route : + +```js +app.post('/api/notes', (request, response) => { + const body = request.body + // highlight-start + if (body.content === undefined) { + return response.status(400).json({ error: 'content missing' }) + } + // highlight-end + + // ... +}) +``` + + +Si la note ne possède pas la propriété content, nous répondons à la requête avec le code d'état 400 bad request. + + +Une façon plus intelligente de valider le format des données avant de les stocker dans la base de données, est d'utiliser la fonctionnalité [validation](https://mongoosejs.com/docs/validation.html) disponible dans Mongoose. + + +Nous pouvons définir des règles de validation spécifiques pour chaque champ du schéma : + +```js +const noteSchema = new mongoose.Schema({ + // highlight-start + content: { + type: String, + minLength: 5, + required: true + }, + date: { + type: Date, + required: true + }, + // highlight-end + important: Boolean +}) +``` + + +Le champ contenu doit désormais comporter au moins cinq caractères. Le champ date est défini comme obligatoire, ce qui signifie qu'il ne peut pas être manquant. La même contrainte est également appliquée au champ contenu, puisque la contrainte de longueur minimale permet au champ d'être manquant. Nous n'avons pas ajouté de contraintes au champ important, sa définition dans le schéma n'a donc pas changé. + + +Les validateurs minLength et required sont [built-in](https://mongoosejs.com/docs/validation.html#built-in-validators) et fournis par Mongoose. La fonctionnalité Mongoose [custom validator](https://mongoosejs.com/docs/validation.html#custom-validators) nous permet de créer de nouveaux validateurs, si aucun des validateurs intégrés ne couvre nos besoins. + + +Si nous essayons de stocker dans la base de données un objet qui ne respecte pas l'une des contraintes, l'opération déclenchera une exception. Modifions notre gestionnaire pour la création d'une nouvelle note afin qu'il transmette toute exception potentielle au middleware de gestion des erreurs : + +```js +app.post('/api/notes', (request, response, next) => { // highlight-line + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + date: new Date(), + }) + + note.save() + .then(savedNote => { + response.json(savedNote) + }) + .catch(error => next(error)) // highlight-line +}) +``` + + +Développons le gestionnaire d'erreurs pour traiter ces erreurs de validation : + +```js +const errorHandler = (error, request, response, next) => { + console.error(error.message) + + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } else if (error.name === 'ValidationError') { // highlight-line + return response.status(400).json({ error: error.message }) // highlight-line + } + + next(error) +} +``` + +Lorsque la validation d'un objet échoue, nous renvoyons le message d'erreur par défaut suivant de Mongoose : + +![](../../images/3/50.png) + +Nous remarquons que le backend a maintenant un problème : les validations ne sont pas effectuées lors de l'édition d'une note. +La [documentation](https://github.com/blakehaswell/mongoose-unique-validator#find--updates) explique quel est le problème, les validations ne sont pas exécutées par défaut lorsque findOneAndUpdate est exécuté. + +La correction est simple. Reformulons aussi un peu le code de la route : + +```js +app.put('/api/notes/:id', (request, response, next) => { + const { content, important } = request.body // highlight-line + + Note.findByIdAndUpdate( + request.params.id, + { content, important }, // highlight-line + { new: true, runValidators: true, context: 'query' } // highlight-line + ) + .then(updatedNote => { + response.json(updatedNote) + }) + .catch(error => next(error)) +}) +``` + +### Déploiement du backend de la base de données en production + +L'application devrait fonctionner presque telle quelle dans Heroku. Nous devons générer un nouveau build de production du frontend en raison des modifications que nous avons apportées à notre frontend. + +Les variables d'environnement définies dans dotenv ne seront utilisées que lorsque le backend n'est pas en mode production, c'est-à-dire Heroku. + +Nous avons défini les variables d'environnement pour le développement dans le fichier .env, mais la variable d'environnement qui définit l'URL de la base de données en production doit être définie sur Heroku avec la commande _heroku config:set_. + +```bash +heroku config:set MONGODB_URI=mongodb+srv://fullstack:secretpasswordhere@cluster0-ostce.mongodb.net/note-app?retryWrites=true +``` + + **NB:** si la commande provoque une erreur, donnez la valeur de MONGODB_URI entre apostrophes : + +```bash +heroku config:set MONGODB_URI='mongodb+srv://fullstack:secretpasswordhere@cluster0-ostce.mongodb.net/note-app?retryWrites=true' +``` + +L'application devrait maintenant fonctionner. Parfois, les choses ne se passent pas comme prévu. S'il y a des problèmes, les logsheroku seront là pour vous aider. Ma propre application n'a pas fonctionné après avoir effectué les changements. Les journaux montraient ce qui suit : + +![](../../images/3/51a.png) + +Pour une raison quelconque, l'URL de la base de données était indéfinie. La commande heroku config a révélé que j'avais accidentellement défini l'URL dans la variable d'environnement MONGO\_URL, alors que le code s'attendait à ce qu'elle soit dans MONGODB\_URI. + +Vous pouvez trouver le code de notre application actuelle dans son intégralité dans la branche part3-5 de [ce dépôt GitHub](https://github.com/fullstack-hy2019/part3-notes-backend/tree/part3-5). + +
    + +
    + +### Exercices 3.19.-3.21. + +#### 3.19* : Base de données du répertoire téléphonique, étape 7 + +Développez la validation de sorte que le nom stocké dans la base de données doive comporter au moins trois caractères. + +Développez le frontend pour qu'il affiche une forme de message d'erreur lorsqu'une erreur de validation se produit. La gestion des erreurs peut être implémentée en ajoutant un bloc catch comme indiqué ci-dessous : + +```js +personService + .create({ ... }) + .then(createdPerson => { + // ... + }) + .catch(error => { + // this is the way to access the error message + console.log(error.response.data.error) + }) +``` + +Vous pouvez afficher les messages d'erreur par défaut renvoyés par Mongoose, même s'ils ne sont pas aussi lisibles qu'ils pourraient l'être : + +![](../../images/3/56e.png) + +**NB:** Lors des opérations de mise à jour, les validateurs de Mongoose sont désactivés par défaut. [Lisez la documentation](https://mongoosejs.com/docs/validation.html) pour déterminer comment les activer. + +#### 3.20* : Base de données du répertoire téléphonique, étape 8 + +Ajoutez la validation à votre application de répertoire téléphonique, qui s'assurera que les numéros de téléphone sont de la bonne forme. Un numéro de téléphone doit +- avoir une longueur de 8 ou plus +- être formé de deux parties séparées par -, la première partie comporte deux ou trois chiffres et la seconde partie est également composée de chiffres + - par exemple, 09-1234556 et 040-22334455 sont des numéros de téléphone valides + - par exemple, 1234556, 1-22334455 et 10-22-334455 ne sont pas valides. + +Utilisez un [validateur personnalisé](https://mongoosejs.com/docs/validation.html#custom-validators) pour mettre en oeuvre la deuxième partie de la validation. + +Si une requête HTTP POST tente d'ajouter un nom qui se trouve déjà dans le répertoire, le serveur doit répondre avec un code d'état et un message d'erreur appropriés. + +#### 3.21 Déploiement du backend de la base de données en production + +Générez une nouvelle version "full stack" de l'application en créant un nouveau build de production du frontend, et copiez-le dans le référentiel du backend. Vérifiez que tout fonctionne localement en utilisant l'application entière à partir de l'adresse . + +Poussez la dernière version vers Heroku et vérifiez que tout fonctionne là aussi. + +
    + +
    + +### Lint + +Avant de passer à la partie suivante, nous allons jeter un coup d'oeil à un outil important appelé [lint](). Wikipedia dit ce qui suit à propos de lint : + +> Généralement, lint ou un linter est tout outil qui détecte et signale les erreurs dans les langages de programmation, y compris les erreurs stylistiques. Le terme de comportement de type lint est parfois appliqué au processus de signalisation de l'utilisation suspecte du langage. Les outils de type linter effectuent généralement une analyse statique du code source. + +Dans les langages compilés à typage statique comme Java, les IDE comme NetBeans peuvent signaler les erreurs dans le code, même celles qui sont plus que de simples erreurs de compilation. Des outils supplémentaires pour effectuer une [analyse statique](https://en.wikipedia.org/wiki/Static_program_analysis) comme [checkstyle](https://checkstyle.sourceforge.io), peuvent être utilisés pour étendre les capacités de l'IDE afin de signaler également les problèmes liés au style, comme l'indentation. + + +Dans l'univers JavaScript, le principal outil d'analyse statique, autrement dit de "linting", est [ESlint](https://eslint.org/). + +Installons ESlint comme une dépendance de développement au projet principal avec la commande : + +```bash +npm install eslint --save-dev +``` + +Après cela, nous pouvons initialiser une configuration ESlint par défaut avec la commande : + +```bash +npx eslint --init +``` + +Nous allons répondre à toutes les questions : + +![](../../images/3/52be.png) + +La configuration sera sauvegardée dans le fichier _.eslintrc.js_ : + +```js +module.exports = { + 'env': { + 'commonjs': true, + 'es2021': true, + 'node': true + }, + 'extends': 'eslint:recommended', + 'parserOptions': { + 'ecmaVersion': 'latest' + }, + 'rules': { + 'indent': [ + 'error', + 4 + ], + 'linebreak-style': [ + 'error', + 'unix' + ], + 'quotes': [ + 'error', + 'single' + ], + 'semi': [ + 'error', + 'never' + ] + } +} +``` + +Changeons immédiatement la règle concernant l'indentation, de sorte que le niveau d'indentation soit de deux espaces. + +```js +"indent": [ + "error", + 2 +], +``` + +L'inspection et la validation d'un fichier comme _index.js_ peuvent être effectuées avec la commande suivante : + +```bash +npx eslint index.js +``` + +Il est recommandé de créer un script _npm_ séparé pour le linting : + +```json +{ + // ... + "scripts": { + "start": "node index.js", + "dev": "nodemon index.js", + // ... + "lint": "eslint ." // highlight-line + }, + // ... +} +``` + +Maintenant, la commande _npm run lint_ vérifiera chaque fichier du projet. + + +Les fichiers du répertoire build seront également vérifiés lors de l'exécution de la commande. Nous ne voulons pas que cela se produise, et nous pouvons y parvenir en créant un fichier [.eslintignore](https://eslint.org/docs/user-guide/configuring#ignoring-files-and-directories) à la racine du projet avec le contenu suivant : + +```bash +build +``` + +Cela fait en sorte que le répertoire entier build ne soit pas vérifié par ESlint. + +Lint a beaucoup de choses à dire sur notre code : + +![](../../images/3/53ea.png) + +Ne corrigeons pas ces problèmes pour l'instant. + +Une meilleure alternative à l'exécution du linter depuis la ligne de commande est de configurer un eslint-plugin à l'éditeur, qui exécute le linter en continu. En utilisant le plugin, vous verrez immédiatement les erreurs dans votre code. Vous pouvez trouver plus d'informations sur le plugin ESLint de Visual Studio [ici](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint). + + +Le plugin VS Code ESlint soulignera les violations de style par une ligne rouge : + +![](../../images/3/54a.png) + + +Cela permet de repérer facilement les erreurs et de les corriger immédiatement. + + +ESlint dispose d'une vaste gamme de [règles](https://eslint.org/docs/rules/) qu'il est facile de mettre en oeuvre en modifiant le fichier .eslintrc.js. + + +Ajoutons la règle [eqeqeq](https://eslint.org/docs/rules/eqeqeq) qui nous avertit, si l'égalité est vérifiée avec autre chose que l'opérateur triple égal. La règle est ajoutée sous le champ rules dans le fichier de configuration. + +```js +{ + // ... + 'rules': { + // ... + 'eqeqeq': 'error', + }, +} +``` + +Pendant que nous y sommes, apportons quelques autres modifications aux règles. + +Empêchons les [espaces de fin](https://eslint.org/docs/rules/no-trailing-spaces) inutiles en fin de ligne, exigeons qu'[il y ait toujours un espace avant et après les accolades](https://eslint.org/docs/rules/object-curly-spacing), et exigeons également une utilisation cohérente des espaces dans les paramètres des fonctions flèches. + +```js +{ + // ... + 'rules': { + // ... + 'eqeqeq': 'error', + 'no-trailing-spaces': 'error', + 'object-curly-spacing': [ + 'error', 'always' + ], + 'arrow-spacing': [ + 'error', { 'before': true, 'after': true } + ] + }, +} +``` + + +Notre configuration par défaut utilise un ensemble de règles prédéterminées provenant de eslint:recommended : + +```bash +'extends': 'eslint:recommended', +``` + + +Cela inclut une règle qui met en garde contre les commandes _console.log_. La [désactivation](https://eslint.org/docs/user-guide/configuring#configuring-rules) d'une règle peut être accomplie en définissant sa "valeur" comme 0 dans le fichier de configuration. Faisons cela pour la règle no-console en attendant. + +```js +{ + // ... + 'rules': { + // ... + 'eqeqeq': 'error', + 'no-trailing-spaces': 'error', + 'object-curly-spacing': [ + 'error', 'always' + ], + 'arrow-spacing': [ + 'error', { 'before': true, 'after': true } + ], + 'no-console': 0 // highlight-line + }, +} +``` + +**NB** lorsque vous apportez des modifications au fichier .eslintrc.js, il est recommandé d'exécuter le linter depuis la ligne de commande. Cela permettra de vérifier que le fichier de configuration est correctement formaté : + +![](../../images/3/55.png) + + +Si quelque chose ne va pas dans votre fichier de configuration, le plugin lint peut se comporter de manière assez erratique. + +De nombreuses entreprises définissent des normes de codage qui sont appliquées dans toute l'organisation par le biais du fichier de configuration ESlint. Il n'est pas recommandé de réinventer la roue encore et encore, et cela peut être une bonne idée d'adopter une configuration prête à l'emploi du projet de quelqu'un d'autre dans le vôtre. Récemment, de nombreux projets ont adopté le [guide de style Javascript](https://github.com/airbnb/javascript) d'Airbnb en utilisant la configuration [ESlint](https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb) d'Airbnb. + +Vous pouvez trouver le code de notre application actuelle dans son intégralité dans la branche part3-7 de [ce dépôt GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-7). +
    + +
    + +### Exercice 3.22. + +#### 3.22 : configuration de Lint + +Ajoutez ESlint à votre application et corrigez tous les avertissements. + +C'était le dernier exercice de cette partie du cours. Il est temps de pousser votre code sur GitHub et de marquer tous vos exercices terminés sur le [système de soumission des exercices](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    diff --git a/src/content/3/ptbr/part3.md b/src/content/3/ptbr/part3.md new file mode 100644 index 00000000000..537ecd54743 --- /dev/null +++ b/src/content/3/ptbr/part3.md @@ -0,0 +1,14 @@ +--- +mainImage: ../../../images/part-3.svg +part: 3 +lang: ptbr +--- + +
    + +Nesta parte, focaremos no back-end, ou seja, na implementação de funcionalidades no lado do servidor. Implementaremos uma API REST simples em Node.js usando a biblioteca Express, e os dados da aplicação serão armazenados em um banco de dados MongoDB. Ao final desta parte, implantaremos nossa aplicação na Internet. + +Parte atualizada em 19 de janeiro de 2023 +- Instruções para a plataforma de hospedagem [https://render.com/](https://render.com/) adicionadas. + +
    diff --git a/src/content/3/ptbr/part3a.md b/src/content/3/ptbr/part3a.md new file mode 100644 index 00000000000..3a567323be6 --- /dev/null +++ b/src/content/3/ptbr/part3a.md @@ -0,0 +1,1026 @@ +--- +mainImage: ../../../images/part-3.svg +part: 3 +letter: a +lang: ptbr +--- + +
    + +Vamos focar no back-end nesta parte: ou seja, na implementação de funcionalidades no lado do servidor. + +Estaremos construindo nosso back-end utilizando [NodeJS](https://nodejs.org/en/), que é um ambiente de execução JavaScript baseado no motor JavaScript [Chrome V8](https://developers.google.com/v8/) do Google. + +O conteúdo desta parte do curso foi escrita com base na versão v18.13.0 do Node.js. Certifique-se de que a versão do seu Node é pelo menos tão nova quanto a versão utilizada aqui (você pode verificar a versão executando _node -v_ na linha de comando). + +Como mencionado na [Parte 1](/ptbr/part1/java_script), os navegadores ainda não suportam as novas funcionalidades de JavaScript, e é por isso que o código em execução no navegador deve ser transpilado com o [babel](https://babeljs.io/), por exemplo. Mas a situação é diferente com JavaScript em execução no back-end. A versão mais recente do Node suporta a maioria das últimas funcionalidades de JavaScript, então podemos usar as últimas funcionalidades sem ter que transpilar nosso código. + +Nosso objetivo é implementar um back-end que funcione com a aplicação de notas da [Parte 2](/ptbr/part2/). No entanto, vamos começar com o básico implementando um clássico programa "Olá, mundo!". + +**Observe** que nem todas as aplicações e exercícios nesta parte são aplicações React, e não usaremos o utilitário create-react-app para inicializar o projeto para esta aplicação. + +Já tínhamos mencionado o [npm](/ptbr/part2/obtendo_dados_do_servidor#npm) na Parte 2, que é uma ferramenta usada para gerenciar pacotes JavaScript. Na verdade, o npm é originário do ecossistema Node. + +Vamos navegar até um diretório apropriado e criar um novo modelo para nossa aplicação com o comando _npm init_. Vamos responder as perguntas apresentadas pelo utilitário, e o resultado será um arquivo package.json gerado automaticamente na raiz do projeto que contém as informações do projeto. + +```json +{ + "name": "backend", + "version": "0.0.1", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Matti Luukkainen", + "license": "MIT" +} +``` + +O arquivo define, por exemplo, que o ponto de entrada da aplicação é o arquivo index.js. + +Vamos fazer uma pequena alteração no objeto scripts: + +```bash +{ + // ... + "scripts": { + "start": "node index.js", // highlight-line + "test": "echo \"Error: no test specified\" && exit 1" + }, + // ... +} +``` + +Agora, vamos criar a primeira versão da nossa aplicação adicionando um arquivo index.js à raiz do projeto com o seguinte código: + +```js +console.log('hello world') +``` + +Podemos executar o programa diretamente com o Node a partir da linha de comando: + +```bash +node index.js +``` + +Ou podemos executá-lo como um [script npm](https://docs.npmjs.com/misc/scripts): + +```bash +npm start +``` + +O script npm start funciona porque o definimos no arquivo package.json: + +```bash +{ + // ... + "scripts": { + "start": "node index.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + // ... +} +``` + +Embora a execução do projeto funcione quando ele é iniciado chamando _node index.js_ a partir da linha de comando, é costume de projetos npm executar tarefas como scripts npm. + +Por padrão, o arquivo package.json também define outro script npm comumente usado chamado npm test. Como nosso projeto ainda não possui uma biblioteca de testes, o comando _npm test_ apenas executa o seguinte comando: + +```bash +echo "Error: no test specified" && exit 1 +``` + +### Um servidor web simples + +Vamos transformar a aplicação em um servidor web editando o arquivo _index.js_ da seguinte maneira: + +```js +const http = require('http') + +const app = http.createServer((request, response) => { + response.writeHead(200, { 'Content-Type': 'text/plain' }) + response.end('Hello World') +}) + +const PORT = 3001 +app.listen(PORT) +console.log(`Server running on port ${PORT}`) +``` + +Uma vez que a aplicação está em execução, a seguinte mensagem é impressa no console: + +```bash +Server running on port 3001 +``` + +Podemos abrir nossa humilde aplicação no navegador entrando no endereço : + +![captura de tela do programa 'hello world'](../../images/3/1.png) + +O servidor funciona da mesma maneira independentemente da última parte da URL, por isso o endereço exibirá o mesmo conteúdo. + +**Obs.:** se a porta 3001 já estiver sendo usada por alguma outra aplicação, iniciar o servidor resultará na seguinte mensagem de erro: + +```bash +➜ hello npm start + +> hello@1.0.0 start /Users/mluukkai/opetus/_2019fullstack-code/part3/hello +> node index.js + +Server running on port 3001 +events.js:167 + throw er; // Unhandled 'error' event + ^ + +Error: listen EADDRINUSE :::3001 + at Server.setupListenHandle [as _listen2] (net.js:1330:14) + at listenInCluster (net.js:1378:12) +``` + +Você tem duas opções: ou encerre a aplicação usando a porta 3001 (o json-server na última parte do material estava usando a porta 3001), ou use uma porta diferente para esta aplicação. + +Vamos olhar mais de perto na primeira linha do código: + +```js +const http = require('http') +``` + +Na primeira linha, a aplicação importa o módulo integrado [web server](https://nodejs.org/docs/latest-v8.x/api/http.html) do Node. Isso é praticamente o que já estávamos fazendo em nosso código no lado do navegador, mas com uma sintaxe um pouco diferente: + +```js +import http from 'http' +``` + +Hoje em dia, o código que roda no navegador usa módulos ES6. Os módulos são definidos com um [export](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export) e usados com um [import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import). + +No entanto, Node.js usa módulos chamados [CommonJS](https://en.wikipedia.org/wiki/CommonJS). A razão para isso é que o ecossistema Node teve a necessidade de usar módulos muito antes de JavaScript os suportar na especificação da linguagem. Node agora também suporta o uso de módulos ES6, mas como o suporte ainda [não é totalmente perfeito](https://nodejs.org/api/esm.html#modules-ecmascript-modules), vamos aderir aos módulos CommonJS. + +Os módulos CommonJS funcionam quase exatamente como os módulos ES6, pelo menos no que diz respeito às nossas necessidades neste curso. + +O próximo trecho em nosso código é assim: + +```js +const app = http.createServer((request, response) => { + response.writeHead(200, { 'Content-Type': 'text/plain' }) + response.end('Hello World') +}) +``` + +O código usa o método _createServer_ ("criarServidor") do módulo [http](https://nodejs.org/docs/latest-v8.x/api/http.html) para criar um novo servidor web. Um gerenciador de evento é registrado no servidor que é chamado sempre que uma requisição HTTP é feita para o endereço http://localhost:3001 do servidor. + +A requisição é respondida com o código de status 200, com o cabeçalho Content-Type definido como text/plain, e o conteúdo do site a ser retornado definido como Hello World. + +As últimas linhas vinculam o servidor http atribuído à variável _app_ para ouvir as requisições HTTP enviadas à porta 3001: + +```js +const PORT = 3001 +app.listen(PORT) +console.log(`Server running on port ${PORT}`) +``` + +O objetivo principal do servidor back-end neste curso é oferecer dados brutos em formato JSON para o front-end. Por esse motivo, vamos imediatamente alterar nosso servidor para retornar uma lista codificada de notas no formato JSON: + +```js +const http = require('http') + +// highlight-start +let notes = [ + { + id: 1, + content: "HTML is easy", + important: true + }, + { + id: 2, + content: "Browser can execute only JavaScript", + important: false + }, + { + id: 3, + content: "GET and POST are the most important methods of HTTP protocol", + important: true + } +] + +const app = http.createServer((request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify(notes)) +}) +// highlight-end + +const PORT = 3001 +app.listen(PORT) +console.log(`Server running on port ${PORT}`) +``` + +Vamos reiniciar o servidor (você pode encerrá-lo pressionando _ctrl + c_ no console) e atualizar o navegador. + +O valor application/json no cabeçalho Content-Type informa o receptor de que os dados estão no formato JSON. O array _notes_ é transformado em JSON com o método JSON.stringify(notes). + +Quando abrimos o navegador, o formato de exibição das notas é o mesmo que vimos na [Parte 2](/ptbr/part2/obtendo_dados_do_servidor) quando usamos o [json-server](https://github.com/typicode/json-server) para servir a lista de notas: + +![dados das notas JSON formatados](../../images/3/2new.png) + +### Express + +É possível implementar nosso código do servidor diretamente com o servidor web [http](https://nodejs.org/docs/latest-v8.x/api/http.html) integrado do Node. No entanto, isso é cansativo, especialmente quando a aplicação fica maior. + +Muitas bibliotecas foram desenvolvidas para facilitar o desenvolvimento do lado do servidor com Node, oferecendo uma interface mais agradável para trabalhar com o módulo integrado http. Essas bibliotecas visam fornecer uma melhor abstração para casos de uso geral que normalmente exigimos para construir um servidor back-end. De longe, a biblioteca mais popular destinada a esse fim é o [Express](http://expressjs.com). + +Vamos usar o Express definindo-o como uma dependência do projeto com o comando: + +```bash +npm install express +``` + +A dependência também é adicionada ao nosso arquivo package.json: + +```json +{ + // ... + "dependencies": { + "express": "^4.18.2" + } +} +``` + +O código-fonte da dependência é instalado no diretório node_modules localizado na raiz do projeto. Além do Express, você pode encontrar uma grande quantidade de outras dependências no diretório: + +![listagem 'ls' das dependências no diretório](../../images/3/4.png) + +Essas são as dependências da biblioteca Express e as dependências de todas as suas dependências e assim por diante. Elas são chamadas de [dependências transitivas](https://lexi-lambda.github.io/blog/2016/08/24/understanding-the-npm-dependency-model/) (transitive dependencies) do nosso projeto. + +A versão 4.18.2 da biblioteca Express foi instalada em nosso projeto. O que significa esse acento circunflexo na frente do número de versão em package.json? + +```json +"express": "^4.18.2" +``` + +O modelo de versionamento usado no npm é chamado de [versionamento semântico](https://docs.npmjs.com/getting-started/semantic-versioning) (semantic versioning). + +O acento circunflexo na frente de ^4.18.2 significa que se e quando as dependências de um projeto forem atualizadas, a versão instalada do Express será pelo menos 4.18.2. No entanto, a versão instalada do Express também pode ter um número de patch maior (o último número) ou um número minor maior (o número do meio). A versão principal da biblioteca indicada pelo primeiro número major deve ser a mesma. + +Podemos atualizar as dependências do projeto com o comando: + +```bash +npm update +``` + +Igualmente, se começarmos a trabalhar no projeto em outro computador, podemos instalar todas as dependências atualizadas do projeto definidas em package.json executando comando a seguir no diretório raiz do projeto: + +```bash +npm install +``` + +Se o número major de uma dependência não mudar, então as novas versões devem ser [compatíveis com versões anteriores](https://en.wikipedia.org/wiki/Backward_compatibility). Isso significa que se nossa aplicação vier a usar a versão 4.99.175 do Express no futuro, então todo o código implementado nesta parte ainda terá que funcionar sem a necessidade de alterações no código. Em contraste, a futura versão 5.0.0 do Express [pode conter](https://expressjs.com/en/guide/migrating-5.html) alterações que farão com que nossa aplicação não funcione mais. + +### Web e Express + +Vamos voltar à nossa aplicação e fazer as seguintes alterações: + +```js +const express = require('express') +const app = express() + +let notes = [ + ... +] + +app.get('/', (request, response) => { + response.send('

    Hello World!

    ') +}) + +app.get('/api/notes', (request, response) => { + response.json(notes) +}) + +const PORT = 3001 +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +``` + +Para colocar a nova versão de nossa aplicação em uso, temos que reiniciar a aplicação. + +A aplicação não mudou muito. Logo no início do nosso código, importamos o _express_ que desta vez é uma função usada para criar uma aplicação express armazenada na variável _app_: + +```js +const express = require('express') +const app = express() +``` + +Em seguida, definimos duas rotas para a aplicação. A primeira define um gerenciador de evento que é usado para lidar com requisições HTTP GET feitas na raiz / da aplicação: + +```js +app.get('/', (request, response) => { + response.send('

    Hello World!

    ') +}) +``` + +A função de gerência de evento aceita dois parâmetros. O primeiro parâmetro [request](http://expressjs.com/en/4x/api.html#req) (requisição) contém todas as informações da requisição HTTP, e o segundo parâmetro [response](http://expressjs.com/en/4x/api.html#res) (resposta) é usado para definir como a requisição é respondida. + +Em nosso código, a requisição é respondida usando o método [send](http://expressjs.com/en/4x/api.html#res.send) (enviar) do objeto _response_. Ao chamar o método, o servidor responde à requisição HTTP enviando uma resposta contendo a string \

    Hello World!\

    que foi passada para o método _send_. Como o parâmetro é uma string, o express define automaticamente o valor do cabeçalho Content-Type como text/html. O código de status da resposta é definido como 200 por padrão. + +Podemos verificar isso na guia Rede nas Ferramentas do Desenvolvedor: + +![guia Rede nas Ferramentas do Desenvolvedor](../../images/3/5.png) + +A segunda rota define um gerenciador de evento que lida com requisições HTTP GET feitas no caminho notes da aplicação: + +```js +app.get('/api/notes', (request, response) => { + response.json(notes) +}) +``` + +A requisição é respondida com o método [json](http://expressjs.com/en/4x/api.html#res.json) do objeto _response_. Quando chamado, o método enviará o array __notes__ que foi passado como uma string formatada em JSON. O express define automaticamente o cabeçalho Content-Type com o valor apropriado de application/json. + +![api/notes fornece os dados JSON formatados novamente](../../images/3/6new.png) + +Em seguida, vamos dar uma olhada rápida nos dados enviados no formato JSON. + +Na versão anterior do código em que estávamos usando apenas Node, tivemos que transformar os dados no formato JSON com o método _JSON.stringify_: + +```js +response.end(JSON.stringify(notes)) +``` + +Com Express isso se torna desnecessário, porque essa transformação acontece automaticamente. + +Vale ressaltar que [JSON](https://en.wikipedia.org/wiki/JSON) (JavaScript Object Notation [Notação de Objeto JavaScript]) é uma string e não um objeto JavaScript como o valor que foi atribuído a _notes_. + +O experimento mostrado abaixo ilustra esse ponto: + +![terminal do node demonstrando que 'json' é do tipo string](../../assets/3/5.png) + +O experimento acima foi feito no [node-repl](https://nodejs.org/docs/latest-v8.x/api/repl.html) interativo. Você pode iniciar o node-repl interativo digitando _node_ no terminal. O repl é particularmente útil para testar como os comandos funcionam enquanto você está escrevendo o código da aplicação. Eu mais que recomendo o uso dessa ferramenta! + +### nodemon + +Se fizermos alterações no código da aplicação, precisamos reiniciá-la para ver as alterações. Reiniciamos a aplicação primeiro encerrando-a pressionando _ctrl + c_ e depois reiniciando-a. Se compararmos isso ao conveniente fluxo de trabalho em React, em que o navegador é recarregado automaticamente após as alterações serem feitas, parece até um pouco trabalhoso. + +A solução para esse problema é o [nodemon](https://github.com/remy/nodemon): + +> nodemon irá monitorar os arquivos no diretório em que ele foi iniciado, e se houver alguma alteração nos arquivos, o nodemon reiniciará automaticamente sua aplicação Node. + +Vamos instalar o nodemon definindo-o como uma dependência de desenvolvimento (development dependency) com o comando: + +```bash +npm install --save-dev nodemon +``` + +O conteúdo do arquivo package.json também foi alterado: + +```json +{ + //... + "dependencies": { + "express": "^4.18.2" + }, + "devDependencies": { + "nodemon": "^2.0.20" + } +} +``` + +Se você usou acidentalmente o comando errado e a dependência nodemon foi adicionada em "dependencies" em vez de "devDependencies", altere manualmente o conteúdo de package.json para que corresponda ao que é mostrado acima. + +Por dependências de desenvolvimento, estamos nos referindo a ferramentas que são necessárias apenas durante o desenvolvimento da aplicação, por exemplo, para testar ou reiniciar automaticamente a aplicação, como o nodemon. + +Essas dependências de desenvolvimento não são necessárias quando a aplicação é executada na fase de produção em um servidor de produção (Fly.io ou Heroku, por exemplo). + +Podemos iniciar nossa aplicação com o nodemon assim: + +```bash +node_modules/.bin/nodemon index.js +``` + +Alterações no código da aplicação agora fazem com que o servidor seja reiniciado automaticamente. Vale ressaltar que, embora o servidor back-end seja reiniciado automaticamente, o navegador ainda deve ser atualizado manualmente. Isso ocorre porque, ao contrário do que acontece ao trabalhar em React, não temos a funcionalidade [hot reload](https://gaearon.github.io/react-hot-loader/getstarted/) (grosso modo, "recarga rápida") necessária para recarregar automaticamente o navegador. + +O comando é longo e bastante desagradável, portanto, vamos definir um script npm dedicado para ele no arquivo package.json: + +```bash +{ + // .. + "scripts": { + "start": "node index.js", + "dev": "nodemon index.js", // highlight-line + "test": "echo \"Error: no test specified\" && exit 1" + }, + // .. +} +``` + +Não é necessário especificar no script o caminho node\_modules/.bin/nodemon para o nodemon, pois o npm pesquisa automaticamente pelo arquivo nesse diretório. + +Agora podemos iniciar o servidor no modo de desenvolvimento com o comando: + +```bash +npm run dev +``` + +Ao contrário dos scripts start e test, também temos que adicionar run ao comando. + +### REST + +Vamos expandir nossa aplicação para que ela forneça a mesma API HTTP RESTful do [json-server](https://github.com/typicode/json-server#routes). + +Representational State Transfer, também conhecido como REST, foi introduzido em 2000 na [dissertação de PhD](https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm) de Roy Fielding. REST é um estilo arquitetural destinado a construir aplicações web escaláveis. + +Não vamos aprofundar a definição de REST de Fielding ou gastar tempo ponderando sobre o que é ou não RESTful. Em vez disso, adotamos uma visão mais [restrita](https://en.wikipedia.org/wiki/Representational_state_transfer#Applied_to_web_services), preocupando-nos apenas com a típica compreensão de APIs RESTful em aplicações web. A definição original de REST não se limita somente à aplicações web. + +Mencionamos na [parte anterior](/ptbr/part2/alterando_dados_no_servidor#rest) que coisas singulares, como as notas no caso da nossa aplicação, são chamadas de recursos no modo RESTful de pensar. Cada recurso tem uma URL associada que é o endereço exclusivo do recurso. + +Uma convenção para criar endereços exclusivos é combinar o nome do tipo de recurso com o identificador exclusivo do recurso. + +Vamos assumir que a URL raiz do nosso serviço é www.example.com/api. + +Se definirmos o tipo de recurso da nota como notes, então o endereço de um recurso de "note" com o identificador 10 tem o endereço exclusivo www.example.com/api/notes/10. + +A URL para toda a coleção de todos os recursos de notes é www.example.com/api/notes. + +Podemos executar diferentes operações em recursos. A operação a ser executada é definida pelo verbo HTTP: + +| URL | verbo | funcionalidade | +| --------------------- | ------------------- | ---------------------------------------------------------------------| +| notes/10 | GET | busca um único recurso | +| notes | GET | busca todos os recursos na coleção | +| notes | POST | cria um novo recurso baseado nos dados requisitados | +| notes/10 | DELETE | exclui um recurso identificado | +| notes/10 | PUT | substitui todo o recurso identificado com os dados requisitados | +| notes/10 | PATCH | substitui uma parte do recurso identificado com os dados requisitados| +| | | | + +É assim que conseguimos definir aproximadamente o que REST chama de [interface uniforme](https://en.wikipedia.org/wiki/Representational_state_transfer#Architectural_constraints) (uniform interface), que significa uma maneira consistente de definir interfaces que tornam possível a cooperação entre sistemas. + +Essa forma de interpretação do modelo REST se enquadra no [segundo nível de maturidade RESTful](https://martinfowler.com/articles/richardsonMaturityModel.html) (second level of RESTful maturity) no Modelo de Maturidade de Richardson. De acordo com a definição fornecida por Roy Fielding, ainda não definimos o que é uma [API REST](http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven). Na verdade, a grande maioria das APIs "REST" do mundo não atende aos critérios originais de Fielding delineados em sua dissertação. + +Em alguns lugares (ver, por exemplo, [Richardson, Ruby: RESTful Web Services](http://shop.oreilly.com/product/9780596529260.do)), verá nosso modelo para uma API [CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete) simples será referenciado como um exemplo de [arquitetura orientada a recursos](https://en.wikipedia.org/wiki/Resource-oriented_architecture) (resource-oriented architecture) em vez de REST. Vamos evitar ficar presos discutindo semântica e, em vez disso, voltar a trabalhar em nossa aplicação. + +### Buscando um único recurso + +Vamos expandir nossa aplicação para que ela ofereça uma interface REST para operar em notas individuais. Primeiro, vamos criar uma [rota](http://expressjs.com/en/guide/routing.html) para buscar um único recurso. + +O endereço único que usaremos para uma nota individual é na forma notes/10, onde o número no final refere-se ao número de identificação único da nota. + +Podemos definir [parâmetros](http://expressjs.com/en/guide/routing.html#route-parameters) para rotas no Express usando a sintaxe de dois-pontos: + +```js +app.get('/api/notes/:id', (request, response) => { + const id = request.params.id + const note = notes.find(note => note.id === id) + response.json(note) +}) +``` + +Agora, app.get('/api/notes/:id', ...) gerenciará todas as requisições HTTP GET que estão na forma /api/notes/X, onde X é uma string arbitrária. + +O parâmetro id na rota de uma requisição pode ser acessado por meio do objeto [request](http://expressjs.com/en/api.html#req): + +```js +const id = request.params.id +``` + +O agora familiar método de arrays _find_ é usado para encontrar a nota com um ID que corresponde ao parâmetro. A nota é então retornada ao remetente da requisição. + +Quando testamos nossa aplicação acessando em nosso navegador, percebemos que ela não parece funcionar, pois o navegador exibe uma página vazia. Isso não é surpresa para nós, desenvolvedores de software, pois é hora de depurar. + +Adicionar comandos console.log em nosso código já é um truque comprovado pelo tempo: + +```js +app.get('/api/notes/:id', (request, response) => { + const id = request.params.id + console.log(id) + const note = notes.find(note => note.id === id) + console.log(note) + response.json(note) +}) +``` + +Quando visitamos novamente o endereço no navegador, o console — que é o terminal (neste caso) — exibirá o seguinte: + +![o terminal exibe 1 e depois 'undefined'](../../images/3/8.png) + +O parâmetro de id da rota é passado para nossa aplicação, mas o método _find_ não encontra uma nota correspondente. + +Para aprofundar nossa investigação, também adicionamos um _console.log_ dentro da função de comparação passada para o método _find_. Para fazer isso, temos que nos livrar da sintaxe de função de seta compactada note => note.id === id, e usar a sintaxe com uma declaração explícita de retorno: + +```js +app.get('/api/notes/:id', (request, response) => { + const id = request.params.id + const note = notes.find(note => { + console.log(note.id, typeof note.id, id, typeof id, note.id === id) + return note.id === id + }) + console.log(note) + response.json(note) +}) +``` + +Quando visitamos a URL novamente no navegador, cada chamada à função de comparação imprime algumas coisas diferentes no console. A saída do console é a seguinte: + +``` +1 'number' '1' 'string' false +2 'number' '1' 'string' false +3 'number' '1' 'string' false +``` + +A causa do bug fica clara. A variável _id_ contém uma string '1', enquanto os ids das notas são números inteiros. Em JavaScript, o comparador de igualdade estrita === considera que todos os valores de tipos diferentes não são iguais por padrão, o que significa que 1 não é igual a '1'. + +Vamos corrigir o problema mudando o parâmetro id de uma string para um [Number](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Number) (construtor Number): + +```js +app.get('/api/notes/:id', (request, response) => { + const id = Number(request.params.id) // highlight-line + const note = notes.find(note => note.id === id) + response.json(note) +}) +``` + +Agora, a busca de um recurso individual funciona. + +![api/notes/1 traz uma única nota como JSON](../../images/3/9new.png) + +No entanto, há outro problema com nossa aplicação. + +Se procurarmos uma nota com um id que não existe, o servidor responde com: + +![ferramentas do desenvolvedor mostrando '200 and content-length 0'](../../images/3/10ea.png) + +O código de status HTTP retornado é 200, o que significa que a resposta teve sucesso. Não são retornados dados com a resposta, uma vez que o valor do cabeçalho content-length é 0, e o mesmo pode ser verificado no navegador. + +A razão para esse comportamento é que a variável note é definida como "undefined" se nenhuma nota correspondente for encontrada. A situação precisa ser gerenciada no servidor de forma correta. Se nenhuma nota for encontrada, o servidor deve responder com o código de status [404 not found](https://www.rfc-editor.org/rfc/rfc9110.html#name-404-not-found) ("404 não encontrado(a)") em vez de 200. + +Vamos fazer a seguinte alteração em nosso código: + +```js +app.get('/api/notes/:id', (request, response) => { + const id = Number(request.params.id) + const note = notes.find(note => note.id === id) + + // highlight-start + if (note) { + response.json(note) + } else { + response.status(404).end() + } + // highlight-end +}) +``` + +Como nenhum dado está anexado à resposta, usamos o método [status](http://expressjs.com/en/4x/api.html#res.status) para definir o status e o método [end](http://expressjs.com/en/4x/api.html#res.end) para responder à requisição sem enviar nenhum dado. + +A condição if aproveita o fato de que todos os objetos JavaScript são [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy) (verdade/verdadeiro), o que significa que eles avaliam como verdadeiros em uma operação de comparação. No entanto, _undefined_ é [falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy) (falso/falsidade), o que significa que ele avaliará como falso. + +Nossa aplicação funciona e envia o código de status de erro se nenhuma nota for encontrada. No entanto, a aplicação não informa nada ao usuário — como as aplicações web normalmente fazem — quando visitamos uma página que não existe. Não precisamos exibir nada no navegador porque as APIs REST são interfaces destinadas ao uso programático, e o código de status de erro já é o necessário para o caso. + +De qualquer forma, é possível dar uma pista sobre a razão para enviar um erro 404 [substituindo a mensagem padrão NOT FOUND](https://stackoverflow.com/questions/14154337/how-to-send-a-custom-http-status-message-in-node-express/36507614#36507614). + +### Excluindo recursos + +A seguir, vamos implementar uma rota para excluir recursos. A exclusão ocorre fazendo uma requisição HTTP DELETE para a URL do recurso: + +```js +app.delete('/api/notes/:id', (request, response) => { + const id = Number(request.params.id) + notes = notes.filter(note => note.id !== id) + + response.status(204).end() +}) +``` + +Se a exclusão do recurso for bem-sucedida, ou seja, se a nota existir e for removida, respondemos à requisição com o código de status [204 no content](https://www.rfc-editor.org/rfc/rfc9110.html#name-204-no-content) ("200 nenhum conteúdo") e não retornamos nenhum dado com a resposta. + +Não há consenso sobre qual código de status deve ser retornado para uma requisição DELETE se o recurso não existir. As únicas duas opções são 204 e 404. Para simplificar, nossa aplicação responderá com 204 em ambos os casos. + +### Postman + +Então, como testar a operação de exclusão? As requisições HTTP GET são fáceis de fazer a partir do navegador. Poderíamos escrever algum JavaScript para testar a exclusão, mas escrever código de teste nem sempre é a melhor solução em todas as situações. + +Existem muitas ferramentas para tornar mais fácil a realização de testes em back-ends. Uma delas é o programa de linha de comando [curl](https://curl.haxx.se). No entanto, em vez do curl, vamos dar uma olhada em como usar o [Postman](https://www.postman.com) para testar a aplicação. + +Vamos baixar [deste site](https://www.postman.com/downloads/) o cliente desktop do Postman e testá-lo: + +![captura de tela do postman na api/notes/2](../../images/3/11x.png) + +É bastante fácil usar o Postman nesta situação. É suficiente definir a URL e selecionar o tipo de requisição correta (DELETE). + +O servidor back-end parece responder corretamente. Ao fazer uma requisição HTTP GET para , vemos que a nota com o id 2 não está mais na lista, o que indica que a exclusão foi bem-sucedida. + +Como as notas na aplicação são salvas apenas na memória, a lista de notas retornará ao seu estado original quando reiniciarmos a aplicação. + +### O cliente REST do Visual Studio Code + +Se quiser o usar o Visual Studio Code, é possível utilizar o plugin VS Code [REST client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) em vez do Postman. + +É muito fácil usar o plugin depois de instalado. Criamos um diretório na raiz da aplicação chamado requests. Salvamos todas as requisições do cliente REST no diretório como arquivos que terminam com a extensão .rest. + +Vamos criar um novo arquivo get\_all\_notes.rest e definir a requisição que busca todas as notas. + +![arquivo rest que obtêm todas as notas com a requisição GET](../../images/3/12ea.png) + +Ao clicar no texto Send Request, o cliente REST executará a requisição HTTP e a resposta do servidor será aberta no editor. + +![resposta do vs code da requisição Get](../../images/3/13new.png) + +### O cliente HTTP do WebStorm + +Se você usar o *IntelliJ WebStorm*, é possível fazer um procedimento semelhante com o Cliente HTTP integrado. Crie um novo arquivo com extensão `.rest` e o editor exibirá suas opções para criar e executar suas requisições. Saiba mais sobre o processo seguindo [este guia](https://www.jetbrains.com/help/webstorm/http-client-in-product-code-editor.html). + +### Recebendo dados + +Em seguida, vamos implementar a funcionalidade de adicionar novas notas ao servidor. É possível adicionar uma nota fazendo uma requisição HTTP POST para o endereço http://localhost:3001/api/notes e enviando todas as informações para a nova nota no [corpo](https://www.w3.org/Protocols/rfc2616/rfc2616-sec7.html#sec7) (body) da requisição em formato JSON. + +Para que possamos acessar os dados facilmente, precisamos da ajuda do [json-parser](https://expressjs.com/en/api.html) do Express, que é usado com o comando _app.use(express.json())_. + +Vamos ativar o json-parser e implementar um gerenciador inicial para lidar com requisições HTTP POST: + +```js +const express = require('express') +const app = express() + +app.use(express.json()) // highlight-line + +//... + +// highlight-start +app.post('/api/notes', (request, response) => { + const note = request.body + console.log(note) + + response.json(note) +}) +// highlight-end +``` + +A função do gerenciador de evento pode acessar os dados da propriedade body do objeto _request_. + +Sem o json-parser, a propriedade body seria indefinida. O json-parser funciona de forma que ele pega os dados JSON de uma requisição, transforma-os em um objeto JavaScript e, em seguida, anexa-os à propriedade body do objeto _request_ antes do gerenciador de rota ser chamado. + +Por enquanto, a aplicação não faz nada com os dados recebidos, exceto imprimi-los no console e enviá-los de volta na resposta. + +Antes de implementarmos o restante da lógica da aplicação, vamos verificar no Postman se os dados são recebidos pelo servidor. Além de definir a URL e o tipo de requisição no Postman, também temos que definir os dados enviados no body: + +![postman - POST em api/notes com o conteúdo do POST](../../images/3/14new.png) + +A aplicação imprime no console os dados que enviamos na requisição: + +![terminal imprimindo o conteúdo provido no postman](../../images/3/15new.png) + +**Obs.:** Mantenha o terminal visível o tempo todo enquanto a aplicação estiver sendo executada quando estiver trabalhando no back-end. Graças ao Nodemon, quaisquer alterações que fizermos no código reiniciarão a aplicação. Se você prestar atenção no console, poderá identificar imediatamente os erros que ocorrem na aplicação: + +![erro do nodemon: 'typing requre not defined'](../../images/3/16.png) + +Da mesma forma, é útil verificar o console para garantir que o back-end está se comportando da forma que esperamos em diferentes situações, como quando enviamos dados com uma requisição HTTP POST. Naturalmente, é uma boa ideia adicionar muitos comandos console.log ao código enquanto a aplicação ainda estiver sendo desenvolvida. + +Uma possível causa de problemas é um cabeçalho Content-Type definido incorretamente em requisições. Isso pode acontecer com o Postman se o tipo body não estiver definido corretamente: + +![postman com o texto definido como 'content-type'](../../images/3/17new.png) + +O cabeçalho Content-Type é definido como text/plain: + +![postman mostrando cabeçalhos and content-type como 'text/plain'](../../images/3/18new.png) + +O servidor parece receber apenas um objeto vazio: + +![saída do nodemon mostrando chaves vazias](../../images/3/19.png) + +O servidor não será capaz de analisar corretamente os dados sem o valor correto no cabeçalho. Ele nem tentará adivinhar o formato dos dados, já que há uma [quantidade enorme](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) de potenciais Content-Types. + +Se você estiver usando o VS Code, deverá instalar o cliente REST do capítulo anterior agora, se ainda não tiver instalado. A requisição POST pode ser enviada com o cliente REST assim: + +![exemplo de requisição post no vscode com dados JSON](../../images/3/20new.png) + +Criamos um novo arquivo create\_note.rest para a requisição. A requisição é formatada de acordo com as [instruções da documentação](https://github.com/Huachao/vscode-restclient/blob/master/README.md#usage). + +Uma vantagem que o cliente REST tem sobre o Postman é que as requisições estão disponíveis convenientemente na raiz do repositório do projeto e podem ser distribuídas para todos na equipe de desenvolvimento. Você também pode adicionar várias requisições no mesmo arquivo usando separadores `###`: + +``` +GET http://localhost:3001/api/notes/ + +### +POST http://localhost:3001/api/notes/ HTTP/1.1 +content-type: application/json + +{ + "name": "sample", + "time": "Wed, 21 Oct 2015 18:27:50 GMT" +} +``` + +O Postman também permite que os usuários salvem requisições, mas a situação pode ficar bastante caótica, especialmente quando você está trabalhando em vários projetos não relacionados. + +> **Observação importante** +> +> Às vezes, ao depurar, é possível que você queira descobrir quais cabeçalhos foram definidos na requisição HTTP. Uma maneira de fazer isso é através do método [get](http://expressjs.com/en/4x/api.html#req.get) do objeto _request_, que pode ser usado para obter o valor de um único cabeçalho. O objeto _request_ também possui a propriedade headers, que contém todos os cabeçalhos de uma requisição específica. +> + +> Podem ocorrer problemas com o cliente REST do VS Code se você adicionar acidentalmente uma linha vazia entre a linha superior e a linha que especifica os cabeçalhos HTTP. Nessa situação, o cliente REST interpreta como se todos os cabeçalhos estivessem vazios, o que faz com que o servidor back-end não saiba que os dados que recebeu estão no formato JSON. +> + +Você será capaz de identificar esse cabeçalho faltando Content-Type se em algum momento no seu código você imprimir todos os cabeçalhos da requisição com o comando _console.log(request.headers)_. + +Vamos voltar para a aplicação. Depois de verificar se a aplicação recebe dados corretamente, é hora de finalizar o gerenciamento da requisição: + +```js +app.post('/api/notes', (request, response) => { + const maxId = notes.length > 0 + ? Math.max(...notes.map(n => n.id)) + : 0 + + const note = request.body + note.id = maxId + 1 + + notes = notes.concat(note) + + response.json(note) +}) +``` + +Precisamos de um id único para a nota. Primeiro, descobrimos o maior número de id na lista atual e o atribuímos à variável _maxId_. O id da nova nota é então definido como _maxId + 1_. Este método não é recomendado, mas vamos conviver com ele por enquanto, pois o substituiremos em breve. + +A versão atual ainda tem o problema de que a requisição HTTP POST pode ser usada para adicionar objetos com propriedades arbitrárias. Vamos melhorar a aplicação definindo que a propriedade content não pode estar vazia. A propriedade important receberá o valor padrão false. Todas as outras propriedades são descartadas: + +```js +const generateId = () => { + const maxId = notes.length > 0 + ? Math.max(...notes.map(n => n.id)) + : 0 + return maxId + 1 +} + +app.post('/api/notes', (request, response) => { + const body = request.body + + if (!body.content) { + return response.status(400).json({ + error: 'content missing' + }) + } + + const note = { + content: body.content, + important: body.important || false, + id: generateId(), + } + + notes = notes.concat(note) + + response.json(note) +}) +``` + +A lógica para gerar às notas um novo número de ID foi extraída para uma função separada _generateId_. + +Se os dados recebidos estiverem faltando um valor para a propriedade content, o servidor responderá à requisição com o código de status [400 bad request](https://www.rfc-editor.org/rfc/rfc9110.html#name-400-bad-request) ("400 requisição inválida"): + +```js +if (!body.content) { + return response.status(400).json({ + error: 'content missing' + }) +} +``` + +Observe que declarar o return é crucial porque, caso contrário, o código será executado até o final e a nota mal formatada será salva na aplicação. + +Se a propriedade content tiver um valor, a nota será baseada nos dados recebidos. +Se estiver faltando a propriedade important, definimos o valor padrão como false. O valor padrão é gerado atualmente de uma forma bastante estranha: + +```js +important: body.important || false, +``` + +Se os dados salvos na variável _body_ tiverem a propriedade important, a expressão resultará no seu valor. Se a propriedade não existir, a expressão resultará em false, que é definido no lado direito das barras verticais. + +> Sendo mais preciso, quando a propriedade important é false, então a expressão body.important || false retornará de fato o false do lado direito... + +É possível encontrar o código atual completo da nossa aplicação na branch part3-1 neste [repositório do GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-1). + +O código para o estado atual da aplicação é especificado na branch [part3-1](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-1). + +![captura de tela da branch 3-1 no GitHub](../../images/3/21.png) + +Se você clonar o projeto, execute o comando _npm install_ antes de iniciar a aplicação com _npm start_ ou _npm run dev_. + +Mais uma coisa antes de prosseguirmos para os exercícios. A função para gerar IDs é esta: + +```js +const generateId = () => { + const maxId = notes.length > 0 + ? Math.max(...notes.map(n => n.id)) + : 0 + return maxId + 1 +} +``` + +O corpo da função contém uma linha que parece um tanto intrigante: + +```js +Math.max(...notes.map(n => n.id)) +``` + +O que exatamente está acontecendo nessa linha de código? notes.map(n => n.id) cria um novo array que contém todos os IDs das notas. [Math.max](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/max) retorna o valor máximo dos números que lhe são passados. No entanto, notes.map(n => n.id) é um array, então ele não pode ser dado diretamente como parâmetro para Math.max. O array pode ser transformado em números individuais usando a sintaxe de espalhamento ou sintaxe de "três pontos" [spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) ... + +
    + +
    + +### Exercícios 3.1 a 3.6 + +**Obs.:** É recomendado fazer todos os exercícios desta parte em um novo repositório Git separado e colocar o código-fonte na raiz do repositório. Caso contrário, você terá problemas no exercício 3.10. + +**Obs.:** Como este não é um projeto de front-end e não estamos trabalhando com React, a aplicação não é criada com "create-react-app". Inicializa-se este projeto com o comando npm init que foi demonstrado anteriormente nesta parte do material. + +**Forte Recomendação:** quando você estiver trabalhando com código back-end, sempre fique de olho no que está acontecendo no terminal que está executando sua aplicação. + +#### 3.1: Phonebook backend — 1º passo + +Implemente uma aplicação Node que retorna uma lista de pessoas da lista telefônica a partir do endereço . + +Dados: + +```js +[ + { + "id": 1, + "name": "Arto Hellas", + "number": "040-123456" + }, + { + "id": 2, + "name": "Ada Lovelace", + "number": "39-44-5323523" + }, + { + "id": 3, + "name": "Dan Abramov", + "number": "12-43-234345" + }, + { + "id": 4, + "name": "Mary Poppendieck", + "number": "39-23-6423122" + } +] +``` + +Saída no navegador após a requisição GET: + +![Dados JSON de 4 pessoas no navegador a partir de api/persons](../../images/3/22e.png) + +Observe que a barra comum na rota api/persons não é um caractere especial, sendo apenas como qualquer outro caractere na string. + +A aplicação deve ser iniciada com o comando _npm start_. + +A aplicação também deve disponibilizar um comando _npm run dev_ que executará a aplicação e reiniciará o servidor sempre que as alterações forem feitas e salvas em um arquivo no código-fonte. + +#### 3.2: Phonebook backend — 2º passo + +Implemente uma página no endereço que se pareça mais ou menos com isto: + +![Captura de tela para o exercício 3.2](../../images/3/23x.png) + +A página deve mostrar a hora em que a requisição foi recebida e quantas entradas há na lista telefônica no momento do processamento da requisição. + +#### 3.3: Phonebook backend — 3º passo + +Implemente uma funcionalidade que exiba as informações de uma única entrada da lista telefônica. A URL para obter os dados de uma pessoa com o id 5 deve ser . + +Se uma entrada para o id fornecido não for encontrada, o servidor deverá responder com o código de status apropriado. + +#### 3.4: Phonebook backend — 4º passo + +Implemente uma funcionalidade que permita excluir uma única entrada da lista telefônica fazendo uma requisição HTTP DELETE para a URL exclusiva dessa entrada na lista telefônica. + +Teste se sua funcionalidade funciona com o Postman ou com o cliente REST do Visual Studio Code. + +#### 3.5: Phonebook backend — 5º passo + +Expanda o back-end para que novas entradas da lista telefônica possam ser adicionadas fazendo requisições HTTP POST para o endereço . + +Gere um novo id para as entradas da lista telefônica com a função [Math.random](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random). Use um intervalo grande o suficiente para seus valores aleatórios, para que a probabilidade de criar ids duplicados seja pequena. + +#### 3.6: Phonebook backend — 6º passo + +Implemente o gerenciamento de erro (error handling) para a criação de novas entradas. A requisição não pode ser bem-sucedida se: + +- O nome ou o número estão faltando; e se +- O nome já existe na lista telefônica. + +Responda a requisições como essas com o código de status apropriado e envie também informações que explicam o motivo do erro, como por exemplo: + +```js +{ error: 'name must be unique' } +``` + +
    + +
    + +### Sobre os tipos de requisição HTTP + +O [padrão HTTP](https://www.rfc-editor.org/rfc/rfc9110.html#name-common-method-properties) fala sobre duas propriedades relacionadas aos tipos de requisição, **segurança** (safety) e **idempotência** (idempotency). + +A requisição HTTP GET deve ser segura: + +> Em particular, a convenção estabelecida diz que os métodos GET e HEAD NÃO devem ter a importância de realizar uma ação além da recuperação. Esses métodos devem ser considerados "seguros". + +Segurança significa que a execução da requisição não deve causar quaisquer efeitos colaterais no servidor. Por efeitos colaterais, entendemos que o estado do banco de dados não deve mudar como resultado da requisição, e a resposta deve retornar apenas os dados que já existem no servidor. + +Nada garante que uma requisição GET é segura, esta é apenas uma recomendação definida no padrão HTTP. Adotando os princípios RESTful em nossa API, as requisições GET são sempre usadas de forma que são seguras. + +O padrão HTTP também define que o tipo de requisição [HEAD](https://www.rfc-editor.org/rfc/rfc9110.html#name-head) deve ser seguro. Na prática, o HEAD deve funcionar exatamente como o GET, mas não retorna nada além do código de status e dos cabeçalhos de resposta. O corpo da resposta não será retornado quando você fizer uma requisição HEAD. + +Todas as requisições HTTP, exceto o POST, devem ser idempotentes: + +> Os métodos também podem ter a propriedade de "idempotência", no sentido de que (além de problemas de erro ou expiração) os efeitos colaterais de N > 0 requisições idênticas são os mesmos que para uma única requisição. Os métodos GET, HEAD, PUT e DELETE compartilham essa propriedade. + +Isso significa que se uma requisição não gera efeitos colaterais, o resultado deve ser o mesmo, independentemente de quantas vezes a requisição for enviada. + +Se fizermos uma requisição HTTP PUT para a URL /api/notes/10 e com a requisição enviarmos os dados { content: "sem efeitos colaterais!", important: true }, o resultado será o mesmo, independentemente de quantas vezes a requisição for enviada. + +Assim como segurança para a requisição GET, idempotência também é apenas uma recomendação no padrão HTTP e não algo que possa ser garantido simplesmente com base no tipo de requisição. No entanto, quando nossa API adere aos princípios RESTful, as requisições GET, HEAD, PUT e DELETE são usadas de forma que são idempotentes. + +POST é o único tipo de requisição HTTP que não é nem seguro nem idempotente. Se enviarmos 5 requisições HTTP POST diferentes para /api/notes com um corpo de +{ content: "muitas iguais", important: true }, as 5 notas resultantes no servidor terão o mesmo conteúdo. + +### Middleware + +O [json-parser](https://expressjs.com/en/api.html) do Express que usamos anteriormente é um [middleware](http://expressjs.com/en/guide/using-middleware.html). + +Middleware são funções que podem ser usadas para lidar com objetos de _request_ e _response_. + +O json-parser que usamos anteriormente pega os dados brutos das requisições armazenadas no objeto _request_, os decompõe em um objeto JavaScript e os atribui ao objeto _request_ como uma nova propriedade body. + +Na prática, é possível usar vários middlewares ao mesmo tempo. Quando você tem mais de um, eles são executados um por um na ordem em que foram adicionados no Express. + +Vamos implementar nosso próprio middleware que imprime informações sobre cada requisição enviada ao servidor. + +Middleware é uma função que recebe três parâmetros: + +```js +const requestLogger = (request, response, next) => { + console.log('Method:', request.method) + console.log('Path: ', request.path) + console.log('Body: ', request.body) + console.log('---') + next() +} +``` + +No final do corpo da função, a função _next_ que foi passada como parâmetro é chamada. A função _next_ cede o controle para o próximo middleware. + +Middleware é usado assim: + +```js +app.use(requestLogger) +``` + +Funções de middleware são chamadas na ordem em que são adicionadas ao objeto do servidor Express com o método _use_. Observe que o json-parser é adicionado antes do middleware _requestLogger_, caso contrário, request.body não será inicializado quando o registrador (logger) for executado! + +Funções de middleware devem ser adicionadas antes das rotas se quisermos que sejam executadas antes que os gerenciadores de evento da rota sejam chamados. Também há situações em que queremos definir funções de middleware depois das rotas. Na prática, isso significa que estamos definindo funções de middleware que só são chamadas se nenhuma rota gerenciar a requisição HTTP. + +Vamos adicionar o middleware a seguir depois das nossas rotas. Este middleware será usado para pegar requisições feitas para rotas inexistentes. Para essas requisições, o middleware retornará uma mensagem de erro no formato JSON. + +```js +const unknownEndpoint = (request, response) => { + response.status(404).send({ error: 'unknown endpoint' }) +} + +app.use(unknownEndpoint) +``` + +É possível encontrar o código da nossa aplicação atual na íntegra na branch part3-2 neste [repositório do GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-2). + +
    + +
    + +### Exercícios 3.7 a 3.8 + +#### 3.7: Phonebook backend — 7º passo + +Adicione o middleware [morgan](https://github.com/expressjs/morgan) na sua aplicação para realizar o registro de logs (logging). Configure-o para que as mensagens sejam registradas no console com base na configuração tiny. + +A documentação do Morgan não é a das melhores, e talvez você precise gastar algum tempo para descobrir como configurá-lo corretamente. No entanto, a maioria das documentações do mundo se enquadra na mesma categoria, então é bom aprender a decifrar e interpretar documentações crípticas de qualquer maneira. + +Morgan é instalado da mesma forma que todas as outras bibliotecas com o comando _npm install_. O uso do Morgan ocorre da mesma forma que a configuração de qualquer outro middleware, usando o comando _app.use_. + +#### 3.8*: Phonebook backend — 8º passo + +Configure o Morgan para que também mostre os dados enviados em requisições HTTP POST: + +![terminal mostrando dados POST sendo enviados](../../images/3/24.png) + +Note que registrar dados, mesmo no console, pode ser perigoso, pois pode conter dados sensíveis e violar leis de privacidade locais (por exemplo, GDPR na UE) ou padrão de negócios. Você não precisa se preocupar com questões de privacidade neste exercício, porém, na prática, evite registrar quaisquer dados sensíveis. + +Este exercício pode ser bastante desafiador, embora a solução não exija muito código. + +Este exercício pode ser concluído de várias maneiras diferentes. Uma das soluções possíveis utiliza essas duas técnicas: + +- [criando novos tokens](https://github.com/expressjs/morgan#creating-new-tokens) +- [JSON.stringify](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) + +
    diff --git a/src/content/3/ptbr/part3b.md b/src/content/3/ptbr/part3b.md new file mode 100644 index 00000000000..782c693ce95 --- /dev/null +++ b/src/content/3/ptbr/part3b.md @@ -0,0 +1,502 @@ +--- +mainImage: ../../../images/part-3.svg +part: 3 +letter: b +lang: ptbr +--- + +
    + +Em seguida, vamos conectar o front-end que fizemos na [Parte 2](/ptbr/part2) ao nosso próprio back-end. + +Na parte anterior, o front-end permitia requisições da lista de notas do json-server que tínhamos como back-end, a partir do endereço http://localhost:3001/notes. +A estrutura de URL do nosso back-end agora é um pouco diferente, pois as notas podem ser encontradas em http://localhost:3001/api/notes. Vamos alterar o atributo __baseUrl__ no src/services/notes.js assim: + +```js +import axios from 'axios' +const baseUrl = 'http://localhost:3001/api/notes' //highlight-line + +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} + +// ... + +export default { getAll, create, update } +``` + +Porém, agora a requisição GET do front-end para não funciona por algum motivo: + +![requisição GET mostrando erro nas ferramentas do desenvolvedor](../../images/3/3ae.png) + +O que está acontecendo aqui? Podemos acessar o back-end através do navegador e do postman sem problemas. + +### Política de Mesma Origem e CORS + +O problema é uma coisa chamada `Política de Mesma Origem`. A origem de uma URL é definida pela combinação do protocolo (protocol, também conhecido como esquema (scheme)), do nome do host e da porta (port). + +```text +http://example.com:80/index.html + +protocol: http +host: example.com +port: 80 +``` + +Quando você visita um site (ou seja, ), o navegador emite uma requisição para o servidor em que o site (example.com) está hospedado. A resposta enviada pelo servidor é um arquivo HTML que pode conter uma ou mais referências a recursos/ativos externos hospedados no mesmo servidor que example.com está hospedado ou em um site diferente. Quando o navegador vê referência(s) a uma URL no HTML de origem, ele emite uma requisição. Se a requisição for feita usando a URL na qual o HTML de origem foi obtido, o navegador processa a resposta sem problemas. No entanto, se o recurso for obtido usando uma URL que não compartilha a mesma origem (esquema, host, porta) que o HTML de origem, o navegador deverá verificar o cabeçalho de resposta _Access-Control-Allow-origin_ (CORS). Se ele contiver _*_ ou a URL do HTML de origem, o navegador processará a resposta, caso contrário, o navegador se recusará a processá-la e lançará um erro. + +A Política de Mesma Origem é um mecanismo de segurança implementado pelos navegadores para impedir sequestro de sessão (session hijacking), entre outras vulnerabilidades de segurança. + +Para permitir requisições legítimas de várias origens (requisições a URLs que não compartilham a mesma origem), a W3C criou um mecanismo chamado CORS (Cross-Origin Resource Sharing [Compartilhamento de Recursos de Origem Cruzada]). De acordo com a [Wikipedia](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing): + +> Cross-Origin Resource Sharing ou CORS é um mecanismo que permite que recursos restritos em uma página web sejam recuperados por outro domínio fora do domínio ao qual pertence o recurso que será recuperado. Uma página web pode integrar livremente recursos de diferentes origens, como imagens, folhas de estilo, scripts, iframes e vídeos. Certas "requisições de domínio cruzado", em particular as requisições Ajax, são proibidas por padrão pela política de segurança de mesma origem. + +O problema é que, por padrão, o código JavaScript de uma aplicação que é executada em um navegador só pode se comunicar com um servidor na mesma [origem](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy) (origin). +Como nosso servidor está em _localhost, porta 3001_, enquanto nosso front-end está em _localhost, porta 3000_, eles não possuem a mesma origem. + +Lembre-se de que a [Política de Mesma Origem](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy) (same-origin policy) e CORS não são específicos de React ou Node. São princípios universais referentes à operação segura de aplicações web. + +Podemos permitir requisições de outras origens usando o middleware [cors](https://github.com/expressjs/cors) do Node. + +No repositório do seu back-end, instale o cors com o comando... + +```bash +npm install cors +``` + +... use o middleware e permita requisições de todas as origens: + +```js +const cors = require('cors') + +app.use(cors()) +``` + +E o front-end funciona! No entanto, a funcionalidade para alternar a importância das notas ainda não foi implementada no back-end. + +Você pode ler mais sobre o CORS na página da [Mozilla](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS). + +A configuração de nosso aplicação agora é a seguinte: + +![diagrama da aplicação React e do navegador](../../images/3/100.png) + +A aplicação React sendo executada no navegador agora obtém os dados do servidor node/express que é executado em localhost:3001. + +### A Aplicação na Internet + +Agora que toda stack está pronta, vamos mover nossa aplicação para a internet. + +Há um número cada vez maior de serviços que podem ser usados para hospedar uma aplicação na internet. Serviços voltados a desenvolvedores (developer-friendly services), como o PaaS (Platform as a Service [Plataforma como Serviço]), cuidam da instalação do ambiente de execução (Node.js, por exemplo) e também podem fornecer vários serviços, como bancos de dados. + +Durante uma década, [Heroku](http://heroku.com) dominou a cena PaaS. Infelizmente, o plano gratuito do Heroku acabou em 27 de novembro de 2022. Muitos desenvolvedores ficaram tristes com isso, especialmente estudantes. O Heroku ainda é uma opção viável se você estiver disposto a gastar algum dinheiro. Eles também têm [um programa para estudantes](https://www.heroku.com/students) que fornece alguns créditos gratuitos. + +Agora estamos apresentando dois serviços: [Fly.io](https://fly.io/) e [Render](https://render.com/), ambos possuem um plano gratuito (limitado). O Fly.io é nosso serviço de hospedagem "oficial", pois pode ser usado com certeza também nas Partes 11 e 13 do curso. O Render será bom pelo menos para as outras partes deste curso. + +Observe que, apesar de usar apenas o plano gratuito, o Fly.io pode exigir que você insira suas informações de cartão de crédito. No momento, o Render pode ser usado sem um cartão de crédito. + +O Render pode ser um pouco mais fácil de usar, pois não requer a instalação de nenhum software em sua máquina. + +Também existem outras opções gratuitas de hospedagem que funcionam bem para este curso, para todas as partes exceto a Parte 11 (CI/CD), que tem um exercício complicado de se fazer em outras plataformas. + +Alguns participantes do curso também usaram estes serviços: + +- [Railway](https://railway.app/) +- [Cyclic](https://www.cyclic.sh/) +- [Replit](https://replit.com) +- [CodeSandBox](https://codesandbox.io) + +Se você conhece outros serviços bons e fáceis de usar para hospedar NodeJS, por favor, nos avise! + +Tanto para o Fly.io quanto para o Render, precisamos mudar, no final do arquivo index.js, a definição da porta que nossa aplicação usa: + +```js +const PORT = process.env.PORT || 3001 // highlight-line +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +``` + +Agora estamos usando a porta definida na [variável de ambiente](https://en.wikipedia.org/wiki/Environment_variable) _PORT_ ou a porta 3001 se a variável de ambiente _PORT_ estiver indefinida. O Fly.io e o Render configuram a porta da aplicação com base nessa variável de ambiente. + +#### Fly.io + +Note que pode ser preciso fornecer seu número de cartão de crédito para o Fly.io, mesmo se estiver usando apenas o plano gratuito! Na verdade, houve relatos conflitantes sobre isso. Fato é que alguns alunos deste curso estão usando o Fly.io sem informar as informações do cartão de crédito. No momento, [Render](https://render.com/) pode ser usado sem um cartão de crédito. + +Por padrão, todos recebem duas máquinas virtuais gratuitas que podem ser usadas para executar duas aplicações ao mesmo tempo. + +Se você decidir usar o [Fly.io](https://fly.io/), comece instalando seu executável _flyctl_ seguindo [este guia](https://fly.io/docs/hands-on/install-flyctl/). Após isso, você deve [criar uma conta Fly.io](https://fly.io/docs/hands-on/sign-up/). + +Comece por [autenticar-se](https://fly.io/docs/hands-on/sign-in/) via linha de comando com o comando: + +```bash +fly auth login +``` + +*Observação:* se o comando _fly_ não funcionar em sua máquina, você pode tentar a versão mais longa _flyctl_. Por exemplo, ambas as formas do comando funcionam no MacOS. + +Se você não conseguir fazer o _flyctl_ funcionar em sua máquina, é possível experimentar o Render (veja a próxima seção), que não requer nada a ser instalado em sua máquina. + +Inicializa-se uma aplicação executando o seguinte comando no diretório raiz do aplicação: + +```bash +fly launch +``` + +Dê um nome à aplicação ou deixe que o Fly.io gere um automaticamente. Escolha uma região onde a aplicação será executada. Não crie um banco de dados Postgres e não crie um banco de dados Upstash Redis, pois eles não são necessários. + +A última pergunta é "Você gostaria de implantar agora? (Would you like to deploy now?)". Devemos responder "não" porque ainda não estamos prontos. + +Fly.io cria um arquivo fly.toml na raiz da sua aplicação onde a mesma é configurada. Para colocar a aplicação em funcionamento, talvez precisemos fazer uma pequena adição na parte [env] da configuração: + +```bash +[env] + PORT = "8080" # adicione isto + +[experimental] + auto_rollback = true + +[[services]] + http_checks = [] + internal_port = 8080 + processes = ["app"] +``` + +Agora definimos na parte [env] que a variável de ambiente _PORT_ obterá a porta correta (definida na parte [services]) onde a aplicação deve criar o servidor. Observe que a definição pode já estar lá, mas às vezes ela falta. + +Agora estamos prontos para implantar (deploy) a aplicação nos servidores Fly.io. Isso é feito com o seguinte comando: + +```bash +fly deploy +``` + +Se tudo correr bem, a aplicação deverá estar em funcionamento. Você pode abri-la no navegador com o comando + +```bash +fly apps open +``` + +Depois da configuração inicial, quando o código da aplicação for atualizado, poderá ser implantada na produção com o comando: + +```bash +fly deploy +``` + +Um comando particularmente importante é _fly logs_. Este comando pode ser usado para visualizar os logs do servidor. É melhor manter os logs sempre visíveis! + +**Atenção:** Em alguns casos (a causa é até agora desconhecida) executar comandos Fly.io, especialmente no Windows WSL, causou problemas. Se o seguinte comando simplesmente travar: + +```bash +flyctl ping -o personal +``` + +seu computador não consegue, por algum motivo, se conectar ao Fly.io. Se isso acontecer com você, [aqui](https://github.com/fullstack-hy2020/misc/blob/master/fly_io_problem.md) encontra-se uma possível maneira de resolver o problema. + +Se a saída do comando abaixo se parecer com isto: + +```bash +$ flyctl ping -o personal +35 bytes from fdaa:0:8a3d::3 (gateway), seq=0 time=65.1ms +35 bytes from fdaa:0:8a3d::3 (gateway), seq=1 time=28.5ms +35 bytes from fdaa:0:8a3d::3 (gateway), seq=2 time=29.3ms +... +``` + +então não há problemas de conexão! + +#### Render + +Este serviço pressupõe que o [login](https://dashboard.render.com/) tenha sido feito com uma conta do GitHub. + +Depois de fazer login, vamos criar um novo "Web Service": + +![imagem mostrando a opção para criar um novo Web Service](../../images/3/r1.png) + +O repositório da aplicação é então conectado ao Render: + +![imagem mostrando o repositório da aplicação no Render](../../images/3/r2.png) + +A conexão parece exigir que o repositório da aplicação seja público. + +A seguir, definiremos as configurações básicas. Se a aplicação não estiver na raiz do repositório, o diretório raiz precisa receber um valor apropriado: + +![imagem mostrando o campo Root Directory como sendo opcional](../../images/3/r3.png) + +Depois disso, a aplicação é iniciada no Render. O painel informa o estado da aplicação e a URL onde ela está sendo executada: + +![no canto esquerdo superior da imagem é possível verificar o estado da aplicação e a sua URL](../../images/3/r4.png) + +De acordo com a [documentação](https://render.com/docs/deploys), cada confirmação no GitHub deve fazer o redeploy (re-implantar) a aplicação. Por alguma razão, isso nem sempre funciona. + +Felizmente, também é possível fazer o redeploy da aplicação manualmente: + +![menu com a opção para fazer o deploy novamente em destaque](../../images/3/r5.png) + +Também é possível ver os logs da aplicação no painel: + +![Guia logs no canto esquerdo em destaque. No lado direito, os los da aplicação](../../images/3/r7.png) + +Observamos nos logs que a aplicação foi iniciada na porta 10000. O código da aplicação obtém a porta correta por meio da variável de ambiente PORT, portanto, é essencial que o arquivo index.js tenha sido atualizado da seguinte maneira: + +```js +const PORT = process.env.PORT || 3001 // highlight-line +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +``` + +### Build de produção do front-end + +Até agora, rodamos o código do React em modo de desenvolvimento. No modo de desenvolvimento, a aplicação é configurada para exibir mensagens de erro claras, renderizar imediatamente as mudanças de código para o navegador, e assim por diante. + +Quando a aplicação é implantada (deployed), é necessário criar um [build de produção](https://reactjs.org/docs/optimizing-performance.html#use-the-production-build), ou seja, uma versão da aplicação otimizada para produção. + +Um build de produção de aplicações gerado com create-react-app pode ser criado com o comando [npm run build](https://github.com/facebookincubator/create-react-app#npm-run-build-or-yarn-build). + +Vamos executar esse comando a partir do diretório raiz do projeto front-end de notas que desenvolvemos na [Parte 2](/ptbr/part2). + +Isso cria um diretório chamado build (que contém o único arquivo HTML da nossa aplicação, index.html) que contém o diretório static. Uma versão [minificada]() do código JavaScript da nossa aplicação será gerada no diretório static. Embora o código da aplicação esteja em vários arquivos, todo o JavaScript será minificado em um arquivo. Todo o código de todas as dependências da aplicação também será minificado neste único arquivo. + +O código minificado não é muito legível. O início do código se parece com isso: + +```js +!function(e){function r(r){for(var n,f,i=r[0],l=r[1],a=r[2],c=0,s=[];cbuild) para a raiz do repositório back-end e configurar o back-end para mostrar a página principal do front-end (o arquivo build/index.html) como sua página principal. + +Começamos copiando o build de produção do front-end para a raiz do back-end. Com um computador Mac ou Linux, a cópia pode ser feita a partir do diretório do front-end com o comando: + +```bash +cp -r build ../backend +``` + +Se estiver usando um computador Windows, é possível usar o comando [copy](https://www.windows-commandline.com/windows-copy-command-syntax-examples/) ou [xcopy](https://www.windows-commandline.com/xcopy-command-syntax-examples/). Caso contrário, basta copiar e colar. + +O diretório do back-end deve ficar assim agora: + +![captura de tela do bash mostrando o diretório build](../../images/3/27new.png) + +Para fazer o Express exibir conteúdo estático — a página index.html e o JavaScript, etc. — que ele busca, precisamos de um middleware embutido do Express chamado [static](http://expressjs.com/en/starter/static-files.html). + +Quando adicionamos o seguinte código em meio às declarações dos middlewares... + +```js +app.use(express.static('build')) +``` + +... sempre que o Express recebe uma requisição HTTP GET, ele primeiro verifica se o diretório build contém um arquivo correspondente ao endereço da requisição. Se um arquivo correto for encontrado, o Express o retornará. + +Agora, as requisições HTTP GET para o endereço www.serversaddress.com/index.html ou www.serversaddress.com mostrarão o front-end do React. As requisições GET para o endereço www.serversaddress.com/api/notes serão gerenciadas pelo código do back-end. + +Dada nossa situação atual, pelo fato de tanto o front-end quanto o back-end estarem no mesmo endereço, podemos declarar o _baseUrl_ como uma URL [relativa](https://www.w3.org/TR/WD-html40-970917/htmlweb.html#h-5.1.2) (relative URL). Isso significa que podemos deixar de fora a parte que declara o servidor. + +```js +import axios from 'axios' +const baseUrl = '/api/notes' // highlight-line + +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} + +// ... +``` + +Depois da alteração, temos que criar uma nova versão de produção e copiá-la para a raiz do repositório back-end. + +A aplicação agora pode ser usada no endereço do back-end: + +![Captura de tela da aplicação Notes](../../images/3/28new.png) + +Nossa aplicação agora funciona exatamente como o exemplo de [Aplicação de Página Única](/ptbr/part0/fundamentos_de_aplicacoes_web#aplicacao-de-pagina-unica-spa-single-page-application) que estudamos na Parte 0. + +Quando usamos um navegador para acessar o endereço http://localhost:3001, o servidor retorna o arquivo index.html do repositório build. O conteúdo resumido do arquivo é o seguinte: + +```html + + + React App + + + +
    + + + + +``` + +O arquivo contém instruções para buscar uma folha de estilo CSS que define os estilos da aplicação, e duas tags script que instruem o navegador a buscar o código JavaScript da aplicação — a aplicação React real. + +O código React busca notas do endereço do servidor e as renderiza na tela. As comunicações entre o servidor e o navegador podem ser vistas na guia Rede das Ferramentas do Desenvolvedor: + +![guia de rede da aplicação de notas no back-end](../../images/3/29new.png) + +A configuração que está pronta para implantação de produção é a seguinte: + +![diagrama da aplicação React pronta para implantação](../../images/3/101.png) + +Ao contrário do que acontece quando a aplicação é executada em um ambiente de desenvolvimento, tudo está agora no mesmo back-end Node/Express que é executado em localhost:3001. Quando o navegador vai até a página, o arquivo index.html é renderizado. Isso faz com que o navegador busque o build de produção da aplicação React. Assim que começa a ser executada, ela busca os dados json do endereço localhost:3001/api/notes. + +### A aplicação toda na internet + +Após garantir que a versão de produção da aplicação funciona localmente, confirme a versão de produção do front-end no repositório do back-end e envie o código para o GitHub novamente. + +Se você estiver usando o Render, um "push" para o GitHub pode ser suficiente. Se a implantação automática não funcionar, selecione "manual deploy" (implantação manual) no painel do Render. + +No caso do Fly.io, a nova implantação é feita com o comando: + +```bash +fly deploy +``` + +A aplicação funciona perfeitamente, com exceção de que ainda não adicionamos a funcionalidade de alternar a importância de uma nota no back-end. + +![captura de tela da aplicação de notas](../../images/3/30new.png) + +Nossa aplicação salva as notas em uma variável. Se a aplicação travar ou for reiniciada, todos os dados desaparecerão. + +A aplicação precisa de um banco de dados. Antes de introduzirmos um, vamos passar por alguns pontos. + +A configuração agora parece assim: + +![diagrama da aplicação React no Heroku com um banco de dados](../../images/3/102.png) + +O back-end Node/Express agora reside no servidor Fly.io/Render. Quando o endereço raiz é acessado, o navegador carrega e executa a aplicação React que busca os dados json do servidor Fly.io/Render. + +### Otimização da implantação do front-end + +Para criar uma nova versão de produção do front-end sem trabalho manual adicional, vamos adicionar alguns scripts npm ao package.json do repositório do back-end. + +#### Fly.io + +O script fica assim: + +```json +{ + "scripts": { + // ... + "build:ui": "rm -rf build && cd ../part2-notes/ && npm run build && cp -r build ../notes-backend", + "deploy": "fly deploy", + "deploy:full": "npm run build:ui && npm run deploy", + "logs:prod": "fly logs" + } +} +``` + +O script _npm run build:ui_ constrói o front-end e copia a versão de produção no repositório do back-end. _npm run deploy_ libera a versão atual do back-end para o Fly.io. + +_npm run deploy:full_ combina esses dois scripts. + +Existe também um script _npm run logs:prod_ para mostrar os logs do Fly.io. + +Observe que os caminhos de diretório no script build:ui dependem da localização dos repositórios no sistema de arquivos. + +#### Render + +No caso do Render, os scripts ficam assim: + +```json +{ + "scripts": { + //... + "build:ui": "rm -rf build && cd ../frontend && npm run build && cp -r build ../backend", + "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push" + } +} +``` + +O script _npm run build:ui_ constrói o front-end e copia a versão de produção no repositório do back-end. _npm run deploy:full_ contém também os comandos necessários git para atualizar o repositório do back-end. + +Observe que os caminhos de diretório no script build:ui dependem da localização dos repositórios no sistema de arquivos. + +>**Obs.:** No Windows, scripts npm são executados em cmd.exe como o shell padrão que não oferece suporte a comandos bash. Para que os comandos bash acima funcionem, é possível alterar o shell padrão para Bash (na instalação padrão do Git para Windows) da seguinte forma: + +```md +npm config set script-shell "C:\\Program Files\\git\\bin\\bash.exe" +``` + +Outra opção é usar o [shx](https://www.npmjs.com/package/shx). + +### Proxy + +As alterações no front-end fizeram com que ele não funcionasse mais no modo de desenvolvimento (quando iniciado com o comando _npm start_), pois a conexão com o back-end não funciona. + +![ferramentas do desenvolvedor mostrando um erro 404 ao obter notas](../../images/3/32new.png) + +Isso se deve à alteração do endereço do back-end para um URL relativo: + +```js +const baseUrl = '/api/notes' +``` + +Como no modo de desenvolvimento o front-end está no endereço localhost:3000, as requisições ao back-end vão para o endereço errado localhost:3000/api/notes. O back-end está em localhost:3001. + +Esse problema é fácil de resolver se o projeto foi criado com "create-react-app". Basta adicionar a seguinte declaração ao arquivo package.json do repositório do front-end. + +```bash +{ + "dependencies": { + // ... + }, + "scripts": { + // ... + }, + "proxy": "http://localhost:3001" // highlight-line +} +``` + +Após a reinicialização, o ambiente de desenvolvimento React funcionará como um [proxy](https://create-react-app.dev/docs/proxying-api-requests-in-development/). Se o código React fizer uma requisição HTTP para um endereço de servidor em http://localhost:3000 não gerenciado pela aplicação React em si (ou seja, quando as requisições não se tratam de buscar o CSS ou JavaScript da aplicação), a requisição será redirecionada para o servidor em http://localhost:3001. + +Agora o front-end já funciona bem: conecta-se ao servidor tanto no modo de desenvolvimento quanto no de produção. + +Um aspecto negativo da nossa abordagem é o quão complicado é implantar o front-end. Implantar uma nova versão requer a geração de um novo build de produção do front-end e a cópia dele para o repositório do back-end. Isso torna a criação de um [pipeline de implantação](https://martinfowler.com/bliki/DeploymentPipeline.html) automatizado mais difícil. Pipeline de implantação refere-se a uma maneira automatizada e controlada de mover o código do computador do desenvolvedor por meio de diferentes testes e verificações de qualidade até o ambiente de produção. A criação de um pipeline de implantação é o tema da [Parte 11](/ptbr/part11) deste curso. + +Existem várias maneiras de conseguir fazer isso (por exemplo, colocando o código do back-end e do front-end [no mesmo repositório](https://github.com/mars/heroku-cra-node)), mas não entraremos nesses detalhes agora. + +Em algumas situações, é sensato implantar o código do front-end como sua própria aplicação. Fazer isso é [simples](https://github.com/mars/create-react-app-buildpack) com aplicações criadas com "create-react-app". + +O código atual do back-end pode ser encontrado no [GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-3), na branch part3-3. As alterações no código do frontend estão na branch part3-1 do [repositório do front-end](https://github.com/fullstack-hy2020/part2-notes/tree/part3-1). + +
    + +
    + +### Exercícios 3.9 a 3.11 + +Os exercícios a seguir não exigem muitas linhas de código. No entanto, podem ser desafiadores, pois você deve entender exatamente o que e onde está acontecendo, e as configurações devem estar corretas. + +#### 3.9: Phonebook backend — 9º passo + +Faça com que o back-end funcione com o front-end da lista telefônica dos exercícios da parte anterior. Não implemente a funcionalidade para fazer alterações nos números de telefone ainda, porque isso será implementado no exercício 3.17. + +Você provavelmente terá que fazer algumas pequenas alterações no front-end, pelo menos nas URLs para o back-end. Lembre-se de manter o Console do desenvolvedor aberto em seu navegador. Se algumas requisições HTTP falharem, você deve verificar na guia Rede o que está acontecendo. Fique de olho também no console do back-end. Se você não fez o exercício anterior, vale a pena imprimir no console os dados da requisição ou request.body no gerenciador de eventos responsável pelas requisições POST. + +#### 3.10: Phonebook backend — 10º passo + +Implante o back-end na internet; pode ser no Fly.io ou no Render, por exemplo. + +Teste o recém-implantado back-end com um navegador, com o Postman ou com o cliente REST do VS Code para garantir que ele esteja funcionando. + +**DICA PRO:** Quando você implantar sua aplicação na internet, é importante pelo menos no início ficar de olho nos logs da aplicação **A TODO MOMENTO**. + +Crie um README.md na raiz do seu repositório e adicione um link de acesso à sua aplicação online. + +**OBSERVAÇÃO** como dito, o deploy do BACKEND deve ser feito em um serviço de nuvem. Se você estiver usando o Fly.io, o comando deve ser executado no diretório raiz do backend (que é o mesmo diretório onde está o arquivo do backend chamado package.json). No caso de estar usando o Render, o backend deve estar na raiz do seu repositório. + +Você NÃO deve fazer o deploy do frontend diretamente nesta parte. O deploy está sendo realizado apenas com o repositório do backend ao longo de toda esta parte do curso. + +#### 3.11: Phonebook backend — 11º passo + +Gere um build de produção do seu front-end e adicione-o à aplicação na internet utilizando o método introduzido nesta parte. + +**Obs.:** Se você usar o Render, certifique-se de que o diretório build não esteja no gitignored. + +Certifique-se também de que o front-end ainda funcione localmente (em modo de desenvolvimento quando iniciado com o comando _npm start_). + +Se tiver problemas para fazer a aplicação funcionar, certifique-se de que a estrutura do seu diretório corresponda [à aplicação de exemplo](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-3). + +
    diff --git a/src/content/3/ptbr/part3c.md b/src/content/3/ptbr/part3c.md new file mode 100644 index 00000000000..731461836c8 --- /dev/null +++ b/src/content/3/ptbr/part3c.md @@ -0,0 +1,949 @@ +--- +mainImage: ../../../images/part-3.svg +part: 3 +letter: c +lang: ptbr +--- + +
    + +Antes de irmos ao assunto principal sobre persistência de dados em um banco de dados, vamos dar uma olhada em algumas maneiras diferentes de depurar aplicações Node. + +### Depurando aplicações Node + +A depuração de aplicações Node é um tanto mais difícil do que depurar JavaScript em execução no navegador. A impressão de dados no console é um método testado e comprovado, e sempre vale a pena utilizá-lo. Algumas pessoas acham que métodos mais sofisticados devem ser usados ​​em vez do console, mas eu discordo. Até os melhores desenvolvedores open-source do mundo [usam](https://tenderlovemaking.com/2016/02/05/i-am-a-puts-debuggerer.html) esse [método](https://swizec.com/blog/javascript-debugging-slightly-beyond-consolelog/). + +#### Visual Studio Code + +O depurador do Visual Studio Code pode ser útil em algumas situações. Você pode iniciar a aplicação no modo de depuração assim: + +![captura de tela mostrando a forma de iniciar o depurador no VS Code](../../images/3/35x.png) + +Observe que a aplicação não deve estar em execução em outro console, caso contrário, a porta já estará em uso. + +__Obs.:__ Uma versão mais recente do Visual Studio Code pode ter _Run_ em vez de _Debug_. Além disso, talvez você precise configurar seu arquivo _launch.json_ para iniciar a depuração. Isso pode ser feito selecionando _Add Configuration..._ no menu, que está localizado ao lado do botão verde de play e acima do menu _VARIABLES_, selecionando _Run "npm start" in a debug terminal_. Para instruções de configuração mais detalhadas, leia a [documentação sobre depuração](https://code.visualstudio.com/docs/editor/debugging) do Visual Studio Code. + +Abaixo você pode ver uma captura de tela onde a execução do código foi interrompida no meio do salvamento uma nova nota: + +![captura de tela do vscode mostrando a execução em um ponto de parada](../../images/3/36x.png) + +A execução parou no ponto de parada (breakpoint) na linha 69. É possível ver no console o valor da variável note. Na janela superior esquerda, é possível ver outras coisas relacionadas ao estado da aplicação. + +As setas na parte superior podem ser usadas para controlar o fluxo do depurador. + +Por alguma razão, eu não uso muito o depurador do Visual Studio Code. + +#### As Ferramentas do Desenvolvedor do Chrome + +Também é possível depurar o código com o Console do Desenvolvedor do Chrome, iniciando a aplicação com o comando: + +```bash +node --inspect index.js +``` + +É possível acessar o depurador clicando no ícone verde — o logotipo do Node — que aparece no console de desenvolvedor do Chrome: + +![ferramentas do desenvolvedor com o logotipo verde do node](../../images/3/37.png) + +A visualização da depuração funciona da mesma maneira que fazíamos com as aplicações React. A guia Fontes (Sources) pode ser usada para definir pontos de parada onde a execução do código será pausada. + +![ferramentas do desenvolvedor - ponto de interrupção na guia fontes e variáveis sendo monitoradas](../../images/3/38eb.png) + +Todas as mensagens do console.log da aplicação aparecerão na guia Console do depurador. Também é possível inspecionar valores de variáveis e executar seu próprio código JavaScript. + +![ferramentas do desenvolvedor - guia console mostrando o objeto de nota digitado](../../images/3/39ea.png) + +#### Questione tudo! + +Depurar aplicações Full Stack pode parecer complicado no início. Em breve, nossa aplicação também terá um banco de dados além do frontend e backend, e haverá muitas áreas potenciais para erros na aplicação. + +Quando a aplicação "não funciona", primeiro precisamos descobrir onde o problema realmente está. É muito comum que o problema esteja em um lugar onde você menos esperava, e pode levar minutos, horas ou até mesmo dias antes de encontrar a fonte do problema. + +O segredo é ser sistemático. Como o problema pode existir em qualquer lugar, você deve questionar tudo e eliminar todas as possibilidades uma por uma. Impressão de logs no console, Postman, depuradores e experiência ajudarão. + +Quando bugs acontecem, a pior de todas as estratégias possíveis é continuar escrevendo mais código. Isso garantirá que seu código gere ainda mais bugs para frente, e depurá-los será ainda mais difícil. O princípio [pare e corrija](http://gettingtolean.com/toyota-principle-5-build-culture-stopping-fix/) do Toyota Production Systems também é muito eficaz nessa situação. + +### MongoDB + +Para armazenar indefinidamente nossas notas que estão sendo salvas, precisamos de um banco de dados. A maioria dos cursos ministrados na Universidade de Helsinque utiliza bancos de dados relacionais. Usaremos na maior parte deste curso o [MongoDB](https://www.mongodb.com/), que é um tipo de [banco de dados de documentos](https://en.wikipedia.org/wiki/Document-oriented_database) (document database). + +A razão para usar o Mongo como banco de dados é devido a sua menor complexidade em comparação com um banco de dados relacional. A [Parte 13](/ptbr/part13) do curso mostra como construir backends em Node.js que usam um banco de dados relacional. + +Bancos de dados de documentos diferem de bancos de dados relacionais em como eles organizam dados, bem como nas linguagens de consulta (query languages) que suportam. Bancos de dados de documentos são geralmente categorizados sob o termo genérico [NoSQL](https://en.wikipedia.org/wiki/NoSQL) (Not Only SQL [Não Somente SQL]). + +É possível ler mais sobre bancos de dados de documentos e NoSQL no material do curso para a [7ª semana](https://tikape-s18.mooc.fi/part7/) do curso de Introdução a Bancos de Dados. Infelizmente, o material está atualmente disponível apenas em finlandês. + +Leia agora os capítulos sobre [coleções](https://docs.mongodb.com/manual/core/databases-and-collections/) (collections) e [documentos](https://docs.mongodb.com/manual/core/document/) (documents) do manual do MongoDB para ter uma ideia básica de como um banco de dados de documentos armazena dados. + +Naturalmente, é possível instalar e executar o MongoDB em seu computador. Porém, a internet também está cheia de serviços de banco de dados Mongo que você pode usar. O provedor MongoDB preferido neste curso será o [MongoDB Atlas](https://www.mongodb.com/atlas/database). + +Depois de criar e fazer login em sua conta, vamos começar selecionando o plano gratuito: + +![implantação no mongodb um banco de dados em nuvem gratuito compartilhado](../../images/3/mongo1.png) + +Escolha o provedor de nuvem e a localização e crie o cluster (grupo, aglomerado): + +![escolha compartilhada do mongodb, aws e região](../../images/3/mongo2.png) + +Vamos esperar o cluster ficar pronto para uso. Isso pode levar alguns minutos. + +**Obs.:** não continue antes que o cluster esteja pronto. + +Vamos usar a guia security (segurança) para criar credenciais de usuário para o banco de dados. Observe que essas não são as mesmas credenciais que você usa para fazer login no MongoDB Atlas. Essas serão usadas para que sua aplicação se conecte ao banco de dados. + +![início rápido de segurança do mongodb](../../images/3/mongo3.png) + +Em seguida, temos que definir os endereços IP que têm permissão de acesso ao banco de dados. Visando simplicidade, permitiremos o acesso de todos os endereços IP: + +![acesso à rede mongodb/adicionar lista de acesso ip](../../images/3/mongo4.png) + +Observação: Se o menu modal for diferente para você, de acordo com a documentação do MongoDB, adicione 0.0.0.0 como o IP e allow access from anywhere (permitir acesso de qualquer lugar). + + +Por fim, estamos prontos para nos conectar ao nosso banco de dados. Comece clicando em connect (conectar). + +![mongodb database deployment connect](../../images/3/mongo5.png) + +e escolha: Connect your application (Conecte sua aplicação): + +![conexão à aplicação do mongodb](../../images/3/mongo6.png) + +A imagem exibe o URI do MongoDB, que é o endereço do banco de dados que forneceremos à biblioteca-cliente do MongoDB que adicionaremos à nossa aplicação. + +O endereço se parece com isso: + +```bash +mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/?retryWrites=true&w=majority +``` + +Estamos prontos para usar o banco de dados. + +Poderíamos usar o banco de dados diretamente do nosso código JavaScript com a biblioteca oficial [MongoDB Node.js Driver](https://mongodb.github.io/node-mongodb-native/), mas ela é bastante complicada de usar. Em vez disso, usaremos a biblioteca [Mongoose](http://mongoosejs.com/index.html), que oferece uma API de alto nível. + +Mongoose poderia ser descrito como um mapeador de documento-objeto (ODM [object document mapper]), que permite salvar diretamente objetos JavaScript como documentos Mongo. + +Vamos instalar o Mongoose: + +```bash +npm install mongoose +``` + +Ainda não vamos adicionar nenhum código relacionado ao Mongo em nosso backend. Em vez disso, vamos fazer uma aplicação prática criando um novo arquivo, mongo.js: + +```js +const mongoose = require('mongoose') + +if (process.argv.length<3) { + console.log('give password as argument') + process.exit(1) +} + +const password = process.argv[2] + +const url = + `mongodb+srv://fullstack:${password}@cluster0.o1opl.mongodb.net/?retryWrites=true&w=majority` + +mongoose.set('strictQuery',false) +mongoose.connect(url) + +const noteSchema = new mongoose.Schema({ + content: String, + important: Boolean, +}) + +const Note = mongoose.model('Note', noteSchema) + +const note = new Note({ + content: 'HTML is Easy', + important: true, +}) + +note.save().then(result => { + console.log('note saved!') + mongoose.connection.close() +}) +``` + +**Obs.:** Dependendo da região que você selecionou ao criar seu cluster, o URI do MongoDB pode ser diferente do exemplo fornecido acima. Você deve verificar e usar o URI correto que foi gerado pelo MongoDB Atlas. + +O código também assume que será passada a senha das credenciais que criamos no MongoDB Atlas como um parâmetro de linha de comando. Podemos acessar o parâmetro da linha de comando assim: + +```js +const password = process.argv[2] +``` + +Quando o código é executado com o comando node mongo.js password, o Mongo adicionará um novo documento ao banco de dados. + +**Obs.:** Observe que a senha usada é a senha criada para o usuário do banco de dados, não a senha do MongoDB Atlas. Além disso, se criou uma senha com caracteres especiais, então você precisará [codificar por cento sua senha (também conhecido como codificação URL ou URL encoding)](https://docs.atlas.mongodb.com/troubleshoot-connection/#special-characters-in-connection-string-password). + +Podemos visualizar o estado atual do banco de dados do MongoDB Atlas a partir da guia Browse collections, na opção Databases. + +![botão de navegação de coleções de bancos de dados mongodb](../../images/3/mongo7.png) + +Como a imagem indica, o document (documento) correspondente à nota foi adicionado à coleção notes no banco de dados myFirstDatabase. + +![guia de coleções do mongodb - 'notes' no banco de dados 'myFirstDatabase'](../../images/3/mongo8new.png) + +Vamos excluir o banco de dados padrão test e mudar o nome do banco de dados referenciado em nossa string de conexão para noteApp, modificando o URI: + +```js +const url = + `mongodb+srv://fullstack:${password}@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority` +``` + +Vamos executar novamente nosso código: + +![guia de coleções mongodb - 'noteApp notes'](../../images/3/mongo9.png) + +Os dados agora estão armazenados no banco de dados correto. A visualização também oferece a funcionalidade create database (criar banco de dados), que pode ser usada para criar novos bancos de dados a partir da plataforma. Não é necessário criar um banco de dados dessa forma, pois o MongoDB Atlas cria automaticamente um novo banco de dados quando uma aplicação tenta se conectar a um banco de dados que ainda não existe. + +### Esquema (Schema) + +Depois de estabelecer a conexão com o banco de dados, definimos o [schema](http://mongoosejs.com/docs/guide.html) (esquema) para uma nota e o [model](http://mongoosejs.com/docs/models.html) (modelo) correspondente: + +```js +const noteSchema = new mongoose.Schema({ + content: String, + important: Boolean, +}) + +const Note = mongoose.model('Note', noteSchema) +``` + +Primeiro, definimos o [schema](http://mongoosejs.com/docs/guide.html) de uma nota que é armazenada na variável _noteSchema_. O esquema informa ao Mongoose como os objetos de nota devem ser armazenados no banco de dados. + +Na definição do modelo _Note_, o primeiro parâmetro "Note" é o nome singular do modelo. O nome da coleção será o plural notes em minúsculo, porque a [convenção do Mongoose](http://mongoosejs.com/docs/models.html) estabelece a nomeação automática de coleções com o seu plural (por exemplo, notes) quando o esquema se refere a elas no singular (por exemplo, Note). + +Bancos de dados de documentos como o Mongo são schemaless (sem esquema), o que significa que o banco de dados em si não se importa com a estrutura dos dados armazenados no banco de dados. É possível armazenar documentos com campos completamente diferentes na mesma coleção. + +A ideia por trás do Mongoose é que os dados armazenados no banco de dados recebam um esquema no nível da aplicação que define a forma dos documentos armazenados em qualquer coleção. + +### Criando e salvando objetos + +Em seguida, a aplicação cria um novo objeto de nota com a ajuda do [modelo](http://mongoosejs.com/docs/models.html) Note: + +```js +const note = new Note({ + content: 'HTML is Easy', + important: false, +}) +``` + +Modelos são chamados de funções construtoras (constructor functions) que criam novos objetos JavaScript com base nos parâmetros fornecidos. Como os objetos são criados com a função construtora do modelo, eles herdam todas as propriedades do modelo, que incluem métodos para salvar o objeto no banco de dados. + +O salvamento do objeto no banco de dados ocorre com o método apropriadamente chamado _save_, que pode ser fornecido com um gerenciador de evento com o método _then_: + +```js +note.save().then(result => { + console.log('note saved!') + mongoose.connection.close() +}) +``` + +Quando o objeto é salvo no banco de dados, o gerenciador de evento fornecido para _then_ é chamado. O gerenciador de evento fecha a conexão do banco de dados com o comando mongoose.connection.close(). Se a conexão não for fechada, o programa nunca terminará sua execução. + +O resultado da operação de salvamento está no parâmetro _result_ do gerenciador de evento. O resultado não é lá muito interessante quando estamos armazenando um objeto no banco de dados. Você pode imprimir o objeto no console se quiser examiná-lo mais de perto enquanto implementa sua aplicação ou durante a depuração. + +Vamos também salvar algumas notas adicionais modificando os dados no código e executando o programa novamente. + +**Obs.:** Infelizmente, a documentação do Mongoose não é muito consistente: usam callbacks em alguns de seus exemplos; em outras partes, outros estilos. Portanto, não é recomendado copiar e colar o código diretamente de lá. Misturar promessas com callbacks antigos no mesmo código não é recomendado. + +### Recuperando objetos do banco de dados + +Vamos comentar o código que gera novas notas e adicionemos o seguinte: + +```js +Note.find({}).then(result => { + result.forEach(note => { + console.log(note) + }) + mongoose.connection.close() +}) +``` + +Quando o código é executado, o programa imprime todas as notas armazenadas no banco de dados: + +![node mongo.js imprime notas no formato JSON](../../images/3/70new.png) + +Os objetos são recuperados do banco de dados com o método [find](https://mongoosejs.com/docs/api/model.html#model_Model-find) do modelo _Note_. O parâmetro do método é um objeto que expressa condições de pesquisa. Como o parâmetro é um objeto vazio{}, obtemos todas as notas armazenadas na coleção _notes_. + +As condições de pesquisa aderem à [sintaxe](https://docs.mongodb.com/manual/reference/operator/) de consulta do Mongo. + +Podemos restringir nossa pesquisa incluindo apenas notas importantes: + +```js +Note.find({ important: true }).then(result => { + // ... +}) +``` + +
    + +
    + +### Exercício 3.12 + +#### 3.12: Command-line database + +Crie um banco de dados MongoDB em nuvem com o MongoDB Atlas para a aplicação da lista telefônica. + +Crie um arquivo mongo.js no diretório do projeto, que pode ser usado para adicionar entradas à lista telefônica e listar todas as entradas existentes na lista telefônica. + +**Obs.:** Não inclua sua senha no arquivo que você enviará ao GitHub! + +A aplicação deve funcionar da seguinte maneira: o programa é usado através de três argumentos de linha de comando (o primeiro é a senha). Por exemplo: + +```bash +node mongo.js suasenha Anna 040-1234556 +``` + +Como resultado, a aplicação imprimirá: + +```bash +added Anna number 040-1234556 to phonebook +``` + +A nova entrada na lista telefônica será salva no banco de dados. Observe que, se o nome contiver caracteres de espaço em branco, o mesmo deverá ser colocado entre aspas: + +```bash +node mongo.js suasenha "Arto Vihavainen" 045-1232456 +``` + +Se a senha for o único parâmetro fornecido ao programa, ou seja, se for chamado assim: + +```bash +node mongo.js suasenha +``` + +o programa deverá exibir todas as entradas da lista telefônica: + +``` +phonebook: +Anna 040-1234556 +Arto Vihavainen 045-1232456 +Ada Lovelace 040-1231236 +``` + +É possível obter os parâmetros da linha de comando através da variável [process.argv](https://nodejs.org/docs/latest-v8.x/api/process.html#process_process_argv). + +**Obs.: não encerre a conexão no lugar errado**. Por exemplo, o código a seguir não funcionará: + +```js +Person + .find({}) + .then(persons=> { + // ... + }) + +mongoose.connection.close() +``` + +No código acima, o comando mongoose.connection.close() será executado imediatamente após a operação Person.find ser iniciada. Isso significa que a conexão com o banco de dados será fechada imediatamente, e a execução nunca chegará ao ponto em que a operação Person.find termina e a função callback é chamada. + +O local correto para fechar a conexão com o banco de dados é no final da função callback: + +```js +Person + .find({}) + .then(persons=> { + // ... + mongoose.connection.close() + }) +``` + +**Obs.:** Se você definir um modelo com o nome Person, o mongoose nomeará automaticamente a coleção associada como people. + +
    + +
    + +### Conectando o backend a um banco de dados + +Agora temos conhecimento suficiente para começar a usar o Mongo em nossa aplicação. + +Vamos rapidamente copiar e colar as definições do Mongoose no arquivo index.js: + +```js +const mongoose = require('mongoose') + +// NÃO SALVE SUA SENHA NO GITHUB!! +const url = + `mongodb+srv://fullstack:${password}@cluster0.o1opl.mongodb.net/?retryWrites=true&w=majority` + +mongoose.set('strictQuery',false) +mongoose.connect(url) + +const noteSchema = new mongoose.Schema({ + content: String, + important: Boolean, +}) + +const Note = mongoose.model('Note', noteSchema) +``` + +Vamos mudar o gerenciador para buscar todas as notas da seguinte forma: + +```js +app.get('/api/notes', (request, response) => { + Note.find({}).then(notes => { + response.json(notes) + }) +}) +``` + +Podemos verificar no navegador que o backend funciona na medida em que exibe todos os documentos: + +![o endereço 'api/notes' no navegador mostra as notas em formato JSON](../../images/3/44ea.png) + +A aplicação funciona quase perfeitamente. O frontend assume que cada objeto tem um id único no campo id. Também não queremos retornar o campo de versionamento do Mongo \_\_v para o frontend. + +Uma maneira de formatar os objetos retornados pelo Mongoose é [modificar](https://stackoverflow.com/questions/7034848/mongodb-output-id-instead-of-id) o método _toJSON_ do esquema, que é usado em todas as instâncias dos modelos produzidos com esse esquema. + +Para modificar o método, precisamos alterar as opções configuráveis ​​do esquema. As opções podem ser alteradas usando o método _set_ do esquema. Entre aqui para obter mais informações sobre este método: https://mongoosejs.com/docs/guide.html#options. Veja https://mongoosejs.com/docs/guide.html#toJSON e https://mongoosejs.com/docs/api.html#document_Document-toObject para obter mais informações sobre a opção _toJSON_. + +Entre no link https://mongoosejs.com/docs/api.html#transform para obter mais informações sobre a função de transformação (transform function). + +```js +noteSchema.set('toJSON', { + transform: (document, returnedObject) => { + returnedObject.id = returnedObject._id.toString() + delete returnedObject._id + delete returnedObject.__v + } +}) +``` + +Embora a propriedade \_id dos objetos Mongoose pareça uma string, na verdade é um objeto. O método _toJSON_ que definimos transforma-o em uma string como garantia. Se não fizéssemos essa mudança, isso nos causaria mais problemas no futuro quando começássemos a escrever testes. + +Nenhuma mudança é necessária no gerenciador: + +```js +app.get('/api/notes', (request, response) => { + Note.find({}).then(notes => { + response.json(notes) + }) +}) +``` + +O código usa automaticamente o já definido _toJSON_ quando formata as notas para serem enviadas como resposta. + +### A configuração do banco de dados em seu próprio módulo + +Antes de refatorarmos o restante do backend para usar o banco de dados, vamos extrair o código específico do Mongoose em seu próprio módulo. + +Vamos criar um novo diretório para o módulo chamado models, onde adicionaremos um arquivo chamado note.js: + +```js +const mongoose = require('mongoose') + +mongoose.set('strictQuery', false) + +const url = process.env.MONGODB_URI // highlight-line + +console.log('connecting to', url) // highlight-line + +mongoose.connect(url) +// highlight-start + .then(result => { + console.log('connected to MongoDB') + }) + .catch((error) => { + console.log('error connecting to MongoDB:', error.message) + }) +// highlight-end + +const noteSchema = new mongoose.Schema({ + content: String, + important: Boolean, +}) + +noteSchema.set('toJSON', { + transform: (document, returnedObject) => { + returnedObject.id = returnedObject._id.toString() + delete returnedObject._id + delete returnedObject.__v + } +}) + +module.exports = mongoose.model('Note', noteSchema) // highlight-line +``` + +A definição de [módulos](https://nodejs.org/docs/latest-v8.x/api/modules.html) do Node difere um pouco da maneira de definir [módulos ES6](/ptbr/part2/renderizacao_de_uma_colecao_e_modulos#refatorando-modulos) da Parte 2. + +A interface pública do módulo é estabelecida ao definir um valor para a variável _module.exports_. Vamos definir o valor como o modelo Note. As outras coisas definidas dentro do módulo, como as variáveis _mongoose_ e _url_, não serão acessíveis ou visíveis para os usuários do módulo. + +A importação do módulo acontece adicionando a seguinte linha no arquivo index.js: + +```js +const Note = require('./models/note') +``` + +Dessa forma, a variável _Note_ será atribuída ao mesmo objeto que o módulo define. + +A forma como a conexão é feita mudou um pouco: + +```js +const url = process.env.MONGODB_URI + +console.log('connecting to', url) + +mongoose.connect(url) + .then(result => { + console.log('connected to MongoDB') + }) + .catch((error) => { + console.log('error connecting to MongoDB:', error.message) + }) +``` + +Não é uma boa ideia colocar o endereço do banco de dados no código; então, em vez disso, o endereço do banco de dados é passado para a aplicação através da variável de ambiente MONGODB_URI. + +Agora, o método para estabelecer a conexão recebe funções para lidar com uma tentativa de conexão bem-sucedida e mal-sucedida. Ambas as funções registram apenas uma mensagem no console sobre o status de sucesso: + +![saída do Node quando nome de usuário/senha são incorretos](../../images/3/45e.png) + +Existem muitas maneiras de definir o valor de uma variável de ambiente. Uma maneira seria defini-la quando a aplicação é iniciada: + +```bash +MONGODB_URI=address_here npm run dev +``` + +Uma maneira mais sofisticada é usar a biblioteca [dotenv](https://github.com/motdotla/dotenv#readme). É possível instalá-la com o comando: + +```bash +npm install dotenv +``` + +Para usar a biblioteca, criamos um arquivo .env na raiz do projeto. As variáveis de ambiente são definidas dentro do arquivo, coisa que se parece assim: + +```bash +MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority +PORT=3001 +``` + +Também adicionamos a porta indicada do servidor na variável de ambiente PORT. + +**O arquivo .env deve ser ignorado (adicionado ao arquivo .gitignore) imediatamente, pois não queremos publicar informações confidenciais na internet!** + +![.gitignore no vscode com a linha .env adicionada](../../images/3/45ae.png) + +As variáveis de ambiente definidas no arquivo .env podem ser utilizadas com a expressão require('dotenv').config(), onde é possível se referir a elas em seu código da mesma maneira que você se refere a variáveis de ambiente normais, com a já familiar sintaxe process.env.MONGODB_URI. + +Vamos mudar o arquivo index.js da seguinte maneira: + +```js +require('dotenv').config() // highlight-line +const express = require('express') +const app = express() +const Note = require('./models/note') // highlight-line + +// .. + +const PORT = process.env.PORT // highlight-line +app.listen(PORT, () => { + console.log(`Server running on port (Servidor em execução na porta) ${PORT}`) +}) +``` + +É importante que o dotenv seja importado antes do modelo note. Isso garante que as variáveis de ambiente do arquivo .env estejam disponíveis globalmente antes que o código dos outros módulos seja importado. + +### Nota importante para usuários do Fly.io + +Como o GitHub não é usado com Fly.io, o arquivo .env também é enviado para os servidores Fly.io quando a aplicação é implantada. Por causa disso, as variáveis de ambiente definidas no arquivo também estarão disponíveis lá. + +No entanto, uma [opção melhor](https://community.fly.io/t/clarification-on-environment-variables/6309) é impedir que .env seja copiado ao Fly.io criando, na raiz do projeto, o arquivo _.dockerignore_ com o seguinte conteúdo: + +```bash +.env +``` + +Defina o valor da variável de ambiente na linha de comando desta forma: + +``` +fly secrets set MONGODB_URI='mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority' +``` + +Já que a variável PORT também é definida em nosso .env, é essencial ignorar o arquivo no Fly.io, caso contrário, a aplicação iniciará na porta errada. + +Ao usar o Render, a url do banco de dados é fornecida definindo a variável de ambiente adequada no painel: + +![navegador mostrando as variáveis de ambiente](../../images/3/render-env.png) + +### Usando o banco de dados em gerenciadores de evento de rotas + +Agora, vamos mudar o resto da funcionalidade do backend para usar o banco de dados. + +Cria-se uma nova nota desta forma: + +```js +app.post('/api/notes', (request, response) => { + const body = request.body + + if (body.content === undefined) { + return response.status(400).json({ error: 'content missing' }) + } + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + + note.save().then(savedNote => { + response.json(savedNote) + }) +}) +``` + +Os objetos de note são criados com a função construtora _Note_. A resposta é enviada dentro da função callback para a operação _save_. Isso garante que a resposta seja enviada somente se a operação tiver sucesso. Discutiremos sobre gerenciamento de erros um pouco mais tarde. + +O parâmetro _savedNote_ na função callback é a nota salva e recém-criada. Os dados enviados de volta na resposta são a versão formatada criada automaticamente com o método _toJSON_: + +```js +response.json(savedNote) +``` + +Busca-se uma nota individual utilizando o método [findById](https://mongoosejs.com/docs/api/model.html#model_Model-findById) (grosso modo, "acharPorId") do Mongoose, onde nosso código altera-se da seguinte forma: + +```js +app.get('/api/notes/:id', (request, response) => { + Note.findById(request.params.id).then(note => { + response.json(note) + }) +}) +``` + +### Verificando a integração entre frontend e backend + +Ao se expandir o backend, sempre é uma boa ideia testá-lo primeiro **com o navegador, com o Postman ou com o cliente REST do VS Code**. Em seguida, vamos tentar criar uma nova nota depois colocar o banco de dados em uso: + +![Cliente REST do VS Code utilizando o método POST](../../images/3/46new.png) + +Somente depois de verificar se tudo funciona no backend, é uma boa ideia testar se o frontend funciona com o backend. É extremamente ineficiente testar as funcionalidades exclusivamente pelo frontend. + +Sempre é uma boa ideia integrar tanto ao frontend quanto ao backend uma funcionalidade de cada vez. Primeiro, poderíamos implementar a busca de todas as notas no banco de dados e testá-la por meio do endpoint do backend no navegador. Após, verificaríamos se o frontend funciona com o novo backend. Depois que tudo parecer estar funcionando, passaríamos para a próxima funcionalidade. +_Nota dos tradutores_: objetivamente, dentro do conceito de API, um endpoint é o endereço URL que identifica e acessa um recurso específico. Leia mais [aqui](https://www.cloudflare.com/pt-br/learning/security/api/what-is-api-endpoint/). + +Uma vez que um banco de dados é introduzido nessa mistura, é sempre útil inspecionar o estado persistido no banco de dados através, por exemplo, do painel de controle do MongoDB Atlas. Muitas vezes, pequenos programas auxiliares do Node como o mongo.js que escrevemos anteriormente podem ser muito úteis durante o desenvolvimento. + +Você pode encontrar o código da nossa aplicação atual na íntegra na branch part3-4 deste repositório do [GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-4). + +
    + +
    + +### Exercícios 3.13 a 3.14 + +Os próximos exercícios são bem simples, mas se o seu frontend parar de funcionar com o backend, pode ser até bastante interessante encontrar e corrigir os bugs que forem surgindo. + +#### 3.13: Phonebook database — 1º passo + +Altere a funcionalidade que busca todas as entradas da lista telefônica para que os dados sejam buscados do banco de dados. + +Verifique se o frontend funciona depois que as alterações forem feitas. + +Nos próximos exercícios, escreva todo o código específico do Mongoose em seu próprio módulo, assim como fizemos no capítulo ["A configuração do banco de dados em seu próprio módulo"](/ptbr/part3/salvando_dados_no_mongo_db#a-configuracao-do-banco-de-dados-em-seu-proprio-modulo). + +#### 3.14: Phonebook database — 2º passo + +Altere o backend para que os novos números sejam salvos no banco de dados. Verifique se o seu frontend ainda funciona após as alterações. + +Você pode ignorar, nesta etapa, se já existe uma pessoa no banco de dados com o mesmo nome da pessoa que você está adicionando. + +
    + +
    + +### Gerenciamento de erros + +Se tentarmos visitar a URL de uma nota com um ID que não existe, por exemplo , onde 5c41c90e84d891c15dfa3431 não é um ID armazenado no banco de dados, a resposta será _null_. + +Vamos mudar esse comportamento para que, se uma nota com o ID fornecido não existir, o servidor responda à requisição com o código de status HTTP "404 not found" (404 não encontrado(a)). Além disso, vamos implementar um simples bloco catch para lidar com casos em que a promessa retornada pelo método findById é rejeitada: + +```js +app.get('/api/notes/:id', (request, response) => { + Note.findById(request.params.id) + .then(note => { + // highlight-start + if (note) { + response.json(note) + } else { + response.status(404).end() + } + // highlight-end + }) + // highlight-start + .catch(error => { + console.log(error) + response.status(500).end() + }) + // highlight-end +}) +``` + +Se nenhum objeto correspondente for encontrado no banco de dados, o valor de _note_ será _null_ e o bloco _else_ será executado. Isso resulta em uma resposta com o código de status 404 not found (404 não encontrado(a)). Se uma promessa retornada pelo método findById for rejeitada, a resposta terá o código de status 500 internal server error (500 erro interno do servidor). O console exibe informações mais detalhadas sobre o erro. + +Além da nota inexistente, há mais uma situação de erro que precisa ser lidada. Nessa situação, estamos tentando buscar uma nota com o tipo errado de _id_, ou seja, um _id_ que não corresponde ao formato de identificador do Mongo. + +Se fizermos a seguinte requisição, obteremos a mensagem de erro mostrada abaixo: + +``` +Method: GET +Path: /api/notes/someInvalidId +Body: {} +--- +{ CastError: Cast to ObjectId failed for value "someInvalidId" at path "_id" + at CastError (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/error/cast.js:27:11) + at ObjectId.cast (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/schema/objectid.js:158:13) + ... +``` + +Dado um ID mal formatado como argumento, o método findById lançará um erro, fazendo com que a promessa retornada seja rejeitada. Isso fará com que a função callback definida no bloco catch seja chamada. + +Vamos fazer alguns pequenos ajustes de resposta no bloco catch: + +```js +app.get('/api/notes/:id', (request, response) => { + Note.findById(request.params.id) + .then(note => { + if (note) { + response.json(note) + } else { + response.status(404).end() + } + }) + .catch(error => { + console.log(error) + response.status(400).send({ error: 'malformatted id' }) // highlight-line + }) +}) +``` + +Se o formato do ID estiver incorreto, o gerenciador de erro definido no bloco _catch_ será chamado. O código de status apropriado para o erro é [400 Bad Request](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1) (400 requisição inválida), porque a situação se encaixa perfeitamente na descrição: + +> A requisição não pôde ser entendida pelo servidor devido a uma sintaxe mal formatada. O cliente NÃO DEVE repetir a requisição sem modificações. + +Também adicionamos alguns dados à resposta para esclarecer a causa do erro. + +Ao lidar com Promessas, quase sempre é uma boa ideia adicionar gerenciamento de erro e exceção. Caso contrário, você se encontrará lidando com bugs estranhos. + +Nunca é uma má ideia imprimir o objeto que causou a exceção no console do gerenciador de erro: + +```js +.catch(error => { + console.log(error) // highlight-line + response.status(400).send({ error: 'malformatted id' }) +}) +``` + +A razão pela qual o gerenciador de erro é chamado pode ser algo completamente diferente do que você havia imaginado. Se você imprimir o erro no console, poderá se salvar de longas e frustrantes sessões de depuração. Além disso, a maioria dos serviços modernos onde você implanta sua aplicação suporta algum tipo de sistema de registro (logging system) que se pode usar para verificar esses logs. Como já mencionado, o Heroku é um deles. + +Toda vez que você trabalha em um projeto com um backend, é crucial ficar de olho na saída do console do backend. Se você está usando uma tela pequena, já é suficiente ver apenas um pedacinho da tela de saída em segundo plano. Qualquer mensagem de erro chamará sua atenção mesmo quando o console estiver bem escondido: + +![amostra de captura de tela mostrando um pedacinho da tela de saída](../../images/3/15b.png) + +### Transferindo o gerenciamento de erro para um middleware + +Escrevemos o código do gerenciador de erro junto ao restante do código. Pode até ser uma solução razoável às vezes, mas há casos em que é melhor implementar todo o gerenciamento de erro em um único lugar. Isso pode ser particularmente útil se quisermos relatar dados relacionados a erros para um sistema externo de rastreamento de erros (external error-tracking system) como o [Sentry](https://sentry.io/welcome/) posteriormente. + +Vamos mudar o gerenciador para a rota /api/notes/:id para que ele passe o erro adiante com a função next. A função _next_ é passada para o gerenciador como o terceiro parâmetro: + +```js +app.get('/api/notes/:id', (request, response, next) => { // highlight-line + Note.findById(request.params.id) + .then(note => { + if (note) { + response.json(note) + } else { + response.status(404).end() + } + }) + .catch(error => next(error)) // highlight-line +}) +``` + +O erro que é passado adiante é devido à função next como parâmetro. Se next for chamada sem um parâmetro, então a execução simplesmente avançará para a próxima rota ou middleware. Se a função next for chamada com um parâmetro, então a execução continuará para o middleware de gerenciamento de erro. + +Os [gerenciadores de erro](https://expressjs.com/en/guide/error-handling.html) do Express são middlewares definidos com uma função que aceita quatro parâmetros. Nosso gerenciador de erro é assim: + +```js +const errorHandler = (error, request, response, next) => { + console.error(error.message) + + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } + + next(error) +} + +// Este deve ser o último middleware a ser carregado. +app.use(errorHandler) +``` + +O gerenciador de erro verifica se o erro é uma exceção CastError (grosso modo, "ErroDeLançamento"), caso em que sabemos que o erro foi causado por um id de objeto inválido para o Mongo. Nessa situação, o gerenciador de erro enviará uma resposta ao navegador com o objeto de resposta passado como parâmetro. Em todas as outras situações de erro, o middleware passa o erro para o gerenciador de erro padrão do Express. + +Observe que o middleware de gerenciamento de erro deve ser o último middleware a ser carregado! + +### A ordem de carregamento dos middlewares + +A ordem de execução dos middlewares é a mesma que a ordem em que são carregados no Express com a função _app.use_. Por esse motivo, é importante ter cuidado ao definir os middlewares. + +A ordem correta é a seguinte: + +```js +app.use(express.static('build')) +app.use(express.json()) +app.use(requestLogger) + +app.post('/api/notes', (request, response) => { + const body = request.body + // ... +}) + +const unknownEndpoint = (request, response) => { + response.status(404).send({ error: 'unknown endpoint' }) +} + +// gerenciador de requisições com um endpoint desconhecido +app.use(unknownEndpoint) + +const errorHandler = (error, request, response, next) => { + // ... +} + +// gerenciador de requisições com um resultado para erros +app.use(errorHandler) +``` + +O middleware JSON-parser ("analisador de JSON") deve estar entre os primeiros middlewares carregados no Express. Se a ordem fosse a seguinte... + +```js +app.use(requestLogger) // request.body torna-se indefinido! + +app.post('/api/notes', (request, response) => { + // request.body torna-se indefinido! + const body = request.body + // ... +}) + +app.use(express.json()) +``` + +... os dados JSON enviados com as requisições HTTP não estariam disponíveis para o middleware de registro (logger middleware) ou para o gerenciador da rota POST, já que o _request.body_ estaria _indefinido_ nesse ponto. + +Também é importante que o middleware que gerencia rotas não suportadas esteja próximo ao último middleware que é carregado no Express, logo antes do gerenciador de erro. + +Por exemplo, a seguinte ordem de carregamento causaria um problema: + +```js +const unknownEndpoint = (request, response) => { + response.status(404).send({ error: 'unknown endpoint' }) +} + +// gerenciador de requisições com endpoint desconhecido +app.use(unknownEndpoint) + +app.get('/api/notes', (request, response) => { + // ... +}) +``` + +Dessa forma, o gerenciamento de endpoints desconhecidos é posto em ordem antes do gerenciador de requisições HTTP. Como o gerenciador de endpoints desconhecidos responde a todas as requisições com 404 unknown endpoint (404 endpoint desconhecido), nenhuma rota ou middleware será chamado após a resposta ter sido enviada pelo middleware de endpoint desconhecido. A única exceção a isso é o gerenciador de erros, que precisa vir no final, após o gerenciador de endpoints desconhecidos. + +### Outras operações + +Vamos adicionar algumas funcionalidades restantes à nossa aplicação, incluindo a exclusão e a atualização de uma nota individual. + +A maneira mais fácil de excluir uma nota do banco de dados é com o método [findByIdAndDelete](https://mongoosejs.com/docs/api/model.html#model_Model-findByIdAndDelete) (grosso modo, "acharPorIdERemover"): + +```js +app.delete('/api/notes/:id', (request, response, next) => { + Note.findByIdAndDelete(request.params.id) + .then(result => { + response.status(204).end() + }) + .catch(error => next(error)) +}) +``` + +Em ambos os casos "bem-sucedidos" de exclusão de um recurso, o backend responde com o código de status 204 no content (204 sem conteúdo). Os dois casos diferentes são: (1) excluir uma nota que existe; e (2) excluir uma nota que não existe no banco de dados. O parâmetro de retorno _result_ poderia ser usado para verificar se um recurso foi realmente excluído, e poderíamos usar essa informação para retornar diferentes códigos de status para os dois casos, caso julgássemos necessário. Qualquer exceção que venha a ocorrer é lançada ao gerenciador de erro. + +A alternância da importância de uma nota pode ser facilmente realizada com o método [findByIdAndUpdate](https://mongoosejs.com/docs/api/model.html#model_Model-findByIdAndUpdate) (grosso modo, "acharPorIdEAtualizar"). + +```js +app.put('/api/notes/:id', (request, response, next) => { + const body = request.body + + const note = { + content: body.content, + important: body.important, + } + + Note.findByIdAndUpdate(request.params.id, note, { new: true }) + .then(updatedNote => { + response.json(updatedNote) + }) + .catch(error => next(error)) +}) +``` + +No código acima, também permitimos a edição do conteúdo da nota. + +Observe que o método findByIdAndUpdate recebe um objeto JavaScript comum como parâmetro, e não um novo objeto de nota criado com a função construtora Note. + +Existe um detalhe importante em relação ao uso do método findByIdAndUpdate. Por padrão, o parâmetro updatedNote do gerenciador de evento recebe o documento original [sem as modificações](https://mongoosejs.com/docs/api/model.html#model_Model-findByIdAndUpdate). Adicionamos o parâmetro opcional { new: true }, que fará com que nosso gerenciador de evento seja chamado com o novo documento modificado em vez do original. + +Após testar o backend diretamente com o Postman e o cliente REST do VS Code, podemos verificar que parece funcionar. O frontend também parece funcionar com o backend usando o banco de dados. + +É possível encontrar o código da nossa aplicação atual na íntegra na branch part3-5 [neste repositório do GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-5). + +### Juramento de um Verdadeiro Programador Full Stack + +Chegou novamente a hora dos exercícios. A complexidade de nossa aplicação cresceu pois, além do frontend e do backend, também temos um banco de dados. Há de fato muitas fontes potenciais de erros. + +Portanto, devemos estender novamente nosso juramento: + +Desenvolvimento Full Stack é algo extremamente difícil, e é por isso que eu usarei todos os meios possíveis para torná-lo mais fácil: + +- Eu manterei meu Console do navegador sempre aberto; +- Eu usarei a guia Rede das Ferramentas do Desenvolvedor do navegador para garantir que o frontend e o backend estejam se comunicando da forma que eu planejei; +- Eu ficarei de olho no estado do servidor para garantir que os dados enviados pelo frontend estejam sendo salvos lá da forma que eu planejei; +- Eu ficarei de olho no banco de dados: o backend salva os dados no banco de dados no formato correto? ; +- Eu vou progredir aos poucos, passo a passo; +- Eu escreverei muitas instruções _console.log_ para ter certeza de que estou entendendo como o código se comporta e para me ajudar a identificar os erros; +- Se meu código não funcionar, não escreverei mais nenhuma linha no código. Em vez disso, começarei a excluir o código até que funcione ou retornarei ao estado em que tudo ainda estava funcionando; e +- Quando eu pedir ajuda no canal do Discord do curso ou em outro lugar, formularei minhas perguntas de forma adequada. Veja [aqui](/ptbr/part0/informacoes_gerais#como-pedir-ajuda-no-discord) como pedir ajuda. + +
    + +
    + +### Exercícios 3.15 a 3.18 + +#### 3.15: Phonebook database — 3º passo + +Altere o backend para que a exclusão de entradas da lista telefônica seja refletida no banco de dados. + +Verifique se o frontend ainda funciona após as alterações. + +#### 3.16: Phonebook database — 4º passo + +Transfira o gerenciamento de erros da aplicação para um novo middleware gerenciador de erros. + +#### 3.17*: Phonebook database — 5º passo + +Se o usuário tentar criar uma nova entrada na lista telefônica para uma pessoa cujo nome já está presente na lista, o frontend tentará atualizar o número de telefone da entrada existente fazendo uma requisição HTTP PUT para a URL única da entrada. + +Modifique o backend para suportar essa requisição. + +Verifique se o frontend funciona após fazer suas alterações. + +#### 3.18*: Phonebook database — 6º passo + +Atualize também o gerenciamento das rotas api/persons/:id e info para usar o banco de dados e verifique se elas funcionam diretamente com o navegador, com o Postman ou com o cliente REST do VS Code. + +A verificação no navegador de uma entrada individual da lista telefônica deve ocorrer da seguinte maneira: + +![captura de tela do navegador mostrando uma pessoa utilizando a rota 'api/persons/their_id'](../../images/3/49.png) + +
    diff --git a/src/content/3/ptbr/part3d.md b/src/content/3/ptbr/part3d.md new file mode 100644 index 00000000000..b32fb904499 --- /dev/null +++ b/src/content/3/ptbr/part3d.md @@ -0,0 +1,399 @@ +--- +mainImage: ../../../images/part-3.svg +part: 3 +letter: d +lang: ptbr +--- + +
    + +Existem restrições que normalmente queremos aplicar aos dados armazenados no banco de dados da nossa aplicação. Nossa aplicação não deve aceitar notas que tenham uma propriedade content em falta ou vazia. A validade da nota é verificada no gerenciador de rota: + +```js +app.post('/api/notes', (request, response) => { + const body = request.body + // highlight-start + if (body.content === undefined) { + return response.status(400).json({ error: 'content missing' }) + } + // highlight-end + + // ... +}) +``` + +Se a nota não tiver a propriedade content, respondemos à requisição com o código de status 400 bad request (400 requisição inválida). + +Uma maneira mais inteligente de validar o formato dos dados antes de serem armazenados no banco de dados é usar a funcionalidade de [validação](https://mongoosejs.com/docs/validation.html) (validation) disponível no Mongoose. + +Podemos definir regras de validação específicas para cada campo no esquema: + +```js +const noteSchema = new mongoose.Schema({ + // highlight-start + content: { + type: String, + minLength: 5, + required: true + }, + // highlight-end + important: Boolean +}) +``` + +O campo content agora deve ter pelo menos cinco caracteres de comprimento e é definido como obrigatório, o que significa que não pode estar faltando. Não adicionamos nenhuma restrição ao campo important, portanto, sua definição no esquema não mudou. + +Os validadores minLength (grosso modo, "comprimentoMínimo") e required são [integrados](https://mongoosejs.com/docs/validation.html#built-in-validators) (built-in) e fornecidos pelo Mongoose. A funcionalidade de [validação personalizada](https://mongoosejs.com/docs/validation.html#custom-validators) do Mongoose nos permite criar novos validadores se nenhum dos integrados atender às nossas necessidades. + +Se tentarmos armazenar no banco de dados um objeto que viola uma das restrições, a operação lançará uma exceção. Vamos alterar nosso gerenciador para criar uma nova nota para que ele passe quaisquer exceções potenciais para o middleware gerenciador de erros: + +```js +app.post('/api/notes', (request, response, next) => { // highlight-line + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + + note.save() + .then(savedNote => { + response.json(savedNote) + }) + .catch(error => next(error)) // highlight-line +}) +``` + +Vamos expandir o gerenciador de erros para lidar com os erros de validação: + +```js +const errorHandler = (error, request, response, next) => { + console.error(error.message) + + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } else if (error.name === 'ValidationError') { // highlight-line + return response.status(400).json({ error: error.message }) // highlight-line + } + + next(error) +} +``` + +Quando a validação de um objeto falha, retornamos a seguinte mensagem de erro padrão do Mongoose: + +![mensagem de erro sendo exibida no postman](../../images/3/50.png) + +Percebemos que o backend tem agora um problema: as validações não são executadas quando uma nota é editada. +A [documentação](https://github.com/blakehaswell/mongoose-unique-validator#find--updates) explica qual é o problema: as validações não são executadas por padrão quando findOneAndUpdate é executado. + +É fácil a correção. Também vamos reformular um pouco o código da rota: + +```js +app.put('/api/notes/:id', (request, response, next) => { + const { content, important } = request.body // highlight-line + + Note.findByIdAndUpdate( + request.params.id, + { content, important }, // highlight-line + { new: true, runValidators: true, context: 'query' } // highlight-line + ) + .then(updatedNote => { + response.json(updatedNote) + }) + .catch(error => next(error)) +}) +``` + +### Implantando o backend do banco de dados para produção + +A aplicação deve funcionar quase como está no Fly.io/Render. Não precisamos gerar um novo build de produção do frontend, uma vez que as alterações até agora ocorreram apenas no backend. + +As variáveis de ambiente definidas em dotenv só serão usadas quando o backend não estiver em modo de produção (production mode), ou seja, no Fly.io ou Render. + +Para colocar em produção, temos que definir a URL do banco de dados no serviço que está hospedando nossa aplicação. + +Isso é feito no Fly.io com _fly secrets set_: + +``` +fly secrets set MONGODB_URI='mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority' +``` + +Quando a aplicação está sendo desenvolvida, é mais do que provável que algo falhe. Por exemplo, quando implantei minha aplicação pela primeira vez com o banco de dados, não foi exibida uma única nota sequer: + +![nenhuma nota sendo exibida na aplicação online](../../images/3/fly-problem1.png) + +O console do navegador na guia Rede revelou que a busca pelas notas não teve sucesso — a requisição simplesmente permaneceu por muito tempo no estado _pendente_ (pending) até falhar e exibir o código de status 502. + +O console do navegador tem que estar aberto o tempo todo! + +Também é vital acompanhar continuamente os logs do servidor. O problema ficou óbvio quando os logs foram abertos com _fly logs_: + +![logs da aplicação online no Fly.io](../../images/3/fly-problem3.png) + +A URL do banco de dados estava _undefined_ (indefinida), então o comando *fly secrets set MONGODB\_URI* foi esquecido. + +Ao usar o Render, a URL do banco de dados é fornecida definindo a variável de ambiente adequada no painel: + +![opção da variáveis de ambiente no painel do Render](../../images/3/render-env.png) + +O Painel do Render mostra os logs do servidor: + +![logs da aplicação online no Render](../../images/3/r7.png) + +É possível encontrar o código da nossa aplicação atual na íntegra na branch part3-5 [neste repositório do GitHub](https://github.com/fullstack-hy2019/part3-notes-backend/tree/part3-5). + +
    + +
    + +### Exercícios 3.19 a 3.21 + +#### 3.19*: Phonebook database — 7º passo + +Expanda a validação para que o nome armazenado no banco de dados tenha pelo menos três caracteres. + +Expanda o frontend para que seja exibido algum tipo de mensagem de erro quando ocorrer um erro de validação. O gerenciamento de erros pode ser implementado adicionando um bloco catch como mostrado abaixo: + +```js +personService + .create({ ... }) + .then(createdPerson => { + // ... + }) + .catch(error => { + // esta é a maneira de acessar a mensagem de erro + console.log(error.response.data.error) + }) +``` + +Você pode exibir a mensagem de erro padrão retornada pelo Mongoose, mesmo que essas mensagens não sejam tão legíveis: + +![captura de tela da lista telefônica mostrando falha na validação de uma pessoa](../../images/3/56e.png) + +**Obs.:** Os validadores do mongoose são desativados por padrão nas operações de atualização. [Leia a documentação](https://mongoosejs.com/docs/validation.html) para saber como ativá-los. + +#### 3.20*: Phonebook database — 8º passo + +Aplique a validação à sua aplicação da lista telefônica, na qual garantirá que os números de telefone estejam no formato correto. Um número de telefone deve: + +- Ter comprimento de 8 ou mais caracteres; +- Se for composto por duas partes que são separadas por "-" (hífen), a primeira parte deve ter dois ou três números e a segunda parte também é completada com o restante do número telefônico: + - Ex.: 09-1234556 e 040-22334455 são números válidos; + - Ex.: 1234556, 1-22334455 e 10-22-334455 são números inválidos. + +Use um [Validador Personalizado](https://mongoosejs.com/docs/validation.html#custom-validators) (Custom validator) para implementar a segunda parte da validação. + +Se uma requisição HTTP POST tentar adicionar uma pessoa com um número de telefone inválido, o servidor deve responder com um código de status apropriado e uma mensagem de erro. + +#### 3.21 Implantação do backend do banco de dados para produção + +Gere uma nova versão "full stack" da aplicação criando um novo build de produção do frontend, assim copiando-o ao repositório do backend. Verifique se tudo funciona localmente acessando a aplicação inteira no endereço . + +Envie a versão mais recente para o Fly.io/Render e verifique se tudo funciona lá também. + +**NOTA:** você deve implantar o BACKEND no serviço em nuvem. Se estiver usando o Fly.io, os comandos devem ser executados no diretório raiz do backend (ou seja, no mesmo diretório onde está o package.json do backend). Caso esteja usando o Render, o backend deve estar na raiz do seu repositório. + +Você NÃO deve implantar o frontend diretamente em nenhuma etapa desta parte. Somente o repositório do backend que é implantado nesta parte, nada mais. + +
    + +
    + +### Lint + +Antes de prosseguirmos para a próxima parte, vamos dar uma olhada em uma ferramenta importante chamada [lint](). A Wikipedia diz o seguinte sobre o lint: + +> De forma genérica, lint ou um linter é qualquer ferramenta que detecta e sinaliza erros em linguagens de programação, incluindo erros de estilo. O termo "lint-like behavior" ("de um jeito lint") é às vezes aplicado ao processo de sinalização do uso suspeito da linguagem. Ferramentas semelhantes ao lint geralmente realizam análise estática do código fonte. + +Em linguagens compiladas de tipagem estática, como Java, IDEs (Integrated Development Environment [Ambiente de Desenvolvimento Integrado]) como o NetBeans podem apontar erros no código, inclusive aqueles que são mais do que apenas erros de compilação. Ferramentas adicionais que fazem [análise estática](https://en.wikipedia.org/wiki/Static_program_analysis) (static analysis), como [checkstyle](https://checkstyle.sourceforge.io), podem ser usadas para expandir as capacidades da IDE e apontar também problemas relacionados ao estilo, como a indentação (indentation). + +No universo JavaScript, a ferramenta líder atual para análise estática, também conhecida como "linting", é o [ESlint](https://eslint.org/). + +Vamos instalar o ESlint como uma dependência de desenvolvimento no projeto backend com o comando: + +```bash +npm install eslint --save-dev +``` + +Após isso, podemos inicializar uma configuração padrão do ESlint com o comando: + +```bash +npx eslint --init +``` + +Responderemos à todas as perguntas: + +![saída do terminal proveniente do comando 'eslint --init'](../../images/3/52new.png) + +A configuração será salva no arquivo _.eslintrc.js_: + +```js +module.exports = { + 'env': { + 'commonjs': true, + 'es2021': true, + 'node': true // highlight-line + }, + 'extends': 'eslint:recommended', + 'parserOptions': { + 'ecmaVersion': 'latest' + }, + 'rules': { + 'indent': [ + 'error', + 4 + ], + 'linebreak-style': [ + 'error', + 'unix' + ], + 'quotes': [ + 'error', + 'single' + ], + 'semi': [ + 'error', + 'never' + ] + } +} +``` + +Vamos mudar imediatamente a regra relacionada à indentação, para que o nível de indentação (ou nível de recuo) seja de dois espaços. + +```js +"indent": [ + "error", + 2 +], +``` + +Inspeciona-se e valida-se um arquivo como _index.js_ da seguinte maneira: + +```bash +npx eslint index.js +``` + +Recomenda-se criar um _npm script_ separado para a análise estática (linting): + +```json +{ + // ... + "scripts": { + "start": "node index.js", + "dev": "nodemon index.js", + // ... + "lint": "eslint ." // highlight-line + }, + // ... +} +``` + +Agora o comando _npm run lint_ verificará todos os arquivos do projeto. + +Também são verificados os arquivos do diretório build quando o comando é executado. Não queremos que isso aconteça; podemos impedir essa análise criando um arquivo [.eslintignore](https://eslint.org/docs/user-guide/configuring#ignoring-files-and-directories) na raiz do projeto adicionando o seguinte: + +```bash +build +``` + +Isso faz com que o diretório inteiro build não seja verificado pelo ESlint. + +O lint tem bastante a dizer sobre o nosso código: + +![saída de erros ESlint no terminal](../../images/3/53ea.png) + +Mas não vamos corrigir esses problemas ainda. + +Uma melhor forma de executar o linter a partir da linha de comando é configurar um eslint-plugin no editor, que executará o linter continuamente. Assim que usar o plugin, você verá erros no seu código imediatamente. É possível encontrar mais informações sobre o plugin Visual Studio ESLint [aqui](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint). + +O plugin ESlint do VS Code sublinhará as violações de estilo com uma linha vermelha: + +![captura de tela do plugin do vscode ESlint exibindo erros](../../images/3/54a.png) + +Isso torna os erros fáceis de detectar e corrigir imediatamente. + +O ESlint tem uma vasta variedade de [regras](https://eslint.org/docs/rules/), das quais são fáceis de usar por meio do arquivo .eslintrc.js. + +Vamos adicionar a regra [eqeqeq](https://eslint.org/docs/rules/eqeqeq) que nos alerta se a igualdade é verificada com algo que não seja o operador de igualdade estrita. A regra é adicionada sob o campo rules no arquivo de configuração. + +```js +{ + // ... + 'rules': { + // ... + 'eqeqeq': 'error', + }, +} +``` + +Já que estamos configurando essa parte, vamos fazer algumas outras mudanças nas regras. + +Vamos evitar [espaços à direita (ou no final da cadeia)](https://eslint.org/docs/rules/no-trailing-spaces) (trailing spaces) ao final das linhas, exigir que [sempre haja um espaço antes e depois das chaves](https://eslint.org/docs/rules/object-curly-spacing) e também exigir um uso consistente de espaços em branco nos parâmetros de funções de seta. + +```js +{ + // ... + 'rules': { + // ... + 'eqeqeq': 'error', + 'no-trailing-spaces': 'error', + 'object-curly-spacing': [ + 'error', 'always' + ], + 'arrow-spacing': [ + 'error', { 'before': true, 'after': true } + ] + }, +} +``` + +Nossa configuração padrão usa uma série de regras pré-determinadas da regra eslint:recommended: + +```bash +'extends': 'eslint:recommended', +``` + +Isso inclui uma regra que avisa sobre comandos _console.log_. Para [desabilitar](https://eslint.org/docs/user-guide/configuring#configuring-rules) uma regra, é necessário que seu "valor" seja definido como 0 no arquivo de configuração. Vamos fazer isso para a regra no-console por enquanto. + +```js +{ + // ... + 'rules': { + // ... + 'eqeqeq': 'error', + 'no-trailing-spaces': 'error', + 'object-curly-spacing': [ + 'error', 'always' + ], + 'arrow-spacing': [ + 'error', { 'before': true, 'after': true } + ], + 'no-console': 0 // highlight-line + }, +} +``` + +**Obs.:** quando se faz alterações no arquivo .eslintrc.js, é recomendado executar o linter a partir da linha de comando. Isso irá verificar se o arquivo de configuração está formatado corretamente: + +![saída do terminal como resultado do comando 'npm run lint'](../../images/3/55.png) + +O plugin do lint irá se comportar incorretamente se houver algo errado em seu arquivo de configuração. + +Muitas empresas definem padrões de código que são aplicados em toda a organização por meio do arquivo de configuração do ESlint. Não é recomendado reinventar a roda toda vez, e pode ser até uma boa ideia adotar uma configuração pré-pronta do projeto de outra pessoa no seu. Muitos projetos adotaram recentemente o [guia de estilo Javascript](https://github.com/airbnb/javascript) da Airbnb, adotando a [configuração do ESlint da empresa](https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb). + +É possível encontrar o código da nossa aplicação atual na íntegra na branch part3-6 [neste repositório do GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-6). +
    + +
    + +### Exercício 3.22 + +#### 3.22: Configuração do Lint + +Adicione o ESlint à sua aplicação e corrija todos os avisos. + +Este foi o último exercício para esta parte do curso, e é hora de enviar seu código para o GitHub e marcar todos os seus exercícios concluídos na guia "my submissions" do [sistema de envio de exercícios](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    diff --git a/src/content/3/zh/part3.md b/src/content/3/zh/part3.md index 394e5a9f2c9..e14d7b800cc 100644 --- a/src/content/3/zh/part3.md +++ b/src/content/3/zh/part3.md @@ -6,9 +6,7 @@ lang: zh
    - - -在这一章中,我们将重点转向后端,也就是转向服务器端的功能实现。 我们将使用 Express 库在 Node.js 中实现一个简单的 REST API,将应用的数据存储在 MongoDB 数据库中。 在本章节的最后,我们将把我们的应用部署到互联网上。 + + 在这一部分,我们的重点转向后端,也就是在堆栈的服务器端实现功能。我们将通过使用Express库在Node.js中实现一个简单的REST API,应用的数据将被存储在MongoDB数据库中。在这一部分的最后,我们将把我们的应用部署到互联网上。
    - diff --git a/src/content/3/zh/part3a.md b/src/content/3/zh/part3a.md index e45b46df853..6ca29c1eb40 100644 --- a/src/content/3/zh/part3a.md +++ b/src/content/3/zh/part3a.md @@ -8,31 +8,36 @@ lang: zh
    - -在这一章中,我们的重点转向后端,也就是转向服务器端的功能实现。 + + 在这一部分中,我们的重点转向后端:也就是说,在堆栈的服务器端实现功能。 - -我们将在[NodeJS](https://nodejs.org/en/)的基础上构建我们的后端,这是一个基于 Google 的 [Chrome V8](https://developers.google.com/v8/) 引擎的 JavaScript 运行时环境。 - -本课程材料是使用 Node.js 的v10.18.0 版本编写的。 请确保您的 Node 版本不低于材料中使用的版本(您可以通过在命令行中运行 _node -v_ 来检查版本)。 + +我们将在 [NodeJS](https://nodejs.org/en/) 的基础上建立我们的后端,这是一个基于谷歌 [Chrome V8](https://developers.google.com/v8/) JavaScript 引擎的 JavaScript 运行时间。 - -正如在 [第1章](/zh/part1/javascript)中提到的,浏览器还不支持 JavaScript 的最新特性,这就是为什么在浏览器中运行的代码必须是[babel](https://babeljs.io/)转译过的。而在后端运行 JavaScript 的情况是不同的。 最新版本的 Node 支持大部分最新的 JavaScript 特性,因此我们可以使用最新的特性而不必转译我们的代码。 - -我们的目标是实现一个后端,它将与 [第2章](/zh/part2/)中的 notes 应用一起工作。 但还是让我们从实现经典的“ hello world”应用的基础开始。 + + 本课程材料是用 Node.js 的 16.13.2 版本编写的。请确保你的 Node 版本至少和教材中使用的版本一样新(你可以通过在命令行中运行 _node -v_ 来检查版本)。 - -注意:本章中的应用和练习并不都是 React 应用,我们不会使用create-react-app工具程序为此应用初始化项目。 + + 正如在 [第一章节](/en/part1/java_script) 中提到的,浏览器还不支持 JavaScript 的最新功能,这就是为什么在浏览器中运行的代码必须用例如 [babel](https://babeljs.io/) 进行 转写 。在后端运行的 JavaScript 的情况则不同。最新版本的 Node 支持 JavaScript 的绝大部分最新特性,所以我们可以使用最新的特性,而不必转译我们的代码。 - -在第2章节中,我们已经提到了[npm](/zh/part2/从服务器获取数据#npm) ,这是一个用于管理 JavaScript 包的工具。 事实上,npm 来源于 Node 生态系统。 + + 我们的目标是实现一个能与 [第二章节](/en/part2/) 中的笔记应用一起工作的后端。然而,让我们从最基本的开始,实现一个经典的 "hello world " 应用。 - -让我们进入到一个合适的目录,并使用_npm init_命令为应用创建一个新模板。 我们将回答该工具提出的问题,结果会在项目根目录下自动生成的package.json 文件,其中包含有关项目的信息。 + + + **注意** 本章节的应用和练习并不都是 React 应用,而且我们不会使用 create-react-app 工具来初始化这个应用的项目。 + + + + 我们在第二章节已经提到了 [npm](/en/part2/getting_data_from_server#npm),它是一个用于管理 JavaScript 包的工具。事实上,npm 起源于 Node 生态系统。 + + + + 让我们导航到一个合适的目录,用 _npm init_ 命令为我们的应用创建一个新模板。我们将回答该工具提出的问题,结果是在项目的根部自动生成一个包含项目信息的 package.json 文件。 ```json { @@ -48,12 +53,13 @@ lang: zh } ``` - -例如,该文件定义应用的入口点是index.js 文件。 + + 该文件定义了,比如说,应用的入口点是 index.js 文件。 - -让我们对scripts 对象做一个小小的修改: + + + 让我们对 scripts 对象做一个小小的改动。 ```bash { @@ -67,30 +73,32 @@ lang: zh ``` - -接下来,我们创建应用的第一个版本,在项目的根目录中添加一个index.js 文件,代码如下: + + 接下来,让我们创建我们应用的第一个版本,在项目的根部添加一个 index.js 文件,代码如下。 ```js console.log('hello world') ``` - -我们可以通过命令行直接用 Node 运行程序: + + 我们可以直接用 Node 从命令行中运行该程序。 ```bash node index.js ``` - -或者我们可以将它作为一个 [npm 脚本](https://docs.npmjs.com/misc/scripts)运行: + + + 或者我们可以作为一个[npm脚本](https://docs.npmjs.com/misc/scripts)运行它。 ```bash npm start ``` - -start 这个npm 脚本之所以有效,是因为我们在 package.json 文件中定义了它: + + + start npm 脚本可以工作,因为我们在 package.json 文件中定义了它。 ```bash { @@ -103,54 +111,56 @@ npm start } ``` - -尽管通过从命令行调用 _node index.js_ 来启动项目是可以工作的,但 npm 项目通常执行 npm 脚本之类的任务。 - -默认情况下,package.json 文件还定义了另一个常用的 npm 脚本,称为npm test。 由于我们的项目还没有测试库,npm test 命令只是执行如下命令: + + 尽管项目的执行在通过从命令行调用 _node index.js_ 来启动时是有效的,但 npm 项目习惯于以 npm 脚本来执行这样的任务。 + + + + 默认情况下,package.json 文件也定义了另一个常用的 npm 脚本,叫做< i>npm test。由于我们的项目还没有一个测试库,_npm test_ 命令只是简单地执行以下命令。 ```bash echo "Error: no test specified" && exit 1 ``` -### Simple web server -【简单的 web 服务器】 +### Simple web server - -让我们把这个应用改成一个 web 服务器: + + + 让我们通过编辑 _index.js_ 文件,将应用变成一个网络服务器,如下所示。 ```js const http = require('http') -const app = http.createServer((req, res) => { - res.writeHead(200, { 'Content-Type': 'text/plain' }) - res.end('Hello World') +const app = http.createServer((request, response) => { + response.writeHead(200, { 'Content-Type': 'text/plain' }) + response.end('Hello World') }) -const port = 3001 -app.listen(port) -console.log(`Server running on port ${port}`) +const PORT = 3001 +app.listen(PORT) +console.log(`Server running on port ${PORT}`) ``` - -一旦运行应用,控制台中就会输出如下消息: + + 一旦应用运行,下面的信息将被打印在控制台。 ```bash Server running on port 3001 ``` - -我们可以在浏览器中通过访问地址 http://localhost:3001打开我们的应用: + + 我们可以通过访问 地址,在浏览器中打开我们卑微的应用。 ![](../../images/3/1.png) - -事实上,无论 URL 的后半部分是什么,服务器的工作方式都是相同的。 地址http://localhost:3001/foo/bar也会显示相同的内容。 + + 事实上,无论 URL 的后半部分是什么,服务器的工作方式都是一样的。同样,地址 将显示相同的内容。 - -注意:如果端口3001已经被其他应用使用,那么启动服务器将产生如下错误消息: + + **NB*如果 3001 端口已经被其他应用使用,那么启动服务器将导致以下错误信息。 ```bash ➜ hello npm start @@ -168,34 +178,35 @@ Error: listen EADDRINUSE :::3001 at listenInCluster (net.js:1378:12) ``` - -你有两个选择。 要么关闭使用3001端口应用(教材上一章最后一章节的 json-server 使用的就是3001端口) ,要么为此应用使用不同的端口。 - -让我们仔细看看代码的第一行: + + 你有两个选择。要么关闭使用 3001 端口的应用(材料最后部分的 json-server 使用的是 3001 端口),要么为这个应用使用一个不同的端口。 + + + 让我们仔细看看这段代码的第一行。 ```js const http = require('http') ``` - -在第一行中,应用导入 Node 的内置 [web server](https://nodejs.org/docs/latest-v8.x/api/http.html)模块。 这实际上是我们在浏览器端代码中已经做过的事情,只是语法稍有不同: + + 在第一行中,应用导入了 Node 的内置 [网络服务器](https://nodejs.org/docs/latest-v8.x/api/http.html) 模块。这实际上就是我们在浏览器端代码中已经在做的事情,但语法略有不同。 ```js import http from 'http' ``` - -如今,在浏览器中运行的代码使用 ES6模块。 模块定义为[export](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export) ,并与[import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import)一起使用。 + + 如今,在浏览器中运行的代码都使用 ES6 模块。模块用 [export](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export) 来定义,用 [import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 来使用。 - -然而,Node.js 使用了所谓的 [CommonJS](https://en.wikipedia.org/wiki/CommonJS)。 原因在于,早在 JavaScript 在语言规范中支持模块之前,Node 生态系统就有对模块需求。 在撰写本文的时候,Node 还不支持 ES6模块,但是支持 ES6 [只是时间问题](https://nodejs.org/api/esm.html) 。 + + 然而,Node.js 使用所谓的 [CommonJS](https://en.wikipedia.org/wiki/CommonJS) 模块。其原因是,早在 JavaScript 在语言规范中支持模块之前,Node 生态系统就有了对模块的需求。Node 现在也支持使用 ES6 模块,但由于支持还 [不是很完善](https://nodejs.org/api/esm.html#modules-ecmascript-modules),我们将坚持使用 CommonJS 模块。 - -Commonjs 模块的功能几乎完全类似于 ES6模块,至少就我们在本课程中的需求而言是这样。 + + CommonJS 模块的功能几乎与 ES6 模块完全一样,至少就我们在本课程中的需求而言是如此。 - -我们代码中的下一块代码如下所示: + +我们的代码中的下一块如下所示: ```js const app = http.createServer((request, response) => { @@ -204,14 +215,16 @@ const app = http.createServer((request, response) => { }) ``` - -该代码使用了 [http](https://nodejs.org/docs/latest-v8.x/api/http.html) 模块的 createServer 方法来创建一个新的 web 服务器。 一个事件处理 被注册到服务器,每次 向服务器的地址http:/localhost:3001 发出 HTTP 请求时,它就被调用。 + + 该代码使用 [http](https://nodejs.org/docs/latest-v8.x/api/http.html) 模块的 _createServer_ 方法来创建一个新的网络服务器。一个 事件处理程序 被注册到服务器上,每当 HTTP 请求被发送到服务器的地址 ,该程序就会被调用。 + + + + 该请求被响应,状态代码为 200,Content-Type 头设置为 text/plain,要返回的网站内容设置为 Hello World。 - -响应请求的状态代码为200,Content-Type 头文件设置为 text/plain,将返回站点的内容设置为Hello World。 - -最后一行将绑定的HTTP 服务器分配给 app 变量 ,并监听发送到端口3001的 HTTP 请求: + + 最后几行绑定了分配给 _app_ 变量的 http 服务器,以监听发送到 3001 端口的 HTTP 请求。 ```js const PORT = 3001 @@ -219,8 +232,9 @@ app.listen(PORT) console.log(`Server running on port ${PORT}`) ``` - -本课程中后端服务器的主要用途是向前端提供 JSON 格式的原始数据。 基于这个原因,让我们立即更改我们的服务器,返回 JSON 格式的“硬编码”便笺列表: + + + 本课程中后端服务器的主要目的是向前端提供 JSON 格式的原始数据。出于这个原因,让我们立即改变我们的服务器以 JSON 格式返回一个硬编码的笔记列表。 ```js const http = require('http') @@ -230,19 +244,19 @@ let notes = [ { id: 1, content: "HTML is easy", - date: "2019-05-30T17:30:31.098Z", + date: "2022-05-30T17:30:31.098Z", important: true }, { id: 2, content: "Browser can execute only Javascript", - date: "2019-05-30T18:39:34.091Z", + date: "2022-05-30T18:39:34.091Z", important: false }, { id: 3, content: "GET and POST are the most important methods of HTTP protocol", - date: "2019-05-30T19:20:14.298Z", + date: "2022-05-30T19:20:14.298Z", important: true } ] @@ -253,94 +267,98 @@ const app = http.createServer((request, response) => { }) // highlight-end -const port = 3001 -app.listen(port) -console.log(`Server running on port ${port}`) +const PORT = 3001 +app.listen(PORT) +console.log(`Server running on port ${PORT}`) ``` - -让我们重新启动服务器(可以通过在控制台中按 Ctrl + c 关闭服务器) ,并刷新浏览器。 + + 让我们重新启动服务器(你可以在控制台中按 _Ctrl+C_ 来关闭服务器),让我们刷新浏览器。 - - Content-Type 头中的 application/json 值通知接收方数据为 JSON 格式。 使用 JSON.stringify(notes) 方法将 _notes_ 数组转换为 JSON。 + + 在 Content-Type 头中的 application/json 值通知接收者,数据是 JSON 格式的。_notes_ 数组通过 JSON.stringify(notes) 方法被转换为 JSON。 - -当我们打开浏览器的时候,显示的格式和第2章节 [第2章](/zh/part2/从服务器获取数据/) 完全一样,在那里我们使用 [json-server](https://github.com/typicode/json-server) 来提供便笺列表: + + 当我们打开浏览器时,显示的格式与 [第二章节](/en/part2/getting_data_from_server/) 中完全一样,在那里我们使用 [json-server](https://github.com/typicode/json-server) 来提供笔记的列表。 ![](../../images/3/2e.png) - ### Express - -直接使用 Node 内置的[http](https://nodejs.org/docs/latest-v8.x/api/http.html) web 服务器实现我们的服务器代码是可行的。 但是,它很麻烦,特别是当应用规模“变大变长”时。 - -为了提供一个比内置的 http 模块更友好的界面,许多库已经开发出来,以简化使用 Node 作为服务器端开发。 到目前为止,最受欢迎的库是[express](http://expressjs.com)。 + + 用 Node 内置的 [http](https://nodejs.org/docs/latest-v8.x/api/http.html) 网络服务器直接实现我们的服务器代码是可行的。然而,这很麻烦,特别是一旦应用的规模扩大。 - -让我们通过下面的命令将它定义为一个项目依赖,来开始使用 express: + + 许多库已经被开发出来,通过提供一个更讨人喜欢的接口来与内置的 http 模块一起工作,从而缓解 Node 的服务器端开发。这些库的目的是为我们通常需要建立后端服务器的一般使用情况提供一个更好的抽象。到目前为止,用于这一目的的最流行的库是 [express](http://expressjs.com)。 + + + 让我们用命令将 express 定义为项目的依赖关系来使用它。 ```bash -npm install express --save +npm install express ``` - -该依赖项也被添加到了我们的package.json 文件中: + + 这个依赖关系也被添加到我们的 package.json 文件中。 ```json { // ... "dependencies": { - "express": "^4.17.1" + "express": "^4.17.2" } } ``` - -依赖的源代码安装在项目根目录中的 node\_modules 目录中。 除了express,你还可以在目录中找到大量的其他依赖项: + + + 该依赖的源代码被安装到位于项目根部的 node/modules 目录中。除了 express 之外,你还可以在该目录中找到大量的其他依赖关系。 ![](../../images/3/4.png) + + 这些实际上是 express 库的依赖关系,以及它所有的依赖关系,等等。这些被称为我们项目的 [transitive dependencies](https://lexi-lambda.github.io/blog/2016/08/24/understanding-the-npm-dependency-model/)。 - -这些实际上是express的依赖项,以及它所有依赖项的依赖项,等等。 这些被称为我们项目的 [传递依赖transitive dependencies](https://lexi-lambda.github.io/blog/2016/08/24/understanding-the-npm-dependency-model/) 。 - -我们的项目中安装了4.17.1版本的express。 在package.json 中,版本号前面的插入符号是什么意思? + + 我们的项目中安装了 4.17.2. 版本的 express。在 package.json 中版本号前面的圆点是什么意思? ```json -"express": "^4.17.1" +"express": "^4.17.2" ``` - -npm 中使用的版本控制模型称为 [语义版本semantic versioning](https://docs.npmjs.com/getting-started/semantic-versioning). - -^4.17.1 前面的插入符号表示,当项目的依赖项更新时,安装的 express 版本至少为 4.17.1。 但是,所安装的 express 版本也可以具有较大的patch 号(最后一个数字)或较大的minor 号(中间的数字)的版本。 第一个major 号表示库的主版本必须相同。 + + npm 中使用的版本管理模式被称为 [语义版本管理](https://docs.npmjs.com/getting-started/semantic-versioning)。 + + + + ^4.17.2 前面的圆点意味着如果项目的依赖关系被更新,所安装的 express 版本将至少是 4.17.2。然而,安装的 express 版本也可以是具有更大的 patch 号(最后一个数字),或者更大的 minor 号(中间的数字)。第一个 major 数字所表示的库的主要版本必须是相同的。 + - -我们可以使用如下命令更新项目的依赖: + + 我们可以用命令更新项目的依赖关系。 ```bash npm update ``` - -同样,如果我们在另一台计算机上开始工作,我们可以使用如下命令安装package.json 中定义的项目的所有最新依赖项: + + 同样地,如果我们在另一台电脑上开始做这个项目,我们可以用命令安装 package.json 中定义的项目的所有最新的依赖项。 ```bash npm install ``` - -如果依赖项的major值没有改变,那么新版本应该是[向后兼容backwards compatible](https://en.wikipedia.org/wiki/Backward_compatibility)。 这意味着,如果我们的应用在将来碰巧使用了 express 的版本4.99.175,那么在这个部分中实现的所有代码仍然必须在不对代码进行更改的情况下正常工作。 相比之下,未来的5.0.0。 Express版本 [可能包含may contain](https://expressjs.com/en/guide/migrating-5.html)更改,将导致我们的应用不能正常工作。 + + 如果一个依赖项的 major 号没有改变,那么较新的版本应该是 [向后兼容](https://en.wikipedia.org/wiki/Backward_compatibility)。这意味着,如果我们的应用在未来碰巧使用了 4.99.175 版本的 Express,那么这部分实现的所有代码仍然要工作,而无需对代码进行修改。相反,未来的 5.0.0. 版本的 express[可能包含](https://expressjs.com/en/guide/migrating-5.html) 变化,会导致我们的应用不再工作。 ### Web and express - -让我们回到我们的应用,并进行如下更改: + + 让我们回到我们的应用,并做如下修改。 ```js const express = require('express') @@ -350,12 +368,12 @@ let notes = [ ... ] -app.get('/', (req, res) => { - res.send('

    Hello World!

    ') +app.get('/', (request, response) => { + response.send('

    Hello World!

    ') }) -app.get('/api/notes', (req, res) => { - res.json(notes) +app.get('/api/notes', (request, response) => { + response.json(notes) }) const PORT = 3001 @@ -365,19 +383,21 @@ app.listen(PORT, () => { ``` - -为了使应用的新版本投入使用,我们必须重新启动应用。 + +为了让我们的应用的新版本投入使用,我们必须重新启动应用。 + - -这个应用没有太大的改变。 在代码的开头我们导入了 _express_,这次是一个function ,用于创建一个存储在 app 变量中的 express 应用: + + 应用并没有发生很大的变化。就在我们代码的开头,我们导入了 _express_,这次是一个 函数 ,用来创建一个存储在 _app_ 变量中的 Express 应用。 ```js const express = require('express') const app = express() ``` - -接下来,我们定义了应用的两个路由。 第一个定义了一个事件处理,用于处理对应用的 / 根发出的 HTTP GET 请求: + + + 接下来,我们定义两个通往应用的 路径 。第一个定义了一个事件处理程序,用来处理对应用 / 根的 HTTP GET 请求。 ```js app.get('/', (request, response) => { @@ -385,22 +405,23 @@ app.get('/', (request, response) => { }) ``` - -事件处理接受两个参数。 第一个[request](http://expressjs.com/en/4x/api.html#req) 参数包含 HTTP 请求的所有信息,第二个 [response](http://expressjs.com/en/4x/api.html#res) 参数用于定义请求的响应方式。 - -在我们的代码中,请求是通过使用 _response_ 对象的[send](http://expressjs.com/en/4x/api.html#res.send) 方法来应答的。 调用该方法,使服务器通过发送 \

    Hello World!\

    字符串,以response响应 HTTP 请求! 这些会被传递给 _send_ 方法。 由于参数是一个字符串,所以 express 会自动将Content-Type 头的值设置为 text/html.。 响应的状态代码默认为200。 + + 该事件处理函数接受两个参数。第一个 [request](http://expressjs.com/en/4x/api.html#req) 参数包含 HTTP 请求的所有信息,第二个 [response](http://expressjs.com/en/4x/api.html#res) 参数用于定义如何对请求进行响应。 - -我们可以通过开发工具中的Network 选项卡来验证这一点: + + 在我们的代码中,请求是通过使用 _response_ 对象的 [send](http://expressjs.com/en/4x/api.html#res.send) 方法回答的。调用该方法使服务器响应 HTTP 请求,发送一个响应,其中包含传递给 _send_ 方法的字符串

    Hello World!

    。由于参数是一个字符串,Express 自动将 Content-Type 头的值设置为 text/html。响应的状态代码默认为 200。 -![](../../images/3/5.png) + + 我们可以从开发者工具中的 网络 标签来验证这一点。 + +![](../../images/3/5.png) - -第二个路由定义了一个事件处理,它处理对应用的notes 路径发出的 HTTP GET 请求: + + 第二个路由定义了一个事件处理程序,处理向应用的 notes 路径发出的 HTTP GET 请求。 ```js app.get('/api/notes', (request, response) => { @@ -408,177 +429,205 @@ app.get('/api/notes', (request, response) => { }) ``` - -请求用response对象的[json](http://expressjs.com/en/4x/api.html#res.json)方法进行响应。 调用该方法会将notes 数组作为 JSON 格式的字符串进行传递。 Express 自动设置Content-Type 头文件,其值为 application/json。 + + + 该请求用 _response_ 对象的 [json](http://expressjs.com/en/4x/api.html#res.json) 方法来响应。调用该方法将发送传给它的 __notes__ 数组,作为 JSON 格式的字符串。Express 自动将 Content-Type 头设置为 application/json 的适当值。 ![](../../images/3/6ea.png) - -接下来,让我们快速看一下以 JSON 格式发送的数据。 + + 接下来,让我们快速浏览一下以 JSON 格式发送的数据。 - -在我们只使用 Node 的早期版本中,我们必须使用 _JSON.stringify_ 方法将数据转换为 JSON 格式: + + 在早期版本中,我们只使用 Node,我们必须用 _JSON.stringify_ 方法将数据转换成 JSON 格式。 ```js response.end(JSON.stringify(notes)) ``` - -对于 express,不再需要这样做,因为这种转换是自动的。 - -值得注意的是,[JSON](https://en.wikipedia.org/wiki/JSON)是一个字符串,而不是像分配给 notes 的值那样的 JavaScript 对象。 + + 有了 Express,这就不再需要了,因为这种转换会自动发生。 - -下面的实验可以说明这一点: -![](../../assets/3/5.png) + + 值得注意的是,[JSON](https://en.wikipedia.org/wiki/JSON) 是一个字符串,而不是像分配给 _notes_ 的值那样的一个 JavaScript 对象。 + + + 下面的实验说明了这一点。 + +![](../../assets/3/5.png) - -上面的实验是在交互式的[node-repl](https://nodejs.org/docs/latest-v8.x/api/repl.html)中完成的。 您可以通过在命令行中键入 node 来启动交互式 node-repl。 在编写应用代码时,对于测试命令的工作方式,repl 特别有用。 我强烈推荐! + + 上面的实验是在交互式 [node-repl](https://nodejs.org/docs/latest-v8.x/api/repl.html) 中完成的。你可以通过在命令行中输入 _node_ 来启动交互式 node-repl。在你写应用代码的时候,这个副本对于测试命令如何工作特别有用。我强烈建议这样做 ! ### nodemon - -如果我们对应用的代码进行更改,我们必须重新启动应用以查看更改。 我们通过键入 _⌃+C_ 首先关闭应用,然后重新启动应用。 与 React 中方便的工作流程相比,Node就有点麻烦,在 React 中,浏览器会在进行更改后自动重新加载。 - -解决这个问题的方法是使用[nodemon](https://github.com/remy/nodemon) : + + 如果我们对应用的代码做了修改,我们必须重新启动应用,以便看到这些修改。我们重启应用的方法是:首先通过输入 _Ctrl+C_ 来关闭它,然后再重启应用。与 React 中方便的工作流程相比,即浏览器在发生变化后自动重新加载,这感觉有点麻烦。 -> -nodemon 将监视启动 nodemon 的目录中的文件,如果任何文件发生更改,nodemon 将自动重启节点应用。 + + 解决这个问题的方法是 [nodemon](https://github.com/remy/nodemon)。 - -让我们通过下面的命令将 nodemon 定义为开发依赖development dependency: + + > nodemon 将观察 nodemon 启动时所在目录中的文件,如果有任何文件发生变化,nodemon 将自动重启你的 node 应用 + + + + 让我们用命令将 nodemon 定义为一个 开发依赖项 来安装它。 ```bash npm install --save-dev nodemon ``` - - package.json 的内容也发生了变化: + + package.json 的内容也有变化。 ```json { //... "dependencies": { - "express": "^4.17.1", + "express": "^4.17.2", }, "devDependencies": { - "nodemon": "^2.0.2" + "nodemon": "^2.0.15" } } ``` - -如果您不小心敲错了命令,并且 nodemon 依赖项被添加到“ dependencies”而不是“ devDependencies” ,那么手动更改package.json 的内容以匹配上面显示的内容也是可以的。 - -通过开发依赖,我们会指向仅在应用开发过程中需要的工具,例如用于测试或自动重启应用的工具,就像nodemon。 + + 如果你不小心用错了命令,nodemon 依赖被添加到了 "dependencies " 下,而不是 "devDependencies " 下,那么请手动修改 package.json 的内容,以符合上面的内容。 + + + + 我们所说的开发依赖,指的是只在应用的开发过程中需要的工具,例如用于测试或自动重启应用,如 nodemon。 + - -当应用在生产服务器(例如 Heroku)的生产模式下运行时,并不需要这些开发依赖项。 + +当应用在生产服务器(如 Heroku)上以生产模式运行时,不需要这些开发依赖性。 - -我们可以用nodemon 这样来启动我们的应用: + + + 我们可以像这样用 nodemon 启动我们的应用。 ```bash node_modules/.bin/nodemon index.js ``` - -对应用代码的更改现在会导致服务器自动重新启动。 值得注意的是,即使后端服务器自动重启,浏览器仍然需要手动刷新。 这是因为不像在 React 中工作,我们甚至没有自动重新加载浏览器所需的[热加载hot reload](https://gaearon.github.io/react-hot-loader/getstarted/) 方法。 - -这个命令很长,而且相当烦人,所以让我们在package.json 文件中为它定义一个专用的npm 脚本: + +现在对应用代码的修改会导致服务器自动重新启动。值得注意的是,即使后端服务器自动重启,浏览器仍然需要手动刷新。这是因为与在 React 中工作时不同,我们没有自动重新加载浏览器所需的 [hot reload](https://gaearon.github.io/react-hot-loader/getstarted/) 功能。 + + + + 这个命令很长,而且很不讨人喜欢,所以让我们在 package.json 文件中为它定义一个专门的 npm 脚本 。 ```bash { // .. "scripts": { "start": "node index.js", - "dev": "nodemon index.js", + "dev": "nodemon index.js", // highlight-line "test": "echo \"Error: no test specified\" && exit 1" }, // .. } ``` - -在脚本中,不需要指定node\_modules/.bin/nodemon 到 nodemon ,因为 npm 自己知道从该目录搜索文件。 + + 在脚本中不需要指定 nodemon 的 node/_modules/.bin/nodemon 路径,因为 _npm_ 自动知道从该目录中搜索该文件。 - -我们现在可以在开发模式下使用如下命令启动服务器: + + + 我们现在可以用命令在开发模式下启动服务器。 ```bash npm run dev ``` - -与starttest 脚本不同,我们还必须将run 添加到命令中。 + + 与 starttest 脚本不同,我们还必须在命令中加入 run。 ### REST - -让我们扩展我们的应用,使它提供像[json-server](https://github.com/typicode/json-server#routes 服务器)那样的 RESTful HTTP API 。 - -Representational State Transfer,又名REST, 是在2000年 Roy Fielding 的[论文](https://www.ics.uci.edu/~Fielding/pubs/dissertation/rest_arch_style.htm)中引入的。 Rest 是一种架构风格,用于构建可伸缩的 web 应用。 - -我们不会深入探究 Fielding 对 REST 的定义,也不会花时间思考什么是 RESTful,什么不是 RESTful。 相反,我们只关注web应用对 RESTful API 的典型理解,从而采取了一种更为狭隘的视角 [narrow view](https://en.wikipedia.org/wiki/Representational_state_transfer#Applied_to_Web_services)。 Rest 的最初定义实际上并不局限于 web 应用。 + + 让我们扩展我们的应用,使其提供与 [json-server](https://github.com/typicode/json-server#routes) 一样的 RESTful HTTP API。 + + + + Representational State Transfer,又称 REST,于 2000 年在 Roy Fielding 的 [论文](https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm) 中提出。REST 是一种架构风格,旨在建立可扩展的网络应用。 + + + + 我们不打算深入研究 Fielding 对 REST 的定义,也不打算花时间去思考什么是 RESTful 和什么不是。相反,我们将采取一个更 [狭窄的观点](https://en.wikipedia.org/wiki/Representational_state_transfer#Applied_to_web_services),只关注 RESTful APIs 在网络应用中的典型理解。事实上,REST 的原始定义甚至不限于网络应用。 + + + + 我们在 [前一部分](/en/part2/altering_data_in_server#rest) 中提到,在 RESTful 思想中,单一的东西,如我们应用中的笔记,被称为 资源 。每个资源都有一个相关的 URL,这是资源的唯一地址。 - -我们在 [上一章节](/zh/part2/在服务端将数据_alert出来#rest) 中提到过,在我们的应用中,像便笺这样的单数实体,在 RESTful thinking 中称为resource。 每个resource都有一个相关联的 URL,这个 URL 是资源的唯一地址。 - -一个约定是结合resource 类型名称和resource的唯一标识符来创建resource唯一的地址。 + + 一个惯例是通过结合资源类型的名称和资源的唯一标识符来创建资源的唯一地址。 - -假设我们的服务的根 URL 是 www.example.com/api 。 - -如果我们将便笺的资源类型定义为note,那么标识为10的便笺资源的地址就是唯一的地址www.example.com/api/notes/10。 + + 让我们假设我们的服务的根 URL 是 www.example.com/api。 - -所有便笺资源的整个集合的 URL 是 www.example.com/api/notes 。 - -我们可以对资源执行不同的操作。要执行的操作由 HTTP动词verb 定义: + + 如果我们把笔记的资源类型定义为 笔记 ,那么标识符为 10 的笔记资源的地址就有唯一的地址 www.example.com/api/notes/10。 -| URL | verb | functionality | -| --------------------- | ------------------- | -----------------------------------------------------------------| -| notes/10    | GET | fetches a single resource | -| notes | GET | fetches all resources in the collection | -| notes | POST | creates a new resource based on the request data | -| notes/10 | DELETE    | removes the identified resource | -| notes/10 | PUT | replaces the entire identified resource with the request data | -| notes/10 | PATCH | replaces a part of the identified resource with the request data | -| | | | - -这就是我们如何粗略地定义 REST 所指的 [统一接口 uniform interface](https://en.wikipedia.org/wiki/Representational_state_transfer#Architectural_constraints) ,这意味着一种一致的定义接口的方式,使系统能够进行合作。 + + 所有笔记资源的整个集合的 URL 是 www.example.com/api/notes。 - -这种解释 REST 的方式在 Richardson Maturity Model 属于[RESTful 成熟度的第二个层次](https://martinfowler.com/articles/richardsonmaturitymodel.html)。 根据 Roy Fielding 提供的定义,我们实际上并没有定义一个[REST API](http://Roy.gbiv.com/untangled/2008/REST-apis-must-be-hypertext-driven)。 事实上,世界上大多数所谓的“REST” API都不符合 Fielding 在其论文中概述的原始标准。 - -在某些地方(例如[Richardson,Ruby: RESTful Web Services](http://shop.oreilly.com/product/9780596529260.do)) ,你会看到我们为一个简单的[CRUD](https://en.wikipedia.org/wiki/create,_read,_update_and_delete) API 建立的模型,这被称为[面向资源架构resource oriented architecture](https://en.wikipedia.org/wiki/resource-oriented_architecture)的例子,而不是 REST。 我们将避免陷入语义学的争论,而是回到应用的工作中。 + +我们可以对资源执行不同的操作。要执行的操作是由 HTTP verb 定义的。 + +| URL | verb | functionality | +| -------- | ------ | ---------------------------------------------------------------- | +| notes/10 | GET | fetches a single resource | +| notes | GET | fetches all resources in the collection | +| notes | POST | creates a new resource based on the request data | +| notes/10 | DELETE | removes the identified resource | +| notes/10 | PUT | replaces the entire identified resource with the request data | +| notes/10 | PATCH | replaces a part of the identified resource with the request data | +| | | | + + + + 这就是我们如何设法粗略地定义 REST 所指的 [统一接口](https://en.wikipedia.org/wiki/Representational_state_transfer#Architectural_constraints),这意味着一种定义接口的一致方式,使系统有可能合作。 + + + +这种解释 REST 的方式属于 Richardson 成熟度模型中的 [RESTful 成熟度第二层次](https://martinfowler.com/articles/richardsonMaturityModel.html)。根据 Roy Fielding 提供的定义,我们实际上并没有定义一个 [REST API](http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven)。事实上,世界上绝大部分所谓的 "REST "API 都不符合 Fielding 在其论文中列出的原始标准。 + + + + 在某些地方(例如见 [Richardson, Ruby: RESTful Web Services](http://shop.oreilly.com/product/9780596529260.do)),你会看到我们的直接 [CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete)API 模型被称为 [面向资源架构](https://en.wikipedia.org/wiki/Resource-oriented_architecture) 的例子,而不是 REST。我们将避免陷入语义学的争论,而是回到我们的应用上工作。 ### Fetching a single resource -【获取一个单一资源】 - -让我们扩展我们的应用,以便它提供一个 REST 接口,用于操作单个便笺。 首先,让我们创建一个[路由](http://expressjs.com/en/guide/routing.html)来获取单个资源。 - -我们将为单个便笺使用的唯一地址是 notes/10,其中末尾的数字指的是便笺的唯一 id 号。 - -我们可以使用冒号语法为express路由定义[参数](http://expressjs.com/en/guide/routing.html#route-parameters) : + + 让我们扩展我们的应用,使其提供一个 REST 接口来操作单个笔记。首先,让我们创建一个 [路由](http://expressjs.com/en/guide/routing.html) 来获取一个单一的资源。 + + + + 我们将为单个笔记使用的唯一地址的形式是 notes/10,其中末尾的数字是指笔记的唯一 ID 号。 + + + + 我们可以通过使用冒号语法为 Express 中的路由定义 [参数](http://expressjs.com/en/guide/routing.html#route-parameters)。 ```js app.get('/api/notes/:id', (request, response) => { @@ -588,25 +637,28 @@ app.get('/api/notes/:id', (request, response) => { }) ``` - -现在, app.get('/api/notes/:id', ...)将处理所有的 HTTP GET 请求,这些请求的格式是/api/notes/SOMETHING,其中SOMETHING 是任意的字符串。 + + 现在 app.get("/api/notes/:id", ...) 将处理所有形式为 /api/notes/SOMETHING 的 HTTP GET 请求,其中 SOMETHING 是一个任意的字符串。 + - -请求路由中的id 参数可以通过[request](http://expressjs.com/en/api.html#req)对象访问: + + 请求路径中的 id 参数,可以通过 [request](http://expressjs.com/en/api.html#req) 对象访问。 ```js const id = request.params.id ``` - -现在使用熟悉的 _find_ 方法查找与 id 参数匹配的的便笺。 然后,便笺被返回给request的发送者。 + + 现在熟悉的数组的 _find_ 方法被用来寻找与参数相匹配的 id 的笔记。然后,该笔记被返回给请求的发送者。 + - -当我们通过在浏览器中键入 http://localhost:3001/api/notes/1来测试我们的应用时,我们注意到它似乎不能正常工作,因为浏览器显示一个空白页面。 这对于我们软件开发人员来说并不奇怪,现在是调试的时候了。 + + 当我们在浏览器中访问 来测试我们的应用时,我们注意到它似乎没有工作,因为浏览器显示的是一个空页面。这对我们这些软件开发者来说并不奇怪,是时候进行调试了。 - -在我们的代码中添加 _console.log_ 命令是一个久经验证的技巧: + + + 在我们的代码中添加 _console.log_ 命令是一个经过时间验证的技巧。 ```js app.get('/api/notes/:id', (request, response) => { @@ -618,18 +670,19 @@ app.get('/api/notes/:id', (request, response) => { }) ``` - -当我们在浏览器中再次访问 http://localhost:3001/api/notes/1时,终端控制台将显示如下内容: + + +当我们在浏览器中再次访问 时,控制台,也就是本例中的终端,将显示如下内容。 ![](../../images/3/8.png) + + 路由中的 id 参数被传递给我们的应用,但 _find_ 方法没有找到一个匹配的笔记。 - -来自 route 的 id 参数被传递给我们的应用,但是 find 方法没有找到匹配的便笺。 - -为了进一步研究,我们还在传递给 find 方法的比较函数中添加了console log。 为了做到这一点,我们必须去掉紧凑箭头函数语法note => note.id === id,并使用显式的 return 语句这种语法: + + 为了进一步调查,我们还在传递给 _find_ 方法的比较函数里面添加了一个控制台日志。为了做到这一点,我们必须摆脱紧凑的箭头函数语法 note => note.id === id,而使用带有明确返回语句的语法。 ```js app.get('/api/notes/:id', (request, response) => { @@ -643,65 +696,65 @@ app.get('/api/notes/:id', (request, response) => { }) ``` - -当我们在浏览器中再次访问 URL 时,对比较函数的每次调用都会向控制台打印一些不同的内容。 控制台输出如下: -
    +
    + 当我们在浏览器中再次访问这个 URL 时,每次调用比较函数都会向控制台打印一些不同的东西。控制台的输出如下。
    +
    +```
     1 'number' '1' 'string' false
    -1‘ number’’1’‘ string’ false
     2 'number' '1' 'string' false
    -2‘ number’’1’‘ string’ false
     3 'number' '1' 'string' false
    -3‘ number’’1’‘ string’ false
    -
    - -这个错误的原因很清楚了。 _id_ 变量包含一个字符串“1” ,而便笺的 id 是整数。 在 JavaScript 中,“三个等号 triple equals”比较默认认为不同类型的所有值都不相等,这意味着1不等于“1”。 +``` + + + 错误的原因变得清晰了。_id_ 变量包含一个字符串 "1",而笔记的 id 是整数。在 JavaScript 中,"triple equals " 比较 === 认为所有不同类型的值默认是不相等的,也就是说,1 不是 "1"。 - -让我们通过将 id 参数从一个字符串更改为一个[number](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/number)来解决这个问题: + + + 让我们通过把 id 参数从一个字符串变成一个 [数字](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number) 来解决这个问题。 ```js app.get('/api/notes/:id', (request, response) => { - const id = Number(request.params.id) + const id = Number(request.params.id) // highlight-line const note = notes.find(note => note.id === id) response.json(note) }) ``` - -现在获取单个资源可以正常工作了。 -![](../../images/3/9ea.png) + + 现在获取一个单独的资源可以了。 +![](../../images/3/9ea.png) - -然而,我们的应用还有另一个问题。 + + 然而,我们的应用还有一个问题。 - -如果我们搜索一个 id 不存在的便笺,服务器会响应: + + 如果我们搜索一个 id 不存在的笔记,服务器的反应是。 ![](../../images/3/10ea.png) + + 返回的 HTTP 状态代码是 200,这意味着响应成功了。由于 content-length 头的值为 0,所以没有数据随响应一起被送回来,同样可以从浏览器中验证。 - -返回的 HTTP状态码还是200,这意味着响应成功了。 content-length 标头的值为0,因为没有将数据与响应一起发送回来,可以从浏览器验证这一点。 - -出现此行为的原因是,如果没有找到匹配的便笺,则将note变量设置为了_undefined_。 需要在服务器上以更好的方式处理这种情况。 如果没有发现任何提示,服务器应该用状态码[404 not found](https://www.w3.org/protocols/rfc2616/rfc2616-sec10.html#sec10.4.5)响应,而不是200。 + + 出现这种行为的原因是,如果没有找到匹配的笔记,_note_ 变量被设置为 _undefined_。这种情况需要在服务器上以更好的方式来处理。如果没有找到笔记,服务器应该用状态代码 [404 not found](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.5) 来响应,而不是 200。 - -让我们对我们的代码进行如下更改: + + 让我们对我们的代码做如下修改。 ```js app.get('/api/notes/:id', (request, response) => { const id = Number(request.params.id) const note = notes.find(note => note.id === id) - + // highlight-start if (note) { response.json(note) @@ -712,21 +765,27 @@ app.get('/api/notes/:id', (request, response) => { }) ``` - -由于响应没有附加任何数据,我们使用[status](http://expressjs.com/en/4x/api.html#res.status)方法来设置状态,并使用[end](http://expressjs.com/en/4x/api.html#res.end)方法来响应request而不发送任何数据。 - -If-condition 基于了这样一个事实,即所有的 JavaScript 对象都是[truthy](https://developer.mozilla.org/en-us/docs/glossary/truthy) ,这意味着它们在比较操作中被当作 true。 然而,undefined 是 [falsy](https://developer.mozilla.org/en-us/docs/glossary/falsy),意思是它将评估为 false。 + + 由于响应中没有附加数据,我们使用 [status](http://expressjs.com/en/4x/api.html#res.status) 方法来设置状态,并使用 [end](http://expressjs.com/en/4x/api.html#res.end) 方法来响应请求,而不发送任何数据。 - -我们的应用正常工作,如果没有找到便笺,则发送错误状态代码。 然而,应用不会返回任何东西显示给用户,就像我们 在web 应用访问一个不存在的页面时所做的那样。 我们实际上不需要在浏览器中显示任何内容,因为 REST API 是用于编程使用的接口,只需要错误状态代码就行了。 + + + if 条件利用了这样一个事实,即所有的 JavaScript 对象都是 [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy),意味着它们在比较操作中计算为真。然而,_undefined_ 是 [falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy),这意味着它将被计算为假。 + + + + 我们的应用工作了,如果没有找到笔记,就会发送错误状态代码。然而,应用并没有返回任何东西给用户看,就像 Web 应用在我们访问一个不存在的页面时通常做的那样。我们实际上不需要在浏览器中显示任何东西,因为 REST APIs 是用于编程的接口,而错误状态代码是需要的全部内容。 + + + 无论如何,我们可以通过 [覆盖默认的 NOT FOUND 信息](https://stackoverflow.com/questions/14154337/how-to-send-a-custom-http-status-message-in-node-express/36507614#36507614) 来提供一个关于发送 404 错误的原因的线索。 ### Deleting resources -【删除资源】 - -接下来,让我们实现一个删除资源的路由。 通过向资源的 url 发出 HTTP DELETE 请求来删除: + + + 接下来让我们实现一个删除资源的路径。删除是通过向资源的 URL 发出 HTTP DELETE 请求来实现的。 ```js app.delete('/api/notes/:id', (request, response) => { @@ -737,174 +796,186 @@ app.delete('/api/notes/:id', (request, response) => { }) ``` - -如果删除资源成功,这意味着便笺存在并被删除,我们用状态码[204 no content](https://www.w3.org/protocols/rfc2616/rfc2616-sec10.html#sec10.2.5)响应请求,并返回没有数据的响应。 + + 如果删除资源是成功的,也就是说,笔记存在并且被删除了,我们用状态代码 [204 无内容](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.2.5) 来响应请求,并且在响应中不返回数据。 - -如果资源不存在,对于应该向 DELETE 请求返回什么状态代码并没有共识。 实际上,只有204和404两个可选项。 为了简单起见,我们的应用在这两种情况下都将响应204。 -### Postman + + 对于在资源不存在的情况下应该向 DELETE 请求返回什么状态码,目前还没有达成共识。实际上,唯一的两个选择是 204 和 404。为了简单起见,我们的应用在这两种情况下都会用 204 来响应。 - -那么我们如何测试删除操作呢? 通过浏览器进行 HTTP GET 请求很容易。 我们可以编写一些 JavaScript 来测试删除,但是编写测试代码并不总是最好的解决方案。 +### Postman - -为了让后端的测试变得更加容易,我们可以使用工具。 其中之一就是命令行程序[curl](https://curl.haxx.se) ,这个命令行程序在本文前面的部分中已经简要地提到过。 + + 那么我们如何测试删除操作呢?HTTP GET 请求很容易从浏览器中发出。我们可以写一些 JavaScript 来测试删除操作,但写测试代码并不总是在每种情况下的最佳解决方案。 - -替代 curl,我们将使用 [Postman](https://www.getpostman.com/) 来测试应用。 + + 有许多工具可以使后端测试更容易。其中之一是一个命令行程序 [curl](https://curl.haxx.se)。然而,我们将看一下使用 [Postman](https://www.postman.com) 来测试应用,而不是 curl。 - -让我们安装 Postman 并尝试一下: + + 让我们安装 Postman 桌面客户端 [从这里](https://www.postman.com/downloads/) 并尝试一下。 -![](../../images/3/11ea.png) +![](../../images/3/11x.png) - -使用Postman在这种情况下是相当容易的。 定义 url 然后选择正确的请求类型就足够了。 + + 在这种情况下,使用 Postman 是非常容易的。只需定义网址,然后选择正确的请求类型(DELETE)。 - -后端服务器似乎响应正确。 通过向 发出 HTTP GET 请求,我们可以看到 id 为2的便笺已经不在列表中,这表明删除是成功的。 + + 后端服务器似乎反应正确。通过对 的 HTTP GET 请求,我们看到 id 为 2 的笔记已经不在列表中了,这表明删除成功了。 - -因为应用中的便笺只保存到了内存中,所以当我们重新启动应用时,便笺列表将返回到原始状态。 + + 因为应用中的笔记只保存在内存中,所以当我们重新启动应用时,笔记的列表将恢复到原来的状态。 ### The Visual Studio Code REST client - -如果你使用 Visual Studio Code,你可以使用 VS Code [REST client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) 插件来代替Postman。 + + 如果你使用 Visual Studio Code,你可以使用 VS Code [REST 客户端](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) 插件,而不是 Postman。 - -一旦插件安装完毕,使用起来非常简单。 我们在应用的根目录创建一个文件夹,名为requests。 我们将目录中的所有 REST 客户端请求保存为以 .rest结尾的文件。 + + 一旦插件安装完毕,使用它就非常简单。我们在应用的根部建立一个名为 requests 的目录。我们将所有的 REST 客户端请求保存在该目录中,作为以 .rest 扩展名结尾的文件。 - -让我们创建一个新的get\_all\_notes.rest 文件,并定义获取所有便笺的请求。 + + 让我们创建一个新的 get\_all\_notes.rest 文件并定义获取所有笔记的请求。 ![](../../images/3/12ea.png) - -通过单击Send Request 文本,REST 客户端将执行 HTTP 请求,并在编辑器中打开来自服务器的响应。 + + 通过点击 发送请求 文本,REST 客户端将执行 HTTP 请求,来自服务器的响应在编辑器中打开。 ![](../../images/3/13ea.png) +### The WebStorm HTTP Client + + + 如果你使用 *IntelliJ WebStorm*,你可以使用其内置的 HTTP 客户端的类似程序。创建一个扩展名为 ".rest " 的新文件,编辑器将显示你创建和运行请求的选项。你可以按照 [本指南](https://www.jetbrains.com/help/webstorm/http-client-in-product-code-editor.html) 来了解更多信息。 ### Receiving data -【接受数据】 - -接下来,让我们使向服务器添加新便笺。 通过向地址 HTTP://localhost:3001/api/notes 发送一个 HTTP POST 请求,并以 JSON 格式在请求[body](https://www.w3.org/protocols/rfc2616/rfc2616-sec7.html#sec7)中发送新便笺的所有信息,就可以添加一个便笺。 - -为了方便地访问数据,我们需要 express [json-parser](https://expressjs.com/en/api.html)的帮助,它与命令_app.use(express.json())_一起使用。 + + 接下来,让我们实现向服务器添加新笔记的功能。通过向 ,并在请求 [body](https://www.w3.org/Protocols/rfc2616/rfc2616-sec7.html#sec7) 中以 JSON 格式发送新笔记的所有信息,就可以添加一个笔记。 + - -让我们激活 json-parser 并实现一个处理 HTTP POST 请求的初始处理程序: + + 为了方便地访问数据,我们需要 express [json-parser](https://expressjs.com/en/api.html) 的帮助,它可以通过命令 _app.use(express.json())_ 来使用。 + + + 让我们激活 json-parser 并实现一个初始处理程序来处理 HTTP POST 请求。 ```js const express = require('express') const app = express() -app.use(express.json()) +app.use(express.json()) // highlight-line //... +// highlight-start app.post('/api/notes', (request, response) => { const note = request.body console.log(note) response.json(note) }) +// highlight-end ``` - -事件处理函数可以从request 对象的body 属性访问数据。 - - -如果没有 json-parser,body 属性将是undefined的。 Json-parser 的功能是获取请求的 JSON 数据,将其转换为 JavaScript 对象,然后在调用路由处理程序之前将其附加到请求对象的 body 属性。 - - -目前,除了将接收到的数据打印到控制台并在响应中将其发送回来之外,应用并不对其执行任何操作。 - - -在实现应用逻辑的剩余部分之前,让我们先用 Postman 验证服务器实际接收到的数据。 除了在 Postman 中定义 URL 和请求类型外,我们还必须定义body 中发送的数据: - -![](../../images/3/14ea.png) + + 事件处理函数可以访问 _request_ 对象的 body 属性中的数据。 + + 如果没有 json-parser,body 属性将是未定义的。json-parser 的功能是将请求的 JSON 数据转化为 JavaScript 对象,然后在调用路由处理程序之前将其附加到 _request_ 对象的 body 属性。 - -该应用将我们在请求中发送到控制台的数据打印出来: + + 就目前而言,应用除了将收到的数据打印到控制台并在响应中送回外,并没有对其做任何处理。 -![](../../images/3/15e.png) + + 在我们实现其余的应用逻辑之前,让我们用 Postman 验证数据是否真的被服务器收到。除了在 Postman 中定义 URL 和请求类型外,我们还必须定义在 body 中发送的数据。 +![](../../images/3/14x.png) + + 应用将我们在请求中发送的数据打印到控制台。 - +![](../../images/3/15new.png) -注意:当你在后端工作时,应该让运行应用的终端始终可见。 受益于 Nodemon,我们对代码所做的任何更改都将重新启动应用。 如果你注意控制台,你会立即发现应用中出现的错误: + + **NB** 当你在后端工作时,保持运行应用的终端始终可见 。由于 Nodemon 的存在,我们对代码的任何改动都会重新启动应用。如果你关注控制台,你将立即能够发现应用中出现的错误。 ![](../../images/3/16.png) + + 同样,检查控制台也很有用,可以确保后端在不同的情况下表现得像我们期望的那样,比如当我们用 HTTP POST 请求发送数据时。当然,当应用还在开发时,在代码中添加大量的 console.log 命令是个好主意。 + + 问题的一个潜在原因是请求中 Content-Type 头的设置不正确。如果正文的类型没有被正确定义,这种情况就会发生在 Postman 上。 - -类似地,检查控制台以确保后端在不同情况下的行为与我们期望的一样,比如在使用 HTTP POST 请求发送数据时。 当然,在开发应用时向代码中添加一些 console.log 命令是一个不错的主意。 +![](../../images/3/17x.png) - -导致问题的一个潜在原因是在请求中错误地设置了Content-Type 头。 如果body类型没有正确定义,这种情况可能发生在 Postman 身上: + + Content-Type 头被设置为 text/plain。 -![](../../images/3/17e.png) +![](../../images/3/18x.png) - - - - Content-Type 的header设置为了 text/plain: - -![](../../images/3/18e.png) - - - - -服务器似乎只接收到一个空对象: + + 服务器似乎只收到一个空对象。 ![](../../images/3/19.png) + +如果头中没有正确的值,服务器将不能正确地解析数据。它甚至不会尝试猜测数据的格式,因为有 [大量](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) 潜在的 Content-Types。 + + 如果你使用的是 VS Code,那么你应该安装上一章中的 REST 客户端, 如果你还没有安装的话 。POST请求可以像这样使用REST客户端发送: - -如果头部没有设置正确的值,服务器将无法正确解析数据。 它甚至不会去猜测数据的格式,因为有大量 [massive amount](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) 的Content-Types 可能性。 +![](../../images/3/20eb.png) + + 我们为这个请求创建了一个新的 create\_note.rest 文件。请求的格式是按照 [文档中的说明](https://github.com/Huachao/vscode-restclient/blob/master/README.md#usage)。 - -如果您正在使用 VS 代码,那么您应该安装上一节中提到的 REST 客户端(如果您还没有安装的话)。 Post 请求可以像这样通过 REST 客户端发送: + + 与 Postman 相比,REST 客户端的一个好处是,请求可以方便地在项目库的根部获得,而且可以分发给开发团队的每个人。你也可以使用`###`分隔符在同一个文件中添加多个请求。 -![](../../images/3/20eb.png) +``` +GET http://localhost:3001/api/notes/ +### +POST http://localhost:3001/api/notes/ HTTP/1.1 +content-type: application/json +{ + "name": "sample", + "time": "Wed, 21 Oct 2015 18:27:50 GMT" +} +``` - -我们为这个请求创建了一个新的create\_note.rest文件,这个请求是根据[文档中的说明](https://github.com/huachao/vscode-restclient/blob/master/readme.md#usage)格式化的。 + + Postman 也允许用户保存请求,但情况可能变得相当混乱,特别是当你在多个不相关的项目上工作时。 - -Rest 客户端相对于 Postman 的一个好处是,请求可以在项目仓库的根部轻松获得,并且可以分发给开发团队中的每个人。 Postman也允许用户保存请求,但是当你在处理多个不相关的项目时,情况会变得非常混乱。 + + > **重要的附注**。 + + > + + > 有时当你在调试时,你可能想找出在 HTTP 请求中设置了哪些头文件。实现这一目的的方法之一是通过 _request_ 对象的 [get](http://expressjs.com/en/4x/api.html#req.get) 方法,该方法可用于获取单个头的值。_request_ 对象也有 headers 属性,它包含一个特定请求的所有头信息。 + + > -> **Important sidenote** -重要旁注 -> -> -> 有时在进行调试时,您可能希望了解 HTTP 请求中设置了哪些头。 实现这一点的一种方法是通过请求对象的[get](http://expressjs.com/en/4x/api.html#req.get)方法,该方法可用于获取单个头的值。 Request 对象还具有headers 属性,该属性包含特定请求的所有头信息。 + + > 如果你不小心在顶行和指定 HTTP 头信息的行之间添加了一个空行,VS REST 客户端就会出现问题。在这种情况下,REST 客户端解释为所有的头信息都是空的,这导致后端服务器不知道它所收到的数据是 JSON 格式的。 + + > -> -如果您不小心在指定 HTTP 头的顶行和行之间添加了一个空行,那么 VS REST 客户端可能会出现问题。 在这种情况下,REST 客户端将其解释为所有头都是空的,这导致后端服务器不知道它接收的数据是 JSON 格式的。 + + 如果在你的代码中的某个时刻,你用 _console.log(request.headers)_ 命令打印所有的请求头,你就能发现这个丢失的 Content-Type 头。 - -如果您在代码中的某个位置使用 _console.log(request.headers)_ 命令打印所有请求头,那么您将能够发现缺少了Content-Type 头。 - -让我们回到应用。 一旦我们知道应用正确地接收了数据,就是时候处理最终请求了: + + 让我们回到应用。一旦我们知道应用正确地接收了数据,就是最后处理请求的时候了。 ```js app.post('/api/notes', (request, response) => { const maxId = notes.length > 0 - ? Math.max(...notes.map(n => n.id)) + ? Math.max(...notes.map(n => n.id)) : 0 const note = request.body @@ -916,11 +987,13 @@ app.post('/api/notes', (request, response) => { }) ``` - -我们需要一个唯一的 id。 首先,找出当前列表中最大的 id 号,并将其赋值给 maxId 变量。 然后将新通知的 id 定义为 maxId + 1。 这种方法实际上是不被推荐的,但是我们暂时接受它,因为我们很快就会替换掉它。 - -当前版本仍然存在 HTTP POST 请求可添加任意属性的问题。 让我们通过定义content 属性不能为空来改进应用。importantdate 属性将被赋予默认值。 所有其他属性都被丢弃: + + 我们需要一个唯一的 id 给这个笔记。首先,我们找出当前列表中最大的 ID 号码,并将其分配给 _maxId_ 变量。然后,新笔记的 id 被定义为 _maxId+1_。这种方法实际上是不推荐的,但我们现在将继续使用它,因为我们很快就会取代它。 + + + + 目前的版本仍然有一个问题,即 HTTP POST 请求可以被用来添加具有任意属性的对象。让我们通过定义 content 属性不得为空来改进这个应用。importantdate 属性将被赋予默认值。所有其他属性都被丢弃。 ```js const generateId = () => { @@ -934,8 +1007,8 @@ app.post('/api/notes', (request, response) => { const body = request.body if (!body.content) { - return response.status(400).json({ - error: 'content missing' + return response.status(400).json({ + error: 'content missing' }) } @@ -952,58 +1025,63 @@ app.post('/api/notes', (request, response) => { }) ``` - -为便笺生成新 id 号的逻辑已经提取到一个单独的 generateId 函数中。 + + 为笔记生成新的 ID 号码的逻辑已经被提取到一个单独的 _generateId_ 函数中。 - -如果接收到的数据缺少content 属性的值,服务器将使用状态码[400 bad request](https://www.w3.org/protocols/rfc2616/rfc2616-sec10.html#sec10.4.1)响应请求: + + + 如果收到的数据缺少 content 属性的值,服务器将以状态代码 [400 bad request](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1) 响应请求。 ```js if (!body.content) { - return response.status(400).json({ - error: 'content missing' + return response.status(400).json({ + error: 'content missing' }) } ``` - -请注意,调用 return 是至关重要的,否则代码将执行到最后才能将格式不正确的通知保存到应用中。 - -如果 content 属性具有值,则说明便笺内容将基于接收到的数据。 正如前面提到的,在服务器上生成时间戳比在浏览器上生成更好,因为我们不能确保运行浏览器的主机的时钟设置是正确的。 现在由服务器生成date 属性。 + + 注意,调用 return 是至关重要的,因为否则代码会执行到最后,错误的笔记会被保存到应用中。 + + + 如果内容属性有一个值,笔记将基于收到的数据。如前所述,在服务器上生成时间戳比在浏览器中生成时间戳更好,因为我们不能相信运行浏览器的主机有正确的时钟设置。现在,date 属性的生成是由服务器完成的。 - -如果缺少important 属性,则将该值默认为false。 当前生成默认值的方式相当奇怪: + + + 如果 重要的 属性丢失,我们将默认其值为 。默认值目前是以一种看起来相当奇怪的方式生成的。 ```js important: body.important || false, ``` - -如果保存在 body 变量中的数据具有important 属性,则表达式将计算它作为值。 如果该属性不存在,那么表达式将默认为 false,该表达式在双竖线的右侧定义。 + + 如果保存在 _body_ 变量中的数据有 important 属性,表达式将计算为其值。如果该属性不存在,那么表达式将计算为 false,这在垂直线的右侧被定义。 -> -确切地说,当important 属性为false 时,那么body.important || false 表达式实际上将从右侧返回false..。 - -您可以在[this github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-1)的part3-1 分支中找到我们当前应用的全部代码。 + + > 确切地说,当 重要 属性是 false 时,那么 body.important || false 表达式实际上将从右侧返回 false... - -注意,仓库的主分支包含应用的后一个版本的代码。 应用当前状态的代码单独在 branch [part3-1](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-1)中。 -![](../../images/3/21.png) + + 你可以在 [这个 github 仓库](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-1) 的 part3-1 分支中找到我们当前应用的全部代码。 + +目前应用状态的代码具体在分支 [part3-1](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-1)。 - -如果您克隆了项目,在启动应用之前运行 npm install 命令,使用 npm start 或 npm run dev运行项目。 +![](../../images/3/21.png) + + + +如果你克隆了这个项目,在用 _npm start_ 或 _npm run dev_ 启动应用之前,运行 _npm install_ 命令。 - -在我们开始练习之前还有一件事,生成 id 的函数现在是这样的: + + 在我们进入练习之前还有一件事。生成 ID 的函数目前看起来是这样的。 ```js const generateId = () => { @@ -1014,105 +1092,142 @@ const generateId = () => { } ``` - -函数体包含一行看起来很有趣的内容: + + + 该函数主体包含了一行看起来有点耐人寻味的内容。 ```js Math.max(...notes.map(n => n.id)) ``` - -这行代码中到底发生了什么? notes.map(n => n.id) 创建一个包含所有便笺 id 的新数组。 [Math.max](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/Math.max)返回传递给它的数的最大值。 然而,notes.map(n => n.id) 是一个数组,因此它不能直接作为 Math.max 的参数。 数组可以通过使用“ 三个点...”[展开](https://developer.mozilla.org/en-us/docs/web/javascript/reference/operators/spread_syntax)语法 转换为单独的数字。 + + 这一行代码到底发生了什么?notes.map(n => n.id) 创建一个新的数组,其中包含了所有笔记的 ID。[Math.max](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/max) 返回传递给它的数字的最大值。然而,notes.map(n => n.id) 是一个 数组 ,所以它不能直接作为一个参数给 _Math.max_。数组可以通过使用 " 三点 "[spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) 语法 ... 转换为单个数字。
    -
    - ### Exercises 3.1.-3.6. + + **NB:** 建议将这部分的所有练习都放到一个新的专用 git 仓库中,并将你的源代码放在仓库的根部。否则你会在练习 3.10 中遇到问题。 + + + + **NB:** 因为这不是一个前端项目,而且我们没有使用 React,所以应用 没有用 create-react-app 创建 。你用 npm init 命令来初始化这个项目,在这部分材料的前面已经演示过了。 - -注意: 建议将本章节的所有练习放到一个新的专用 git 仓库中,并将源代码放在仓库的根部。 否则你会在 - + + **强烈建议:** 当你在处理后端代码时,始终关注运行你的应用的终端中发生的事情。 -注意: 因为这不是一个前端项目,我们没有使用 React,所以应用没有用 create-react-app创建。 您可以使用 npm init 命令初始化这个项目,该命令在本章节的前面已经演示过了。 - +#### 3.1: Phonebook backend step1 -强烈建议: 当你在处理后端代码时,始终关注运行应用的终端中发生了什么。 + + 实现一个 Node 应用,从地址 返回一个硬编码的电话簿条目列表。 -#### 3.1: Phonebook backend 步骤1 - -实现一个 Node 应用,从地址 http://localhost:3001/api/persons 返回一个硬编码的电话簿条目列表: + + +数据: + +```js +[ + { + "id": 1, + "name": "Arto Hellas", + "number": "040-123456" + }, + { + "id": 2, + "name": "Ada Lovelace", + "number": "39-44-5323523" + }, + { + "id": 3, + "name": "Dan Abramov", + "number": "12-43-234345" + }, + { + "id": 4, + "name": "Mary Poppendieck", + "number": "39-23-6423122" + } +] +``` + + + GET 请求后在浏览器中的输出。 ![](../../images/3/22e.png) + + 注意路由 api/persons 中的正斜杠不是一个特殊字符,和字符串中的其他字符一样。 + + + 应用必须用 _npm start_ 命令启动。 + + + 应用还必须提供一个 _npm run dev_ 命令,该命令将运行应用,并在做出改变并保存到源代码中的文件时重新启动服务器。 + +#### 3.2: Phonebook backend step2 + + + 在地址 上实现一个页面,大致是这样的。 + +![](../../images/3/23x.png) - -请注意,路由 api/persons 中的正斜杠不是特殊字符,它与字符串中的任何其他字符一样。 + + 该页面必须显示收到请求的时间和处理请求时电话簿中的条目数量。 +#### 3.3: Phonebook backend step3 - -应用必须以命令 npm start 启动。 - -应用还必须提供 npm run dev命令,该命令将运行应用,并在进行更改并将更改保存到源代码中的文件时重新启动服务器。 + + 实现显示单个电话簿条目信息的功能。获取一个 ID 为 5 的人的数据的网址应该是 。 + + 如果没有找到给定 ID 的条目,服务器必须以适当的状态码进行响应。 -#### 3.2: Phonebook backend 步骤2 - -在地址http://localhost:3001/info 实现一个页面,大致如下: +#### 3.4: Phonebook backend step4 -![](../../images/3/23ea.png) - -该页面必须显示接收请求的时间,以及在处理请求时展示电话簿中有多少条目。 + + 实现功能,使其有可能通过向电话簿条目的唯一 URL 发出 HTTP DELETE 请求来删除单个电话簿条目。 -#### 3.3: Phonebook backend 步骤3 - -实现显示单个电话簿条目信息的功能。 用于获取 id 为5的用户数据的 url 应该是 http://localhost:3001/api/persons/5 + + 测试你的功能是否能与 Postman 或 Visual Studio Code REST 客户端一起工作。 - -如果没有找到给定 id 的条目,服务器必须使用适当的状态代码进行响应。 +#### 3.5: Phonebook backend step5 -#### 3.4: Phonebook backend 步骤4 + + 扩展后端,使新的电话簿条目可以通过 HTTP POST 请求添加到地址 。 - -通过向电话簿条目的唯一 URL 发出 HTTP DELETE 请求,实现可以删除单个电话簿条目的功能。 - -测试您的功能是否能与Postman 或 Visual Studio Code REST client一起工作。 + + 用 [Math.random](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random) 函数为电话簿条目生成一个新的 ID。为你的随机值使用一个足够大的范围,这样创建重复 ID 的可能性就很小。 -#### 3.5: Phonebook backend 步骤5 - -扩展后端,以便通过向地址 发送 HTTP POST 请求来添加新的电话簿条目。 +#### 3.6: Phonebook backend step6 - -使用[Math.random](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/Math.random)函数为电话簿条目生成一个新 id。 使用一个足够大的范围作为您的随机值,以便创建重复 id 的可能性是很小的。 -#### 3.6: Phonebook backend 步骤6 - -为创建新条目实现错误处理。以下情况,请求不允许成功,如: -- -- 姓名或电话号码遗失 -- -- 电话簿里已经有这个名字了 + + 实现创建新条目的错误处理。请求不允许成功,如果。 + + - 名称或数字缺失 + + - 名字已经存在于电话簿中 - -使用适当的状态代码响应这些请求,并发回解释错误原因的信息,例如: + + 用适当的状态代码响应类似这样的请求,同时发回解释错误原因的信息,例如。 ```js { error: 'name must be unique' } @@ -1120,74 +1235,79 @@ Math.max(...notes.map(n => n.id))
    -
    +### About HTTP request types + + + [HTTP 标准](https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html) 谈到了与请求类型有关的两个属性:**安全** 和 **空闲**。 + + + HTTP GET 请求应该是 安全的 。 + + + > 特别是,约定俗成的是,GET 和 HEAD 方法不应具有除检索以外的行动意义。这些方法应该被认为是 " 安全的 "。。 + -### About HTTP request types -【关于 HTTP 请求类型】 + + 安全意味着执行的请求不能在服务器中引起任何 副作用 。我们所说的副作用是指数据库的状态不能因为请求而改变,而且响应必须只返回服务器上已经存在的数据。 - -[HTTP 标准](https://www.w3.org/protocols/rfc2616/rfc2616-sec9.html)讨论了与请求类型相关的两个属性,**安全** 和 **幂等性** 。 - -Http GET 请求应该是满足安全性的: + + 没有什么能保证 GET 请求实际上是 安全的 ,这实际上只是 HTTP 标准中定义的一个建议。通过在我们的 API 中坚持 RESTful 原则,GET 请求实际上总是以一种 安全 的方式被使用。 -> In particular, the convention has been established that the GET and HEAD methods SHOULD NOT have the significance of taking an action other than retrieval. These methods ought to be considered "safe". -特别是,已经建立了一个约定,即 GET 和 HEAD 方法除了检索之外不应该有其他行动的含义。 这些方法应该被认为是“安全的”。 - -安全性意味着执行请求不能在服务器中引起任何副作用。 副作用是指数据库的状态不能因请求而改变,响应只能返回服务器上已经存在的数据。 + + HTTP 标准还定义了请求类型 [HEAD](https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4),这应该是安全的。在实践中,HEAD 的工作方式应该与 GET 完全一样,但它不返回任何东西,只返回状态代码和响应头。当你发出 HEAD 请求时,响应体将不会被返回。 - -没有什么能够保证 GET 请求实际上是安全的,这实际上只是 HTTP 标准中定义的一个建议。 通过遵守我们的 API 中的 RESTful 原则,GET 请求实际上总是以一种安全safe 的方式使用。 + + 除了 POST,所有的 HTTP 请求都应该是 idempotent。 - -Http 标准还定义了应该是安全的请求类型[HEAD](https://www.w3.org/protocols/rfc2616/rfc2616-sec9.html#sec9.4)。 实际上,HEAD 应该像 GET 一样工作,但是它只返回状态码和响应头。 当您发出 HEAD 请求时,不会返回响应主体。 + + > 方法也可以具有 " 同位素 " 的属性,即(除了错误或过期问题)N>0 个相同的请求的副作用与单个请求相同。GET、HEAD、PUT 和 DELETE 等方法都有这个属性 。 - -除了 POST 之外的所有 HTTP 请求都应该是幂等: + + 这意味着,如果一个请求有副作用,那么无论这个请求被发送多少次,结果都应该是一样的。 -> Methods can also have the property of "idempotence" in that (aside from error or expiration issues) the side-effects of N > 0 identical requests is the same as for a single request. The methods GET, HEAD, PUT and DELETE share this property -方法也可以具有“幂等”属性,即(除了错误或过期问题) N > 0 相同请求的副作用与单个请求相同。 方法 GET、 HEAD、 PUT 和 DELETE 都具有此属性 + + 如果我们向 url /api/notes/10 发出 HTTP PUT 请求,并随请求发送数据 { content:"no side effects!", important: true },无论发送多少次请求,结果都是一样的。 - -这意味着,如果一个请求有副作用,那么无论发送多少次请求,结果都应该是相同的。 - -如果我们对 /api/notes/10 发出 HTTP PUT 请求,并且在发出请求时发送数据{ content: "no side effects!", important: true },结果是相同的,不管请求被发送多少次。 + + 就像 GET 请求的 安全 一样, 空闲 也只是 HTTP 标准中的一个建议,并不是简单地基于请求类型就能保证的。然而,当我们的 API 遵守 RESTful 原则时,那么 GET、HEAD、PUT 和 DELETE 请求的使用方式就是 idempotent。 - -就像 GET 请求的安全性 一样,幂等也只是 HTTP 标准中的一个推荐,而不是仅仅基于请求类型就可以保证的东西。 但是,当我们的 API 遵循 RESTful 原则时,GET、 HEAD、 PUT 和 DELETE 请求的使用方式是等幂的。 - -Post 是唯一既不是安全性 也不是幂等 的 HTTP 请求类型。 如果我们向 /api/notes 发送5个不同的 HTTP POST 请求,其中包含 {content: "many same", important: true},那么服务器上得到的5个便笺将具有相同的内容。 + + POST 是唯一的 HTTP 请求类型,既不 安全 也不 空闲 。如果我们向 /api/notes 发送 5 个不同的 HTTP POST 请求,其正文为 {content:"many same", important: true},服务器上产生的 5 个笔记都会有相同的内容。 ### Middleware -【中间件】 - -我们之前使用的 express [json-parser](https://expressjs.com/en/api.html)是所谓的[中间件](http://expressjs.com/en/guide/using-middleware.html)。 - -中间件是可用于处理请求和响应对象的函数。 + + 我们之前使用的快递 [json-parser](https://expressjs.com/en/api.html) 是一个所谓的 [中间件](http://expressjs.com/en/guide/using-middleware.html)。 - -我们前面使用的 json-parser 从请求对象中存储的请求中获取原始数据,将其解析为一个 JavaScript 对象,并将其作为一个新的属性、body 分配给请求对象。 - -在实践中,您可以同时使用多个中间件。 当你有多于一个的时候,将按照他们被使用的顺序,一个接一个地执行。 + + 中间件是可以用来处理 _request_ 和 _response_ 对象的函数。 - -让我们实现我们自己的中间件,打印有关发送到服务器的每个请求的信息。 + + 我们之前使用的 json-parser 从请求中获取原始数据,这些数据存储在 _request_ 对象中,将其解析为一个 JavaScript 对象,并将其作为一个新的属性 body 分配给 _request_ 对象。 - -中间件是一个接收三个参数的函数: + + 在实践中,你可以同时使用几个中间件。当你有多个中间件时,它们会按照在 Express 中被使用的顺序一个一个地被执行。 + + + + 让我们来实现我们自己的中间件,它可以打印出发送到服务器的每个请求的信息。 + + + + 中间件是一个接收三个参数的函数。 ```js const requestLogger = (request, response, next) => { @@ -1199,25 +1319,26 @@ const requestLogger = (request, response, next) => { } ``` - -在函数体的末尾,调用作为参数传递的下一个函数。 函数将控制权交给下一个中间件。 + + 在函数体的最后,调用作为参数传递的 _next_ 函数。这个 _next_ 函数将控制权交给下一个中间件。 - -中间件是这样使用的: + + 中间件是这样被使用的。 ```js app.use(requestLogger) ``` - -中间件函数按照与express服务器对象的使用方法一起使用的顺序调用。 请注意,json-parser 是在 requestLogger 中间件之前使用的,否则在执行日志记录器时,不会初始化我们的 request.body ! + + 中间件函数的调用顺序是它们被 Express 服务器对象的 _use_ 方法所使用的顺序。请注意,json-parser 是在 _requestLogger_ 中间件之前被使用的,因为否则在执行记录器的时候,request.body 将不会被初始化。 + - -如果我们希望在调用路由事件处理程序之前执行路由,则必须在路由之前使用中间件函数。 还有一些情况,我们希望在路由之后定义中间件函数。 实际上,这意味着我们定义的中间件函数只有在没有路由处理 HTTP 请求的情况下才被调用。 + + 如果我们想让中间件函数在路由事件处理程序被调用前执行,那么就必须在路由之前使用这些中间件函数。也有一些情况,我们想在路由之后定义中间件函数。在实践中,这意味着我们要定义的中间件函数只有在没有路由处理 HTTP 请求时才会被调用。 - -让我们在路由之后添加如下中间件,它用于捕获对不存在的路由发出的请求。 对于这些请求,中间件将返回 JSON 格式的错误消息。 + + 让我们在路由之后添加以下中间件,用于捕捉向不存在的路由发出的请求。对于这些请求,中间件将返回一个 JSON 格式的错误信息。 ```js const unknownEndpoint = (request, response) => { @@ -1227,44 +1348,49 @@ const unknownEndpoint = (request, response) => { app.use(unknownEndpoint) ``` - -您可以在[this github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-2).的part3-2 分支中找到我们当前应用的全部代码。 -
    + + 你可以在 [这个 github 仓库](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-2) 的 part3-2 分支中找到我们当前应用的全部代码。 +
    - ### Exercises 3.7.-3.8. -#### 3.7: Phonebook backend 步骤7 - -在你的日志应用中添加[morgan](https://github.com/expressjs/morgan) 中间件。 将其配置为基于tiny 配置,将消息记录到控制台。 - -Morgan 的文档不是最好的,您可能需要花费一些时间来弄清楚如何正确地配置它。 然而,世界上大多数文档都属于同一级别,因此无论如何,学习解释和解释神秘的文档都是有益的。 +#### 3.7: Phonebook backend step7 - -Morgan 的安装方式与使用 _npm install_ 命令的所有其他库一样。 使用 morgan 与使用 _app.use_ 命令配置任何其他中间件一样。 + + 将 [morgan](https://github.com/expressjs/morgan) 中间件添加到你的应用中进行记录。根据 tiny 配置,将信息记录到你的控制台。 + + Morgan 的文档不是最好的,你可能需要花一些时间来弄清楚如何正确地配置它。然而,世界上的大多数文档都属于同一类别,所以无论如何,学会破译和解释神秘的文档是件好事。 -#### 3.8*: Phonebook backend 步骤8 - -配置 morgan,让它同时显示 HTTP POST 请求中发送的数据: + + 摩根和其他所有的库一样,通过 _npm install_ 命令来安装。使用 Morgan 的方式和配置其他中间件一样,都是使用 _app.use_ 命令。 -![](../../images/3/24.png) +#### 3.8*: Phonebook backend step8 - -尽管解决方案不需要很多代码,但这个练习可能相当具有挑战性。 + + 配置 morgan,使它也显示 HTTP POST 请求中发送的数据。 - -这个练习可以通过几种不同的方式来完成。其中一种可能的解决方案利用了如下两种技巧: +![](../../images/3/24.png) -- [创建新的令牌](https://github.com/expressjs/morgan#creating-new-tokens) -- [JSON.stringify](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/JSON.stringify) + + 注意,即使在控制台中记录数据也是危险的,因为它可能包含敏感数据,并可能违反当地的隐私法(如欧盟的 GDPR)或商业标准。在这个练习中,你不必担心隐私问题,但在实践中,尽量不要记录任何敏感数据。 + + + 这个练习可能相当有挑战性,尽管解决方案不需要大量的代码。 -
    + + 这个练习可以用几种不同的方式完成。其中一个可能的解决方案是利用这两种技术。 + + - [创建新令牌](https://github.com/expressjs/morgan#creating-new-tokens) + + - [JSON.stringify](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) + + diff --git a/src/content/3/zh/part3b.md b/src/content/3/zh/part3b.md index 3a63c2b8321..e7532be4a4c 100644 --- a/src/content/3/zh/part3b.md +++ b/src/content/3/zh/part3b.md @@ -7,18 +7,13 @@ lang: zh
    + + 接下来让我们把我们在[第二章节](/zh/part2)中制作的前端连接到我们自己的后端。 - -接下来,让我们将[第2章节](/zh/part2)中制作的前端连接到我们自己的后端。 - - -在前面的部分中,前端可以从作为后端的 json 服务器向地址 http://localhost:3001/notes 索取便笺列表。 - - -我们的后端有一个稍微不同的 url 结构,便笺可以从 http//localhost:3001/api/notes 中获取到。 - - -让我们像下面这样修改 src/services/notes.js 中的__baseUrl__属性 : + + 在上一部分中,前端可以从我们作为后端的json-server中询问笔记列表,地址是http://localhost:3001/notes 。 + + 我们的后端现在有一个稍微不同的url结构,因为笔记可以在http://localhost:3001/api/notes 。让我们改变src/services/notes.js中的属性__baseUrl__,像这样。 ```js import axios from 'axios' @@ -34,51 +29,60 @@ const getAll = () => { export default { getAll, create, update } ``` + + 我们也需要改变App.js中效果中指定的url。 +```js + useEffect(() => { + axios + .get('http://localhost:3001/api/notes') + .then(res => { + setNotes(res.data) + }) + }, []) +``` - -现在前端的 GET 请求由于某些原因不能工作: http://localhost:3001/api/notes: + + + 现在到 前端的GET请求由于某些原因不能工作。 ![](../../images/3/3ae.png) - - - - -这是怎么回事? 我们可以从浏览器和Postman访问后端,没有任何问题。 + + + 这里发生了什么?我们可以从浏览器和postman访问后端,没有任何问题。 ### Same origin policy and CORS -【同源政策和 CORS】 - -问题出在一个叫 CORS 的东西上,或者叫跨来源资源共享。 + + 问题在于一个叫做CORS的东西,或者说跨源资源共享。 - -根据[维基百科](https://en.Wikipedia.org/wiki/cross-origin_resource_sharing) : + + 根据[维基百科](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing)。 -> Cross-origin resource sharing (CORS) is a mechanism that allows restricted resources (e.g. fonts) on a web page to be requested from another domain outside the domain from which the first resource was served. A web page may freely embed cross-origin images, stylesheets, scripts, iframes, and videos. Certain "cross-domain" requests, notably Ajax requests, are forbidden by default by the same-origin security policy. -Cross-origin resource sharing (CORS)是一种机制,它允许一个网页上受限制的资源(例如字体),从提供一手资源的域名以外的另一个域名请求跨来源资源共享。 一个网页可以自由地嵌入跨来源的图片、样式表、脚本、 iframe 和视频。 默认情况下,同源安全策略禁止某些“跨域”请求,特别是 Ajax 请求。 + + > 跨源资源共享(CORS)是一种机制,它允许网页上的限制性资源(如字体)从第一个资源所来自的域之外的另一个域被请求。一个网页可以自由嵌入跨源图像、样式表、脚本、iframe和视频。某些 "跨域 "请求,特别是Ajax请求,在默认情况下是被同源安全策略所禁止的。 - -在我们的上下文中,问题出在了,默认情况下,运行在浏览器应用的 JavaScript 代码只能与相同 [源](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy)的服务器通信。 - -因为我们的服务器位于本地主机端口3001,而我们的前端位于本地主机端口3000,所以它们不具有相同的源。 + +在我们的环境中,问题在于,默认情况下,在浏览器中运行的应用的JavaScript代码只能与同一[来源](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy)的服务器通信。 + +因为我们的服务器在localhost 3001端口,而我们的前端在localhost 3000端口,它们没有相同的起源。 - -请记住,[同源策略](https://developer.mozilla.org/en-us/docs/web/security/same-origin_policy)和 CORS 并不是特定于 React 或 Node 的。 它们实际上是 web 应用操作的通用原则。 + + 请记住,[同源策略](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy)和 CORS 并不是专门针对 React 或 Node。它们实际上是网络应用操作的普遍原则。 - -我们可以通过使用 Node 的[cors](https://github.com/expressjs/cors) 中间件来允许来自其他源的请求。 + + 我们可以通过使用 Node's [cors](https://github.com/expressjs/cors) 中间件来允许来自其他原点的请求。 - -使用命令安装cors + +在你的后端仓库中,用命令安装cors。 ```bash -npm install cors --save +npm install cors ``` - -使用中间件并允许来自所有来源的请求: + +取中间件来使用,并允许来自所有源的请求。 ```js const cors = require('cors') @@ -86,29 +90,36 @@ const cors = require('cors') app.use(cors()) ``` - -前端工作正常了!但是,在后端还没有实现更改便笺重要性的功能。 + + 前端就可以工作了!然而,改变笔记重要性的功能还没有在后端实现。 + + + 你可以从[Mozillas页面](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)上阅读更多关于CORS的信息。 - -你可以 从[Mozillas 页面](https://developer.mozilla.org/en-us/docs/web/http/CORS)阅读更多关于 CORS的内容。 + + 我们的应用的设置现在看起来如下。 +![](../../images/3/100.png) + + + 在浏览器中运行的react应用现在从运行在localhost:3001的node/express-server获取数据。 ### Application to the Internet -【将应用部署到网上】 - -现在整个栈已经准备就绪,让我们将应用迁移到互联网上。 我们将使用古老的 Heroku https://www.Heroku.com。 -> -如果您以前从未使用过 Heroku,您可以从[Heroku 文档](Heroku https://devcenter.Heroku.com/articles/getting-started-with-nodejs 文档)或通过谷歌搜索找到指令。 + + 现在整个堆栈已经准备好了,让我们把我们的应用移到互联网上。我们将使用古老的[Heroku](https://www.heroku.com)来完成。 + + + >如果你以前从未使用过Heroku,你可以从[Heroku文档](https://devcenter.heroku.com/articles/getting-started-with-nodejs)中找到说明,或者通过谷歌搜索。 - -向项目的根目录添加一个名为 Procfile的文件,告诉 Heroku 如何启动应用。 + + 在后端项目的根目录下添加一个名为Procfile的文件,告诉Heroku如何启动应用。 ```bash -web: node index.js +web: npm start ``` - -更改应用在index.js 文件底部使用的端口定义,如下所示: + +在index.js文件的底部改变我们应用使用的端口的定义,像这样。 ```js const PORT = process.env.PORT || 3001 // highlight-line @@ -117,98 +128,133 @@ app.listen(PORT, () => { }) ``` - -现在我们使用定义在[环境变量](https://en.wikipedia.org/wiki/environment_variable)的端口,如果环境变量 _PORT_ 是未定义的,则使用端口3001。 - -Heroku 会在环境变量的基础上配置应用端口。 + + 现在我们使用的是[环境变量](https://en.wikipedia.org/wiki/Environment_variable) _PORT_中定义的端口,如果环境变量_PORT_未定义,则使用3001端口。 + +Heroku根据环境变量来配置应用的端口。 - -在项目目录中创建一个 Git 仓库,并使用如下内容添加 .gitignore + + 在项目目录下创建一个Git仓库,并添加.gitignore,内容如下 ```bash node_modules ``` + +在 https://devcenter.heroku.com/ ,创建Heroku账户 + + 使用命令安装Heroku包:npm install -g heroku + + 用命令heroku create创建一个Heroku应用,将你的代码提交到版本库,并用命令git push heroku main将其移到Heroku。 - -使用命令heroku create创建一个 Heroku 应用,在 application 目录中创建一个 Git 仓库,提交代码并将其移动到 Heroku,命令Git push Heroku master。 - - -如果一切顺利,应用就能正常工作: + + 如果一切顺利,应用就能工作。 ![](../../images/3/25ea.png) - -如果没有运行成功,可以通过使用命令heroku logs 读取 heroku logs 来发现问题。 + + 如果没有,可以通过命令heroku logs阅读heroku日志来发现问题。 ->**NB** At least in the beginning it's good to keep an eye on the heroku logs at all times. The best way to do this is with command heroku logs -t which prints the logs to console whenever something happens on the server. -注意:至少在开始的时候,随时关注 heroku 日志是有好处的。 实现这一点的最佳方法是使用命令 heroku logs -t ,该命令会让服务器上发生任何事情时将日志打印到控制台。 + + > **NB** 至少在开始的时候,随时注意heroku的日志是很好的。最好的方法是使用命令heroku logs -t,它可以在服务器上发生任何事情时将日志打印到控制台。 - -前端也与 Heroku 的后端一起工作。 你可以通过更改前端的后端地址,更改为后端在 Heroku 的地址http://localhost:3001。 + + > **NB** 如果你从一个git仓库部署,而你的代码不在主分支上(例如,如果你正在改变上一课的[notes repo](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-2)),你将需要运行_git push heroku HEAD:master_。如果你已经做了推送到heroku,你可能需要运行_git push heroku HEAD:main --force_。 - -下一个问题是,我们如何将前端部署到互联网? 我们有多种选择。 接下来我们来看看其中的一个。 + + 前端也可以和Heroku的后端一起工作。你可以通过把前端的后端地址改为Heroku中的后端地址,而不是http://localhost:3001来检查。 + + + 接下来的问题是,我们如何将前端部署到互联网上?我们有多种选择。接下来让我们来看看其中的一个。 ### Frontend production build -【前端生产构建】 - -到目前为止,我们一直在开发模式 中运行 React code。 在开发模式下,应用被配置为提供清晰的错误消息,立即向浏览器渲染代码更改,等等。 + + 到目前为止,我们一直在开发模式中运行React代码。在开发模式下,应用被配置为给出清晰的错误信息,立即向浏览器渲染代码变化,等等。 + + + 当应用被部署时,我们必须创建一个[生产构建](https://reactjs.org/docs/optimizing-performance.html#use-the-production-build)或一个为生产而优化的应用版本。 + + + 用create-react-app创建的应用的生产构建可以用[npm run build](https://github.com/facebookincubator/create-react-app#npm-run-build-or-yarn-build)命令创建。 + + + **注意:**在撰写本文时(2022年1月20日)create-react-app有一个错误,导致以下错误 _TypeError: MiniCssExtractPlugin不是一个构造函数_ 。 + + + 从[这里](https://github.com/facebook/create-react-app/issues/11930)可以找到一个可能的修正。在文件package.json中添加以下内容 + +```json +{ + // ... + "resolutions": { + "mini-css-extract-plugin": "2.4.5" + } +} +``` + + + 然后运行命令 - -当应用被部署时,我们必须创建一个[生产构建](https://reactjs.org/docs/optimizing-performance.html#use-the-production-build)或一个为生产而优化的应用版本。 +``` +rm -rf package-lock.json +rm -rf node_modules +npm cache clean --force +npm install +``` - -使用create-react-app 创建的应用的生产构建可以使用命令[npm run build](https://github.com/facebookincubator/create-react-app#npm-run-build-or-yarn-build)创建。 + + 在这些_npm run build_之后,应该可以工作。 - -让我们从前端项目的根目录运行这个命令。 + + 让我们从前端项目的根部运行这个命令。 - -这将创建一个名为build 的目录(其中包含应用中唯一的 HTML 文件index. HTML) ,其中包含目录static。 我们应用的 JavaScript 代码的[Minified](https://en.wikipedia.org/wiki/minification_(programming))版本将生成到static 目录。 即使应用代码位于多个文件中,所有的 JavaScript 都将被缩小到一个文件中。 实际上,来自所有应用依赖项的所有代码也将缩小到这个单一文件中。 + + 这将创建一个名为build的目录(其中包含我们应用的唯一HTML文件,index.html),该目录包含static。我们应用的[Minified]()版本的JavaScript代码将被生成到static目录中。即使应用的代码在多个文件中,所有的JavaScript都将被最小化为一个文件。事实上,所有应用的依赖性代码也将被压缩到这个文件中。 - -缩小后的代码可读性不是很好,代码的开头是这样的: + + 分解后的代码可读性不强。代码的开头如下所示: ```js !function(e){function r(r){for(var n,f,i=r[0],l=r[1],a=r[2],c=0,s=[];cbuild directory) to the root of the backend repository and configure the backend to show the frontend's main page (the file build/index.html) as its main page. --> -部署前端的一个选择是将生产构建( build 目录)复制到后端仓库的根目录,并配置后端以显示前端的 main page (文件 build/index.html)作为其主页。 +### Serving static files from the backend + + + 部署前端的一个选择是将生产构建(build目录)复制到后端仓库的根目录,并配置后端以显示前端的主页(文件build/index.html)作为其主页面。 - -我们从将前端的生产构建复制到后端的根开始。 使用我的计算机,可以通过命令从前端目录进行复制 + + 我们首先把前端的生产构建复制到后端的根目录下。在Mac或Linux电脑上,复制可以在前端目录下用命令完成 ```bash -cp -r build ../../../osa3/notes-backend +cp -r build ../notes-backend ``` - -后端目录现在应该如下所示: + + 如果你使用的是Windows电脑,你可以用[copy](https://www.windows-commandline.com/windows-copy-command-syntax-examples/)或[xcopy](https://www.windows-commandline.com/xcopy-command-syntax-examples/)命令代替。否则,只需进行复制和粘贴。 -![](../../images/3/27ea.png) + + 后端目录现在应该是这样的。 - -为了让 express 显示 static content、 页面 index.html 和它用来fetch的 JavaScript 等等,我们需要一个来自 express 的内置中间件,称为[static](http://expressjs.com/en/starter/static-files.html)。 +![](../../images/3/27ea.png) - -当我们在中间件声明中添加如下内容时 + + 为了使express显示静态内容,页面index.html和它获取的JavaScript等,我们需要express的一个内置的中间件,叫做[static](http://expressjs.com/en/starter/static-files.html)。 + +当我们在中间件的声明中加入以下内容时 ```js app.use(express.static('build')) ``` - -每当 express 收到一个 HTTP GET 请求时,它都会首先检查build 目录是否包含与请求地址对应的文件。 如果找到正确的文件,express 将返回该文件。 + +每当express收到一个HTTP GET请求时,它将首先检查build目录中是否包含一个与请求地址相对应的文件。如果找到了正确的文件,express将返回它。 - -现在 HTTP GET 向地址www.serversaddress.com/index.html www.serversaddress.com 的GET请求,将显示 React 前端。 Get 请求到地址 www.serversaddress.com/notes 将由后端代码处理。 + + 现在,对地址www.serversaddress.com/index.htmlwww.serversaddress.com的HTTP GET请求将显示React前端。对地址www.serversaddress.com/api/notes的GET请求将由后端代码处理。 - -因为在我们的情况下,前端和后端都在同一个地址,所以我们可以声明 baseUrl 为[relative](https://www.w3.org/tr/wd-html40-970917/htmlweb.html#h-5.1.2) URL。 这意味着我们可以省略声明服务器的部分。 + + 由于我们的情况,前端和后端都在同一个地址,我们可以将_baseUrl_声明为一个[相对](https://www.w3.org/TR/WD-html40-970917/htmlweb.html#h-5.1.2)URL。这意味着我们可以省去声明服务器的部分。 ```js import axios from 'axios' @@ -222,19 +268,19 @@ const getAll = () => { // ... ``` - -更改之后,我们必须创建一个新的生产构建,并将其复制到后端存储库的根。 + + 更改后,我们必须创建一个新的生产构建,并将其复制到后端仓库的根目录。 - -该应用现在可以从后端 地址 http://localhost:3001中使用: + + 应用现在可以从后端地址使用。 ![](../../images/3/28e.png) - -我们的应用现在的工作方式与我们在第0章节中研究的[单页应用](/zh/part0/web_应用的基础设施#single-page-app) 示例应用完全一样。 + + 我们的应用现在的工作方式与我们在第0章节学习的[单页应用](/en/part0/fundamentals_of_web_apps#single-page-app)示例应用完全相同。 - -当我们使用浏览器访问地址 http://localhost:3001时,服务器从build 仓库返回index. html 文件。 档案的摘要内容如下: + +当我们用浏览器进入地址时,服务器从build仓库返回index.html文件。该文件的内容摘要如下。 ```html @@ -250,79 +296,104 @@ const getAll = () => { ``` - -该文件包含一些指令,用于获取定义应用样式的 CSS 样式表,以及两个script 标签,这些标记说明浏览器获取应用的 JavaScript 代码——即实际的 React 应用。 + + 该文件包含获取定义应用样式的CSS样式表的指令,以及两个script标签,指示浏览器获取应用的JavaScript代码--实际的React应用。 - -React代码从服务器地址 获取便笺,并将它们渲染到屏幕上。 服务器和浏览器之间的通信可以在开发控制台的Network 选项卡中看到: + + React代码从服务器地址获取注释,并将其渲染到屏幕上。服务器和浏览器之间的通信可以在开发者控制台的Network标签中看到。 ![](../../images/3/29ea.png) - -确保应用的生产版本在本地正常工作之后,将前端的生产构建提交到后端存储库,并将代码再次推送到 Heroku。 + + 准备用于产品部署的设置看起来如下。 + +![](../../images/3/101.png) - -除了我们还没有添加改变后端便笺重要性的功能之外,[应用](https://vast-oasis-81447.herokuapp.com/)运行得非常好。 + + 与在开发环境中运行应用时不同,现在所有东西都在同一个节点/express-backend中,该节点在localhost:3001中运行。当浏览器进入页面时,文件index.html被渲染。这导致浏览器获取React应用的产品版本。一旦开始运行,它就从localhost:3001/api/notes这个地址获取json-data。 + +### The whole app to internet + + + 在确保应用的生产版本在本地运行后,将前端的生产构建提交到后端仓库,并再次将代码推送到Heroku。 + + + [应用](https://obscure-harbor-49797.herokuapp.com/)工作得很好,只是我们还没有在后端添加改变笔记重要性的功能。 ![](../../images/3/30ea.png) - -我们的应用将便笺保存到一个变量中。 如果应用崩溃或重新启动,所有数据都将消失。 + + 我们的应用将笔记保存在一个变量中。如果应用崩溃或重新启动,所有的数据都会消失。 + + + 该应用需要一个数据库。在我们引入一个数据库之前,让我们先看一下几件事。 + + +现在的设置看起来如下。 + +![](../../images/3/102.png) - -应用需要一个数据库。在我们引入数据库之前,让我们先了解几个知识点。 + + 节点/express-backend现在驻扎在Heroku服务器上。当访问形式为https://glacial-ravine-74819.herokuapp.com/ 的根地址时,浏览器会加载并执行React应用,从Heroku服务器上获取json数据。 ### Streamlining deploying of the frontend -【流程化前端部署】 - -为了在没有额外手工工作的情况下创建前端的新的生产构建,我们在后端存储库的package.json 中添加一些 npm-scripts: + + + 为了创建一个新的前端生产构建,不需要额外的手工工作,让我们在后端仓库的package.json中添加一些npm脚本。 ```json { "scripts": { - //... - "build:ui": "rm -rf build && cd ../../osa2/materiaali/notes-new && npm run build --prod && cp -r build ../../../osa3/notes-backend/", - "deploy": "git push heroku master", - "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && npm run deploy", + //... + "build:ui": "rm -rf build && cd ../part2-notes/ && npm run build && cp -r build ../notes-backend", + "deploy": "git push heroku main", + "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && npm run deploy", "logs:prod": "heroku logs --tail" } } ``` - -脚本 _npm run build:ui_用于构建前端,并在后端存储库下复制生产版本。_npm run deploy_ 会将当前的后端版本发布到heroku. + + 脚本 _npm run build:ui_ 构建前端,并将生产版本复制到后端仓库下。 _npm run deploy_释放当前的后端到heroku。 + + + _npm run deploy:full_结合了这两者,并包含必要的git命令来更新后端仓库。 + + + 还有一个脚本_npm run logs:prod_来显示heroku的日志。 - -_npm run deploy:full_ 会将这两者结合起来,并包含更新后端存储库所需的git 命令。 + + 注意,脚本build:ui中的目录路径取决于文件系统中存储库的位置。 - -还有一个脚本 _npm run logs:prod_ 用于显示 heroku 日志。 + + >**NB** 在Windows上,npm脚本在cmd.exe中执行,作为默认的shell,不支持bash命令。为了让上述bash命令发挥作用,你可以将默认的shell改为Bash(在默认的Git for Windows安装中),方法如下。 - -注意,我构建的脚本中的目录路径 build:ui 依赖于文件系统中存储库的位置。 +```md +npm config set script-shell "C:\\Program Files\\git\\bin\\bash.exe" +``` ->**NB** build:ui does not work on Windows, go to [Solution](https://github.com/fullstackopen-2019/fullstackopen-2019.github.io/issues/420) -注意 build: ui 不能在 Windows 上工作,请转到[解决方案](https://github.com/fullstackopen-2019/fullstackopen-2019.github.io/issues/420) + + 另一个选择是使用 [shx](https://www.npmjs.com/package/shx)。 ### Proxy -【代理】 - -前端上的更改导致它不能再在开发模式下工作(当使用命令 npm start 启动时) ,因为到后端的连接无法工作。 + + + 前端的变化导致它在开发模式下不再工作(当用_npm start_命令启动时),因为与后端的连接不起作用。 ![](../../images/3/32ea.png) - -这是由于将后端地址更改为了一个相对 URL: + + 这是由于将后端地址改为相对的URL。 ```js const baseUrl = '/api/notes' ``` - -因为在开发模式下,前端位于地址localhost: 3000,所以对后端的请求会发送到错误的地址localhost:3000/api/notes。 而后端位于localhost: 3001。 + + 因为在开发模式下,前端的地址是localhost:3000,对后端的请求会进入错误的地址localhost:3000/api/notes。后端是在localhost:3001。 - -如果这个项目是用 create-react-app 创建的,那么这个问题很容易解决。 将如下声明添加到前端仓库的package.json 文件中就足够了。 + + 如果该项目是用create-react-app创建的,这个问题很容易解决。只需在前端仓库的package.json文件中添加以下声明。 ```bash { @@ -336,80 +407,83 @@ const baseUrl = '/api/notes' } ``` - -在重新启动之后,React 开发环境将作为一个[代理](https://create-React-app.dev/docs/proxying-api-requests-in-development/)工作。 如果 React 代码对服务器地址http://localhost:3000发出了一个 HTTP 请求,而不是 React 应用本身管理的地址(即当请求不是为了获取应用的 CSS 或 JavaScript) ,那么该请求将被重定向到 HTTP://localhost:3001 的服务器。 + + 重启后,React开发环境将作为一个[代理](https://create-react-app.dev/docs/proxying-api-requests-in-development/)工作。如果React代码向http://localhost:3000的服务器地址做HTTP请求,而不是由React应用本身管理(即当请求不是关于获取应用的CSS或JavaScript),该请求将被重定向到http://localhost:3001的服务器。 - -现在前端也工作良好,可以在开发和生产模式下与服务器一起工作。 + + 现在前端也很好,在开发和生产模式下都能与服务器一起工作。 - -我们方法的一个劣势,是前端部署的复杂程度。 部署新版本需要生成新的前端生产构建并将其复制到后端存储库。 这使得创建一个自动化的[部署管道](https://martinfowler.com/bliki/deploymentpipeline.html)变得更加困难。 部署管道是指通过不同的测试和质量检查将代码从开发人员的计算机转移到生产环境的自动化控制的方法。 + + 我们的方法的一个消极方面是部署前端是多么的复杂。部署一个新的版本需要生成新的前端生产版本并将其复制到后端仓库。这使得创建一个自动化的[部署管道](https://martinfowler.com/bliki/DeploymentPipeline.html)更加困难。部署管道是指通过不同的测试和质量检查,将代码从开发者的电脑中转移到生产环境中的一种自动化和可控的方式。构建一个部署管道是本课程[第11部分](https://fullstackopen.com/en/part11)的主题。 - -有多种方法可以实现这一点(例如将后端和前端代码[放到同一仓库中](https://github.com/mars/heroku-cra-node)) ,但我们现在不讨论这些。 + +有多种方法来实现这个目标(例如,将后端和前端的代码[放在同一个仓库](https://github.com/mars/heroku-cra-node)),但我们现在不会去讨论这些。 - -在某些情况下,将前端代码部署为它自己的应用可能是合理的。 通过create-react-app 创建的应用是[简单的](https://github.com/mars/create-react-app-buildpack)。 + + 在某些情况下,将前端代码部署为自己的应用可能是明智的。对于用create-react-app创建的应用,这是[直接的](https://github.com/mars/create-react-app-buildpack)。 - -后端的当前代码可以在分支part3-3 中的[Github](https://Github.com/fullstack-hy2020/part3-notes-backend/tree/part3-3)上找到。 前端代码的更改位于 [前端仓库frontend repository](https://github.com/fullstack-hy2020/part2-notes/tree/part3-1)的part3-1 分支。 + + 后端的当前代码可以在[Github](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-3)的分支part3-3中找到。前端代码的变化在[frontend repository](https://github.com/fullstack-hy2020/part2-notes/tree/part3-1)的part3-1分支中。
    -
    - ### Exercises 3.9.-3.11. - -下面的练习不需要很多行代码。 但是,它们可能是具有挑战性的,因为您必须准确地理解正在发生什么、在哪里发生,而且配置必须恰到好处。 + + 下面的练习不需要很多行的代码。然而,它们可能是具有挑战性的,因为你必须确切地了解正在发生什么和在哪里发生,而且配置必须恰到好处。 + +#### 3.9 phonebook backend step9 + + + 使后端与上一部分练习中的电话簿前端一起工作。先不要实现对电话号码进行修改的功能,这将在练习3.17中实现。 -#### 3.9 phonebook backend 步骤9 - -使后端工作与上一章的前端部分联调起来。 不要实现更改电话号码的功能,这将在 + + 你可能需要对前端做一些小的改动,至少要对后端的URL做一些改动。记得在你的浏览器中保持开放的开发者控制台。如果一些HTTP请求失败了,你应该从Network-tab中检查发生了什么。也要注意后端的控制台。如果你没有做前面的练习,在负责POST请求的事件处理程序中把请求数据或request.body打印到控制台是值得的。 - -您可能需要对前端做一些小的更改,至少对后端的 url 做一些更改。 记住,在浏览器中保持开发者控制台的打开状态。 如果一些 HTTP 请求失败,您应该从Network-标签检查发生了什么。 同时也要注意后端的控制台。 如果您没有执行前面的练习,那么将请求数据或request.body 打印到控制台是提倡的,这个控制台是指负责 POST 请求的事件处理程序。 +#### 3.10 phonebook backend step10 -#### 3.10 phonebook backend 步骤10 - -将后端部署到互联网,例如 Heroku。 + + 将后端部署到互联网上,例如部署到Heroku。 - -注意:命令 heroku 在部门的电脑和新生的笔记本电脑上可以工作。 如果由于某种原因不能[安装](https://devcenter.Heroku.com/articles/Heroku-cli) Heroku 到你的计算机,你可以使用命令[npx heroku-cli](https://www.npmjs.com/package/heroku-cli)。 + + **NB**命令_heroku_在系里的电脑和新生的笔记本上都可以使用。如果由于某些原因你不能[安装](https://devcenter.heroku.com/articles/heroku-cli)Heroku到你的电脑上,你可以使用命令[npx heroku](https://www.npmjs.com/package/heroku)。 - -使用浏览器和Postman或 VS Code REST 客户端测试已部署的后端,以确保其工作正常。 + + 用浏览器和Postman或VS Code REST客户端测试已部署的后端,以确保其工作。 - -专业提示: 当你将应用部署到 Heroku 时,至少在开始的时候使用命令heroku logs -t 关注 Heroku 应用的日志是值得的。 + + **专业提示:**当你将你的应用部署到Heroku时,至少在开始时值得用命令heroku logs -t来关注heroku应用的日志,**在任何时候。 - -下面是一个典型出问题的日志。 Heroku 找不到express 所表示的依赖项: + + 下面是一个典型问题的日志。Heroku无法找到应用的依赖项express。 ![](../../images/3/33.png) - -原因是当我安装express时,选项--save被忘记了,因此关于依赖项的信息没有保存到我的 package.json 文件中。 + + 原因是express包没有被npm install express命令安装,所以关于这个依赖的信息没有被保存到package.json文件中。 - -另一个典型的问题是,应用没有配置为使用设置为环境变量 PORT的端口: + + 另一个典型的问题是,应用没有被配置为使用设置在环境变量PORT中的端口。 ![](../../images/3/34.png) - -在存储库的根部创建 README.md,并向其中添加一个指向在线应用的链接。 + + 在你的版本库根部创建一个README.md,并在其中添加一个在线应用的链接。 #### 3.11 phonebook full stack - -生成前端的生产构建,并使用本章节介绍的方法将其添加到 internet 应用中。 - -**注意**确保我构建的 build 目录没有放到gitignored文件中。 + + 为你的前端生成一个生产版本,并使用本章节介绍的方法将其添加到互联网应用中。 - -还要确保前端仍然可以在本地工作。 + + **NB** 确保目录build没有被gitignored -
    + + 还要确保前端在本地仍然可以工作(在开发模式下,用_npm start_命令启动)。 + + + 如果你有问题让应用工作,请确保你的目录结构与[示例应用](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-3)的结构一致。 + diff --git a/src/content/3/zh/part3c.md b/src/content/3/zh/part3c.md index 5eccfc89edd..180cf742385 100644 --- a/src/content/3/zh/part3c.md +++ b/src/content/3/zh/part3c.md @@ -7,177 +7,178 @@ lang: zh
    - -在讨论在数据库中保存数据的议题之前,我们将看一下调试 Node 应用的几种不同方法。 +在我们进入关于在数据库中持久化数据的主题之前,我们先来看一下调试 Node 应用程序的几种不同方法。 -### Debugging Node applications -【调试Node应用】 - -调试 Node 应用比调试在浏览器中运行的 JavaScript 稍微困难一些。 将数据打印到控制台是一种可靠的方法,而且总是值得一试。 有些人认为应该用更复杂的方法来代替,但我不同意。 即使是世界上最顶尖的开源开发者也会使用 [use](https://tenderlovemaking.com/2016/02/05/i-am-a-puts-debuggerer.html) 这种方法[method](https://swizec.com/blog/javascript-debugging-slightly-beyond-console-log/swizec/6633)。 +### Debugging Node applications -#### Visual Studio Code + +调试 Node 应用程序比调试在浏览器中运行的 JavaScript 稍微困难一些。打印到控制台是一种经过验证的方法,值得一试。有些人认为应该使用更复杂的方法,但我不同意。即使是世界上顶级的开源开发人员也会使用这种方法。 +#### Visual Studio Code - - Visual Studio Code代码调试器在某些情况下可能很有用。 你可以像这样在调试模式下启动应用: + +在某些情况下,Visual Studio Code 的调试器可能很有用。您可以像这样以调试模式启动应用程序(在这个和接下来的几个图像中,注释中有一个名为“日期”的字段,在当前版本的应用程序中已被删除): -![](../../images/3/35.png) +![截图显示如何在 vscode 中启动调试器](../../images/3/35x.png) -注意,应用不应该在另一个控制台中运行,否则该端口将会冲突。 +请注意,应用程序不应该在另一个控制台中运行,否则端口将已经被占用。 + + +__注意__:Visual Studio Code 的较新版本可能会将“Debug”更改为“Run”。此外,您可能需要配置您的 _launch.json_ 文件来开始调试。您可以通过选择下拉菜单上方的绿色播放按钮旁边的 _Add Configuration..._,然后选择 _Run "npm start" in a debug terminal_ 来进行配置。有关更详细的设置说明,请访问 Visual Studio Code 的[调试文档](https://code.visualstudio.com/docs/editor/debugging)。 -下面你可以看到一个屏幕截图,代码执行在保存新便笺的过程中被暂停: +下面是一张截图,显示代码执行在保存新笔记的过程中被暂停: -![](../../images/3/36e.png) +![断点处执行的vscode屏幕截图](../../images/3/36x.png) - -执行在第63行的断点 处停止。 在控制台中,您可以看到note 变量的值。 在左上角的窗口中,您可以看到与应用状态相关的其他内容。 + +代码执行在第 69 行的断点处停止。在控制台中,您可以看到 note 变量的值。在左上角的窗口中,您可以看到与应用程序状态相关的其他信息。 -顶部的箭头可用于控制调试器的流。 +顶部的箭头可以用于控制调试器的流程。 -出于某种原因,我并不经常使用 Visual Studio Code 调试器。 +出于某种原因,我并不经常使用 Visual Studio Code 的调试器。 #### Chrome dev tools -【Chrome开发工具】 + -利用 Chrome 开发者控制台,通过如下命令启动应用,也可以进行调试: +您也可以通过在命令中启动应用程序来使用 Chrome 开发者控制台进行调试: ```bash node --inspect index.js ``` - -你可以通过点击 Chrome 开发者控制台中的绿色图标来进入调试器: + +您还可以将 `--inspect` 标志传递给 `nodemon`: + +```bash +nodemon --inspect index.js +``` + + +您可以通过点击 Chrome 开发者控制台中出现的绿色图标(node logo)来访问调试器: -![](../../images/3/37.png) +![带有绿色node标志图标的开发者工具](../../images/3/37.png) -调试视图的工作方式与 React 应用相同。Sources 选项卡可用于设置中断点,中断点将暂停代码的执行。 +调试器的界面与在 React 应用程序中的使用方式相同。可以使用Sources选项卡设置断点,代码执行将在断点处暂停。 -![](../../images/3/38eb.png) +![开发者工具的 Sources 选项卡,包含断点和监视变量](../../images/3/38eb.png) - -应用的所有 console.log 消息都将出现在调试器的Console 选项卡中。 您还可以检查变量的值并执行自己的 JavaScript 代码。 + +应用程序的所有console.log消息都将出现在调试器的Console选项卡中。您还可以检查变量的值并执行自己的 JavaScript 代码。 -![](../../images/3/39ea.png) +![开发者工具的控制台选项卡显示输入的笔记对象](../../images/3/39ea.png) +#### Question everything -### Question everything -【质疑一切】 -调试全栈应用起初可能看起来很棘手。 不久,我们的应用除了前端和后端之外,还将有一个数据库,并且应用中将有许多潜在的 bug。 +调试全栈应用程序可能一开始看起来很棘手。很快,我们的应用程序除了前端和后端之外还将有一个数据库,而应用程序中可能存在许多潜在的错误。 - -当应用“不工作”时,我们必须首先找出问题实际发生在哪里。 这个问题存在于一个你没有预料到的地方是很常见的,它可能需要几分钟,几个小时,甚至几天,你才能找到问题的根源。 + +当应用程序"无法工作"时,我们首先必须找出问题实际发生在哪里。问题往往存在于您意想不到的地方,可能需要几分钟、几小时甚至几天才能找到问题的根源。 -关键是要有系统性。 既然问题可以存在于任何地方,你就必须质疑每一件事,并逐一排除所有的可能性。 登录到控制台、Postman、调试器和经验都将有所帮助。 +关键是要有系统性。由于问题可能存在于任何地方,您必须对所有事物提出质疑,逐个排除所有可能性。记录到控制台、使用 Postman、调试器和经验都会有所帮助。 - -当出现 bug 时,所有可能的策略中最糟糕的就是继续编写代码。 这将保证你的代码很快会有另外十个 bug,并且调试它们将会更加困难。 在这种情况下,丰田生产系统公司(Toyota Production Systems)的 [stop and fix](http://gettingtolean.com/toyota-principle-5-build-culture-stopping-fix/#.Wjv9axP1WCQ) 原则也非常有效。 + +当出现错误时,最糟糕的策略就是继续编写代码。这将确保您的代码很快会有更多的错误,并且调试它们将变得更加困难。丰田生产系统的 [Jidoka](https://leanscape.io/principles-of-lean-13-jidoka/)(停止和修复)原则 在这种情况下也非常有效。 ### MongoDB - -为了永久地存储我们保存的便笺,我们需要一个数据库。 赫尔辛基大学教授的大多数课程都使用关系数据库。 在本课程中,我们将使用[MongoDB](https://www.MongoDB.com/ 数据库) ,这是一个所谓的[文档数据库](https://en.wikipedia.org/wiki/document-oriented_database)。 - -文档数据库在组织数据的方式以及它们所支持的查询语言方面不同于关系数据库。 文档数据库通常被归类为[NoSQL](https://en.wikipedia.org/wiki/NoSQL)的术语集。 + +为了永久存储我们保存的笔记,我们需要一个数据库。赫尔辛基大学的大多数课程使用关系数据库。在本课程的大部分内容中,我们将使用 [MongoDB](https://www.mongodb.com/),这是一种所谓的 [文档数据库](https://en.wikipedia.org/wiki/Document-oriented_database)。 - -你可以阅读更多关于文档数据库和 NoSQL 的资料,这些资料来自数据库导论课程的[第7周](https://tikape-s18.mooc.fi/part7/)课程。 不幸的是,这些材料目前只有芬兰语版。 + +选择使用 Mongo 作为数据库的原因是它相对于关系数据库来说更简单。本课程的 [第13部分](/zh/part13) 展示了如何构建使用关系数据库的 Node.js 后端。 - -现在阅读 MongoDB 手册中关于[集合](https://docs.MongoDB.com/manual/core/databases-and-collections/)和[文档](https://docs.MongoDB.com/manual/core/document/)的章节,了解文档数据库如何存储数据的基本概念。 + +文档数据库与关系数据库在数据组织方式和支持的查询语言方面有所不同。文档数据库通常被归类为 [NoSQL](https://en.wikipedia.org/wiki/NoSQL) 的范畴。 - -当然,您可以在自己的计算机上安装和运行 MongoDB。 然而,互联网上也充满了你可以使用的 Mongo 数据库服务。 在本课程中,我们首选的 MongoDB 提供者将是[MongoDB Atlas](https://www.MongoDB.com/cloud/Atlas)。 + +您可以从 [数据库导论课程](https://tikape-s18.mooc.fi/part7/) 的 [part7](https://tikape-s18.mooc.fi/part7/) 材料中了解有关文档数据库和 NoSQL 的更多信息。不幸的是,该材料目前仅提供芬兰语版本。 - -一旦你创建并登录到你的账户,Atlas 会建议你创建一个集群: + +现在,请阅读 MongoDB 手册中关于 [集合(collections)](https://docs.mongodb.com/manual/core/databases-and-collections/) 和 [文档(documents)](https://docs.mongodb.com/manual/core/document/) 的章节,以了解文档数据库如何存储数据的基本概念。 -![](../../images/3/57.png) + +当然,您可以在计算机上安装和运行 MongoDB。然而,互联网上也有许多可用的 Mongo 数据库服务。在本课程中,我们首选的 MongoDB 提供商将是 [MongoDB Atlas](https://www.mongodb.com/atlas/database)。 - -让我们选择AWSFrankfurt 并创建一个集群。 + +创建并登录到您的帐户后,让我们首先选择免费选项: -![](../../images/3/58.png) - - -让我们等待集群准备好可以使用。这大约需要10分钟。 - - -**注意**在集群准备好之前不要继续。 +![mongodb部署云数据库免费共享](../../images/3/mongo1.png) - -让我们使用database access 选项卡为数据库创建用户凭据。 请注意,这些不是您登录到 MongoDB Atlas 所使用的相同凭据。 + +选择云提供商和位置,并创建集群: -![](../../images/3/59.png) +![选择共享、AWS 和区域的 MongoDB](../../images/3/mongo2.png) - -让我们授予用户读写数据库的权限。 + +让我们等待集群准备就绪。这可能需要几分钟时间。 -![](../../images/3/60.png) + +**注意**:在集群准备就绪之前,请不要继续进行。 - -**注意** 对于某些人来说,新的用户证书在创建后没有立即生效。 在某些情况下,这些凭证需要几分钟的时间才能生效。 + +让我们使用security(安全)选项卡为数据库创建用户凭据。请注意,这些凭据与您用于登录 MongoDB Atlas 的凭据不同。这些凭据将用于您的应用程序连接到数据库。 - -接下来,我们必须定义允许访问数据库的 IP 地址。 +![mongodb security quickstart](../../images/3/mongo3.png) -![](../../images/3/61ea.png) + +接下来,我们需要定义允许访问数据库的 IP 地址。为简单起见,我们将允许所有 IP 地址访问: - -为了简单起见,我们将允许所有访问的 IP 地址: +![MongoDB 网络访问/添加 IP 访问列表](../../images/3/mongo4.png) -![](../../images/3/62.png) + +注意:如果对话框菜单对您而言不同,根据 MongoDB 文档,将 0.0.0.0 添加为 IP 地址也允许从任何地方访问。 - -最后,我们准备连接到我们的数据库 + +最后,我们准备好连接到我们的数据库了。首先点击connect: -![](../../images/3/63ea.png) +![MongoDB 数据库部署连接](../../images/3/mongo5.png) - -选择Connect your application: + +然后选择:Connect to your application -![](../../images/3/64ea.png) +![MongoDB 连接应用程序](../../images/3/mongo6.png) -该视图显示MongoDB URI,这是我们将提供给我们将添加到应用的 MongoDB 客户端库的数据库地址。 +视图显示了MongoDB URI,这是我们将提供给我们的应用程序的 MongoDB 客户端库的数据库地址。 -地址是这样的: +地址看起来是这样子的: -```bash -mongodb+srv://fullstack:@cluster0-ostce.mongodb.net/test?retryWrites=true +```js +mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/?retryWrites=true&w=majority ``` -我们现在可以使用数据库了。 +我们现在已经准备好使用数据库了。 - -我们可以通过官方的 MongoDb Node.js 驱动程序库直接从 JavaScript 代码中使用这个数据库,但是使用起来相当麻烦。 相反,我们将使用提供更高级别 API 的[Mongoose](http://mongoosejs.com/index.html)库。 + +我们可以直接从我们的 JavaScript 代码中使用数据库,使用[官方的 MongoDB Node.js 驱动程序](https://mongodb.github.io/node-mongodb-native/),但是使用起来相当麻烦。我们将使用[Mongoose](http://mongoosejs.com/index.html)库,它提供了一个更高级的 API。 - -Mongoose 可以被描述为object document mapper (ODM) ,并且将 JavaScript 对象保存为 Mongo 文档对于Mongoose库来说很简单。 + +Mongoose可以被描述为一个对象文档映射器(ODM),使用这个库将JavaScript对象保存为Mongo文档非常简单。 - -让我们安装 Mongoose: + +让我们在笔记项目的后端中安装Mongoose: ```bash -npm install mongoose --save +npm install mongoose ``` - -现在还不要在后端添加任何处理 Mongo 的代码。 相反,让我们在mongo.js 文件中创建一个实践应用: + +暂时先不要在后端添加任何与Mongo相关的代码。相反,我们可以通过在笔记后端应用程序的根目录下创建一个新文件mongo.js来创建一个练习应用程序: ```js const mongoose = require('mongoose') -if ( process.argv.length<3 ) { +if (process.argv.length<3) { console.log('give password as argument') process.exit(1) } @@ -185,116 +186,115 @@ if ( process.argv.length<3 ) { const password = process.argv[2] const url = - `mongodb+srv://fullstack:${password}@cluster0-ostce.mongodb.net/test?retryWrites=true` + `mongodb+srv://fullstack:${password}@cluster0.o1opl.mongodb.net/?retryWrites=true&w=majority` -mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) +mongoose.set('strictQuery',false) + +mongoose.connect(url) const noteSchema = new mongoose.Schema({ content: String, - date: Date, important: Boolean, }) const Note = mongoose.model('Note', noteSchema) const note = new Note({ - content: 'HTML is Easy', - date: new Date(), + content: 'HTML is easy', important: true, }) -note.save().then(response => { +note.save().then(result => { console.log('note saved!') mongoose.connection.close() }) ``` - -该代码假定它将作为命令行参数从我们在 MongoDB Atlas 中创建的凭据中传递密码。 我们可以像这样访问命令行参数: + +**注意**:根据您在构建集群时选择的区域,MongoDB URI可能与上面提供的示例不同。您应该验证并使用从MongoDB Atlas生成的正确URI。 + + +代码还假设它将通过命令行参数传递从我们在MongoDB Atlas中创建的凭据中生成的密码。我们可以像这样访问命令行参数: ```js const password = process.argv[2] ``` - -当使用命令node mongo.js password运行代码时,Mongo 将向数据库添加一个新文档。 - - -我们可以从Collections 中查看 MongoDB Atlas 中数据库的当前状态 - -在概览标签页。 + +当使用命令node mongo.js yourPassword运行代码时,Mongo将向数据库添加一个新文档。 -![](../../images/3/65.png) + +**注意**:请注意,密码是为数据库用户创建的密码,而不是您的MongoDB Atlas密码。此外,如果您创建了一个带有特殊字符的密码,那么您需要对该密码进行[URL编码](https://docs.atlas.mongodb.com/troubleshoot-connection/#special-characters-in-connection-string-password)。 - -正如视图所指出的那样,匹配便笺的document 已经添加到test 数据库中的notes 集合中。 + +我们可以从MongoDB Atlas的浏览集合选项卡中查看数据库的当前状态。 -![](../../images/3/66a.png) +![MongoDB 数据库浏览集合按钮](../../images/3/mongo7.png) - -我们应该给这个数据库起个更好的名字。 正如文档所说,我们可以从 URI 改变数据库的名称: + +正如视图所示,与笔记匹配的文档已添加到myFirstDatabase数据库中的notes集合中。 -![](../../images/3/67.png) +![MongoDB 集合选项卡 db myfirst app notes](../../images/3/mongo8new.png) - -让我们通过修改 URI,将数据库的名称更改为note-app: + +让我们销毁默认数据库test,并通过修改URI中引用的数据库名称将其更改为noteApp: -```bash -mongodb+srv://fullstack:@cluster0-ostce.mongodb.net/note-app?retryWrites=true +```js +const url = + `mongodb+srv://fullstack:${password}@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority` ``` - -让我们再次运行代码。 + +让我们再次运行我们的代码: -![](../../images/3/68.png) +![mongodb collections tab noteApp notes](../../images/3/mongo9.png) - -数据现在存储在正确的数据库中。 该视图还提供了create database 功能,可用于从网站创建新的数据库。 这样创建数据库是没有必要的,因为当应用试图连接到一个尚不存在的数据库时,MongoDB Atlas 会自动创建一个新的数据库。 + +数据现在存储在正确的数据库中。该视图还提供了create database(创建数据库)功能,可以用于从网站创建新数据库。这样创建数据库是不必要的,因为当应用程序尝试连接到尚不存在的数据库时,MongoDB Atlas会自动创建一个新数据库。 ### Schema + -在建立到数据库的连接之后,我们为一个便笺定义[模式schema](http://mongoosejs.com/docs/guide.html)和匹配的[模型](http://mongoosejs.com/docs/models.html) : +在与数据库建立连接后,我们为笔记定义了[schema](http://mongoosejs.com/docs/guide.html),并创建了相应的[model](http://mongoosejs.com/docs/models.html): ```js const noteSchema = new mongoose.Schema({ content: String, - date: Date, important: Boolean, }) const Note = mongoose.model('Note', noteSchema) ``` - -首先,我们定义了存储在 noteSchema 变量中的便笺的[模式](http://mongoosejs.com/docs/guide.html)。 模式告诉 Mongoose 如何将 note 对象存储在数据库中。 + +首先,我们定义了存储在 _noteSchema_ 变量中的笔记的[schema](http://mongoosejs.com/docs/guide.html)。该schema告诉 Mongoose 如何将笔记对象存储在数据库中。 - -在 Note 模型定义中,第一个 "Note"参数是模型的单数名。 集合的名称将是小写的复数 notes,因为[Mongoose 约定](http://mongoosejs.com/docs/models.html)是当模式以单数(例如Note)引用集合时自动将其命名为复数(例如notes)。 + +在 _Note_ 模型定义中,第一个"Note"参数是模型的单数名称。集合的名称将是小写复数形式的notes,因为[Mongoose的惯例](http://mongoosejs.com/docs/models.html)是自动将集合命名为复数形式(例如notes),当schema以单数形式(例如Note)引用它们时。 -像 Mongo 这样的文档数据库是schemaaless,这意味着数据库本身并不关心存储在数据库中的数据的结构。 可以在同一集合中存储具有完全不同字段的文档。 +像Mongo这样的文档数据库是schemaless,这意味着数据库本身并不关心存储在数据库中的数据的结构。可以在同一集合中存储具有完全不同字段的文档。 -Mongoose 背后的思想是,存储在数据库中的数据在application 级别上被赋予一个schema ,该模式定义了存储在任何给定集合中的文档的形状。 +Mongoose的思想是,存储在数据库中的数据在应用程序级别被赋予一个schema,该schema定义了存储在任何给定集合中的文档的形状。 ### Creating and saving objects -【创建和保存对象】 + -接下来,应用在Note [model](http://mongoosejs.com/docs/models.html)的帮助下创建一个新的 Note 对象: +接下来,应用程序使用Note[model](http://mongoosejs.com/docs/models.html)创建一个新的笔记对象: ```js const note = new Note({ - content: 'Browser can execute only Javascript', - date: new Date(), + content: 'HTML is Easy', important: false, }) ``` -模型是所谓的构造函数constructor function,它根据提供的参数创建新的 JavaScript 对象。 由于对象是使用模型的构造函数创建的,因此它们具有模型的所有属性,其中包括将对象保存到数据库的方法。 +模型(Models)是所谓的构造函数,它根据提供的参数创建新的JavaScript对象。由于对象是用模型的构造函数创建的,因此它们具有模型的所有属性,这包括用于将对象保存到数据库的方法。 - -将对象保存到数据库是通过恰当命名的 save 方法实现的,可以通过 then 方法提供一个事件处理程序: + +将对象保存到数据库使用的是适当命名的 _save_ 方法,可以通过 _then_ 方法提供一个事件处理程序: ```js note.save().then(result => { @@ -304,22 +304,21 @@ note.save().then(result => { ``` -当对象保存到数据库时,将调用提供给该对象的事件处理。 事件处理程序使用命令代码 mongoose.connection.close() 关闭数据库连接。 如果连接没有关闭,程序将永远不能完成它的执行。 +当对象保存到数据库时,提供给 _then_ 的事件处理程序会被调用。事件处理程序使用命令 mongoose.connection.close() 关闭数据库连接。如果不关闭连接,程序将永远不会结束执行。 - -保存操作的结果存在事件处理程序的结果参数中。 当我们将一个对象存储到数据库时,结果并不那么有趣。 如果希望在实现应用或调试过程中仔细查看对象,可以将该对象打印到控制台。 + +保存操作的结果在事件处理程序的 _result_ 参数中。当我们在数据库中存储一个对象时,结果并不那么有趣。如果你想在实现应用程序或在调试期间仔细查看它,你可以将对象打印到控制台。 -我们还可以通过修改代码中的数据和再次执行程序来保存更多的便笺。 +我们也可以通过修改代码中的数据并再次执行程序来保存更多的笔记。 - -遗憾的是 Mongoose 文档在其示例中使用了回调函数,因此不建议直接从那里复制粘贴代码。 不建议在同一代码中将承诺与老式的回调混合使用。 + +**注意:**不幸的是,Mongoose的文档并不非常一致,部分文档在其示例中使用回调,其他部分使用其他样式,因此不建议直接从那里复制和粘贴代码。不建议在同一代码中混合使用promise和旧式的回调。 ### Fetching objects from the database -【从数据库中获取对象】 -让我们注释掉生成新便笺的代码,并用如下代码替换它: +让我们注释掉生成新笔记的代码,并用以下内容替换它: ```js Note.find({}).then(result => { @@ -331,18 +330,18 @@ Note.find({}).then(result => { ``` -当代码执行时,程序会输出存储在数据库中的所有便笺: +当代码执行时,程序会打印出数据库中存储的所有笔记: -![](../../images/3/70ea.png) +![node mongo.js outputs notes as JSON](../../images/3/70new.png) - -这些对象是通过 Note 模型的[find](https://mongoosejs.com/docs/api.html#model_model.find)方法从数据库中检索的。 该方法的参数是表示搜索条件的对象。 因为参数是一个空的对象{},所以我们得到了存储在 notes 集合中的所有便笺。 + +通过_Note_模型的[find](https://mongoosejs.com/docs/api/model.html#model_Model-find)方法从数据库中检索对象。该方法的参数是一个表示搜索条件的对象。由于参数是一个空对象{},我们得到了_notes_集合中存储的所有笔记。 -搜索条件遵循 Mongo 搜索查询[语法](https://docs.mongodb.com/manual/reference/operator/)。 +搜索条件遵循Mongo搜索查询[syntax](https://docs.mongodb.com/manual/reference/operator/)。 -我们可以限制我们的搜索,只包括重要的便笺,如下所示: +我们可以限制我们的搜索只包括重要的笔记,像这样: ```js Note.find({ important: true }).then(result => { @@ -352,65 +351,64 @@ Note.find({ important: true }).then(result => {
    -
    - ### Exercise 3.12. -#### 3.12: Command-line database 命令行数据库 - -使用 MongoDB Atlas 为电话簿应用创建基于云的 MongoDB 数据库。 + +#### 3.12: Command-line database + + +使用MongoDB Atlas为电话簿应用程序创建一个基于云的MongoDB数据库。 -在项目目录中创建一个mongo.js 文件,该文件可用于向电话簿添加条目,以及列出电话簿中的所有现有条目。 +在项目目录中创建一个mongo.js文件,该文件可以用于向电话簿添加条目,以及列出电话簿中所有已有的条目。 - -不要在你提交的文件中包含密码并推送到 GitHub! + +**注意:** 不要在你提交和推送到GitHub的文件中包含密码! -应用的工作方式如下。 通过传递三个命令行参数(第一个是密码)来使用该程序,例如: +应用程序应该如下工作。你通过传递三个命令行参数(第一个是密码)来使用程序,例如: ```bash node mongo.js yourpassword Anna 040-1234556 ``` -因此,应用将打印: +因此,应用程序将打印: ```bash added Anna number 040-1234556 to phonebook ``` -电话簿的新条目将被保存到数据库中。 请注意,如果名称包含空格字符,则必须用引号括起来: +新的电话簿条目将被保存到数据库中。注意,如果名字包含空格字符,它必须被包含在引号中: ```bash -node mongo.js yourpassword "Arto Vihavainen" 040-1234556 +node mongo.js yourpassword "Arto Vihavainen" 045-1232456 ``` -如果密码是程序的唯一参数,这意味着它是这样调用的: +如果密码是给程序的唯一参数,意味着它像这样被调用: ```bash node mongo.js yourpassword ``` -然后程序应该显示电话簿中的所有条目: +那么程序应该显示电话簿中的所有条目: -
    +```
     phonebook:
     Anna 040-1234556
     Arto Vihavainen 045-1232456
     Ada Lovelace 040-1231236
    -
    - +``` - -您可以从[process.argv](https://nodejs.org/docs/latest-v8.x/api/process.html#process_process_argv)变量中获得命令行参数。 + +你可以从[process.argv](https://nodejs.org/docs/latest-v18.x/api/process.html#process_process_argv)变量获取命令行参数。 -**注意: 不要在错误的地方关闭连接 **,例如,如下的代码就无法工作: +**注意:不要在错误的地方关闭连接**。例如,以下代码将无法工作: ```js Person @@ -423,10 +421,10 @@ mongoose.connection.close() ``` -在上面的代码中,mongoose.connection.close() 命令将在 Person.find 操作启动后立即执行。 这意味着数据库连接将立即关闭,执行将永远不会到达Person.find 操作结束并调用回调 函数的地方。 +在上面的代码中,mongoose.connection.close()命令将在Person.find操作开始后立即执行。这意味着数据库连接将立即关闭,执行永远不会到达Person.find操作完成和callback函数被调用的地方。 -关闭数据库连接的正确位置是在回调函数的末尾: +关闭数据库连接的正确位置是在回调函数的末尾: ```js Person @@ -437,43 +435,43 @@ Person }) ``` - -**注意** 如果定义一个名为Person 的模型,mongoose 将自动将相关的集合命名为people。 + +**注意:** 如果你用Person这个名字定义一个模型,mongoose会自动将关联的集合命名为people
    -
    +### Connecting the backend to a database -### Backend connected to a database -【后端连接到数据库】 - -现在我们有足够的知识,可以在我们的应用中使用 Mongo了。 + +现在我们已经有足够的知识开始在我们的笔记应用程序后端中使用Mongo。 - -让我们通过复制粘贴 Mongoose 定义到index.js 文件来快速开始: + +让我们通过复制粘贴Mongoose定义到index.js文件来快速开始: ```js const mongoose = require('mongoose') +const password = process.argv[2] + // DO NOT SAVE YOUR PASSWORD TO GITHUB!! const url = - 'mongodb+srv://fullstack:sekred@cluster0-ostce.mongodb.net/note-app?retryWrites=true' + `mongodb+srv://fullstack:${password}@cluster0.o1opl.mongodb.net/?retryWrites=true&w=majority` -mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) +mongoose.set('strictQuery',false) +mongoose.connect(url) const noteSchema = new mongoose.Schema({ content: String, - date: Date, important: Boolean, }) const Note = mongoose.model('Note', noteSchema) ``` - -让我们将获取所有便笺的处理程序更改为如下形式: + +让我们将获取所有笔记的处理器更改为以下形式: ```js app.get('/api/notes', (request, response) => { @@ -484,15 +482,22 @@ app.get('/api/notes', (request, response) => { ``` -我们可以在浏览器中验证后端是否可以显示所有文档: +我们可以在浏览器中验证后端是否可以显示所有的文档: -![](../../images/3/44ea.png) +![api/notes in browser shows notes in JSON](../../images/3/44ea.png) -这个应用运行得几乎完美。 前端假设每个对象在id 字段中都有唯一的 id。 我们也不想将 mongo 版本控制字段 \_\_v 返回到前端。 +应用程序几乎完美地工作。前端假设每个对象在id字段中都有一个唯一的id。我们也不想将mongo版本控制字段\_\_v返回给前端。 - -格式化 Mongoose 返回的对象的一种方法是[修改](https://stackoverflow.com/questions/7034848/mongodb-output-id-instead-of-id)对象的 toJSON 方法。 修改方法的过程如下: + +格式化Mongoose返回的对象的一种方法是[modify(修改)](https://stackoverflow.com/questions/7034848/mongodb-output-id-instead-of-id)模式的 _toJSON_ 方法,该方法在用该模式产生的模型的所有实例上使用。 + + +要modify(修改)该方法,我们需要更改模式的可配置选项,可以使用模式的set方法更改选项,更多关于此方法的信息请参见: 。有关 _toJSON_ 选项的更多信息,请参阅 。 + + + +有关 _transform_ 函数的更多信息,请参阅 。 ```js noteSchema.set('toJSON', { @@ -504,51 +509,51 @@ noteSchema.set('toJSON', { }) ``` - -尽管 Mongoose 对象的 id 属性看起来像一个字符串,但实际上它是一个对象。 为了安全起见,我们定义的 toJSON 方法将其转换为字符串。 如果我们不进行这个更改,那么一旦我们开始编写测试,它将在未来对我们造成更大的麻烦。 + +尽管Mongoose对象的\_id属性看起来像一个字符串,但实际上它是一个对象。我们定义的 _toJSON_ 方法将其转换为字符串以确保安全。如果我们不做这个改变,一旦我们开始编写测试,它将在未来对我们造成更大的麻烦。 - -让我们用一个用 toJSON 方法格式化的对象列表来响应 HTTP 请求: + +在处理器中不需要做任何改变: ```js app.get('/api/notes', (request, response) => { Note.find({}).then(notes => { - response.json(notes.map(note => note.toJSON())) + response.json(notes) }) }) ``` - -现在,notes 变量被分配给 Mongo 返回的对象数组。 当我们调用notes.map(note => note.toJSON()) 时,结果是一个新数组,其中旧数组中的每个项都用 toJSON 方法映射到一个新对象。 - + +代码在格式化响应的笔记时将自动使用定义的 _toJSON_ 。 ### Database configuration into its own module -【数据库逻辑配置到单独的模块】 - -在我们重构后端的其余部分来使用数据库之前,让我们将 Mongoose 特定的代码提取到它自己的模块中。 + + +在我们将后端的其余部分重构为使用数据库之前,让我们将Mongoose特定的代码提取到它自己的模块中。 -让我们为模块models 创建一个新目录,并添加一个名为note.js 的文件: +让我们为模块创建一个名为models的新目录,并添加一个名为note.js的文件: ```js const mongoose = require('mongoose') +mongoose.set('strictQuery', false) + const url = process.env.MONGODB_URI // highlight-line console.log('connecting to', url) // highlight-line -mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) +mongoose.connect(url) // highlight-start .then(result => { console.log('connected to MongoDB') }) - .catch((error) => { + .catch(error => { console.log('error connecting to MongoDB:', error.message) }) // highlight-end const noteSchema = new mongoose.Schema({ content: String, - date: Date, important: Boolean, }) @@ -563,82 +568,82 @@ noteSchema.set('toJSON', { module.exports = mongoose.model('Note', noteSchema) // highlight-line ``` - -定义 Node [modules](https://nodejs.org/docs/latest-v8.x/api/modules.html)与第2章节中定义[ES6模块](/zh/part2/从渲染集合到模块学习#refactoring- 模块s)的方式稍有不同。 + +定义Node [modules(模块)](https://nodejs.org/docs/latest-v18.x/api/modules.html)的方式与在第2部分中定义[ES6 modules](/zh/part2/从渲染集合到模块学习#refactoring-modules)的方式略有不同。 -模块的公共接口是通过将值设置为 module.exports 变量来定义的。 我们将该值设置为Note 模型。 模块内部定义的其他东西,比如变量 mongoose 和 url 对于模块的用户来说是不可访问的或者不可见的。 +modules(模块)的公共接口是通过为 _module.exports_ 变量设置一个值来定义的。我们将值设置为Note模型。在模块内部定义的其他东西,如变量 _mongoose_ 和 _url_ ,对模块的用户来说将不可访问或不可见。 -导入模块的方法是在index.js 中添加如下代码行: +导入模块是通过在index.js中添加以下行来实现的: ```js const Note = require('./models/note') ``` -这样,Note 变量将被分配给模块定义的同一个对象。 +这样,_Note_ 变量将被赋值为模块定义的同一个对象。 -建立链接的方式略有改变: +建立连接的方式有所改变: ```js const url = process.env.MONGODB_URI console.log('connecting to', url) -mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) +mongoose.connect(url) .then(result => { console.log('connected to MongoDB') }) - .catch((error) => { + .catch(error => { console.log('error connecting to MongoDB:', error.message) }) ``` -将数据库的地址硬编码到代码中并不是一个好主意,因此数据库的地址通过MONGODB_URI 环境变量传递给应用。 +将数据库的地址硬编码到代码中并不是一个好主意,所以我们通过MONGODB_URI环境变量将数据库的地址传递给应用程序。 -建立连接的方法现在被赋予处理成功和失败的连接尝试的函数。 这两个函数都只是向控制台发送一条关于成功状态的消息: +建立连接的方法现在被赋予了处理成功和失败的连接尝试的函数。两个函数只是将成功状态的消息记录到控制台: -![](../../images/3/45e.png) +![node output when wrong username/password](../../images/3/45e.png) -有很多方法可以定义环境变量的值。 一种方法是在应用启动时定义它: +有许多方法可以定义环境变量的值。一种方法是在启动应用程序时定义它: ```bash -MONGODB_URI=address_here npm run watch +MONGODB_URI=address_here npm run dev ``` -一个更复杂的方法是使用[dotenv](https://github.com/motdotla/dotenv#readme) ,你可以使用如下命令安装库: +更聪明的方法是使用[dotenv](https://github.com/motdotla/dotenv#readme)库。你可以用以下命令安装这个库: ```bash -npm install dotenv --save +npm install dotenv ``` -为了使用这个库,我们创建一个 .env 文件在项目的根部。 环境变量是在文件内部定义的,它可以是这样的: +要使用这个库,我们在项目的根目录下创建一个.env文件。环境变量在文件内部定义,它可以像这样: ```bash -MONGODB_URI='mongodb+srv://fullstack:sekred@cluster0-ostce.mongodb.net/note-app?retryWrites=true' +MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority PORT=3001 ``` -我们还将服务器的硬编码端口添加到 PORT 环境变量中。 +我们也将服务器的硬编码端口添加到PORT环境变量中。 - -** .env 文件应立即放到gitignore中,因为我们不希望在网上公开任何机密信息! ** + +**我们应该立即将.env文件添加到gitignore中,因为我们不希望公开发布任何机密信息!** -![](../../images/3/45ae.png) +![.gitignore in vscode with .env line added](../../images/3/45ae.png) - -可以使用require('dotenv').config()命令来使用 dotenv 文件中定义的环境变量。您可以在代码中引用它们,就像引用普通环境变量一样,使用熟悉的 process.env.MONGODB_URI语法。 + +在.env文件中定义的环境变量可以通过表达式require('dotenv').config()引入,你可以像引用普通环境变量一样在代码中引用它们,使用process.env.MONGODB_URI语法。 -让我们如下面的方式更改index.js 文件: +让我们以以下方式更改index.js文件: ```js require('dotenv').config() // highlight-line @@ -654,16 +659,46 @@ app.listen(PORT, () => { }) ``` - -在导入note 模型之前导入dotenv 非常重要。 这样可以确保在导入其他模块的代码之前, .env 文件是全局可用的。 + +在导入note模型之前导入dotenv非常重要。这确保了在导入其他模块的代码之前,.env文件中的环境变量在全局范围内可用。 + +### Important note to Fly.io users + + +因为GitHub不是与Fly.io一起使用的,所以当应用程序被部署时,.env文件也会被传到Fly.io服务器。因此,文件中定义的环境变量将在那里可用。 + + +然而,[更好的选择](https://community.fly.io/t/clarification-on-environment-variables/6309)是通过在项目根目录创建 _.dockerignore_ 文件,内容如下 + +```bash +.env +``` + + +并使用以下命令从命令行设置环境值: + +```bash +fly secrets set MONGODB_URI="mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority" +``` + + +由于PORT也在我们的.env中定义,所以实际上在Fly.io中忽略该文件是至关重要的,否则应用程序将在错误的端口启动。 + + +在使用Render时,通过在仪表板中定义适当的环境变量给出数据库url: + +![browser showing render environment variables](../../images/3/render-env.png) + + +只需将以mongodb+srv://开头的URL设置到_value_字段。 ### Using database in route handlers -【在路由处理程序中使用数据库】 + -接下来,让我们更改后端功能的其余部分来使用数据库。 +接下来,让我们将后端的其余功能更改为使用数据库。 -创建一个新的便笺是这样完成的: +创建新的笔记可以这样完成: ```js app.post('/api/notes', (request, response) => { @@ -676,165 +711,150 @@ app.post('/api/notes', (request, response) => { const note = new Note({ content: body.content, important: body.important || false, - date: new Date(), }) note.save().then(savedNote => { - response.json(savedNote.toJSON()) + response.json(savedNote) }) }) ``` - -使用 Note 构造函数创建 Note 对象。 请求的响应是在保存操作的回调函数中发送的。 这确保只有在操作成功时才发送响应。 稍后我们将讨论错误处理。 + +笔记对象是用 _Note_ 构造函数创建的。响应在 _save_ 操作的回调函数内部发送。这确保只有在操作成功时才发送响应。我们稍后会讨论错误处理。 - -回调函数中的 _savedNote_ 参数是保存的和新创建的便笺。 返回的数据是用 toJSON 方法创建的格式化版本: + +回调函数中的 _savedNote_ 参数是保存的新创建的笔记。响应中发送回来的数据是用 _toJSON_ 方法自动创建的格式化版本: ```js -response.json(savedNote.toJSON()) +response.json(savedNote) ``` - -取一个单独的便笺代码改为: + +使用Mongoose的[findById](https://mongoosejs.com/docs/api/model.html#model_Model-findById)方法,获取单个笔记的操作变为以下形式: ```js app.get('/api/notes/:id', (request, response) => { Note.findById(request.params.id).then(note => { - response.json(note.toJSON()) + response.json(note) }) }) ``` -### Verifying frontend and backend integration -【验证前端和后端的集成】 +### Verifying frontend and backend integration + -当后端扩展时,最好先用 **浏览器,Postman 或者 VS Code REST 客户端 **来测试后端。 接下来,让我们尝试在使用数据库之后创建一个新的便笺: +当后端的功能被扩展时,首先使用**浏览器、Postman或VS Code REST客户端**测试后端是个好主意。接下来,让我们在启用数据库后尝试创建一个新的笔记: -![](../../images/3/46e.png) +![VS code rest client doing a post](../../images/3/46new.png) -只有当所有的东西都被验证在后端工作良好时,测试前端是否与后端一起工作才是一个好主意。 仅仅通过前端测试是非常低效的。 +只有在后端的所有内容都经过验证并正常工作后,才是测试前端与后端是否协同工作的好时机。仅通过前端进行测试效率极低。 - -一次集成一个前端和后端功能可能是个好主意。 首先,我们可以实现从数据库中获取所有便笺,并通过浏览器中的后端端点对其进行测试。 在此之后,我们可以验证前端是否与新的后端一起工作。 一旦一切看起来正常,我们就会进入下一个特性。 + +逐个集成前端和后端的功能可能是个好主意。首先,我们可以实现从数据库获取所有笔记的功能,并通过浏览器中的后端端点进行测试。然后,我们可以验证前端是否能与新的后端一起工作。一旦所有东西看起来都在工作,我们就会转向下一个功能。 -一旦我们将数据库混入其中,检查数据库中持久存储的状态就很有用了,例如,通过 MongoDB Atlas 中的控制面板来检查。 很多时候,像我们前面编写的mongo.js 程序这样的小型 Node helper 程序在开发过程中会非常有用。 +一旦我们引入数据库,查看数据库中持久化的状态是非常有用的,例如,从MongoDB Atlas的控制面板中查看。在开发过程中,像我们之前写的mongo.js这样的小型Node助手程序往往非常有帮助。 - -您可以在[this Github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-4)的part3-4 分支中找到我们当前应用的全部代码。 + +你可以在part3-4分支的[这个GitHub仓库](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-4)中找到我们当前应用程序的完整代码。
    ### Exercises 3.13.-3.14. - -下面的练习非常简单,但是如果前端与后端脱节了,那么查找和修复 bug 将会非常有趣。 -#### 3.13: Phonebook database, 步骤1 + +以下练习相当简单,但如果你的前端停止与后端一起工作,那么找出并修复错误可能会相当有趣。 + +#### 3.13: Phonebook database, step 1 + -更改所有电话簿条目的获取,以便从数据库获取数据。 +更改所有电话簿条目的获取方式,使数据从数据库中获取。 -验证前端是否在更改之后仍能正常工作。 +在更改后,验证前端是否正常工作。 - -在下面的练习中,将所有特定于 mongoose 的代码写入它自己的模块,就像我们在[Database configuration into its own module](/zh/part3/将数据存入_mongo_db#database-configuration-into-its-own- 模块)一章中所做的那样. + +在接下来的练习中,将所有Mongoose特定的代码写入其自己的模块,就像我们在[数据库配置到自己的模块](/zh/part3/将数据存入_mongo_db#database-configuration-into-its-own-module)一章中做的那样。 -#### 3.14: Phonebook database, 步骤2 -3.14: 电话簿数据库,第二步 +#### 3.14: Phonebook database, step 2 -更改后端,以便将新号码保存到数据库。 确认您的前端在更改之后仍然可以工作。 +更改后端,使新的号码保存到数据库中。验证更改后你的前端是否仍然工作。 - -此时,您可以选择只允许用户创建所有电话簿条目。 在这个阶段,电话簿可以为同一个名字的人提供多个条目。 + +在这个阶段,你可以忽略是否已经有一个人在数据库中与你要添加的人同名。
    -
    - ### Error handling -【错误处理】 - -如果我们试图向数据库访问一个实际上并不存在的 id 的便笺的 URL,比如 http://localhost:3001/api/notes/5c41c90e84d891c15dfa3431,其中5c41c90e84d891c15dfa3431 不是一个存储在数据库中的 id,那么浏览器将简单地“卡住” ,因为服务器无法响应请求。 - -我们可以在后端的日志中看到如下错误消息: + +如果我们尝试访问一个不存在的笔记的URL,例如,其中5c41c90e84d891c15dfa3431不是存储在数据库中的id,那么响应将为 _null_ 。 -![](../../images/3/47.png) - - -请求失败,相关的承诺已被拒绝。 因为我们不处理承诺的拒绝,所以请求不会得到响应。 在第2章节中,我们已经了解了[handling errors in promises](/zh/part2/在服务端将数据_alert出来#promises-and-errors). - - -让我们添加一个简单的错误处理程序: + +让我们改变这种行为,如果给定id的笔记不存在,服务器将以HTTP状态码404未找到来响应请求。此外,让我们实现一个简单的catch块来处理findById方法返回的promise被拒绝的情况: ```js app.get('/api/notes/:id', (request, response) => { Note.findById(request.params.id) .then(note => { - response.json(note.toJSON()) + // highlight-start + if (note) { + response.json(note) + } else { + response.status(404).end() + } + // highlight-end }) + // highlight-start .catch(error => { console.log(error) - response.status(404).end() + response.status(500).end() }) + // highlight-end }) ``` - -每一个导致错误的请求都会在 HTTP状态码404没有找到的情况下被响应。 控制台显示有关错误的详细信息。 + +如果在数据库中没有找到匹配的对象, _note_ 的值将为 _null_ ,并执行 _else_ 块。这将导致一个带有状态码404 not found的响应。如果 findById 方法返回的 promise 被拒绝,响应将有状态码500内部服务器错误。控制台会显示关于错误的更详细的信息。 - -实际上有两种不同类型的错误情况。 在其中一种情况下,我们试图获取一个带有错误类型的 id 的便笺,这意味着一个与 mongo 标识符格式不匹配的 id。 + +除了不存在的笔记,还有一个需要处理的错误情况。在这种情况下,我们试图获取一个错误类型的_id_,也就是说,_id_与Mongo标识符格式不匹配。 -如果我们提出如下请求,我们将得到如下所示的错误消息: +如果我们发出以下请求,我们将得到下面的错误消息: -
    +```
     Method: GET
    -Path:   /api/notes/5a3b7c3c31d61cb9f8a0343
    +Path:   /api/notes/someInvalidId
     Body:   {}
     ---
    -{ CastError: Cast to ObjectId failed for value "5a3b7c3c31d61cb9f8a0343" at path "_id"
    +{ CastError: Cast to ObjectId failed for value "someInvalidId" at path "_id"
         at CastError (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/error/cast.js:27:11)
         at ObjectId.cast (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/schema/objectid.js:158:13)
         ...
    -
    - -另一种错误情况发生在 id 格式正确,但在数据库中没有找到该 id 的便笺时。 +``` -
    -Method: GET
    -Path:   /api/notes/5a3b7c3c31d61cbd9f8a0343
    -Body:   {}
    ----
    -TypeError: Cannot read property 'toJSON' of null
    -    at Note.findById.then.note (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/index.js:27:24)
    -    at process._tickCallback (internal/process/next_tick.js:178:7)
    -
    - -我们应该区分这两种不同类型的错误情况。 后者实际上是由我们自己的代码引起的错误。 + +给出一个格式错误的id作为参数,findById方法将抛出错误,导致返回的promise被拒绝。这将导致在catch块中定义的回调函数被调用。 - -让我们如下面的方式修改代码: + +让我们对catch块中的响应做一些小的调整: ```js app.get('/api/notes/:id', (request, response) => { Note.findById(request.params.id) .then(note => { - // highlight-start if (note) { - response.json(note.toJSON()) + response.json(note) } else { response.status(404).end() } - // highlight-end }) .catch(error => { console.log(error) @@ -843,54 +863,50 @@ app.get('/api/notes/:id', (request, response) => { }) ``` - -如果在数据库中没有找到匹配的对象,将不定义 note 的值,并执行 else 块。 这将导致响应状态代码 404 not found. + +如果id的格式不正确,那么我们将进入在_catch_块中定义的错误处理程序。适合这种情况的状态码是[400 Bad Request](https://www.rfc-editor.org/rfc/rfc9110.html#name-400-bad-request),因为这种情况完全符合描述: - -如果 id 的格式不正确,那么我们将在 catch 块中定义的错误处理程序中结束。 适合这种情况的状态代码是 [400 bad request](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1) ,因为这种情况完全符合描述: - -> The request could not be understood by the server due to malformed syntax. The client SHOULD NOT repeat the request without modifications. -由于格式不正确的语法,服务器无法理解请求。 客户端不应该在没有修改的情况下重复请求。 + +> 400 (Bad Request) 状态码表示服务器不能或不会处理请求,因为有些东西被认为是客户端错误(例如,请求语法格式错误,请求消息帧格式无效,或请求路由欺骗)。 -我们还在响应中添加了一些数据,以阐明错误的原因。 +我们还在响应中添加了一些数据,以便解释错误的原因。 - -在处理 Promises 时,添加错误和异常处理几乎总是一个好主意,否则您将发现自己正在处理奇怪的 bug。 + +在处理Promises时,几乎总是添加错误和异常处理的好主意。否则,你会发现自己在处理奇怪的错误。 -打印导致错误处理程序控制台异常的对象绝不是个坏主意: +在错误处理程序中打印引发异常的对象永远不是个坏主意: ```js .catch(error => { - console.log(error) + console.log(error) // highlight-line response.status(400).send({ error: 'malformatted id' }) }) ``` - -调用错误处理程序的原因可能与您预期的完全不同。 如果您将错误记录到控制台,您可以避免冗长和令人沮丧的调试会话。 + +错误处理程序被调用的原因可能完全不同于你预期的。如果你将错误记录到控制台,你可能会从长时间和令人沮丧的调试会话中解救出来。此外,大多数现代服务在你部署应用程序时都支持某种形式的日志系统,你可以用来检查这些日志。如前所述,Fly.io就是其中之一。 -每次您使用后端处理项目时,关注后端的控制台输出是至关重要的。 如果你在一个小屏幕上工作,只需要在背景中看到输出的一小部分就足够了。 任何错误消息都会引起你的注意,即使控制台远在后台: +每次你在一个有后端的项目上工作时,关注后端的控制台输出是至关重要的。如果你在一个小屏幕上工作,只需要在背景中看到一小部分输出就足够了。任何错误消息都会引起你的注意,即使控制台在后端很远: -![](../../images/3/15b.png) +![sample screenshot showing tiny slice of output](../../images/3/15b.png) +### Moving error handling into middleware -### Moving error handling into middleware -【将错误处理移入中间件】 - -我们在代码的其余部分中编写了错误处理程序的代码。 有时这可能是一个合理的解决方案,但在某些情况下,最好在单个位置实现所有错误处理。 如果我们以后想要将与错误相关的数据报告给外部的错误跟踪系统,比如[Sentry](https://sentry.io/welcome/),那么这么做就特别有用。 + +我们在其他代码中编写了错误处理程序的代码。有时这可能是一个合理的解决方案,但有些情况下,最好在一个地方实现所有的错误处理。如果我们稍后想向像[Sentry](https://sentry.io/welcome/)这样的外部错误跟踪系统报告与错误相关的数据,这可能特别有用。 - -让我们更改 /api/notes/:id 路由的处理程序,以便它使用next 函数向下传递错误。 下一个函数作为第三个参数传递给处理程序: + +让我们更改/api/notes/:id路由的处理程序,使其使用next函数将错误传递下去。下一个函数作为第三个参数传递给处理程序: ```js app.get('/api/notes/:id', (request, response, next) => { // highlight-line Note.findById(request.params.id) .then(note => { if (note) { - response.json(note.toJSON()) + response.json(note) } else { response.status(404).end() } @@ -899,41 +915,45 @@ app.get('/api/notes/:id', (request, response, next) => { // highlight-line }) ``` - -将向前传递的错误作为参数给 next 函数。 如果在没有参数的情况下调用 next,那么执行将简单地转移到下一个路由或中间件上。 如果使用参数调用next 函数,那么执行将继续到error 处理程序中间件。 + +向前传递的错误作为一个参数给到next函数。如果next没有参数被调用,那么执行将简单地移动到下一个路由或中间件。如果next函数带有参数被调用,那么执行将继续到错误处理中间件 -Express [error handlers](https://expressjs.com/en/guide/error-handling.html)是一种中间件,它定义了一个接受4个参数 的函数。 我们的错误处理程序如下所示: +Express的[(error handlers)错误处理器](https://expressjs.com/en/guide/error-handling.html)是定义了一个接受四个参数的函数的中间件。我们的错误处理器看起来像这样: ```js const errorHandler = (error, request, response, next) => { console.error(error.message) - if (error.name === 'CastError' && error.kind === 'ObjectId') { + if (error.name === 'CastError') { return response.status(400).send({ error: 'malformatted id' }) } next(error) } +// this has to be the last loaded middleware, also all the routes should be registered before this! app.use(errorHandler) ``` - -错误处理程序检查错误是否是CastError 异常,在这种情况下,我们知道错误是由 Mongo 的无效对象 id 引起的。 在这种情况下,错误处理程序将向浏览器发送响应,并将response对象作为参数传递。 在所有其他错误情况下,中间件将错误转发给缺省的 Express 错误处理程序。 + +错误处理器检查错误是否为CastError异常,如果是,我们知道错误是由Mongo的无效对象id引起的。在这种情况下,错误处理器将使用作为参数传递的响应对象向浏览器发送响应。在所有其他错误情况下,中间件将错误传递给默认的Express错误处理器。 + + +注意,错误处理中间件必须是最后加载的中间件,所有的路由都应该在错误处理器之前注册! + +### The order of middleware loading -### The order of middleware loading -【中间件加载顺序】 - -中间件的执行顺序与通过 app.use 函数加载到 express 中的顺序相同。 出于这个原因,在定义中间件时一定要小心。 + +中间件的执行顺序与它们被加载到express的app.use函数的顺序相同。因此,定义中间件时需要小心。 -正确的顺序如下: +正确的顺序是: ```js -app.use(express.static('build')) +app.use(express.static('dist')) app.use(express.json()) -app.use(logger) +app.use(requestLogger) app.post('/api/notes', (request, response) => { const body = request.body @@ -956,13 +976,12 @@ app.use(errorHandler) ``` -Json-parser 中间件应该是最早加载到 Express 中的中间件之一,如果顺序变成了下面这样: - +json-parser中间件应该是加载到Express中的第一个中间件。如果顺序是以下的: ```js -app.use(logger) // request.body is empty! +app.use(requestLogger) // request.body is undefined! app.post('/api/notes', (request, response) => { - // request.body is empty! + // request.body is undefined! const body = request.body // ... }) @@ -970,14 +989,14 @@ app.post('/api/notes', (request, response) => { app.use(express.json()) ``` - -那么,由 HTTP 请求发送的 JSON 数据将不能用于日志记录器中间件或 POST 路由处理程序,因为 request.body 将是一个空对象。 + +那么,HTTP请求发送的JSON数据在logger中间件或POST路由处理器中将不可用,因为在这个点上 _request.body_ 将是 _undefined_。 -同样重要的是,用于处理不支持路由的中间件位于加载到 Express 的最后一个中间件的旁边,就在错误处理程序之前。 +同样重要的是,处理不支持的路由的中间件是加载到Express中的最后一个中间件,就在错误处理器之前。 -例如,下面的加载顺序会导致一个问题: +例如,以下加载顺序会导致问题: ```js const unknownEndpoint = (request, response) => { @@ -993,20 +1012,19 @@ app.get('/api/notes', (request, response) => { ``` -现在,对未知端点的处理先于 HTTP 请求处理程序。 由于未知端点处理程序使用404未知端点 响应所有请求,在未知端点中间件发送响应之后,将不会调用任何路由或中间件。 唯一的例外是错误处理程序,它需要出现在未知的端点处理程序之后的最后一个端点。 +现在,未知端点的处理是在HTTP请求处理器之前进行的。由于未知端点处理器对所有请求都以404 unknown endpoint响应,所以在未知端点中间件发送响应后,不会调用任何路由或中间件。唯一的例外是错误处理器,它需要在未知端点处理器之后,放在最后。 ### Other operations -【其他操作】 -让我们为我们的应用添加一些缺失的功能,包括删除和更新单个便笺。 +让我们为我们的应用程序添加一些缺失的功能,包括删除和更新单个笔记。 - -从数据库中删除便笺最简单的方法是使用[findByIdAndRemove](https://mongoosejs.com/docs/api.html#model_model.findByIdAndRemove)方法: + +从数据库删除笔记的最简单方法是使用[findByIdAndDelete](https://mongoosejs.com/docs/api/model.html#Model.findByIdAndDelete())方法: ```js app.delete('/api/notes/:id', (request, response, next) => { - Note.findByIdAndRemove(request.params.id) + Note.findByIdAndDelete(request.params.id) .then(result => { response.status(204).end() }) @@ -1014,11 +1032,11 @@ app.delete('/api/notes/:id', (request, response, next) => { }) ``` - -在删除资源的两个“成功”案例中,后端都使用状态码 204 no content.进行响应。 两种不同的情况是删除已存在的便笺,以及删除数据库中不存在的便笺。 结果回调参数可用于检查资源是否实际被删除,如果认为有必要,我们可以使用该信息为两种情况返回不同的状态代码。 发生的任何异常都会传递到错误处理程序上。 + +在删除资源的两种"成功"情况下,后端都以 204 no content 的状态码响应。这两种不同的情况是删除存在的笔记,和删除数据库中不存在的笔记 _result_ 回调参数可以用于检查是否实际删除了资源,如果我们认为有必要,我们可以使用这个信息为这两种情况返回不同的状态码。任何发生的异常都会传递给错误处理器。 - -通过[findbyidanddupdate](https://mongoosejs.com/docs/api.html#model_model.findByIdAndUpdate)方法可以轻松地切换便笺的重要性。 + +使用[findByIdAndUpdate](https://mongoosejs.com/docs/api/model.html#model_Model-findByIdAndUpdate)方法可以轻松地切换笔记的重要性。 ```js app.put('/api/notes/:id', (request, response, next) => { @@ -1031,82 +1049,96 @@ app.put('/api/notes/:id', (request, response, next) => { Note.findByIdAndUpdate(request.params.id, note, { new: true }) .then(updatedNote => { - response.json(updatedNote.toJSON()) + response.json(updatedNote) }) .catch(error => next(error)) }) ``` - -在上面的代码中,我们也允许编辑便笺的内容。 然而,出于显而易见的原因,我们不支持更改创建日期。 + +在上面的代码中,我们还允许编辑笔记的内容。 -注意,findByIdAndUpdate 方法接收一个常规的 JavaScript 对象作为参数,而不是用 Note 构造函数创建的新便笺对象。 +注意,findByIdAndUpdate方法接收的是一个常规的JavaScript对象作为参数,而不是一个用Note构造函数创建的新笔记对象。 - -关于 findByIdAndUpdate方法的使用有一个重要的细节。 默认情况下,事件处理程序的 updatedNote 参数接收原始文档[无需修改](https://mongoosejs.com/docs/api.html#model_model.findbyidandupdate)。 我们添加了可选的代码{ new: true } 参数,这将导致使用新修改的文档而不是原始文档调用事件处理程序。 + +关于使用findByIdAndUpdate方法有一个重要的细节。默认情况下,事件处理器的updatedNote参数接收的是[没有修改的](https://mongoosejs.com/docs/api/model.html#model_Model-findByIdAndUpdate)原始文档。我们添加了可选的{ new: true }参数,这将导致我们的事件处理器被调用时,使用新的修改过的文档而不是原始文档。 - -在使用 Postman 和 VS Code REST 客户端直接测试后端之后,我们可以验证它似乎可以工作。 前端似乎也与使用数据库的后端一起工作。 + +在直接使用Postman或VS Code REST客户端测试后端后,我们可以验证它似乎是工作的。前端也似乎能够使用数据库与后端一起工作。 - -当我们切换注意事项的重要性时,我们会在控制台中看到如下令人担忧的错误消息: + +你可以在part3-5分支的[这个GitHub仓库](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-5)中找到我们当前应用程序的完整代码。 +### A true full stack developer's oath -![](../../images/3/48.png) + +现在又是练习的时候了。我们的应用程序的复杂性现在又上升了一个阶段,因为除了前端和后端,我们还有一个数据库。 - -谷歌错误信息将导向 [instructions](https://stackoverflow.com/questions/52572852/deprecationwarning-collection-findandmodify-is-deprecated-use-findoneandupdate) 解决问题。 根据 Mongoose 文档中的建议,我们在note.js 文件中添加了如下内容 : + +的确,有很多可能的错误来源。 -```js -const mongoose = require('mongoose') + +所以我们应该再次扩展我们的誓言: -mongoose.set('useFindAndModify', false) // highlight-line + +全栈开发是极其困难的,这就是为什么我会使用所有可能的手段来使它变得更容易 -// ... - -module.exports = mongoose.model('Note', noteSchema) -``` + - -您可以在[this github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-5).的part3-5 分支中找到我们当前应用的全部代码。 +- 我会一直打开浏览器开发者控制台 +- 我会使用浏览器开发工具的网络标签,确保前端和后端的通信符合我的预期 +- 我会不断关注服务器的状态,确保前端发送到那里的数据按我预期的方式保存 +- 我会关注数据库:后端是否以正确的格式保存数据 +- 我会以小步骤前进 +- 我会写很多的_console.log_语句,以确保我理解代码的行为,并帮助定位问题 +- 如果我的代码不能工作,我不会写更多的代码。相反,我开始删除代码,直到它工作,或者只是返回到一切都还在工作的状态 +- 当我在课程的Discord频道或其他地方寻求帮助时,我会合适地提出我的问题,看[这里](https://fullstackopen.com/en/part0/general_info#how-to-get-help-in-discord)了解如何寻求帮助。
    -
    - ### Exercises 3.15.-3.18. -#### 3.15: Phonebook database, 步骤3 + +#### 3.15: Phonebook database, step 3 + -更改后端,以便删除电话簿条目反映在数据库中。 +更改后端,使得删除电话簿条目在数据库中得到反映。 -验证前端在进行更改后是否仍然可用。 +在进行更改后,验证前端是否仍然工作。 -#### 3.16: Phonebook database, 步骤4 - -将应用的错误处理移动到新的错误处理程序中间件。 +#### 3.16: Phonebook database, step 4 + + +将应用程序的错误处理移动到新的错误处理中间件。 + +#### 3.17*: Phonebook database, step 5 -#### 3.17*: Phonebook database, 步骤5 -如果用户试图为名字已经在电话簿中的人创建一个新的电话簿条目,前端将通过向条目的唯一 URL 发出 HTTP PUT 请求来更新现有条目的电话号码。 +如果用户试图为电话簿中已有姓名的人创建新的电话簿条目,前端将尝试通过向条目的唯一URL发送HTTP PUT请求来更新现有条目的电话号码。 -修改后端以支持此请求。 +修改后端以支持这个请求。 -在进行更改后,验证前端是否工作正常。 +在进行更改后,验证前端是否工作。 + +#### 3.18*: Phonebook database step 6 -#### 3.18*: Phonebook database 步骤6 -还要更新使用数据库的 api/persons/:idinfo 路由的处理,并验证它们是否直接与浏览器、Postman或 VS Code REST 客户端一起工作。 +也试试更新api/persons/:idinfo路由的处理,以使用数据库,并验证它们是否可以直接使用浏览器、Postman或VS Code REST客户端工作。 -通过浏览器查看一个电话簿条目应该是这样的: - -![](../../images/3/49.png) +从浏览器查看单个电话簿条目应该是这样的: +![screenshot of browser showing one person with api/persons/their_id](../../images/3/49.png)
    - diff --git a/src/content/3/zh/part3d.md b/src/content/3/zh/part3d.md index a34292be45c..1e69b10cdb7 100644 --- a/src/content/3/zh/part3d.md +++ b/src/content/3/zh/part3d.md @@ -7,9 +7,8 @@ lang: zh
    - -我们通常希望对存储在应用数据库中的数据应用一些约束。 我们的应用不应该接受缺少或空的content 属性的便笺。 在路由处理程序中检查便笺的有效性: +我们通常希望对存储在应用程序数据库中的数据应用一些约束。我们的应用程序不应接受缺少或空的content属性的笔记。在路由处理器中检查笔记的有效性: ```js app.post('/api/notes', (request, response) => { @@ -25,24 +24,20 @@ app.post('/api/notes', (request, response) => { ``` -如果便笺没有content 属性,我们将使用状态码400 bad request 响应该请求。 +如果笔记没有content属性,我们将以400 bad request的状态码响应请求。 - -在数据存储到数据库之前验证数据格式的一个更聪明的方法是使用 Mongoose 提供的[validation](https://mongoosejs.com/docs/validation.html)功能。 + +在数据存储到数据库之前验证数据格式的一种更智能的方法是使用Mongoose提供的[验证](https://mongoosejs.com/docs/validation.html)功能。 -我们可以为模式中的每个字段定义特定的验证规则: +我们可以为模式中的每个字段定义特定的验证规则: ```js const noteSchema = new mongoose.Schema({ // highlight-start content: { type: String, - minlength: 5, - required: true - }, - date: { - type: Date, + minLength: 5, required: true }, // highlight-end @@ -50,14 +45,14 @@ const noteSchema = new mongoose.Schema({ }) ``` - -现在要求content 字段至少有五个字符长。date 字段被设置为必需的,这意味着它不能丢失。 同样的约束也隐式地应用于content 字段,因为缺省情况下最小长度约束要求字段不丢失。 我们没有向important 字段添加任何约束,因此模式中的定义没有更改。 + +现在,content字段要求至少为五个字符长,并且被设置为必需,意味着它不能缺失。我们没有对important字段添加任何约束,所以它在模式中的定义没有改变。 - - minlength required 验证器是[内置的](https://mongoosejs.com/docs/validation.html#built-in-validators) ,由 Mongoose 提供。 Mongoose允许我们创建新的验证器[自定义验证器](https://mongoosejs.com/docs/validation.html#custom-validators),如果没有一个内置的验证器满足我们的需求的话。 + +minLengthrequired验证器是Mongoose提供的[内置](https://mongoosejs.com/docs/validation.html#built-in-validators)验证器。如果没有一个内置的验证器能满足我们的需求,Mongoose的[自定义验证器](https://mongoosejs.com/docs/validation.html#custom-validators)功能允许我们创建新的验证器。 -如果我们尝试在数据库中存储一个打破其中一个约束的对象,操作将引发异常。 让我们改变我们的处理程序来创建一个新的便笺,这样它就可以将任何潜在的异常传递给错误处理中间件: +如果我们试图在数据库中存储一个违反了某些约束的对象,操作将会抛出异常。让我们改变我们创建新笔记的处理器,使其将任何可能的异常传递给错误处理中间件: ```js app.post('/api/notes', (request, response, next) => { // highlight-line @@ -66,19 +61,18 @@ app.post('/api/notes', (request, response, next) => { // highlight-line const note = new Note({ content: body.content, important: body.important || false, - date: new Date(), }) note.save() .then(savedNote => { - response.json(savedNote.toJSON()) + response.json(savedNote) }) .catch(error => next(error)) // highlight-line }) ``` -让我们展开错误处理程序来处理这些验证错误: +让我们扩展错误处理器以处理这些验证错误: ```js const errorHandler = (error, request, response, next) => { @@ -95,144 +89,97 @@ const errorHandler = (error, request, response, next) => { ``` -当验证一个对象失败时,我们从 Mongoose 返回如下缺省错误消息: - -![](../../images/3/50.png) - - -### Promise chaining -【承诺链】 - -许多路由处理程序通过调用 toJSON 方法将响应数据更改为正确的格式。 当我们创建一个新的便笺时,toJSON 方法被调用,作为参数传递给下面的对象: +当对象验证失败时,我们从Mongoose返回以下默认错误消息: -```js -app.post('/api/notes', (request, response, next) => { - // ... +![postman显示错误消息](../../images/3/50.png) - note.save() - .then(savedNote => { - response.json(savedNote.toJSON()) - }) - .catch(error => next(error)) -}) -``` + +我们注意到后端现在有一个问题:在编辑笔记时没有进行验证。 +这个问题可以解决,[update-validators 文档](https://mongoosejs.com/docs/validation.html#update-validators)解释说,在执行findOneAndUpdate和相关方法时,默认不会运行验证。 - -我们可以用一种更简洁的方式来实现同样的功能,比如[承诺链](https://javascript.info/promise-chaining) : + +但是要修复这个问题很简单。让我们也稍微改写一下路由代码: ```js -app.post('/api/notes', (request, response, next) => { - // ... - - note - .save() - // highlight-start - .then(savedNote => { - return savedNote.toJSON() +app.put('/api/notes/:id', (request, response, next) => { + const { content, important } = request.body // highlight-line + + Note.findByIdAndUpdate( + request.params.id, + { content, important }, // highlight-line + { new: true, runValidators: true, context: 'query' } // highlight-line + ) + .then(updatedNote => { + response.json(updatedNote) }) - .then(savedAndFormattedNote => { - response.json(savedAndFormattedNote) - }) - // highlight-end - .catch(error => next(error)) + .catch(error => next(error)) }) ``` - -在第一个 _then_ ,我们收到 savedNote 对象返回的 Mongoose 和格式化它。 返回操作的结果。 然后,正如[我们之前讨论的](/zh/part2/在服务端将数据_alert出来#extracting-communication-with-the-backend-into-a-separate- 模块) ,then 的方法也返回了一个承诺。 这意味着,当我们从回调函数返回_savedNote.toJSON()_ 时,我们实际上是在创建一个承诺,该承诺将接收格式化的便笺作为其值。 我们可以通过使用 then 方法注册一个新的回调函数来访问带格式的便笺。 - - -我们可以使用箭头函数的紧凑语法来清理我们的代码: - -```js -app.post('/api/notes', (request, response, next) => { - // ... +### Deploying the database backend to production - note - .save() - .then(savedNote => savedNote.toJSON()) // highlight-line - .then(savedAndFormattedNote => { - response.json(savedAndFormattedNote) - }) - .catch(error => next(error)) -}) -``` + +该应用程序应该能在Fly.io/Render上按原样工作。由于到目前为止我们只对后端进行了修改,所以我们不需要生成前端的新生产构建。 - -在这个例子中,承诺链没有提供多少好处。 但要是有许多必须按顺序进行的异步操作,情况就会发生变化。 我们不会进一步深入探讨这个议题。 在本课程的下一章节中,我们将学习 JavaScript 中的async/await语法,这将使编写后续的异步操作变得容易得多。 + +在dotenv中定义的环境变量只会在后端不处于生产模式时使用,即在Fly.io或Render中。 -### Deploying the database backend to production -【将数据库后端部署到生产环境】 - -该应用在 Heroku 的运行情况应该基本一样。 由于我们对前端进行了更改,我们必须生成一个新的前端生产版本。 + +对于生产环境,我们需要在托管我们应用的服务中设置数据库URL。 - -dotenv 中定义的环境变量仅在后端时使用,不处于生产模式 (即 Heroku)。 + +在Fly.io中,可以通过_fly secrets set_命令来完成: - -我们在文件 .env中定义了用于开发的环境变量。 但是在生产环境中定义数据库 URL 的环境变量应该使用 _heroku config:set_ 命令来设置 Heroku。 ```bash -heroku config:set MONGODB_URI=mongodb+srv://fullstack:secretpasswordhere@cluster0-ostce.mongodb.net/note-app?retryWrites=true +fly secrets set MONGODB_URI='mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority' ``` - -**注意**:如果命令行产生了一个错误,在撇号中给 MONGODB_URI 设置一个值 + +当应用正在开发过程中,很可能会出现一些失败的情况。例如,当我第一次部署带有数据库的应用时,一个笔记都没有看到: -```bash -heroku config:set MONGODB_URI='mongodb+srv://fullstack:secretpasswordhere@cluster0-ostce.mongodb.net/note-app?retryWrites=true' -``` +![浏览器显示没有出现任何笔记](../../images/3/fly-problem1.png) - -应用现在应该可以工作了。 有时事情不会按计划进行。 如果有什么问题,heroku log会尽力帮忙的。 我自己的应用在进行更改后不工作。 日志显示了如下情况: + +浏览器控制台的网络标签页显示,获取笔记的请求并未成功,请求只是在_pending_状态下停留了很长时间,直到最后以502状态码失败。 -![](../../images/3/51a.png) + +浏览器控制台必须始终保持打开状态! - -由于某种原因,数据库的 URL 未定义。heroku config 命令显示,我不小心定义了 MONGO\_URL 环境变量的 URL,而代码希望它位于 MONGODB\_URI中。 + +同时,持续关注服务器日志也非常重要。当我打开 _fly logs_ 查看日志时,问题就显而易见了: - -您可以在[this github repository](https://github.com/fullstack-hy2019/part3-notes-backend/tree/part3-5)的part3-5 分支中找到我们当前应用的全部代码。 -
    - - - - -
    +![fly.io服务器日志显示连接到未定义](../../images/3/fly-problem3.png) + +数据库URL是 _undefined_ ,所以忘记了执行 *fly secrets set MONGODB\_URI* 命令。 + +在使用Render时,可以通过在仪表板中定义适当的环境变量来提供数据库URL: -### Exercises 3.19.-3.21. - +![render仪表板显示MONGODB_URI环境变量](../../images/3/render-env.png) + +Render仪表板显示服务器日志: -#### 3.19: Phonebook database, 步骤7 - -为您的应用添加验证,这将确保您只能在电话簿中为某人添加一个号码。 我们当前的前端不允许用户尝试创建副本,但我们可以尝试直接使用Postman或 VS Code REST 客户端创建副本。 +![render仪表板上有箭头指向在端口10000上运行的服务器](../../images/3/r7.png) - -Mongoose 没有为此提供内置的验证器,可以使用 npm 安装[mongoose-unique-validator](https://github.com/blakehaswell/mongoose-unique-validator#readme) 并使用它。 + +你可以在[此GitHub仓库](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-6)的part3-6分支中找到我们当前应用的完整代码。 - -如果 HTTP POST 请求试图添加电话簿中已有的名称,服务器必须用适当的状态码和错误消息作出响应。 +
    - -**注意: **unique-validator 会将警告打印到控制台 +
    -``` -(node:49251) DeprecationWarning: collection.ensureIndex is deprecated. Use createIndexes instead. -connected to MongoDB -``` +### Exercises 3.19.-3.21. - -阅读mongoose [文档](https://mongoosejs.com/docs/deprecations.html 文档) ,找出如何摆脱警告。 +#### 3.19*: Phonebook database, step 7 -#### 3.20*: Phonebook database, 步骤8 - -扩展验证,以便存储在数据库中的名称必须至少有三个字符长,电话号码必须至少有8个数字。 + +将验证扩展,使得存储在数据库中的名称至少需要三个字符长。 -扩展前端,以便在发生验证错误时显示某种形式的错误消息。 错误处理可以通过添加 catch 块来实现,如下所示: - +扩展前端,使其在发生验证错误时显示某种形式的错误消息。可以通过添加一个 catch 块来实现错误处理,如下所示: ```js personService @@ -242,126 +189,170 @@ personService }) .catch(error => { // this is the way to access the error message - console.log(error.response.data) + console.log(error.response.data.error) }) ``` -你可以显示 Mongoose 返回的默认错误消息,即使它们并不具有可读性: +你可以显示由Mongoose返回的默认错误消息,尽管它们并不像它们可能的那样易读: -![](../../images/3/56e.png) +![电话簿屏幕截图显示人员验证失败](../../images/3/56e.png) +**注意:**在 _update_ 操作中,mongoose验证器默认是关闭的。[阅读文档](https://mongoosejs.com/docs/validation.html)以确定如何启用它们。 +#### 3.20*: Phonebook database, step 8 -#### 3.21 Deploying the database backend to production -【将数据库后端部署到生产环境】 - -通过创建前端的新生产版本,生成应用的新“完整栈”版本,并将其复制到后端存储库。 通过使用地址 https://localhost:3001的整个应用来验证所有的东西都能在本地工作。 + +为你的电话簿应用添加验证,确保电话号码的格式正确。电话号码必须: - -将最新版本推送到 Heroku,并验证那里的工作一切正常。 + -
    +- 长度为8或更多 +- 由两部分组成,两部分由-分隔,第一部分有两个或三个数字,第二部分也由数字组成 + - 例如,09-1234556和040-22334455是有效的电话号码 + - 例如,1234556,1-22334455和10-22-334455是无效的电话号码 + +使用[自定义验证器](https://mongoosejs.com/docs/validation.html#custom-validators)来实现验证的第二部分。 -
    + +如果HTTP POST请求试图添加一个电话号码无效的人,服务器应该以适当的状态码和错误消息响应。 +#### 3.21 Deploying the database backend to production +Generate a new "full stack" version of the application by creating a new production build of the frontend, and copying it to the backend repository. Verify that everything works locally by using the entire application from the address . +通过创建前端的新的生产构建,生成应用程序的新的"全栈"版本,并将其复制到后端仓库。验证本地的所有操作是否正常,通过从地址使用整个应用程序。 -### Lint + +将最新版本推送到 Fly.io/Render,并验证那里的所有操作是否也正常。 + + +**NOTE**: 你应该将后端部署到云服务。如果你使用的是Fly.io,命令应该在后端的根目录中运行(也就是在后端的package.json所在的目录中)。如果使用的是Render,后端必须位于你的仓库的根目录中。 + +在这一部分的任何阶段,你都不应该直接部署前端。整个部分都是部署后端仓库,没有其他的。 - -在我们进入下一章节之前,我们将看看一个重要的工具,叫做[lint](https://en.wikipedia.org/wiki/lint_(software))。 关于 lint,维基百科是这么说的: +
    + +
    + +### Lint -> Generically, lint or a linter is any tool that detects and flags errors in programming languages, including stylistic errors. The term lint-like behavior is sometimes applied to the process of flagging suspicious language usage. Lint-like tools generally perform static analysis of source code. -通常,lint 或 linter 是检测和标记编程语言中的错误,包括文本错误的一种工具。 lint-like 这个术语有时用于标记可疑的语言使用情况。 类似 lint 的工具通常对源代码执行静态分析。 + +在我们进入下一部分之前,我们介绍一个重要的工具,叫做[lint]()。维基百科对lint的描述如下: - -在像 Java 这样的编译静态类型语言中,像 NetBeans 这样的 ide 可以指出代码中的错误,甚至那些不仅仅是编译错误的错误。 执行[静态分析](https://en.wikipedia.org/wiki/Static_program_analysis)的额外工具,如[检查样式](http://checkstyle.sourceforge.net/) ,可以用来扩展 IDE 的功能,也指出与样式有关的问题,如缩进。 + +> 一般来说,lint或者linter是任何检测和标记编程语言中错误的工具,包括样式错误。术语 _lint-like behavior_ 有时用于标记可疑语言使用的过程。Lint类 的工具通常对源代码进行静态分析。 - -在 JavaScript 的世界里,目前主要的静态分析工具又名“ linting”是[ESlint](https://ESlint.org/)。 + +在编译的静态类型语言如Java中,像NetBeans这样的IDE可以指出代码中的错误,甚至是编译错误之外的错误。像[checkstyle](https://checkstyle.sourceforge.io)这样的用于执行[静态分析](https://en.wikipedia.org/wiki/Static_program_analysis)的附加工具,可以用来扩展IDE的能力,也可以指出与样式相关的问题,如缩进。 - -让我们使用下面的命令安装 ESlint 作为后端项目的开发依赖项: + +在JavaScript领域,目前主导的静态分析(又名"linting")工具是[ESlint](https://eslint.org/)。 + + +让我们使用以下命令将ESlint作为开发依赖项安装到notes后端项目中: ```bash npm install eslint --save-dev ``` -在这之后,我们可以使用如下命令初始化默认的 ESlint 配置: +之后我们可以用以下命令初始化一个默认的ESlint配置: ```bash -node_modules/.bin/eslint --init +npx eslint --init ``` -我们将回答所有问题: +我们要回答所有的问题: + +![ESlint初始化的终端输出](../../images/3/52new.png) -![](../../images/3/52be.png) + +配置将会保存在 _.eslintrc.js_ 文件中。我们将在 _env_ 配置中将 _browser_ 改为 _node_: +```js +module.exports = { + "env": { + "commonjs": true, + "es2021": true, + "node": true // highlight-line + }, + "overrides": [ + { + "env": { + "node": true + }, + "files": [ + ".eslintrc.{js,cjs}" + ], + "parserOptions": { + "sourceType": "script" + } + } + ], + "parserOptions": { + "ecmaVersion": "latest" + }, + "rules": { + } +} +``` + +让我们稍微修改一下配置。安装一个[插件](https://eslint.style/packages/js),该插件定义了一套与代码风格相关的规则: - -该配置将保存在.eslintrc.js 文件中: +``` +npm install --save-dev @stylistic/eslint-plugin-js +``` + + +启用插件并添加一个扩展定义和四个代码风格规则: ```js module.exports = { - 'env': { - 'commonjs': true, - 'es6': true, - 'node': true - }, + // ... + 'plugins': [ + '@stylistic/js' + ], 'extends': 'eslint:recommended', - 'globals': { - 'Atomics': 'readonly', - 'SharedArrayBuffer': 'readonly' - }, - 'parserOptions': { - 'ecmaVersion': 2018 - }, 'rules': { - 'indent': [ + '@stylistic/js/indent': [ 'error', - 4 + 2 ], - 'linebreak-style': [ + '@stylistic/js/linebreak-style': [ 'error', 'unix' ], - 'quotes': [ + '@stylistic/js/quotes': [ 'error', 'single' ], - 'semi': [ + '@stylistic/js/semi': [ 'error', 'never' - ] + ], } } ``` - -让我们立即修改关于缩进的规则,使缩进级别为两个空格。 - -```js -"indent": [ - "error", - 2 -], -``` + +扩展 _eslint:recommended_ 将一套[推荐的规则](https://eslint.org/docs/latest/rules/)添加到项目中。此外,还添加了关于缩进、换行、连字符和分号的规则。这四条规则都在[Eslint样式插件](https://eslint.style/packages/js)中定义了。 -检查和验证像 index.js 这样的文件可以通过如下命令完成: +可以使用以下命令检查和验证像 _index.js_ 这样的文件: ```bash -node_modules/.bin/eslint index.js +npx eslint index.js ``` -建议为 linting 创建一个单独的 npm 脚本: +我们建议为linting创建一个单独的 _npm script_: ```json { @@ -370,51 +361,49 @@ node_modules/.bin/eslint index.js "start": "node index.js", "dev": "nodemon index.js", // ... - "lint": "eslint ." + "lint": "eslint ." // highlight-line }, // ... } ``` -现在 _npm run lint_ 命令将检查项目中的每个文件。 +现在,_npm run lint_ 命令将检查项目中的每个文件。 - -当命令运行时, build 目录中的文件也会被检查。 我们不希望这种情况发生,我们可以通过创建一个 [.eslintignore](https://eslint.org/docs/user-guide/configuring#ignoring-files-and-directories)文件,内容如下: + +当运行命令时,dist 目录中的文件也会被检查,我们不希望这种情况发生、我们可以通过在项目的根目录中创建一个[.eslintignore](https://eslint.org/docs/latest/use/configure/ignore#the-eslintignore-file) 文件来实现这一点,文件的内容如下: ```bash -build +dist ``` - -这将导致 ESlint 不检查整个 build 目录。 + +这将导致整个dist目录不被ESlint检查。 -Lint 对我们的代码有很多要说的: +Lint对我们的代码有很多意见: -![](../../images/3/53ea.png) +![ESlint错误的终端输出](../../images/3/53ea.png) -让我们先不要解决这些问题。 +我们暂时不去修复这些问题。 - -从命令行执行连接程序的一个更好的替代方法是为编辑器配置一个eslint-plugin,它可以连续运行lint程序。 通过使用该插件,您将立即看到代码中的错误。 你可以找到更多关于 Visual Studio ESLint 插件的信息[点击这里](google https://marketplace.visualstudio.com/items?itemname=dbaeumer.vscode-ESLint)。 + +从命令行执行linter的更好替代方案是将eslint-plugin配置到编辑器中,这将连续运行linter。通过使用插件,你将立即在代码中看到错误。你可以在[这里](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)找到更多关于Visual Studio ESLint插件的信息。 -代码 ESlint 插件会用红线来强调风格的违反: - -![](../../images/3/54a.png) - +VS Code的ESlint插件会用红线下划出风格违规: +![VScode ESLint插件显示错误的截图](../../images/3/54a.png) -这使得错误很容易发现和立即修复。 +这使得错误很容易被发现并立即修复。 -Eslint 有大量的[规则](https://ESlint.org/docs/rules/) ,可以通过编辑 .eslintrc.js 文件轻松使用。 +ESlint有大量的[规则](https://eslint.org/docs/rules/),这些规则通过编辑 .eslintrc.js 文件就可以很容易地使用。 -让我们添加一个[eqeqeq](https://eslint.org/docs/rules/eqeqeq)规则,它警告我们,如果除了三个等于运算符之外,相等是被检查的。 该规则是在配置文件的rules 字段下添加的。 +让我们添加[eqeqeq](https://eslint.org/docs/rules/eqeqeq)规则,如果用非三等号运算符检查等式,它会发出警告。该规则是在配置文件的rules字段下添加的。 ```js { @@ -427,10 +416,10 @@ Eslint 有大量的[规则](https://ESlint.org/docs/rules/) ,可以通过编 ``` -既然学到这里,让我们对规则做一些其他的改变。 +在我们进行这项工作的同时,让我们对规则进行一些其他更改。 -让我们在行的末尾避免不必要的[拖尾空格](https://eslint.org/docs/rules/no-trailing-spaces),让我们要求[在大括号之前和之后总有一个空格](https://eslint.org/docs/rules/object-curly-spacing) ,让我们也要求在箭头函数的函数参数中一致使用空格。 +让我们阻止行尾的不必要的[尾随空格](https://eslint.org/docs/rules/no-trailing-spaces),要求[大括号前后始终有一个空格](https://eslint.org/docs/rules/object-curly-spacing),并且也要求箭头函数的函数参数中一致使用空格。 ```js { @@ -450,66 +439,57 @@ Eslint 有大量的[规则](https://ESlint.org/docs/rules/) ,可以通过编 ``` -我们的默认配置从 eslint:recommended来的: +我们的默认配置从eslint:recommended中使用了一堆预定的规则: ```bash 'extends': 'eslint:recommended', ``` - -这包括一个警告 console.log 命令的规则。 [禁用](https://eslint.org/docs/user-guide/configuring#configuring-rules)规则可以通过在配置文件中将其“ value”定义为0来实现。 在此期间让我们这样做把no-console检查关掉 。 + +这包括一个关于 _console.log_ 命令的警告规则。可以通过在配置文件中将其"值"定义为0来[禁用](https://eslint.org/docs/latest/use/configure/rules)一条规则。我们暂时为no-console规则这样做。 ```js { // ... 'rules': { - // ... - 'eqeqeq': 'error', - 'no-trailing-spaces': 'error', - 'object-curly-spacing': [ - 'error', 'always' - ], - 'arrow-spacing': [ - 'error', { 'before': true, 'after': true } - ] - }, + // ... + 'eqeqeq': 'error', + 'no-trailing-spaces': 'error', + 'object-curly-spacing': [ + 'error', 'always' + ], + 'arrow-spacing': [ + 'error', { 'before': true, 'after': true } + ], 'no-console': 0 // highlight-line }, } ``` -当你修改 .eslintrc.js 文件中,建议从命令行运行 linter。 这将验证配置文件的格式是否正确: - -![](../../images/3/55.png) - - +**注意** 当你对.eslintrc.js文件进行更改时,建议从命令行运行linter。这将验证配置文件是否正确格式化: -如果您的配置文件出现错误,lint 插件的行为可能相当错乱。 +如果你的配置文件中有什么错误,lint插件可能会表现得相当不稳定。 -许多公司定义了通过 ESlint 配置文件在整个组织中执行的编码标准。 建议不要一遍又一遍地使用重造轮子,从别人的项目中采用现成的配置到自己的项目中可能是一个好主意。 最近,很多项目都采用了 Airbnb 的 Javascript 风格指南,使用了 Airbnb 的 [ESlint](https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb) 。 +许多公司定义编码标准,这些标准通过ESlint配置文件在整个组织中强制执行。不建议反复重新发明轮子,采用别人项目中的现成配置可能是个好主意。最近,许多项目通过采用Airbnb的[ESlint](https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb)配置,采纳了Airbnb的[Javascript风格指南](https://github.com/airbnb/javascript)。 - -您可以在 [this github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-6)的part3-6 分支中找到我们当前应用的全部代码。 -
    + +你可以在 part3-7 分支的[这个GitHub仓库](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part3-7)中找到我们当前应用程序的全部代码。 +
    - - ### Exercise 3.22. - - #### 3.22: Lint configuration - -向应用中添加 ESlint 并修复所有警告。 +将ESlint添加到你的应用程序中,并修复所有的警告。 -这是本课程这一章节的最后一个练习,现在是时候把你的代码推送到 GitHub,并将所有完成的练习标记到[练习提交系统](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)。 +这是课程的这一部分的最后一个练习。现在是时候将你的代码推送到GitHub,并在[练习提交系统](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)中标记你已完成的所有练习了。 +
    diff --git a/src/content/4/en/part4.md b/src/content/4/en/part4.md index 635d3e2f34b..abbcf075a23 100644 --- a/src/content/4/en/part4.md +++ b/src/content/4/en/part4.md @@ -8,4 +8,9 @@ lang: en In this part, we will continue our work on the backend. Our first major theme will be writing unit and integration tests for the backend. After we have covered testing, we will take a look at implementing user authentication and authorization. - \ No newline at end of file +Part updated 13th August 2025 +- Node updated to version v22.3.0 +- Express updated to version 5 and the express-async-errors library removed from part 4b +- Small fixes and improvements + + diff --git a/src/content/4/en/part4a.md b/src/content/4/en/part4a.md index 69f1a01aaab..fc9fe94b95c 100644 --- a/src/content/4/en/part4a.md +++ b/src/content/4/en/part4a.md @@ -7,38 +7,36 @@ lang: en
    - -Let's continue our work on the backend of the notes application we started in [part 3](/en/part3). - +Let's continue our work on the backend of the notes application we started in [part 3](/en/part3). ### Project structure +**Note**: this course material was written with version v22.3.0 of Node.js. Please make sure that your version of Node is at least as new as the version used in the material (you can check the version by running _node -v_ in the command line). Before we move into the topic of testing, we will modify the structure of our project to adhere to Node.js best practices. -After making the changes to the directory structure of our project, we end up with the following structure: +Once we make the changes to the directory structure of our project, we will end up with the following structure: ```bash -├── index.js -├── app.js -├── build -│ ├── ... ├── controllers │ └── notes.js +├── dist +│ └── ... ├── models │ └── note.js -├── package-lock.json -├── package.json ├── utils │ ├── config.js │ ├── logger.js │ └── middleware.js +├── app.js +├── index.js +├── package-lock.json +├── package.json ``` - -So far we have been using console.log and console.error to print different information from the code. -However, this is not a very good way to do things. -Let's separate all printing to the console to it's own module utils/logger.js: +So far we have been using console.log and console.error to print different information from the code. +However, this is not a very good way to do things. +Let's separate all printing to the console to its own module utils/logger.js: ```js const info = (...params) => { @@ -49,45 +47,22 @@ const error = (...params) => { console.error(...params) } -module.exports = { - info, error -} +module.exports = { info, error } ``` - The logger has two functions, __info__ for printing normal log messages, and __error__ for all error messages. -Extracting logging into its own module is a good idea in more ways than one. If we wanted to start writing logs to a file or send them to an external logging service like [graylog](https://www.graylog.org/) or [papertrail](https://papertrailapp.com) we would only have to make changes in one place. - -The contents of the index.js file used for starting the application gets simplified as follows: - -```js -const app = require('./app') // the actual Express application -const http = require('http') -const config = require('./utils/config') -const logger = require('./utils/logger') - -const server = http.createServer(app) - -server.listen(config.PORT, () => { - logger.info(`Server running on port ${config.PORT}`) -}) -``` - -The index.js file only imports the actual application from the app.js file and then starts the application. The function _info_ of the logger-module is used for the console printout telling that the application is running. +Extracting logging into its own module is a good idea in several ways. If we wanted to start writing logs to a file or send them to an external logging service like [graylog](https://www.graylog.org/) or [papertrail](https://papertrailapp.com) we would only have to make changes in one place. The handling of environment variables is extracted into a separate utils/config.js file: ```js require('dotenv').config() -let PORT = process.env.PORT -let MONGODB_URI = process.env.MONGODB_URI +const PORT = process.env.PORT +const MONGODB_URI = process.env.MONGODB_URI -module.exports = { - MONGODB_URI, - PORT -} +module.exports = { MONGODB_URI, PORT } ``` The other parts of the application can access the environment variables by importing the configuration module: @@ -108,7 +83,7 @@ const Note = require('../models/note') notesRouter.get('/', (request, response) => { Note.find({}).then(notes => { - response.json(notes.map(note => note.toJSON())) + response.json(notes) }) }) @@ -116,7 +91,7 @@ notesRouter.get('/:id', (request, response, next) => { Note.findById(request.params.id) .then(note => { if (note) { - response.json(note.toJSON()) + response.json(note) } else { response.status(404).end() } @@ -130,18 +105,17 @@ notesRouter.post('/', (request, response, next) => { const note = new Note({ content: body.content, important: body.important || false, - date: new Date() }) note.save() .then(savedNote => { - response.json(savedNote.toJSON()) + response.json(savedNote) }) .catch(error => next(error)) }) notesRouter.delete('/:id', (request, response, next) => { - Note.findByIdAndRemove(request.params.id) + Note.findByIdAndDelete(request.params.id) .then(() => { response.status(204).end() }) @@ -149,16 +123,20 @@ notesRouter.delete('/:id', (request, response, next) => { }) notesRouter.put('/:id', (request, response, next) => { - const body = request.body + const { content, important } = request.body - const note = { - content: body.content, - important: body.important, - } + Note.findById(request.params.id) + .then(note => { + if (!note) { + return response.status(404).end() + } + + note.content = content + note.important = important - Note.findByIdAndUpdate(request.params.id, note, { new: true }) - .then(updatedNote => { - response.json(updatedNote.toJSON()) + return note.save().then((updatedNote) => { + response.json(updatedNote) + }) }) .catch(error => next(error)) }) @@ -180,29 +158,27 @@ module.exports = notesRouter The module exports the router to be available for all consumers of the module. - -All routes are now defined for the router object, in a similar fashion to what we had previously done with the object representing the entire application. - +All routes are now defined for the router object, similar to what was done before with the object representing the entire application. It's worth noting that the paths in the route handlers have shortened. In the previous version, we had: ```js -app.delete('/api/notes/:id', (request, response) => { +app.delete('/api/notes/:id', (request, response, next) => { ``` And in the current version, we have: ```js -notesRouter.delete('/:id', (request, response) => { +notesRouter.delete('/:id', (request, response, next) => { ``` So what are these router objects exactly? The Express manual provides the following explanation: > A router object is an isolated instance of middleware and routes. You can think of it as a “mini-application,” capable only of performing middleware and routing functions. Every Express application has a built-in app router. -The router is in fact a middleware, that can be used for defining "related routes" in a single place, that is typically placed in its own module. +The router is in fact a middleware, that can be used for defining "related routes" in a single place, which is typically placed in its own module. -The app.js file that creates the actual application, takes the router into use as shown below: +The app.js file that creates the actual application takes the router into use as shown below: ```js const notesRouter = require('./controllers/notes') @@ -211,22 +187,22 @@ app.use('/api/notes', notesRouter) The router we defined earlier is used if the URL of the request starts with /api/notes. For this reason, the notesRouter object must only define the relative parts of the routes, i.e. the empty path / or just the parameter /:id. - -After making these changes, our app.js file looks like this: +A file defining the application, app.js, has been created in the root of the repository: ```js -const config = require('./utils/config') const express = require('express') -const app = express() -const cors = require('cors') -const notesRouter = require('./controllers/notes') -const middleware = require('./utils/middleware') -const logger = require('./utils/logger') const mongoose = require('mongoose') +const config = require('./utils/config') +const logger = require('./utils/logger') +const middleware = require('./utils/middleware') +const notesRouter = require('./controllers/notes') + +const app = express() logger.info('connecting to', config.MONGODB_URI) -mongoose.connect(config.MONGODB_URI, { useNewUrlParser: true, useUnifiedTopology: true }) +mongoose + .connect(config.MONGODB_URI) .then(() => { logger.info('connected to MongoDB') }) @@ -234,8 +210,7 @@ mongoose.connect(config.MONGODB_URI, { useNewUrlParser: true, useUnifiedTopology logger.error('error connection to MongoDB:', error.message) }) -app.use(cors()) -app.use(express.static('build')) +app.use(express.static('dist')) app.use(express.json()) app.use(middleware.requestLogger) @@ -290,18 +265,12 @@ The responsibility of establishing the connection to the database has been given ```js const mongoose = require('mongoose') -mongoose.set('useFindAndModify', false) - const noteSchema = new mongoose.Schema({ content: { type: String, required: true, minlength: 5 }, - date: { - type: Date, - required: true, - }, important: Boolean, }) @@ -316,33 +285,114 @@ noteSchema.set('toJSON', { module.exports = mongoose.model('Note', noteSchema) ``` +The contents of the index.js file used for starting the application gets simplified as follows: + +```js +const app = require('./app') // the actual Express application +const config = require('./utils/config') +const logger = require('./utils/logger') + +app.listen(config.PORT, () => { + logger.info(`Server running on port ${config.PORT}`) +}) +``` + +The index.js file only imports the actual application from the app.js file and then starts the application. The function _info_ of the logger-module is used for the console printout telling that the application is running. + +Now the Express app and the code taking care of the web server are separated from each other following the [best](https://dev.to/nermineslimane/always-separate-app-and-server-files--1nc7) practices. One of the advantages of this method is that the application can now be tested at the level of HTTP API calls without actually making calls via HTTP over the network, this makes the execution of tests faster. To recap, the directory structure looks like this after the changes have been made: ```bash -├── index.js -├── app.js -├── build -│ ├── ... ├── controllers │ └── notes.js +├── dist +│ └── ... ├── models │ └── note.js -├── package-lock.json -├── package.json ├── utils │ ├── config.js │ ├── logger.js │ └── middleware.js +├── app.js +├── index.js +├── package-lock.json +├── package.json +``` + +For smaller applications, the structure does not matter that much. Once the application starts to grow in size, you are going to have to establish some kind of structure and separate the different responsibilities of the application into separate modules. This will make developing the application much easier. + +There is no strict directory structure or file naming convention that is required for Express applications. In contrast, Ruby on Rails does require a specific structure. Our current structure simply follows some of the best practices that you can come across on the internet. + +You can find the code for our current application in its entirety in the part4-1 branch of [this GitHub repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-1). + +If you clone the project for yourself, run the _npm install_ command before starting the application with _npm run dev_. + +### Note on exports + +We have used two different kinds of exports in this part. Firstly, e.g. the file utils/logger.js does the export as follows: + +```js +const info = (...params) => { + console.log(...params) +} + +const error = (...params) => { + console.error(...params) +} + +module.exports = { info, error } // highlight-line ``` -For smaller applications the structure does not matter that much. Once the application starts to grow in size, you are going to have to establish some kind of structure, and separate the different responsibilities of the application into separate modules. This will make developing the application much easier. +The file exports an object that has two fields, both of which are functions. The functions can be used in two different ways. The first option is to require the whole object and refer to functions through the object using the dot notation: -There is no strict directory structure or file naming convention that is required for Express applications. To contrast this, Ruby on Rails does require a specific structure. Our current structure simply follows some of the best practices you can come across on the internet. +```js +const logger = require('./utils/logger') -You can find the code for our current application in its entirety in the part4-1 branch of [this Github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-1). +logger.info('message') + +logger.error('error message') +``` + +The other option is to destructure the functions to their own variables in the require statement: + +```js +const { info, error } = require('./utils/logger') -If you clone the project for yourself, run the _npm install_ command before starting the application with _npm start_. +info('message') +error('error message') +``` + +The second way of exporting may be preferable if only a small portion of the exported functions are used in a file. E.g. in file controller/notes.js exporting happens as follows: + +```js +const notesRouter = require('express').Router() +const Note = require('../models/note') + +// ... + +module.exports = notesRouter // highlight-line +``` + +In this case, there is just one "thing" exported, so the only way to use it is the following: + +```js +const notesRouter = require('./controllers/notes') + +// ... + +app.use('/api/notes', notesRouter) +``` + +Now the exported "thing" (in this case a router object) is assigned to a variable and used as such. + +#### Finding the usages of your exports with VS Code + +VS Code has a handy feature that allows you to see where your modules have been exported. This can be very helpful for refactoring. For example, if you decide to split a function into two separate functions, your code could break if you don't modify all the usages. This is difficult if you don't know where they are. However, you need to define your exports in a particular way for this to work. + +If you right-click on a variable in the location it is exported from and select "Find All References", it will show you everywhere the variable is imported. However, if you assign an object directly to module.exports, it will not work. A workaround is to assign the object you want to export to a named variable and then export the named variable. It also will not work if you destructure where you are importing; you have to import the named variable and then destructure, or just use dot notation to use the functions contained in the named variable. + +The nature of VS Code bleeding into how you write your code is probably not ideal, so you need to decide for yourself if the trade-off is worthwhile.
    @@ -350,50 +400,46 @@ If you clone the project for yourself, run the _npm install_ command before star ### Exercises 4.1.-4.2. -In the exercises for this part we will be building a blog list application, that allows users to save information about interesting blogs they have stumbled across on the internet. For each listed blog we will save the author, title, url, and amount of upvotes from users of the application. +**Note**: this course material was written with version v22.3.0 of Node.js. Please make sure that your version of Node is at least as new as the version used in the material (you can check the version by running _node -v_ in the command line). + +In the exercises for this part, we will be building a blog list application, that allows users to save information about interesting blogs they have stumbled across on the internet. For each listed blog we will save the author, title, URL, and amount of upvotes from users of the application. -#### 4.1 Blog list, step1 +#### 4.1 Blog List, step 1 -Let's imagine a situation, where you receive an email that contains the following application body: +Let's imagine a situation, where you receive an email that contains the following application body and instructions: ```js -const http = require('http') const express = require('express') -const app = express() -const cors = require('cors') const mongoose = require('mongoose') +const app = express() + const blogSchema = mongoose.Schema({ title: String, author: String, url: String, - likes: Number + likes: Number, }) const Blog = mongoose.model('Blog', blogSchema) const mongoUrl = 'mongodb://localhost/bloglist' -mongoose.connect(mongoUrl, { useNewUrlParser: true, useUnifiedTopology: true }) +mongoose.connect(mongoUrl) -app.use(cors()) app.use(express.json()) app.get('/api/blogs', (request, response) => { - Blog - .find({}) - .then(blogs => { - response.json(blogs) - }) + Blog.find({}).then((blogs) => { + response.json(blogs) + }) }) app.post('/api/blogs', (request, response) => { const blog = new Blog(request.body) - blog - .save() - .then(result => { - response.status(201).json(result) - }) + blog.save().then((result) => { + response.status(201).json(result) + }) }) const PORT = 3003 @@ -402,35 +448,32 @@ app.listen(PORT, () => { }) ``` -Turn the application into a functioning npm project. In order to keep your development productive, configure the application to be executed with nodemon. You can create a new database for your application with MongoDB Atlas, or use the same database from the previous part's exercises. +Turn the application into a functioning npm project. To keep your development productive, configure the application to be executed with node --watch. You can create a new database for your application with MongoDB Atlas, or use the same database from the previous part's exercises. -Verify that it is possible to add blogs to list with Postman or the VS Code REST client and that the application returns the added blogs at the correct endpoint. +Verify that it is possible to add blogs to the list with Postman or the VS Code REST client and that the application returns the added blogs at the correct endpoint. -#### 4.2 Blog list, step2 +#### 4.2 Blog List, step 2 Refactor the application into separate modules as shown earlier in this part of the course material. - -**NB** refactor your application in baby steps and verify that the application works after every change you make. If you try to take a "shortcut" by refactoring many things at once, then [Murphy's law](https://en.wikipedia.org/wiki/Murphy%27s_law) will kick in and it is almost certain that something will break in your application. The "shortcut" will end up taking more time than moving forward slowly and systematically. - +**NB** refactor your application in baby steps and verify that it works after every change you make. If you try to take a "shortcut" by refactoring many things at once, then [Murphy's law](https://en.wikipedia.org/wiki/Murphy%27s_law) will kick in and it is almost certain that something will break in your application. The "shortcut" will end up taking more time than moving forward slowly and systematically. One best practice is to commit your code every time it is in a stable state. This makes it easy to rollback to a situation where the application still works. +If you're having issues with content.body being undefined for seemingly no reason, make sure you didn't forget to add app.use(express.json()) near the top of the file. +
    - ### Testing Node applications - We have completely neglected one essential area of software development, and that is automated testing. - Let's start our testing journey by looking at unit tests. The logic of our application is so simple, that there is not much that makes sense to test with unit tests. Let's create a new file utils/for_testing.js and write a couple of simple functions that we can use for test writing practice: ```js -const palindrome = (string) => { +const reverse = (string) => { return string .split('') .reverse() @@ -446,180 +489,128 @@ const average = (array) => { } module.exports = { - palindrome, + reverse, average, } ``` -> The _average_ function uses the array [reduce](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce) method. If the method is not familiar to you yet, then now is a good time to watch the first three videos from the [Functional Javascript](https://www.youtube.com/watch?v=BMUiFMZr7vk&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84) series on Youtube. - -There are many different testing libraries or test runners available for JavaScript. In this course we will be using a testing library developed and used internally by Facebook called [jest](https://jestjs.io/), that resembles the previous king of JavaScript testing libraries [Mocha](https://mochajs.org/). Other alternatives do exist, like [ava](https://github.com/avajs/ava) that has gained popularity in some circles. - - -Jest is a natural choice for this course, as it works well for testing backends, and it shines when it comes to testing React applications. +> The _average_ function uses the array [reduce](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce) method. If the method is not familiar to you yet, then now is a good time to watch the first three videos from the [Functional JavaScript](https://www.youtube.com/watch?v=BMUiFMZr7vk&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84) series on YouTube. +There are a large number of test libraries, or test runners, available for JavaScript. +The old king of test libraries is [Mocha](https://mochajs.org/), which was replaced a few years ago by [Jest](https://jestjs.io/). A newcomer to the libraries is [Vitest](https://vitest.dev/), which bills itself as a new generation of test libraries. -> **Windows users:** Jest may not work if the path of the project directory contains a directory that has spaces in its name. +Nowadays, Node also has a built-in test library [node:test](https://nodejs.org/docs/latest/api/test.html), which is well suited to the needs of the course. -Since tests are only executed during the development of our application, we will install jest as a development dependency with the command: +Let's define the npm script _test_ for the test execution: -```bash -npm install --save-dev jest -``` - - -Let's define the npm script _test_ to execute tests with Jest and to report about the test execution with the verbose style: - -```bash +```js { - //... + // ... "scripts": { "start": "node index.js", - "dev": "nodemon index.js", - "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend", - "deploy": "git push heroku master", - "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy", - "logs:prod": "heroku logs --tail", - "lint": "eslint .", - "test": "jest --verbose" // highlight-line + "dev": "node --watch index.js", + "test": "node --test", // highlight-line + "lint": "eslint ." }, - //... + // ... } ``` -Jest requires one to specify that the execution environment is Node. This can be done by adding the following to the end of package.json: -```js -{ - //... - "jest": { - "testEnvironment": "node" - } -} -``` - -Alternatively, Jest can look for a configuration file with the default name jest.config.js, where we can define the execution environment like this: +Let's create a separate directory for our tests called tests and create a new file called reverse.test.js with the following contents: ```js -module.exports = { - testEnvironment: 'node', -}; -``` +const { test } = require('node:test') +const assert = require('node:assert') -Let's create a separate directory for our tests called tests and create a new file called palindrome.test.js with the following contents: +const reverse = require('../utils/for_testing').reverse -```js -const palindrome = require('../utils/for_testing').palindrome - -test('palindrome of a', () => { - const result = palindrome('a') +test('reverse of a', () => { + const result = reverse('a') - expect(result).toBe('a') + assert.strictEqual(result, 'a') }) -test('palindrome of react', () => { - const result = palindrome('react') +test('reverse of react', () => { + const result = reverse('react') - expect(result).toBe('tcaer') + assert.strictEqual(result, 'tcaer') }) -test('palindrome of releveler', () => { - const result = palindrome('releveler') +test('reverse of saippuakauppias', () => { + const result = reverse('saippuakauppias') - expect(result).toBe('releveler') + assert.strictEqual(result, 'saippuakauppias') }) ``` +The test defines the keyword _test_ and the library [assert](https://nodejs.org/docs/latest/api/assert.html), which is used by the tests to check the results of the functions under test. -The ESLint configuration we added to the project in the previous part complains about the _test_ and _expect_ commands in our test file, since the configuration does not allow globals. Let's get rid of the complaints by adding "jest": true to the env property in the .eslintrc.js file. +In the next row, the test file imports the function to be tested and assigns it to a variable called _reverse_: ```js -module.exports = { - "env": { - "commonjs": true - "es6": true, - "node": true, - "jest": true, // highlight-line - }, - "extends": "eslint:recommended", - "rules": { - // ... - }, -}; +const reverse = require('../utils/for_testing').reverse ``` - -In the first row, the test file imports the function to be tested and assigns it to a variable called _palindrome_: - -```js -const palindrome = require('../utils/for_testing').palindrome -``` - - -Individual test cases are defined with the _test_ function. The first parameter of the function is the test description as a string. The second parameter is a function, that defines the functionality for the test case. The functionality for the second test case looks like this: +Individual test cases are defined with the _test_ function. The first argument of the function is the test description as a string. The second argument is a function, that defines the functionality for the test case. The functionality for the second test case looks like this: ```js () => { - const result = palindrome('react') + const result = reverse('react') - expect(result).toBe('tcaer') + assert.strictEqual(result, 'tcaer') } ``` - -First we execute the code to be tested, meaning that we generate a palindrome for the string react. Next we verify the results with the [expect](https://facebook.github.io/jest/docs/en/expect.html#content) function. Expect wraps the resulting value into an object that offers a collection of matcher functions, that can be used for verifying the correctness of the result. Since in this test case we are comparing two strings, we can use the [toBe](https://facebook.github.io/jest/docs/en/expect.html#tobevalue) matcher. - +First, we execute the code to be tested, meaning that we generate a reverse for the string react. Next, we verify the results with the method [strictEqual](https://nodejs.org/docs/latest/api/assert.html#assertstrictequalactual-expected-message) of the [assert](https://nodejs.org/docs/latest/api/assert.html) library. As expected, all of the tests pass: -![](../../images/4/1.png) +![terminal output from npm test with all tests passing](../../images/4/1new.png) +In the course, we follow the convention where test file names end with .test.js, as the node:test testing library automatically executes test files named this way. -Jest expects by default that the names of test files contain .test. In this course, we will follow the convention of naming our tests files with the extension .test.js. - - -Jest has excellent error messages, let's break the test to demonstrate this: +Let's break the test: ```js -test('palindrom of react', () => { - const result = palindrome('react') +test('reverse of react', () => { + const result = reverse('react') - expect(result).toBe('tkaer') + assert.strictEqual(result, 'tkaer') }) ``` +Running this test results in the following error message: -Running the tests above results in the following error message: - -![](../../images/4/2e.png) +![terminal output shows failure from npm test](../../images/4/2new.png) - -Let's add a few tests for the _average_ function, into a new file tests/average.test.js. +Let's add a few tests for the average function as well. Let's create a new file tests/average.test.js and add the following content to it: ```js +const { test, describe } = require('node:test') +const assert = require('node:assert') + const average = require('../utils/for_testing').average describe('average', () => { test('of one value is the value itself', () => { - expect(average([1])).toBe(1) + assert.strictEqual(average([1]), 1) }) test('of many is calculated right', () => { - expect(average([1, 2, 3, 4, 5, 6])).toBe(3.5) + assert.strictEqual(average([1, 2, 3, 4, 5, 6]), 3.5) }) test('of empty array is zero', () => { - expect(average([])).toBe(0) + assert.strictEqual(average([]), 0) }) }) ``` - The test reveals that the function does not work correctly with an empty array (this is because in JavaScript dividing by zero results in NaN): -![](../../images/4/3.png) - +![terminal output showing empty array fails](../../images/4/3new.png) Fixing the function is quite easy: @@ -635,11 +626,9 @@ const average = array => { } ``` +If the length of the array is 0 then we return 0, and in all other cases, we use the _reduce_ method to calculate the average. -If the length of the array is 0 then we return 0, and in all other cases we use the _reduce_ method to calculate the average. - - -There's a few things to notice about the tests that we just wrote. We defined a describe block around the tests that was given the name _average_: +There are a few things to notice about the tests that we just wrote. We defined a describe block around the tests that were given the name _average_: ```js describe('average', () => { @@ -647,20 +636,17 @@ describe('average', () => { }) ``` +Describe blocks can be used for grouping tests into logical collections. The test output also uses the name of the describe block: -Describe blocks can be used for grouping tests into logical collections. The test output of Jest also uses the name of the describe block: - -![](../../images/4/4.png) - +![screenshot of npm test showing describe blocks](../../images/4/4new.png) As we will see later on describe blocks are necessary when we want to run some shared setup or teardown operations for a group of tests. - Another thing to notice is that we wrote the tests in quite a compact way, without assigning the output of the function being tested to a variable: ```js test('of empty array is zero', () => { - expect(average([])).toBe(0) + assert.strictEqual(average([]), 0) }) ``` @@ -668,17 +654,13 @@ test('of empty array is zero', () => {
    - ### Exercises 4.3.-4.7. +Let's create a collection of helper functions that are best suited for working with the describe sections of the blog list. Create the functions into a file called utils/list_helper.js. Write your tests into an appropriately named test file under the tests directory. -Let's create a collection of helper functions that are meant to assist dealing with the blog list. Create the functions into a file called utils/list_helper.js. Write your tests into an appropriately named test file under the tests directory. - - -#### 4.3: helper functions and unit tests, step1 - +#### 4.3: Helper Functions and Unit Tests, step 1 -First define a _dummy_ function that receives an array of blog posts as a parameter and always returns the value 1. The contents of the list_helper.js file at this point should be the following: +First, define a _dummy_ function that receives an array of blog posts as a parameter and always returns the value 1. The contents of the list_helper.js file at this point should be the following: ```js const dummy = (blogs) => { @@ -690,31 +672,28 @@ module.exports = { } ``` - Verify that your test configuration works with the following test: ```js +const { test, describe } = require('node:test') +const assert = require('node:assert') const listHelper = require('../utils/list_helper') test('dummy returns one', () => { const blogs = [] const result = listHelper.dummy(blogs) - expect(result).toBe(1) + assert.strictEqual(result, 1) }) ``` - -#### 4.4: helper functions and unit tests, step2 - +#### 4.4: Helper Functions and Unit Tests, step 2 Define a new _totalLikes_ function that receives a list of blog posts as a parameter. The function returns the total sum of likes in all of the blog posts. +Write appropriate tests for the function. It's recommended to put the tests inside of a describe block so that the test report output gets grouped nicely: -Write appropriate tests for the function. It's recommended to put the tests inside of a describe block, so that the test report output gets grouped nicely: - -![](../../images/4/5.png) - +![npm test passing for list_helper_test](../../images/4/5.png) Defining test inputs for the function can be done like this: @@ -725,56 +704,34 @@ describe('total likes', () => { _id: '5a422aa71b54a676234d17f8', title: 'Go To Statement Considered Harmful', author: 'Edsger W. Dijkstra', - url: 'http://www.u.arizona.edu/~rubinson/copyright_violations/Go_To_Considered_Harmful.html', + url: 'https://homepages.cwi.nl/~storm/teaching/reader/Dijkstra68.pdf', likes: 5, __v: 0 } ] - test('when list has only one blog equals the likes of that', () => { + test('when list has only one blog, equals the likes of that', () => { const result = listHelper.totalLikes(listWithOneBlog) - expect(result).toBe(5) + assert.strictEqual(result, 5) }) }) ``` - If defining your own test input list of blogs is too much work, you can use the ready-made list [here](https://github.com/fullstack-hy2020/misc/blob/master/blogs_for_test.md). +You are bound to run into problems while writing tests. Remember the things that we learned about [debugging](/en/part3/saving_data_to_mongo_db#debugging-node-applications) in part 3. You can print things to the console with _console.log_ even during test execution. -You are bound to run into problems while writing tests. Remember the things that we learned about [debugging](/en/part3/saving_data_to_mongo_db#debugging-node-applications) in part 3. You can print things to the console with _console.log_ even during test execution. It is even possible to use the debugger while running tests, you can find instructions for that [here](https://jestjs.io/docs/en/troubleshooting). - - -**NB:** if some test is failing, then it is recommended to only run that test while you are fixing the issue. You can run a single test with the [only](https://facebook.github.io/jest/docs/en/api.html#testonlyname-fn-timeout) method. - - -Another way of running a single test (or describe block) is to specify the name of the test to be run with the [-t](https://jestjs.io/docs/en/cli.html) flag: - -```js -npm test -- -t 'when list has only one blog, equals the likes of that' -``` +#### 4.5*: Helper Functions and Unit Tests, step 3 -#### 4.5*: helper functions and unit tests, step3 +Define a new _favoriteBlog_ function that receives a list of blogs as a parameter. The function returns the blog with the most likes. If there are multiple favorites, it is sufficient for the function to return any one of them. -Define a new _favoriteBlog_ function that receives a list of blogs as a parameter. The function finds out which blog has most likes. If there are many top favorites, it is enough to return one of them. - -The value returned by the function could be in the following format: - -```js -{ - title: "Canonical string reduction", - author: "Edsger W. Dijkstra", - likes: 12 -} -``` - -**NB** when you are comparing objects, the [toEqual](https://jestjs.io/docs/en/expect#toequalvalue) method is probably what you want to use, since the [toBe](https://jestjs.io/docs/en/expect#tobevalue) tries to verify that the two values are the same value, and not just that they contain the same properties. +**NB** when you are comparing objects, the [deepStrictEqual](https://nodejs.org/api/assert.html#assertdeepstrictequalactual-expected-message) method is probably what you want to use, as it ensures that the objects have the same attributes. For differences between various assert module functions, you can refer to [this Stack Overflow answer](https://stackoverflow.com/a/73937068/15291501). Write the tests for this exercise inside of a new describe block. Do the same for the remaining exercises as well. -#### 4.6*: helper functions and unit tests, step4 +#### 4.6*: Helper Functions and Unit Tests, step 4 -This and the next exercise are a little bit more challenging. Finishing these two exercises is not required in order to advance in the course material, so it may be a good idea to return to these once you're done going through the material for this part in its entirety. +This and the next exercise are a little bit more challenging. Finishing these two exercises is not required to advance in the course material, so it may be a good idea to return to these once you're done going through the material for this part in its entirety. Finishing this exercise can be done without the use of additional libraries. However, this exercise is a great opportunity to learn how to use the [Lodash](https://lodash.com/) library. @@ -787,14 +744,11 @@ Define a function called _mostBlogs_ that receives an array of blogs as a parame } ``` - If there are many top bloggers, then it is enough to return any one of them. +#### 4.7*: Helper Functions and Unit Tests, step 5 -#### 4.7*: helper functions and unit tests, step5 - - -Define a function called _mostLikes_ that receives an array of blogs as its parameter. The function returns the author, whose blog posts have the largest amount of likes. The return value also contains the total number of likes that the author has received: +Define a function called _mostLikes_ that receives an array of blogs as its parameter. The function returns the author whose blog posts have the largest amount of likes. The return value also contains the total number of likes that the author has received: ```js { @@ -803,7 +757,6 @@ Define a function called _mostLikes_ that receives an array of blogs as its para } ``` - If there are many top bloggers, then it is enough to show any one of them.
    diff --git a/src/content/4/en/part4b.md b/src/content/4/en/part4b.md index 5b902f186de..ea3871ad4f6 100644 --- a/src/content/4/en/part4b.md +++ b/src/content/4/en/part4b.md @@ -7,52 +7,38 @@ lang: en
    - We will now start writing tests for the backend. Since the backend does not contain any complicated logic, it doesn't make sense to write [unit tests](https://en.wikipedia.org/wiki/Unit_testing) for it. The only potential thing we could unit test is the _toJSON_ method that is used for formatting notes. +In some situations, it can be beneficial to implement some of the backend tests by mocking the database instead of using a real database. One library that could be used for this is [mongodb-memory-server](https://github.com/nodkz/mongodb-memory-server). -In some situations, it can be beneficial to implement some of the backend tests by mocking the database instead of using a real database. One library that could be used for this is [mongo-mock](https://github.com/williamkapke/mongo-mock). - - -Since our application's backend is still relatively simple, we will make the decision to test the entire application through its REST API, so that the database is also included. This kind of testing where multiple components of the system are being tested as a group, is called [integration testing](https://en.wikipedia.org/wiki/Integration_testing). - +Since our application's backend is still relatively simple, we will decide to test the entire application through its REST API, so that the database is also included. This kind of testing where multiple components of the system are being tested as a group is called [integration testing](https://en.wikipedia.org/wiki/Integration_testing). ### Test environment - -In one of the previous chapters of the course material, we mentioned that when your backend server is running in Heroku, it is in production mode. - +In one of the previous chapters of the course material, we mentioned that when your backend server is running in Fly.io or Render, it is in production mode. The convention in Node is to define the execution mode of the application with the NODE\_ENV environment variable. In our current application, we only load the environment variables defined in the .env file if the application is not in production mode. It is common practice to define separate modes for development and testing. -Next, let's change the scripts in our package.json so that when tests are run, NODE\_ENV gets the value test: +Next, let's change the scripts in our notes application package.json file, so that when tests are run, NODE\_ENV gets the value test: ```json { // ... "scripts": { - "start": "NODE_ENV=production node index.js",// highlight-line - "dev": "NODE_ENV=development nodemon index.js",// highlight-line - "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend", - "deploy": "git push heroku master", - "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy", - "logs:prod": "heroku logs --tail", - "lint": "eslint .", - "test": "NODE_ENV=test jest --verbose --runInBand"// highlight-line - }, + "start": "NODE_ENV=production node index.js", // highlight-line + "dev": "NODE_ENV=development node --watch index.js", // highlight-line + "test": "NODE_ENV=test node --test", // highlight-line + "lint": "eslint ." + } // ... } ``` -We also added the [runInBand](https://jestjs.io/docs/en/cli.html#--runinband) option to the npm script that executes the tests. This option will prevent Jest from running tests in parallel; we will discuss its significance once our tests start using the database. - - -We specified the mode of the application to be development in the _npm run dev_ script that uses nodemon. We also specified that the default _npm start_ command will define the mode as production. +We specified the mode of the application to be development in the _npm run dev_ script. We also specified that the default _npm start_ command will define the mode as production. - -There is a slight issue in the way that we have specified the mode of the application in our scripts: it will not work on Windows. We can correct this by installing the [cross-env](https://www.npmjs.com/package/cross-env) package with the command: +There is a slight issue in the way that we have specified the mode of the application in our scripts: it will not work on Windows. We can correct this by installing the [cross-env](https://www.npmjs.com/package/cross-env) package as a project dependency using the command: ```bash npm install cross-env @@ -64,10 +50,10 @@ We can then achieve cross-platform compatibility by using the cross-env library { // ... "scripts": { - "start": "cross-env NODE_ENV=production node index.js", - "dev": "cross-env NODE_ENV=development nodemon index.js", - // ... - "test": "cross-env NODE_ENV=test jest --verbose --runInBand", + "start": "cross-env NODE_ENV=production node index.js", // highlight-line + "dev": "cross-env NODE_ENV=development node --watch index.js", // highlight-line + "test": "cross-env NODE_ENV=test node --test", // highlight-line + "lint": "eslint ." }, // ... } @@ -76,25 +62,21 @@ We can then achieve cross-platform compatibility by using the cross-env library Now we can modify the way that our application runs in different modes. As an example of this, we could define the application to use a separate test database when it is running tests. +We can create our separate test database in MongoDB Atlas. This is not an optimal solution in situations where many people are developing the same application. Test execution in particular typically requires a single database instance that is not used by tests that are running concurrently. -We can create our separate test database in Mongo DB Atlas. This is not an optimal solution in situations where there are many people developing the same application. Test execution in particular typically requires that a single database instance is not used by tests that are running concurrently. - - -It would be better to run our tests using a database that is installed and running in the developer's local machine. The optimal solution would be to have every test execution use its own separate database. This is "relatively simple" to achieve by [running Mongo in-memory](https://docs.mongodb.com/manual/core/inmemory/) or by using [Docker](https://www.docker.com) containers. We will not complicate things and will instead continue to use the MongoDB Atlas database. +It would be better to run our tests using a database that is installed and running on the developer's local machine. The optimal solution would be to have every test execution use a separate database. This is "relatively simple" to achieve by [running Mongo in-memory](https://docs.mongodb.com/manual/core/inmemory/) or by using [Docker](https://www.docker.com) containers. We will not complicate things and will instead continue to use the MongoDB Atlas database. - -Let's make some changes to the module that defines the application's configuration: +Let's make some changes to the module that defines the application's configuration in _utils/config.js_: ```js require('dotenv').config() -let PORT = process.env.PORT -let MONGODB_URI = process.env.MONGODB_URI +const PORT = process.env.PORT // highlight-start -if (process.env.NODE_ENV === 'test') { - MONGODB_URI = process.env.TEST_MONGODB_URI -} +const MONGODB_URI = process.env.NODE_ENV === 'test' + ? process.env.TEST_MONGODB_URI + : process.env.MONGODB_URI // highlight-end module.exports = { @@ -106,20 +88,19 @@ module.exports = { The .env file has separate variables for the database addresses of the development and test databases: ```bash -MONGODB_URI=mongodb+srv://fullstack:secred@cluster0-ostce.mongodb.net/note-app?retryWrites=true +MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.a5qfl.mongodb.net/noteApp?retryWrites=true&w=majority&appName=Cluster0 PORT=3001 // highlight-start -TEST_MONGODB_URI=mongodb+srv://fullstack:secret@cluster0-ostce.mongodb.net/note-app-test?retryWrites=true +TEST_MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.a5qfl.mongodb.net/testNoteApp?retryWrites=true&w=majority&appName=Cluster0 // highlight-end ``` -The _config_ module that we have implemented slightly resembles the [node-config](https://github.com/lorenwest/node-config) package. Writing our own implementation is justified since our application is simple, and also because it teaches us valuable lessons. +The _config_ module that we have implemented slightly resembles the [node-config](https://github.com/lorenwest/node-config) package. Writing our implementation is justified since our application is simple, and also because it teaches us valuable lessons. These are the only changes we need to make to our application's code. -You can find the code for our current application in its entirety in the part4-2 branch of [this github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-2). - +You can find the code for our current application in its entirety in the part4-2 branch of [this GitHub repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-2). ### supertest @@ -134,6 +115,7 @@ npm install --save-dev supertest Let's write our first test in the tests/note_api.test.js file: ```js +const { test, after } = require('node:test') const mongoose = require('mongoose') const supertest = require('supertest') const app = require('../app') @@ -147,8 +129,8 @@ test('notes are returned as json', async () => { .expect('Content-Type', /application\/json/) }) -afterAll(() => { - mongoose.connection.close() +after(async () => { + await mongoose.connection.close() }) ``` @@ -156,45 +138,45 @@ The test imports the Express application from the app.js module and wraps Our test makes an HTTP GET request to the api/notes url and verifies that the request is responded to with the status code 200. The test also verifies that the Content-Type header is set to application/json, indicating that the data is in the desired format. -The test contains some details that we will explore [a bit later on](/en/part4/testing_the_backend#async-await). The arrow function that defines the test is preceded by the async keyword and the method call for the api object is preceded by the await keyword. We will write a few tests and then take a closer look at this async/await magic. Do not concern yourself with them for now, just be assured that the example tests work correctly. The async/await syntax is related to the fact that making a request to the API is an asynchronous operation. The [Async/await syntax](https://facebook.github.io/jest/docs/en/asynchronous.html) can be used for writing asynchronous code with the appearance of synchronous code. - -Once all the tests (there is currently only one) have finished running we have to close the database connection used by Mongoose. This can be easily achieved with the [afterAll](https://facebook.github.io/jest/docs/en/api.html#afterallfn-timeout) method: +Checking the value of the header uses a bit strange looking syntax: ```js -afterAll(() => { - mongoose.connection.close() -}) +.expect('Content-Type', /application\/json/) ``` -When running your tests you may run across the following console warning: +The desired value is now defined as [regular expression](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions) or in short regex. The regex starts and ends with a slash /, and because the desired string application/json also contains the same slash, it is preceded by a \ so that it is not interpreted as a regex termination character. + +In principle, the test could also have been defined as a string -![](../../images/4/8.png) +```js +.expect('Content-Type', 'application/json') +``` + +The problem here, however, is that when using a string, the value of the header must be exactly the same. For the regex we defined, it is acceptable that the header contains the string in question. The actual value of the header is application/json; charset=utf-8, i.e. it also contains information about character encoding. However, our test is not interested in this and therefore it is better to define the test as a regex instead of an exact string. +The test contains some details that we will explore [a bit later on](/en/part4/testing_the_backend#async-await). The arrow function that defines the test is preceded by the async keyword and the method call for the api object is preceded by the await keyword. We will write a few tests and then take a closer look at this async/await magic. Do not concern yourself with them for now, just be assured that the example tests work correctly. The async/await syntax is related to the fact that making a request to the API is an asynchronous operation. The async/await syntax can be used for writing asynchronous code with the appearance of synchronous code. -If this occurs, let's follow the [instructions](https://mongoosejs.com/docs/jest.html) and add a jest.config.js file at the root of the project with the following content: +Once all the tests (there is currently only one) have finished running we have to close the database connection used by Mongoose. Without this, the test program will not terminate. This can be easily achieved with the [after](https://nodejs.org/api/test.html#afterfn-options) method: ```js -module.exports = { - testEnvironment: 'node' -} +after(async () => { + await mongoose.connection.close() +}) ``` -One tiny but important detail: at the [beginning](/en/part4/structure_of_backend_application_introduction_to_testing#project-structure) of this part we extracted the Express application into the app.js file, and the role of the index.js file was changed to launch the application at the specified port with Node's built-in http object: +One tiny but important detail: at the [beginning](/en/part4/structure_of_backend_application_introduction_to_testing#project-structure) of this part we extracted the Express application into the app.js file, and the role of the index.js file was changed to launch the application at the specified port via _app.listen_: ```js const app = require('./app') // the actual Express app -const http = require('http') const config = require('./utils/config') const logger = require('./utils/logger') -const server = http.createServer(app) - -server.listen(config.PORT, () => { +app.listen(config.PORT, () => { logger.info(`Server running on port ${config.PORT}`) }) ``` -The tests only use the express application defined in the app.js file: +The tests only use the Express application defined in the app.js file, which does not listen to any ports: ```js const mongoose = require('mongoose') @@ -206,47 +188,61 @@ const api = supertest(app) // highlight-line // ... ``` - The documentation for supertest says the following: > if the server is not already listening for connections then it is bound to an ephemeral port for you so there is no need to keep track of ports. +In other words, supertest takes care that the application being tested is started at the port that it uses internally. This is one of the reasons why we are going with supertest instead of something like axios, as we do not need to run another instance of the server separately before beginning to test. The other reason is that supertest provides functions like expect(), which makes testing easier. -In other words, supertest takes care that the application being tested is started at the port that it uses internally. - +Let's add two notes to the test database using the _mongo.js_ program (here we must remember to switch to the correct database url). Let's write a few more tests: ```js -test('there are two notes', async () => { +const assert = require('node:assert') +// ... + +test('all notes are returned', async () => { const response = await api.get('/api/notes') - expect(response.body).toHaveLength(2) + assert.strictEqual(response.body.length, 2) }) -test('the first note is about HTTP methods', async () => { +test('a specific note is within the returned notes', async () => { const response = await api.get('/api/notes') - expect(response.body[0].content).toBe('HTML is easy') + const contents = response.body.map(e => e.content) + assert.strictEqual(contents.includes('HTML is easy'), true) }) + +// ... ``` -Both tests store the response of the request to the _response_ variable, and unlike the previous test that used the methods provided by _supertest_ for verifying the status code and headers, this time we are inspecting the response data stored in response.body property. Our tests verify the format and content of the response data with the [expect](https://facebook.github.io/jest/docs/en/expect.html#content) method of Jest. +Both tests store the response of the request to the _response_ variable, and unlike the previous test that used the methods provided by _supertest_ for verifying the status code and headers, this time we are inspecting the response data stored in response.body property. Our tests verify the format and content of the response data with the method [strictEqual](https://nodejs.org/docs/latest/api/assert.html#assertstrictequalactual-expected-message) of the assert-library. + +We could simplify the second test a bit, and use the [assert](https://nodejs.org/docs/latest/api/assert.html#assertokvalue-message) itself to verify that the note is among the returned ones: + +```js +test('a specific note is within the returned notes', async () => { + const response = await api.get('/api/notes') + + const contents = response.body.map(e => e.content) + assert(contents.includes('HTML is easy')) +}) +``` The benefit of using the async/await syntax is starting to become evident. Normally we would have to use callback functions to access the data returned by promises, but with the new syntax things are a lot more comfortable: ```js -const res = await api.get('/api/notes') +const response = await api.get('/api/notes') // execution gets here only after the HTTP request is complete -// the result of HTTP request is saved in variable res -expect(res.body).toHaveLength(2) +// the result of HTTP request is saved in variable response +assert.strictEqual(response.body.length, 2) ``` - - -The middleware that outputs information about the HTTP requests is obstructing the test execution output. Let us modify the logger so that it does not print to console in test mode: +The middleware that outputs information about the HTTP requests is obstructing the test execution output. Let us modify the logger so that it does not print to the console in test mode: ```js const info = (...params) => { @@ -258,7 +254,11 @@ const info = (...params) => { } const error = (...params) => { - console.error(...params) + // highlight-start + if (process.env.NODE_ENV !== 'test') { + console.error(...params) + } + // highlight-end } module.exports = { @@ -268,30 +268,37 @@ module.exports = { ### Initializing the database before tests -Testing appears to be easy and our tests are currently passing. However, our tests are bad as they are dependent on the state of the database (that happens to be correct in my test database). In order to make our tests more robust, we have to reset the database and generate the needed test data in a controlled manner before we run the tests. +Currently, our tests have an issue where their success depends on the state of the database. The tests pass if the test database happens to contain two notes, one of which has the content 'HTML is easy'. To make them more robust, we have to reset the database and generate the needed test data in a controlled manner before we run the tests. -Our tests are already using the [afterAll](https://facebook.github.io/jest/docs/en/api.html#afterallfn-timeout) function of Jest to close the connection to the database after the tests are finished executing. Jest offers many other [functions](https://facebook.github.io/jest/docs/en/setup-teardown.html#content) that can be used for executing operations once before any test is run, or every time before a test is run. +Our tests are already using the [after](https://nodejs.org/api/test.html#afterfn-options) function to close the connection to the database after the tests are finished executing. The library node:test offers many other functions that can be used for executing operations once before any test is run or every time before a test is run. -Let's initialize the database before every test with the [beforeEach](https://jestjs.io/docs/en/api.html#aftereachfn-timeout) function: +Let's initialize the database before every test with the [beforeEach](https://nodejs.org/api/test.html#beforeeachfn-options) function: ```js + +const assert = require('node:assert') +const { test, after, beforeEach } = require('node:test') // highlight-line const mongoose = require('mongoose') const supertest = require('supertest') const app = require('../app') +const Note = require('../models/note') // highlight-line + const api = supertest(app) -const Note = require('../models/note') +// highlight-start const initialNotes = [ { content: 'HTML is easy', important: false, }, { - content: 'Browser can execute only Javascript', + content: 'Browser can execute only JavaScript', important: true, }, ] +// highlight-end +// highlight-start beforeEach(async () => { await Note.deleteMany({}) @@ -301,38 +308,62 @@ beforeEach(async () => { noteObject = new Note(initialNotes[1]) await noteObject.save() }) +// highlight-end + +// ... ``` -The database is cleared out at the beginning, and after that we save the two notes stored in the _initialNotes_ array to the database. Doing this, we ensure that the database is in the same state before every test is run. +The database is cleared out at the beginning, and after that, we save the two notes stored in the _initialNotes_ array to the database. By doing this, we ensure that the database is in the same state before every test is run. -Let's also make the following changes to the last two tests: +Let's modify the test that checks the number of notes as follows: ```js +// ... + test('all notes are returned', async () => { const response = await api.get('/api/notes') - expect(response.body).toHaveLength(initialNotes.length) // highlight-line + assert.strictEqual(response.body.length, initialNotes.length) // highlight-line }) -test('a specific note is within the returned notes', async () => { - const response = await api.get('/api/notes') +// ... + +``` + +You can find the code for our current application in its entirety in the part4-3 branch of [this GitHub repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-3). + +### Running tests one by one + +The _npm test_ command executes all of the tests for the application. When we are writing tests, it is usually wise to only execute one or two tests. - const contents = response.body.map(r => r.content) // highlight-line +There are a few different ways of accomplishing this, one of which is the [only](https://nodejs.org/api/test.html#testonlyname-options-fn) method. With this method we can define in the code what tests should be executed: + +```js +test.only('notes are returned as json', async () => { + await api + .get('/api/notes') + .expect(200) + .expect('Content-Type', /application\/json/) +}) + +test.only('all notes are returned', async () => { + const response = await api.get('/api/notes') - expect(contents).toContain( - 'Browser can execute only Javascript' // highlight-line - ) + assert.strictEqual(response.body.length, 2) }) ``` -Pay special attention to the expect in the latter test. The response.body.map(r => r.content) command is used to create an array containing the content of every note returned by the API. The [toContain](https://facebook.github.io/jest/docs/en/expect.html#tocontainitem) method is used for checking that the note given to it as a parameter is in the list of notes returned by the API. +When tests are run with option _--test-only_, that is, with the command: -### Running tests one by one +``` +npm test -- --test-only +``` +only the _only_ marked tests are executed. -The _npm test_ command executes all of the tests of the application. When we are writing tests, it is usually wise to only execute one or two tests. Jest offers a few different ways of accomplishing this, one of which is the [only](https://jestjs.io/docs/en/api#testonlyname-fn-timeout) method. If tests are written across many files, this method is not great. +The danger of _only_ is that one forgets to remove those from the code. -A better option is to specify the tests that need to be run as parameter of the npm test command. +Another option is to specify the tests that need to be run as arguments of the npm test command. The following command only runs the tests found in the tests/note_api.test.js file: @@ -340,25 +371,21 @@ The following command only runs the tests found in the tests/note_api.test.js npm test -- tests/note_api.test.js ``` -The -t option can be used for running tests with a specific name: +The [--tests-by-name-pattern](https://nodejs.org/api/test.html#filtering-tests-by-name) option can be used for running tests with a specific name: ```js -npm test -- -t 'a specific note is within the returned notes' +npm test -- --test-name-pattern="a specific note is within the returned notes" ``` -The provided parameter can refer to the name of the test or the describe block. The parameter can also contain just a part of the name. The following command will run all of the tests that contain notes in their name: +The provided argument can refer to the name of the test or the describe block. It can also contain just a part of the name. The following command will run all of the tests that contain notes in their name: ```js -npm test -- -t 'notes' +npm run test -- --test-name-pattern="notes" ``` - -**NB**: When running a single test, the mongoose connection might stay open if no tests using the connection are run. -The problem might be due to the fact that supertest primes the connection, but jest does not run the afterAll portion of the code. - ### async/await -Before we write more tests let's take a look at the _async_ and _await_ keywords. +Before we write more tests let's take a look at the _async_ and _await_ keywords. The async/await syntax that was introduced in ES7 makes it possible to use asynchronous functions that return a promise in a way that makes the code look synchronous. @@ -370,19 +397,16 @@ Note.find({}).then(notes => { }) ``` - The _Note.find()_ method returns a promise and we can access the result of the operation by registering a callback function with the _then_ method. - -All of the code we want to execute once the operation finishes is written in the callback function. If we wanted to make several asynchronous function calls in sequence, the situation would soon become painful. The asynchronous calls would have to be made in the callback. This would likely lead to complicated code and could potentially give birth to a so-called [callback hell](http://callbackhell.com/). - +All of the code we want to execute once the operation finishes is written in the callback function. If we wanted to make several asynchronous function calls in sequence, the situation would soon become painful. The asynchronous calls would have to be made in the callback. This would likely lead to complicated code and could potentially give birth to a so-called [callback hell](https://stackoverflow.com/a/25098230). By [chaining promises](https://javascript.info/promise-chaining) we could keep the situation somewhat under control, and avoid callback hell by creating a fairly clean chain of _then_ method calls. We have seen a few of these during the course. To illustrate this, you can view an artificial example of a function that fetches all notes and then deletes the first one: ```js Note.find({}) .then(notes => { - return notes[0].remove() + return notes[0].deleteOne() }) .then(response => { console.log('the first note is removed') @@ -408,14 +432,14 @@ The slightly complicated example presented above could be implemented by using a ```js const notes = await Note.find({}) -const response = await notes[0].remove() +const response = await notes[0].deleteOne() console.log('the first note is removed') ``` Thanks to the new syntax, the code is a lot simpler than the previous then-chain. -There are a few important details to pay attention to when using async/await syntax. In order to use the await operator with asynchronous operations, they have to return a promise. This is not a problem as such, as regular asynchronous functions using callbacks are easy to wrap around promises. +There are a few important details to pay attention to when using async/await syntax. To use the await operator with asynchronous operations, they have to return a promise. This is not a problem as such, as regular asynchronous functions using callbacks are easy to wrap around promises. The await keyword can't be used just anywhere in JavaScript code. Using await is possible only inside of an [async](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function) function. @@ -426,40 +450,48 @@ const main = async () => { // highlight-line const notes = await Note.find({}) console.log('operation returned the following notes', notes) - const response = await notes[0].remove() + const response = await notes[0].deleteOne() console.log('the first note is removed') } main() // highlight-line ``` -The code declares that the function assigned to _main_ is asynchronous. After this the code calls the function with main(). +The code declares that the function assigned to _main_ is asynchronous. After this, the code calls the function with main(). ### async/await in the backend -Let's change the backend to async and await. As all of the asynchronous operations are currently done inside of a function, it is enough to change the route handler functions into async functions. +Let's start to change the backend to async and await. Let's start with the route responsible for fetching all notes. -The route for fetching all notes gets changed to the following: +As all of the asynchronous operations are currently done inside of a function, it is enough to change the route handler functions into async functions. The route for fetching all notes + +```js +notesRouter.get('/', (request, response) => { + Note.find({}).then((notes) => { + response.json(notes) + }) +}) +``` + +gets changed to the following: ```js notesRouter.get('/', async (request, response) => { const notes = await Note.find({}) - response.json(notes.map(note => note.toJSON())) + response.json(notes) }) ``` We can verify that our refactoring was successful by testing the endpoint through the browser and by running the tests that we wrote earlier. -You can find the code for our current application in its entirety in the part4-3 branch of [this Github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-3). - -### More tests and refactoring the backend +### Refactoring the route responsible for adding a note When code gets refactored, there is always the risk of [regression](https://en.wikipedia.org/wiki/Regression_testing), meaning that existing functionality may break. Let's refactor the remaining operations by first writing a test for each route of the API. -Let's start with the operation for adding a new note. Let's write a test that adds a new note and verifies that the amount of notes returned by the API increases, and that the newly added note is in the list. +Let's start with the operation for adding a new note. Let's write a test that adds a new note and verifies that the number of notes returned by the API increases and that the newly added note is in the list. ```js -test('a valid note can be added', async () => { +test('a valid note can be added ', async () => { const newNote = { content: 'async/await simplifies making async calls', important: true, @@ -468,21 +500,37 @@ test('a valid note can be added', async () => { await api .post('/api/notes') .send(newNote) - .expect(200) + .expect(201) .expect('Content-Type', /application\/json/) const response = await api.get('/api/notes') const contents = response.body.map(r => r.content) - expect(response.body).toHaveLength(initialNotes.length + 1) - expect(contents).toContain( - 'async/await simplifies making async calls' - ) + assert.strictEqual(response.body.length, initialNotes.length + 1) + + assert(contents.includes('async/await simplifies making async calls')) }) ``` -The test passes just like we hoped and expected it to. +The test fails because we accidentally returned the status code 200 OK when a new note is created. Let us change that to the status code 201 CREATED: + +```js +notesRouter.post('/', (request, response, next) => { + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + + note.save() + .then(savedNote => { + response.status(201).json(savedNote) // highlight-line + }) + .catch(error => next(error)) +}) +``` Let's also write a test that verifies that a note without content will not be saved into the database. @@ -499,7 +547,7 @@ test('note without content is not added', async () => { const response = await api.get('/api/notes') - expect(response.body).toHaveLength(initialNotes.length) + assert.strictEqual(response.body.length, initialNotes.length) }) ``` @@ -509,7 +557,7 @@ Both tests check the state stored in the database after the saving operation, by const response = await api.get('/api/notes') ``` -The same verification steps will repeat in other tests later on, and it is a good idea to extract these steps into helper functions. Let's add the function into a new file called tests/test_helper.js that is in the same directory as the test file. +The same verification steps will repeat in other tests later on, and it is a good idea to extract these steps into helper functions. Let's add the function into a new file called tests/test_helper.js which is in the same directory as the test file. ```js const Note = require('../models/note') @@ -517,12 +565,10 @@ const Note = require('../models/note') const initialNotes = [ { content: 'HTML is easy', - date: new Date(), important: false }, { - content: 'Browser can execute only Javascript', - date: new Date(), + content: 'Browser can execute only JavaScript', important: true } ] @@ -530,7 +576,7 @@ const initialNotes = [ const nonExistingId = async () => { const note = new Note({ content: 'willremovethissoon' }) await note.save() - await note.remove() + await note.deleteOne() return note._id.toString() } @@ -545,19 +591,21 @@ module.exports = { } ``` -The module defines the _notesInDb_ function that can be used for checking the notes stored in the database. The _initialNotes_ array containing the initial database state is also in the module. We also define the _nonExistingId_ function ahead of time, that can be used for creating a database object ID that does not belong to any note object in the database. +The module defines the _notesInDb_ function that can be used for checking the notes stored in the database. The _initialNotes_ array containing the initial database state is also in the module. We also define the _nonExistingId_ function ahead of time, which can be used for creating a database object ID that does not belong to any note object in the database. -Our tests can now use helper module and be changed like this: +Our tests can now use the helper module and be changed like this: ```js -const supertest = require('supertest') +const assert = require('node:assert') +const { test, after, beforeEach } = require('node:test') const mongoose = require('mongoose') -const helper = require('./test_helper') // highlight-line +const supertest = require('supertest') const app = require('../app') -const api = supertest(app) - +const helper = require('./test_helper') // highlight-line const Note = require('../models/note') +const api = supertest(app) + beforeEach(async () => { await Note.deleteMany({}) @@ -578,16 +626,14 @@ test('notes are returned as json', async () => { test('all notes are returned', async () => { const response = await api.get('/api/notes') - expect(response.body).toHaveLength(helper.initialNotes.length) // highlight-line + assert.strictEqual(response.body.length, helper.initialNotes.length) // highlight-line }) test('a specific note is within the returned notes', async () => { const response = await api.get('/api/notes') - const contents = response.body.map(r => r.content) - expect(contents).toContain( - 'Browser can execute only Javascript' - ) + const contents = response.body.map(e => e.content) + assert(contents.includes('HTML is easy')) }) test('a valid note can be added ', async () => { @@ -599,16 +645,14 @@ test('a valid note can be added ', async () => { await api .post('/api/notes') .send(newNote) - .expect(200) + .expect(201) .expect('Content-Type', /application\/json/) const notesAtEnd = await helper.notesInDb() // highlight-line - expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1) // highlight-line + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length + 1) // highlight-line const contents = notesAtEnd.map(n => n.content) // highlight-line - expect(contents).toContain( - 'async/await simplifies making async calls' - ) + assert(contents.includes('async/await simplifies making async calls')) }) test('note without content is not added', async () => { @@ -623,75 +667,82 @@ test('note without content is not added', async () => { const notesAtEnd = await helper.notesInDb() // highlight-line - expect(notesAtEnd).toHaveLength(helper.initialNotes.length) // highlight-line + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length) // highlight-line }) -afterAll(() => { - mongoose.connection.close() -}) +after(async () => { + await mongoose.connection.close() +}) ``` The code using promises works and the tests pass. We are ready to refactor our code to use the async/await syntax. -We make the following changes to the code that takes care of adding a new note(notice that the route handler definition is preceded by the _async_ keyword): +The route responsible for adding a new note ```js -notesRouter.post('/', async (request, response, next) => { +notesRouter.post('/', (request, response, next) => { const body = request.body const note = new Note({ content: body.content, - important: body.important === undefined ? false : body.important, - date: new Date(), + important: body.important || false, }) - const savedNote = await note.save() - response.json(savedNote.toJSON()) + note + .save() + .then((savedNote) => { + response.status(201).json(savedNote) + }) + .catch((error) => next(error)) }) ``` -There's a slight problem with our code: we don't handle error situations. How should we deal with them? - -### Error handling and async/await - -If there's an exception while handling the POST request we end up in a familiar situation: - -![](../../images/4/6.png) - -In other words we end up with an unhandled promise rejection, and the request never receives a response. - -With async/await the recommended way of dealing with exceptions is the old and familiar _try/catch_ mechanism: +changes as follows: ```js -notesRouter.post('/', async (request, response, next) => { +notesRouter.post('/', async (request, response) => { // highlight-line const body = request.body const note = new Note({ content: body.content, - important: body.important === undefined ? false : body.important, - date: new Date(), + important: body.important || false, }) + // highlight-start - try { - const savedNote = await note.save() - response.json(savedNote.toJSON()) - } catch(exception) { - next(exception) - } + const savedNote = await note.save() + response.status(201).json(savedNote) // highlight-end }) ``` -The catch block simply calls the _next_ function, which passes the request handling to the error handling middleware. +You need to add the _async_ keyword at the beginning of the handler to enable the use of _async/await_ syntax. The code becomes much simpler. -After making the change, all of our tests will pass once again. +Notably, possible errors no longer need to be forwarded separately for handling. In code using promises, a possible error was passed to the error-handling middleware like this: -Next, let's write tests for fetching and removing an individual note: +```js + note + .save() + .then((savedNote) => { + response.json(savedNote) + }) + .catch((error) => next(error)) // highlight-line +``` + +When using _async/await_ syntax, Express will [automatically call](https://expressjs.com/en/guide/error-handling.html) the error-handling middleware if an await statement throws an error or the awaited promise is rejected. This makes the final code even cleaner. + +**Note:** This feature is available starting from Express version 5. If you installed Express as a dependency before March 31, 2025, you might still be using version 4. You can check your project's Express version in the _package.json_ file. If you have an older version, update to version 5 with the following command: + + ```bash + npm install express@5 + ``` + +### Refactoring the route responsible for fetching a single note + +Next, let's write a test for viewing the details of a single note. The code highlights the actual API operation being performed: ```js test('a specific note can be viewed', async () => { const notesAtStart = await helper.notesInDb() - const noteToView = notesAtStart[0] // highlight-start @@ -701,167 +752,67 @@ test('a specific note can be viewed', async () => { .expect('Content-Type', /application\/json/) // highlight-end - expect(resultNote.body).toEqual(noteToView) -}) - -test('a note can be deleted', async () => { - const notesAtStart = await helper.notesInDb() - const noteToDelete = notesAtStart[0] - -// highlight-start - await api - .delete(`/api/notes/${noteToDelete.id}`) - .expect(204) -// highlight-end - - const notesAtEnd = await helper.notesInDb() - - expect(notesAtEnd).toHaveLength( - helper.initialNotes.length - 1 - ) - - const contents = notesAtEnd.map(r => r.content) - - expect(contents).not.toContain(noteToDelete.content) + assert.deepStrictEqual(resultNote.body, noteToView) }) ``` -Both tests share a similar structure. In the initialization phase they fetch a note from the database. After this, the tests call the actual operation being tested, which is highlighted in the code block. Lastly, the tests verify that the outcome of the operation is as expected. +First, the test fetches a single note from the database. Then, it checks that the specific note can be retrieved through the API. Finally, it verifies that the content of the fetched note is as expected. -The tests pass and we can safely refactor the tested routes to use async/await: +There is one point worth noting in the test. Instead of the previously used method [strictEqual](https://nodejs.org/api/assert.html#assertstrictequalactual-expected-message), the method [deepStrictEqual](https://nodejs.org/api/assert.html#assertdeepstrictequalactual-expected-message) is used: ```js -notesRouter.get('/:id', async (request, response, next) => { - try{ - const note = await Note.findById(request.params.id) - if (note) { - response.json(note.toJSON()) - } else { - response.status(404).end() - } - } catch(exception) { - next(exception) - } -}) - -notesRouter.delete('/:id', async (request, response, next) => { - try { - await Note.findByIdAndRemove(request.params.id) - response.status(204).end() - } catch (exception) { - next(exception) - } -}) +assert.deepStrictEqual(resultNote.body, noteToView) ``` -You can find the code for our current application in its entirety in the part4-4 branch of [this Github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-4). - -### Eliminating the try-catch +The reason for this is that _strictEqual_ uses the method [Object.is](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is) to compare similarity, i.e. it compares whether the objects are the same. In our case, we want to check that the contents of the objects, i.e. the values of their fields, are the same. For this purpose _deepStrictEqual_ is suitable. - -Async/await unclutters the code a bit, but the 'price' is the try/catch structure required for catching exceptions. -All of the route handlers follow the same structure +The tests pass and we can safely refactor the tested route to use async/await: ```js -try { - // do the async operations here -} catch(exception) { - next(exception) -} +notesRouter.get('/:id', async (request, response) => { + const note = await Note.findById(request.params.id) + if (note) { + response.json(note) + } else { + response.status(404).end() + } +}) ``` - -One starts to wonder, if it would be possible to refactor the code to eliminate the catch from the methods? +### Refactoring the route responsible for deleting a note - -The [express-async-errors](https://github.com/davidbanham/express-async-errors) library has a solution for this. - - -Let's install the library - -```bash -npm install express-async-errors --save -``` - - -Using the library is very easy. -You introduce the library in src/app.js: +Let's also add a test for the route that handles deleting a note: ```js -const config = require('./utils/config') -const express = require('express') -require('express-async-errors') // highlight-line -const app = express() -const cors = require('cors') -const notesRouter = require('./controllers/notes') -const middleware = require('./utils/middleware') -const logger = require('./utils/logger') -const mongoose = require('mongoose') +test('a note can be deleted', async () => { + const notesAtStart = await helper.notesInDb() + const noteToDelete = notesAtStart[0] -// ... + await api + .delete(`/api/notes/${noteToDelete.id}`) + .expect(204) -module.exports = app -``` + const notesAtEnd = await helper.notesInDb() - -The 'magic' of the library allows us to eliminate the try-catch blocks completely. -For example the route for deleting a note + const contents = notesAtEnd.map(n => n.content) + assert(!contents.includes(noteToDelete.content)) -```js -notesRouter.delete('/:id', async (request, response, next) => { - try { - await Note.findByIdAndRemove(request.params.id) - response.status(204).end() - } catch (exception) { - next(exception) - } + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length - 1) }) ``` - -becomes +The test is structured similarly to the one that checks viewing a single note. First, a single note is fetched from the database, then its deletion via the API is tested. Finally, it is verified that the note no longer exists in the database and that the total number of notes has decreased by one. + +The tests still pass, so we can safely proceed with refactoring the route: ```js notesRouter.delete('/:id', async (request, response) => { - await Note.findByIdAndRemove(request.params.id) + await Note.findByIdAndDelete(request.params.id) response.status(204).end() }) ``` - -Because of the library, we do not need the _next(exception)_ call anymore. -The library handles everything under the hood. If an exception occurs in a async route, the execution is automatically passed to the error handling middleware. - - -The other routes become: - -```js -notesRouter.post('/', async (request, response) => { - const body = request.body - - const note = new Note({ - content: body.content, - important: body.important === undefined ? false : body.important, - date: new Date(), - }) - - const savedNote = await note.save() - response.json(savedNote.toJSON()) -}) - -notesRouter.get('/:id', async (request, response) => { - const note = await Note.findById(request.params.id) - if (note) { - response.json(note.toJSON()) - } else { - response.status(404).end() - } -}) -``` - - -The code for our application can be found from [github](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-5), branch part4-5. +You can find the code for our current application in its entirety in the part4-4 branch of [this GitHub repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-4). ### Optimizing the beforeEach function @@ -879,7 +830,7 @@ beforeEach(async () => { }) ``` -The function saves the first two notes from the _helper.initialNotes_ array into the database with two separate operations. The solution is alright, but there's a better way of saving multiple objects to the database: +The function saves the first two notes from the _helper.initialNotes_ array into the database with two separate operations. The solution is alright, but there's a better way of saving multiple objects to the database: ```js beforeEach(async () => { @@ -900,25 +851,24 @@ test('notes are returned as json', async () => { } ``` -We save the notes stored in the array into the database inside of a _forEach_ loop. The tests don't quite seem to work however, so we have added some console logs to help us find the problem. +We save the notes stored in the array into the database inside of a _forEach_ loop. The tests don't quite seem to work however, so we have added some console logs to help us find the problem. The console displays the following output: -
    +```
     cleared
     done
     entered test
     saved
     saved
    -
    +``` -Despite our use of the async/await syntax, our solution does not work like we expected it to. The test execution begins before the database is initialized! +Despite our use of the async/await syntax, our solution does not work as we expected it to. The test execution begins before the database is initialized! -The problem is that every iteration of the forEach loop generates its own asynchronous operation, and _beforeEach_ won't wait for them to finish executing. In other words, the _await_ commands defined inside of the _forEach_ loop are not in the _beforeEach_ function, but in separate functions that _beforeEach_ will not wait for. +The problem is that each iteration of the _forEach_ loop generates its own asynchronous operation, and the _beforeEach_ function does not wait for their completion. In other words, the await commands inside the _forEach_ loop are not part of the _beforeEach_ function but are instead in separate functions, which _beforeEach_ does not wait for. Additionally, [the _forEach_ method expects a synchronous function as its parameter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach#description), so the _async/await_ structure does not work correctly within it. Since the execution of tests begins immediately after _beforeEach_ has finished executing, the execution of tests begins before the database state is initialized. - One way of fixing this is to wait for all of the asynchronous operations to finish executing with the [Promise.all](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) method: ```js @@ -932,16 +882,12 @@ beforeEach(async () => { }) ``` - The solution is quite advanced despite its compact appearance. The _noteObjects_ variable is assigned to an array of Mongoose objects that are created with the _Note_ constructor for each of the notes in the _helper.initialNotes_ array. The next line of code creates a new array that consists of promises, that are created by calling the _save_ method of each item in the _noteObjects_ array. In other words, it is an array of promises for saving each of the items to the database. - -The [Promise.all](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) method can be used for transforming an array of promises into a single promise, that will be fulfilled once every promise in the array passed to it as a parameter is resolved. The last line of code await Promise.all(promiseArray) waits that every promise for saving a note is finished, meaning that the database has been initialized. - +The [Promise.all](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) method can be used for transforming an array of promises into a single promise, that will be fulfilled once every promise in the array passed to it as an argument is resolved. The last line of code await Promise.all(promiseArray) waits until every promise for saving a note is finished, meaning that the database has been initialized. > The returned values of each promise in the array can still be accessed when using the Promise.all method. If we wait for the promises to be resolved with the _await_ syntax const results = await Promise.all(promiseArray), the operation will return an array that contains the resolved values for each promise in the _promiseArray_, and they appear in the same order as the promises in the array. - Promise.all executes the promises it receives in parallel. If the promises need to be executed in a particular order, this will be problematic. In situations like this, the operations can be executed inside of a [for...of](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...of) block, that guarantees a specific execution order. ```js @@ -955,86 +901,76 @@ beforeEach(async () => { }) ``` - The asynchronous nature of JavaScript can lead to surprising behavior, and for this reason, it is important to pay careful attention when using the async/await syntax. Even though the syntax makes it easier to deal with promises, it is still necessary to understand how promises work! -
    - -
    - - -### Exercises 4.8.-4.12. - - -**NB:** the material uses the [toContain](https://facebook.github.io/jest/docs/en/expect.html#tocontainitem) matcher in several places to verify that an array contains a specific element. It's worth noting that the method uses the === operator for comparing and matching elements, which means that it is often not well-suited for matching objects. In most cases, the appropriate method for verifying objects in arrays is the [toContainEqual](https://facebook.github.io/jest/docs/en/expect.html#tocontainequalitem) matcher. However, the model solutions don't check for objects in arrays with matchers, so using the method is not required for solving the exercises. - - -**Warning:** If you find yourself using async/await and then methods in the same code, it is almost guaranteed that you are doing something wrong. Use one or the other and don't mix the two. - - -#### 4.8: Blog list tests, step1 - - -Use the supertest package for writing a test that makes an HTTP GET request to the /api/blogs url. Verify that the blog list application returns the correct amount of blog posts in the JSON format. - - -Once the test is finished, refactor the route handler to use the async/await syntax instead of promises. +However, there is an even simpler way to implement the _beforeEach_ function. The easiest way to handle the situation is by utilizing Mongoose's built-in method _insertMany_: +```js +beforeEach(async () => { + await Note.deleteMany({}) + await Note.insertMany(helper.initialNotes) // highlight-line +}) +``` -Notice that you will have to make similar changes to the code that were made [in the material](/en/part4/testing_the_backend#test-environment), like defining the test environment so that you can write tests that use their own separate database. +The code for our application can be found on [GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-5), branch part4-5. +### A true full stack developer's oath -**NB:** When running the tests, you may run into the following warning: +Making tests brings yet another layer of challenge to programming. We have to update our full stack developer oath to remind you that systematicity is also key when developing tests. -![](../../images/4/8a.png) +So we should once more extend our oath: +Full stack development is extremely hard, that is why I will use all the possible means to make it easier -If this happens, follow the [instructions](https://mongoosejs.com/docs/jest.html) and create a new jest.config.js file at the root of the project with the following contents: - -```js -module.exports = { - testEnvironment: 'node' -} -``` - +- I will have my browser developer console open all the time +- I will use the network tab of the browser dev tools to ensure that frontend and backend are communicating as I expect +- I will constantly keep an eye on the state of the server to make sure that the data sent there by the frontend is saved as I expect +- I will keep an eye on the database: does the backend save data there in the right format +- I will progress in small steps +- I will write lots of _console.log_ statements to make sure I understand how the code and the tests behave and to help pinpoint problems +- If my code does not work, I will not write more code. Instead, I start deleting the code until it works or just return to a state when everything is still working +- If a test does not pass, I make sure that the tested functionality for sure works in the application +- When I ask for help in the course Discord channel or elsewhere I formulate my questions properly, see [here](/en/part0/general_info#how-to-get-help-in-discord) how to ask for help +
    +
    -**NB:** when you are writing your tests **it is better to not execute all of your tests**, only execute the ones you are working on. Read more about this [here](/en/part4/testing_the_backend#running-tests-one-by-one). +### Exercises 4.8.-4.12. +**Warning:** If you find yourself using async/await and then methods in the same code, it is almost guaranteed that you are doing something wrong. Use one or the other and don't mix the two. -#### 4.9*: Blog list tests, step2 +#### 4.8: Blog List Tests, step 1 +Use the SuperTest library for writing a test that makes an HTTP GET request to the /api/blogs URL. Verify that the blog list application returns the correct amount of blog posts in the JSON format. -Write a test that verifies that the unique identifier property of the blog posts is named id, by default the database names the property _id. Verifying the existence of a property is easily done with Jest's [toBeDefined](https://jestjs.io/docs/en/expect#tobedefined) matcher. +Once the test is finished, refactor the route handler to use the async/await syntax instead of promises. +Notice that you will have to make similar changes to the code that were made [in the material](/en/part4/testing_the_backend#test-environment), like defining the test environment so that you can write tests that use separate databases. -Make the required changes to the code so that it passes the test. The [toJSON](/en/part3/saving_data_to_mongo_db#backend-connected-to-a-database) method discussed in part 3 is an appropriate place for defining the id parameter. +**NB:** when you are writing your tests **it is better to not execute them all**, only execute the ones you are working on. Read more about this [here](/en/part4/testing_the_backend#running-tests-one-by-one). +#### 4.9: Blog List Tests, step 2 -#### 4.10: Blog list tests, step3 +Write a test that verifies that the unique identifier property of the blog posts is named id, by default the database names the property _id. +Make the required changes to the code so that it passes the test. The [toJSON](/en/part3/saving_data_to_mongo_db#connecting-the-backend-to-a-database) method discussed in part 3 is an appropriate place for defining the id parameter. -Write a test that verifies that making an HTTP POST request to the /api/blogs url successfully creates a new blog post. At the very least, verify that the total number of blogs in the system is increased by one. You can also verify that the content of the blog post is saved correctly to the database. +#### 4.10: Blog List Tests, step 3 +Write a test that verifies that making an HTTP POST request to the /api/blogs URL successfully creates a new blog post. At the very least, verify that the total number of blogs in the system is increased by one. You can also verify that the content of the blog post is saved correctly to the database. Once the test is finished, refactor the operation to use async/await instead of promises. - -#### 4.11*: Blog list tests, step4 - +#### 4.11*: Blog List Tests, step 4 Write a test that verifies that if the likes property is missing from the request, it will default to the value 0. Do not test the other properties of the created blogs yet. - Make the required changes to the code so that it passes the test. +#### 4.12*: Blog List tests, step 5 -#### 4.12*: Blog list tests, step5 - - -Write a test related to creating new blogs via the /api/blogs endpoint, that verifies that if the title and url properties are missing from the request data, the backend responds to the request with the status code 400 Bad Request. - +Write tests related to creating new blogs via the /api/blogs endpoint, that verify that if the title or url properties are missing from the request data, the backend responds to the request with the status code 400 Bad Request. Make the required changes to the code so that it passes the test. @@ -1042,33 +978,29 @@ Make the required changes to the code so that it passes the test.
    - ### Refactoring tests Our test coverage is currently lacking. Some requests like GET /api/notes/:id and DELETE /api/notes/:id aren't tested when the request is sent with an invalid id. The grouping and organization of tests could also use some improvement, as all tests exist on the same "top level" in the test file. The readability of the test would improve if we group related tests with describe blocks. - Below is an example of the test file after making some minor improvements: ```js -const supertest = require('supertest') +const assert = require('node:assert') +const { test, after, beforeEach, describe } = require('node:test') const mongoose = require('mongoose') -const helper = require('./test_helper') +const supertest = require('supertest') const app = require('../app') -const api = supertest(app) - +const helper = require('./test_helper') const Note = require('../models/note') -beforeEach(async () => { - await Note.deleteMany({}) - - const noteObjects = helper.initialNotes - .map(note => new Note(note)) - const promiseArray = noteObjects.map(note => note.save()) - await Promise.all(promiseArray) -}) +const api = supertest(app) describe('when there is initially some notes saved', () => { + beforeEach(async () => { + await Note.deleteMany({}) + await Note.insertMany(helper.initialNotes) + }) + test('notes are returned as json', async () => { await api .get('/api/notes') @@ -1079,162 +1011,127 @@ describe('when there is initially some notes saved', () => { test('all notes are returned', async () => { const response = await api.get('/api/notes') - expect(response.body).toHaveLength(helper.initialNotes.length) + assert.strictEqual(response.body.length, helper.initialNotes.length) }) test('a specific note is within the returned notes', async () => { const response = await api.get('/api/notes') - const contents = response.body.map(r => r.content) - expect(contents).toContain( - 'Browser can execute only Javascript' - ) + const contents = response.body.map(e => e.content) + assert(contents.includes('HTML is easy')) }) -}) -describe('viewing a specific note', () => { - test('succeeds with a valid id', async () => { - const notesAtStart = await helper.notesInDb() + describe('viewing a specific note', () => { + test('succeeds with a valid id', async () => { + const notesAtStart = await helper.notesInDb() + const noteToView = notesAtStart[0] - const noteToView = notesAtStart[0] + const resultNote = await api + .get(`/api/notes/${noteToView.id}`) + .expect(200) + .expect('Content-Type', /application\/json/) - const resultNote = await api - .get(`/api/notes/${noteToView.id}`) - .expect(200) - .expect('Content-Type', /application\/json/) + assert.deepStrictEqual(resultNote.body, noteToView) + }) - expect(resultNote.body).toEqual(noteToView) - }) + test('fails with statuscode 404 if note does not exist', async () => { + const validNonexistingId = await helper.nonExistingId() - test('fails with statuscode 404 if note does not exist', async () => { - const validNonexistingId = await helper.nonExistingId() + await api.get(`/api/notes/${validNonexistingId}`).expect(404) + }) - console.log(validNonexistingId) + test('fails with statuscode 400 id is invalid', async () => { + const invalidId = '5a3d5da59070081a82a3445' - await api - .get(`/api/notes/${validNonexistingId}`) - .expect(404) + await api.get(`/api/notes/${invalidId}`).expect(400) + }) }) - test('fails with statuscode 400 id is invalid', async () => { - const invalidId = '5a3d5da59070081a82a3445' + describe('addition of a new note', () => { + test('succeeds with valid data', async () => { + const newNote = { + content: 'async/await simplifies making async calls', + important: true, + } - await api - .get(`/api/notes/${invalidId}`) - .expect(400) - }) -}) + await api + .post('/api/notes') + .send(newNote) + .expect(201) + .expect('Content-Type', /application\/json/) -describe('addition of a new note', () => { - test('succeeds with valid data', async () => { - const newNote = { - content: 'async/await simplifies making async calls', - important: true, - } - - await api - .post('/api/notes') - .send(newNote) - .expect(200) - .expect('Content-Type', /application\/json/) + const notesAtEnd = await helper.notesInDb() + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length + 1) + const contents = notesAtEnd.map(n => n.content) + assert(contents.includes('async/await simplifies making async calls')) + }) - const notesAtEnd = await helper.notesInDb() - expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1) + test('fails with status code 400 if data invalid', async () => { + const newNote = { important: true } - const contents = notesAtEnd.map(n => n.content) - expect(contents).toContain( - 'async/await simplifies making async calls' - ) - }) + await api.post('/api/notes').send(newNote).expect(400) - test('fails with status code 400 if data invaild', async () => { - const newNote = { - important: true - } + const notesAtEnd = await helper.notesInDb() - await api - .post('/api/notes') - .send(newNote) - .expect(400) - - const notesAtEnd = await helper.notesInDb() - - expect(notesAtEnd).toHaveLength(helper.initialNotes.length) + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length) + }) }) -}) -describe('deletion of a note', () => { - test('succeeds with status code 204 if id is valid', async () => { - const notesAtStart = await helper.notesInDb() - const noteToDelete = notesAtStart[0] - - await api - .delete(`/api/notes/${noteToDelete.id}`) - .expect(204) + describe('deletion of a note', () => { + test('succeeds with status code 204 if id is valid', async () => { + const notesAtStart = await helper.notesInDb() + const noteToDelete = notesAtStart[0] - const notesAtEnd = await helper.notesInDb() + await api.delete(`/api/notes/${noteToDelete.id}`).expect(204) - expect(notesAtEnd).toHaveLength( - helper.initialNotes.length - 1 - ) + const notesAtEnd = await helper.notesInDb() - const contents = notesAtEnd.map(r => r.content) + const contents = notesAtEnd.map(n => n.content) + assert(!contents.includes(noteToDelete.content)) - expect(contents).not.toContain(noteToDelete.content) + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length - 1) + }) }) }) -afterAll(() => { - mongoose.connection.close() +after(async () => { + await mongoose.connection.close() }) ``` +The test output in the console is grouped according to the describe blocks: -The test output is grouped according to the describe blocks: - -![](../../images/4/7.png) - +![node:test output showing grouped describe blocks](../../images/4/7new.png) There is still room for improvement, but it is time to move forward. This way of testing the API, by making HTTP requests and inspecting the database with Mongoose, is by no means the only nor the best way of conducting API-level integration tests for server applications. There is no universal best way of writing tests, as it all depends on the application being tested and available resources. - -You can find the code for our current application in its entirety in the part4-6 branch of [this Github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-6). +You can find the code for our current application in its entirety in the part4-6 branch of [this GitHub repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-6).
    - ### Exercises 4.13.-4.14. - -#### 4.13 Blog list expansions, step1 - +#### 4.13 Blog List Expansions, step 1 Implement functionality for deleting a single blog post resource. - Use the async/await syntax. Follow [RESTful](/en/part3/node_js_and_express#rest) conventions when defining the HTTP API. +Implement tests for the functionality. -Feel free to implement tests for the functionality if you want to. Otherwise verify that the functionality works with Postman or some other tool. - - -#### 4.14 Blog list expansions, step2 - +#### 4.14 Blog List Expansions, step 2 Implement functionality for updating the information of an individual blog post. - Use async/await. +The application mostly needs to update the number of likes for a blog post. You can implement this functionality the same way that we implemented updating notes in [part 3](/en/part3/saving_data_to_mongo_db#other-operations). -The application mostly needs to update the amount of likes for a blog post. You can implement this functionality the same way that we implemented updating notes in [part 3](/en/part3/saving_data_to_mongo_db#other-operations). - - -Feel free to implement tests for the functionality if you want to. Otherwise verify that the functionality works with Postman or some other tool. +Implement tests for the functionality.
    diff --git a/src/content/4/en/part4c.md b/src/content/4/en/part4c.md index b4d7008b77f..649601f7b82 100644 --- a/src/content/4/en/part4c.md +++ b/src/content/4/en/part4c.md @@ -7,38 +7,27 @@ lang: en
    - We want to add user authentication and authorization to our application. Users should be stored in the database and every note should be linked to the user who created it. Deleting and editing a note should only be allowed for the user who created it. - Let's start by adding information about users to the database. There is a one-to-many relationship between the user (User) and notes (Note): -![](https://yuml.me/a187045b.png) - +![diagram linking user and notes](https://yuml.me/a187045b.png) If we were working with a relational database the implementation would be straightforward. Both resources would have their separate database tables, and the id of the user who created a note would be stored in the notes table as a foreign key. - When working with document databases the situation is a bit different, as there are many different ways of modeling the situation. - The existing solution saves every note in the notes collection in the database. If we do not want to change this existing collection, then the natural choice is to save users in their own collection, users for example. +Like with all document databases, we can use object IDs in Mongo to reference documents in other collections. This is similar to using foreign keys in relational databases. -Like with all document databases, we can use object id's in Mongo to reference documents in other collections. This is similar to using foreign keys in relational databases. - - -Traditionally document databases like Mongo do not support join queries that are available in relational databases, used for aggregating data from multiple tables. However starting from version 3.2. Mongo has supported [lookup aggregation queries](https://docs.mongodb.com/manual/reference/operator/aggregation/lookup/). We will not be taking a look at this functionality in this course. - - -If we need a functionality similar to join queries, we will implement it in our application code by making multiple queries. In certain situations Mongoose can take care of joining and aggregating data, which gives the appearance of a join query. However, even in these situations Mongoose makes multiple queries to the database in the background. +Traditionally document databases like Mongo do not support join queries that are available in relational databases, used for aggregating data from multiple tables. However, starting from version 3.2. Mongo has supported [lookup aggregation queries](https://docs.mongodb.com/manual/reference/operator/aggregation/lookup/). We will not be taking a look at this functionality in this course. +If we need functionality similar to join queries, we will implement it in our application code by making multiple queries. In certain situations, Mongoose can take care of joining and aggregating data, which gives the appearance of a join query. However, even in these situations, Mongoose makes multiple queries to the database in the background. ### References across collections - -If we were using a relational database the note would contain a reference key to the user who created it. In document databases we can do the same thing. - +If we were using a relational database the note would contain a reference key to the user who created it. In document databases, we can do the same thing. Let's assume that the users collection contains two users: @@ -52,10 +41,9 @@ Let's assume that the users collection contains two users: username: 'hellas', _id: 141414, }, -]; +] ``` - The notes collection contains three notes that all have a user field that references a user in the users collection: ```js @@ -81,7 +69,6 @@ The notes collection contains three notes that all have a user fie ] ``` - Document databases do not demand the foreign key to be stored in the note resources, it could also be stored in the users collection, or even both: ```js @@ -99,11 +86,9 @@ Document databases do not demand the foreign key to be stored in the note resour ] ``` - Since users can have many notes, the related ids are stored in an array in the notes field. - -Document databases also offer a radically different way of organizing the data: In some situations it might be beneficial to nest the entire notes array as a part of the documents in the users collection: +Document databases also offer a radically different way of organizing the data: In some situations, it might be beneficial to nest the entire notes array as a part of the documents in the users collection: ```js [ @@ -135,20 +120,15 @@ Document databases also offer a radically different way of organizing the data: ] ``` +In this schema, notes would be tightly nested under users and the database would not generate ids for them. -In this schema notes would be tightly nested under users and the database would not generate ids for them. - - -The structure and schema of the database is not as self-evident as it was with relational databases. The chosen schema must be one which supports the use cases of the application the best. This is not a simple design decision to make, as all use cases of the applications are not known when the design decision is made. - - -Paradoxically, schema-less databases like Mongo require developers to make far more radical design decisions about data organization at the beginning of the project than relational databases with schemas. On average, relational databases offer a more-or-less suitable way of organizing data for many applications. +The structure and schema of the database are not as self-evident as it was with relational databases. The chosen schema must support the use cases of the application the best. This is not a simple design decision to make, as all use cases of the applications are not known when the design decision is made. +Paradoxically, schema-less databases like Mongo require developers to make far more radical design decisions about data organization at the beginning of the project than relational databases with schemas. On average, relational databases offer a more or less suitable way of organizing data for many applications. ### Mongoose schema for users - -In this case, we make the decision to store the ids of the notes created by the user in the user document. Let's define the model for representing a user in the models/user.js file: +In this case, we decide to store the ids of the notes created by the user in the user document. Let's define the model for representing a user in the models/user.js file: ```js const mongoose = require('mongoose') @@ -180,7 +160,6 @@ const User = mongoose.model('User', userSchema) module.exports = User ``` - The ids of the notes are stored within the user document as an array of Mongo ids. The definition is as follows: ```js @@ -190,11 +169,9 @@ The ids of the notes are stored within the user document as an array of Mongo id } ``` +The field type is ObjectId, meaning it refers to another document. The ref field specifies the name of the model being referenced. Mongo does not inherently know that this is a field that references notes, the syntax is purely related to and defined by Mongoose. -The type of the field is ObjectId that references note-style documents. Mongo does not inherently know that this is a field that references notes, the syntax is purely related to and defined by Mongoose. - - -Let's expand the schema of the note defined in the model/note.js file so that the note contains information about the user who created it: +Let's expand the schema of the note defined in the models/note.js file so that the note contains information about the user who created it: ```js const noteSchema = new mongoose.Schema({ @@ -203,7 +180,6 @@ const noteSchema = new mongoose.Schema({ required: true, minlength: 5 }, - date: Date, important: Boolean, // highlight-start user: { @@ -214,38 +190,36 @@ const noteSchema = new mongoose.Schema({ }) ``` - In stark contrast to the conventions of relational databases, references are now stored in both documents: the note references the user who created it, and the user has an array of references to all of the notes created by them. - ### Creating users - -Let's implement a route for creating new users. Users have a unique username, a name and something called a passwordHash. The password hash is the output of a [one-way hash function](https://en.wikipedia.org/wiki/Cryptographic_hash_function) applied to the user's password. It is never wise to store unencrypted plaintext passwords in the database! - +Let's implement a route for creating new users. Users have a unique username, a name and something called a passwordHash. The password hash is the output of a [one-way hash function](https://en.wikipedia.org/wiki/Cryptographic_hash_function) applied to the user's password. It is never wise to store unencrypted plain text passwords in the database! Let's install the [bcrypt](https://github.com/kelektiv/node.bcrypt.js) package for generating the password hashes: ```bash -npm install bcrypt --save +npm install bcrypt ``` - Creating new users happens in compliance with the RESTful conventions discussed in [part 3](/en/part3/node_js_and_express#rest), by making an HTTP POST request to the users path. - Let's define a separate router for dealing with users in a new controllers/users.js file. Let's take the router into use in our application in the app.js file, so that it handles requests made to the /api/users url: ```js -const usersRouter = require('./controllers/users') +// ... +const notesRouter = require('./controllers/notes') +const usersRouter = require('./controllers/users') // highlight-line // ... -app.use('/api/users', usersRouter) -``` +app.use('/api/notes', notesRouter) +app.use('/api/users', usersRouter) // highlight-line +// ... +``` -The contents of the file that defines the router are as follows: +The contents of the file, controllers/users.js, that defines the router is as follows: ```js const bcrypt = require('bcrypt') @@ -253,41 +227,35 @@ const usersRouter = require('express').Router() const User = require('../models/user') usersRouter.post('/', async (request, response) => { - const body = request.body + const { username, name, password } = request.body const saltRounds = 10 - const passwordHash = await bcrypt.hash(body.password, saltRounds) + const passwordHash = await bcrypt.hash(password, saltRounds) const user = new User({ - username: body.username, - name: body.name, + username, + name, passwordHash, }) const savedUser = await user.save() - response.json(savedUser) + response.status(201).json(savedUser) }) module.exports = usersRouter ``` - The password sent in the request is not stored in the database. We store the hash of the password that is generated with the _bcrypt.hash_ function. - -The fundamentals of [storing passwords](https://codahale.com/how-to-safely-store-a-password/) is outside the scope of this course material. We will not discuss what the magic number 10 assigned to the [saltRounds](https://github.com/kelektiv/node.bcrypt.js/#a-note-on-rounds) variable means, but you can read more about it in the linked material. - +The fundamentals of [storing passwords](https://codahale.com/how-to-safely-store-a-password/) are outside the scope of this course material. We will not discuss what the magic number 10 assigned to the [saltRounds](https://github.com/kelektiv/node.bcrypt.js/#a-note-on-rounds) variable means, but you can read more about it in the linked material. Our current code does not contain any error handling or input validation for verifying that the username and password are in the desired format. - The new feature can and should initially be tested manually with a tool like Postman. However testing things manually will quickly become too cumbersome, especially once we implement functionality that enforces usernames to be unique. - It takes much less effort to write automated tests, and it will make the development of our application much easier. - Our initial tests could look like this: ```js @@ -318,19 +286,18 @@ describe('when there is initially one user in db', () => { await api .post('/api/users') .send(newUser) - .expect(200) + .expect(201) .expect('Content-Type', /application\/json/) const usersAtEnd = await helper.usersInDb() - expect(usersAtEnd).toHaveLength(usersAtStart.length + 1) + assert.strictEqual(usersAtEnd.length, usersAtStart.length + 1) const usernames = usersAtEnd.map(u => u.username) - expect(usernames).toContain(newUser.username) + assert(usernames.includes(newUser.username)) }) }) ``` - The tests use the usersInDb() helper function that we implemented in the tests/test_helper.js file. The function is used to help us verify the state of the database after a user is created: ```js @@ -351,7 +318,6 @@ module.exports = { } ``` - The beforeEach block adds a user with the username root to the database. We can write a new test that verifies that a new user with the same username can not be created: ```js @@ -373,107 +339,137 @@ describe('when there is initially one user in db', () => { .expect(400) .expect('Content-Type', /application\/json/) - expect(result.body.error).toContain('`username` to be unique') - const usersAtEnd = await helper.usersInDb() - expect(usersAtEnd).toHaveLength(usersAtStart.length) + assert(result.body.error.includes('expected `username` to be unique')) + + assert.strictEqual(usersAtEnd.length, usersAtStart.length) }) }) ``` - The test case obviously will not pass at this point. We are essentially practicing [test-driven development (TDD)](https://en.wikipedia.org/wiki/Test-driven_development), where tests for new functionality are written before the functionality is implemented. - -Let's validate the uniqueness of the username with the help of Mongoose validators. As we mentioned in exercise [3.19](/en/part3/validation_and_es_lint#exercises-3-19-3-21), Mongoose does not have a built-in validator for checking the uniqueness of a field. We can find a ready-made solution for this from the [mongoose-unique-validator](https://www.npmjs.com/package/mongoose-unique-validator) npm package. Let's install it: - -```bash -npm install --save mongoose-unique-validator -``` - - -We must make the following changes to the schema defined in the models/user.js file: +Mongoose validations do not provide a direct way to check the uniqueness of a field value. However, it is possible to achieve uniqueness by defining [uniqueness index](https://mongoosejs.com/docs/schematypes.html) for a field. The definition is done as follows: ```js const mongoose = require('mongoose') -const uniqueValidator = require('mongoose-unique-validator') // highlight-line -const userSchema = new mongoose.Schema({ +const userSchema = mongoose.Schema({ + // highlight-start username: { type: String, - unique: true // highlight-line + required: true, + unique: true // this ensures the uniqueness of username }, + // highlight-end name: String, passwordHash: String, - // highlight-start notes: [ { type: mongoose.Schema.Types.ObjectId, ref: 'Note' } ], - // highlight-end }) -userSchema.plugin(uniqueValidator) // highlight-line - // ... ``` -We could also implement other validations into the user creation. We could check that the username is long enough, that the username only consists of permitted characters, or that the password is strong enough. Implementing these functionalities is left as an optional exercise. +However, we want to be careful when using the uniqueness index. If there are already documents in the database that violate the uniqueness condition, [no index will be created](https://dev.to/akshatsinghania/mongoose-unique-not-working-16bf). So when adding a uniqueness index, make sure that the database is in a healthy state! The test above added the user with username _root_ to the database twice, and these must be removed for the index to be formed and the code to work. + +Mongoose validations do not detect the index violation, and instead of _ValidationError_ they return an error of type _MongoServerError_. We therefore need to extend the error handler for that case: + +```js +const errorHandler = (error, request, response, next) => { + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } else if (error.name === 'ValidationError') { + return response.status(400).json({ error: error.message }) +// highlight-start + } else if (error.name === 'MongoServerError' && error.message.includes('E11000 duplicate key error')) { + return response.status(400).json({ error: 'expected `username` to be unique' }) + } + // highlight-end + next(error) +} +``` + +After these changes, the tests will pass. + +We could also implement other validations into the user creation. We could check that the username is long enough, that the username only consists of permitted characters, or that the password is strong enough. Implementing these functionalities is left as an optional exercise. Before we move onward, let's add an initial implementation of a route handler that returns all of the users in the database: ```js usersRouter.get('/', async (request, response) => { const users = await User.find({}) - response.json(users.map(u => u.toJSON())) + response.json(users) }) ``` -The list looks like this: +For making new users in a production or development environment, you may send a POST request to ```/api/users/``` via Postman or REST Client in the following format: -![](../../images/4/9.png) +```js +{ + "username": "root", + "name": "Superuser", + "password": "salainen" +} +``` + +The list looks like this: +![browser api/users shows JSON data with notes array](../../images/4/9.png) -You can find the code for our current application in its entirety in the part4-7 branch of [this github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-7). +You can find the code for our current application in its entirety in the part4-7 branch of [this GitHub repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-7). ### Creating a new note The code for creating a new note has to be updated so that the note is assigned to the user who created it. -Let's expand our current implementation so, that the information about the user who created a note is sent in the userId field of the request body: +Let's expand our current implementation in controllers/notes.js so that the information about the user who created a note is sent in the userId field of the request body: ```js -const User = require('../models/user') +const notesRouter = require('express').Router() +const Note = require('../models/note') +const User = require('../models/user') //highlight-line //... -notesRouter.post('/', async (request, response, next) => { +notesRouter.post('/', async (request, response) => { const body = request.body - const user = await User.findById(body.userId) //highlight-line + const user = await User.findById(body.userId)// highlight-line + + // highlight-start + if (!user) { + return response.status(400).json({ error: 'userId missing or not valid' }) + } + // highlight-end const note = new Note({ content: body.content, - important: body.important === undefined ? false : body.important, - date: new Date(), + important: body.important || false, user: user._id //highlight-line }) const savedNote = await note.save() user.notes = user.notes.concat(savedNote._id) //highlight-line await user.save() //highlight-line - - response.json(savedNote.toJSON()) + + response.status(201).json(savedNote) }) + +// ... ``` -It's worth noting that the user object also changes. The id of the note is stored in the notes field: +The database is first queried for a user using the userId provided in the request. If the user is not found, the response is sent with a status code of 400 (Bad Request) and an error message: "userId missing or not valid". + +It's worth noting that the user object also changes. The id of the note is stored in the notes field of the user object: ```js -const user = User.findById(userId) +const user = await User.findById(body.userId) // ... @@ -483,76 +479,76 @@ await user.save() Let's try to create a new note -![](../../images/4/10e.png) +![Postman creating a new note](../../images/4/10e.png) The operation appears to work. Let's add one more note and then visit the route for fetching all users: -![](../../images/4/11e.png) +![api/users returns JSON with users and their array of notes](../../images/4/11e.png) -We can see that the user has two notes. +We can see that the user has two notes. Likewise, the ids of the users who created the notes can be seen when we visit the route for fetching all notes: -![](../../images/4/12e.png) +![api/notes shows ids of users in JSON](../../images/4/12e.png) + +Due to the changes we made, the tests no longer pass, but we leave fixing the tests as an optional exercise. The changes we made have also not been accounted for in the frontend, so the note creation functionality no longer works. We will fix the frontend in part 5 of the course. ### Populate -We would like our API to work in such a way, that when an HTTP GET request is made to the /api/users route, the user objects would also contain the contents of the user's notes, and not just their id. In a relational database, this functionality would be implemented with a join query. +We would like our API to work in such a way, that when an HTTP GET request is made to the /api/users route, the user objects would also contain the contents of the user's notes and not just their id. In a relational database, this functionality would be implemented with a join query. As previously mentioned, document databases do not properly support join queries between collections, but the Mongoose library can do some of these joins for us. Mongoose accomplishes the join by doing multiple queries, which is different from join queries in relational databases which are transactional, meaning that the state of the database does not change during the time that the query is made. With join queries in Mongoose, nothing can guarantee that the state between the collections being joined is consistent, meaning that if we make a query that joins the user and notes collections, the state of the collections may change during the query. - -The Mongoose join is done with the [populate](http://mongoosejs.com/docs/populate.html) method. Let's update the route that returns all users first: +The Mongoose join is done with the [populate](http://mongoosejs.com/docs/populate.html) method. Let's update the route that returns all users first in controllers/users.js file: ```js usersRouter.get('/', async (request, response) => { const users = await User // highlight-line .find({}).populate('notes') // highlight-line - response.json(users.map(u => u.toJSON())) + response.json(users) }) ``` - -The [populate](http://mongoosejs.com/docs/populate.html) method is chained after the find method making the initial query. The parameter given to the populate method defines that the ids referencing note objects in the notes field of the user document will be replaced by the referenced note documents. +The [populate](http://mongoosejs.com/docs/populate.html) method is chained after the find method making the initial query. The argument given to the populate method defines that the ids referencing note objects in the notes field of the user document will be replaced by the referenced note documents. Mongoose first queries the users collection for the list of users, and then queries the collection corresponding to the model object specified by the ref property in the users schema for data with the given object id. The result is almost exactly what we wanted: -![](../../images/4/13ea.png) +![JSON data showing populated notes and users data with repetition](../../images/4/13new.png) -We can use the populate parameter for choosing the fields we want to include from the documents. The selection of fields is done with the Mongo [syntax](https://docs.mongodb.com/manual/tutorial/project-fields-from-query-results/#return-the-specified-fields-and-the-id-field-only): +We can use the populate method for choosing the fields we want to include from the documents. In addition to the field id we are now only interested in content and important. + +The selection of fields is done with the Mongo [syntax](https://www.mongodb.com/docs/manual/tutorial/project-fields-from-query-results/#return-the-specified-fields-and-the-_id-field-only): ```js usersRouter.get('/', async (request, response) => { const users = await User - .find({}).populate('notes', { content: 1, date: 1 }) + .find({}).populate('notes', { content: 1, important: 1 }) - response.json(users.map(u => u.toJSON())) -}); + response.json(users) +}) ``` The result is now exactly like we want it to be: -![](../../images/4/14ea.png) +![combined data showing no repetition](../../images/4/14new.png) -Let's also add a suitable population of user information to notes: +Let's also add a suitable population of user information to notes in the controllers/notes.js file: ```js notesRouter.get('/', async (request, response) => { const notes = await Note .find({}).populate('user', { username: 1, name: 1 }) - response.json(notes.map(note => note.toJSON())) -}); + response.json(notes) +}) ``` - Now the user's information is added to the user field of note objects. -![](../../images/4/15ea.png) - +![notes JSON now has user info embedded too](../../images/4/15new.png) -It's important to understand that the database does not actually know that the ids stored in the user field of notes reference documents in the user collection. +It's important to understand that the database does not know that the ids stored in the user field of the notes collection reference documents in the user collection. The functionality of the populate method of Mongoose is based on the fact that we have defined "types" to the references in the Mongoose schema with the ref option: @@ -563,7 +559,6 @@ const noteSchema = new mongoose.Schema({ required: true, minlength: 5 }, - date: Date, important: Boolean, user: { type: mongoose.Schema.Types.ObjectId, @@ -572,6 +567,6 @@ const noteSchema = new mongoose.Schema({ }) ``` -You can find the code for our current application in its entirety in the part4-8 branch of [this github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-8). +You can find the code for our current application in its entirety in the part4-8 branch of [this GitHub repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-8).
    diff --git a/src/content/4/en/part4d.md b/src/content/4/en/part4d.md index 3b6cae48779..08642b3abf7 100644 --- a/src/content/4/en/part4d.md +++ b/src/content/4/en/part4d.md @@ -7,32 +7,31 @@ lang: en
    -Users must be able to log into our application, and when a user is logged in, their user information must automatically be attached to any new notes they create. +Users must be able to log into our application, and when a user is logged in, their user information must automatically be attached to any new notes they create. -We will now implement support for [token based authentication](https://scotch.io/tutorials/the-ins-and-outs-of-token-based-authentication#toc-how-token-based-works) to the backend. +We will now implement support for [token-based authentication](https://www.digitalocean.com/community/tutorials/the-ins-and-outs-of-token-based-authentication#how-token-based-works) to the backend. -The principles of token based authentication are depicted in the following sequence diagram: +The principles of token-based authentication are depicted in the following sequence diagram: -![](../../images/4/16e.png) +![sequence diagram of token-based authentication](../../images/4/16new.png) -- User starts by logging in using a login form implemented with React - - We will add the login form to the frontend in [part 5](/en/part5) -- This causes the React code to send the username and the password to the server address /api/login as a HTTP POST request. -- If the username and the password are correct, the server generates a token which somehow identifies the logged in user. +- User starts by logging in using a login form implemented with React + - We will add the login form to the frontend in [part 5](/en/part5) +- This causes the React code to send the username and the password to the server address /api/login as an HTTP POST request. +- If the username and the password are correct, the server generates a token that somehow identifies the logged-in user. - The token is signed digitally, making it impossible to falsify (with cryptographic means) -- The backend responds with a status code indicating the operation was successful, and returns the token with the response. -- The browser saves the token, for example to the state of a React application. +- The backend responds with a status code indicating the operation was successful and returns the token with the response. +- The browser saves the token, for example to the state of a React application. - When the user creates a new note (or does some other operation requiring identification), the React code sends the token to the server with the request. - The server uses the token to identify the user Let's first implement the functionality for logging in. Install the [jsonwebtoken](https://github.com/auth0/node-jsonwebtoken) library, which allows us to generate [JSON web tokens](https://jwt.io/). ```bash -npm install jsonwebtoken --save +npm install jsonwebtoken ``` -The code for login functionality goes to the file controllers/login.js. - +The code for login functionality goes to the file controllers/login.js. ```js const jwt = require('jsonwebtoken') @@ -41,12 +40,12 @@ const loginRouter = require('express').Router() const User = require('../models/user') loginRouter.post('/', async (request, response) => { - const body = request.body + const { username, password } = request.body - const user = await User.findOne({ username: body.username }) + const user = await User.findOne({ username }) const passwordCorrect = user === null ? false - : await bcrypt.compare(body.password, user.passwordHash) + : await bcrypt.compare(password, user.passwordHash) if (!(user && passwordCorrect)) { return response.status(401).json({ @@ -69,17 +68,37 @@ loginRouter.post('/', async (request, response) => { module.exports = loginRouter ``` -The code starts by searching for the user from the database by the username attached to the request. -Next, it checks the password, also attached to the request. -Because the passwords themselves are not saved to the database, but hashes calculated from the passwords, the _bcrypt.compare_ method is used to check if the password is correct: +The code starts by searching for the user from the database by the username attached to the request. ```js -await bcrypt.compare(body.password, user.passwordHash) +const user = await User.findOne({ username }) ``` -If the user is not found, or the password is incorrect, the request is responded to with the status code [401 unauthorized](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.2). The reason for the failure is explained in the response body. +Next, it checks the password, also attached to the request. + +```js +const passwordCorrect = user === null + ? false + : await bcrypt.compare(password, user.passwordHash) +``` + +Because the passwords themselves are not saved to the database, but hashes calculated from the passwords, the _bcrypt.compare_ method is used to check if the password is correct: + +```js +await bcrypt.compare(password, user.passwordHash) +``` -If the password is correct, a token is created with the method _jwt.sign_. The token contains the username and the user id in a digitally signed form. +If the user is not found, or the password is incorrect, the request is responded with the status code [401 unauthorized](https://www.rfc-editor.org/rfc/rfc9110.html#name-401-unauthorized). The reason for the failure is explained in the response body. + +```js +if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) +} +``` + +If the password is correct, a token is created with the method _jwt.sign_. The token contains the username and the user id in a digitally signed form. ```js const userForToken = { @@ -91,12 +110,18 @@ const token = jwt.sign(userForToken, process.env.SECRET) ``` The token has been digitally signed using a string from the environment variable SECRET as the secret. -The digital signature ensures that only parties who know the secret can generate a valid token. -The value for the environment variable must be set in the .env file. +The digital signature ensures that only parties who know the secret can generate a valid token. +The value for the environment variable must be set in the .env file. -A successful request is responded to with the status code 200 OK. The generated token and the username of the user are sent back in the response body. +A successful request is responded to with the status code 200 OK. The generated token and the username of the user are sent back in the response body. -Now the code for login just has to be added to the application by adding the new router to app.js. +```js +response + .status(200) + .send({ token, username: user.username, name: user.name }) +``` + +Now the code for login just has to be added to the application by adding the new router to app.js. ```js const loginRouter = require('./controllers/login') @@ -106,11 +131,11 @@ const loginRouter = require('./controllers/login') app.use('/api/login', loginRouter) ``` -Let's try logging in using VS Code REST-client: +Let's try logging in using VS Code REST-client: -![](../../images/4/17e.png) +![vscode rest post with username/password](../../images/4/17e.png) -It does not work. The following is printed to console: +It does not work. The following is printed to the console: ```bash (node:32911) UnhandledPromiseRejectionWarning: Error: secretOrPrivateKey must have a value @@ -119,33 +144,32 @@ It does not work. The following is printed to console: (node:32911) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 2) ``` -The command _jwt.sign(userForToken, process.env.SECRET)_ fails. We forgot to set a value to the environment variable SECRET. It can be any string. When we set the value in file .env, the login works. +The command _jwt.sign(userForToken, process.env.SECRET)_ fails. We forgot to set a value to the environment variable SECRET. It can be any string. When we set the value in file .env (and restart the server), the login works. -A successful login returns the user details and the token: +A successful login returns the user details and the token: -![](../../images/4/18ea.png) +![vs code rest response showing details and token](../../images/4/18ea.png) A wrong username or password returns an error message and the proper status code: -![](../../images/4/19ea.png) +![vs code rest response for incorrect login details](../../images/4/19ea.png) -### Limiting creating new notes to logged in users +### Limiting creating new notes to logged-in users -Let's change creating new notes so that it is only possible if the post request has a valid token attached. -The note is then saved to the notes list of the user identified by the token. +Let's change creating new notes so that it is only possible if the post request has a valid token attached. The note is then saved to the notes list of the user identified by the token. -There are several ways of sending the token from the browser to the server. We will use the [Authorization](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) header. The header also tells which [authentication schema](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#Authentication_schemes) is used. This can be necessary if the server offers multiple ways to authenticate. -Identifying the schema tells the server how the attached credentials should be interpreted. +There are several ways of sending the token from the browser to the server. We will use the [Authorization](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) header. The header also tells which [authentication scheme](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#Authentication_schemes) is used. This can be necessary if the server offers multiple ways to authenticate. +Identifying the scheme tells the server how the attached credentials should be interpreted. -The Bearer schema is suitable to our needs. +The Bearer scheme is suitable for our needs. -In practice, this means that if the token is for example, the string eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW, the Authorization header will have the value: +In practice, this means that if the token is, for example, the string eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW, the Authorization header will have the value: -
    +```
     Bearer eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW
    -
    +``` -Creating new notes will change like so: +Creating new notes will change like so (controllers/notes.js): ```js const jwt = require('jsonwebtoken') //highlight-line @@ -154,8 +178,8 @@ const jwt = require('jsonwebtoken') //highlight-line //highlight-start const getTokenFrom = request => { const authorization = request.get('authorization') - if (authorization && authorization.toLowerCase().startsWith('bearer ')) { - return authorization.substring(7) + if (authorization && authorization.startsWith('Bearer ')) { + return authorization.replace('Bearer ', '') } return null } @@ -164,20 +188,21 @@ const getTokenFrom = request => { notesRouter.post('/', async (request, response) => { const body = request.body //highlight-start - const token = getTokenFrom(request) - - const decodedToken = jwt.verify(token, process.env.SECRET) - if (!token || !decodedToken.id) { - return response.status(401).json({ error: 'token missing or invalid' }) + const decodedToken = jwt.verify(getTokenFrom(request), process.env.SECRET) + if (!decodedToken.id) { + return response.status(401).json({ error: 'token invalid' }) } const user = await User.findById(decodedToken.id) //highlight-end + if (!user) { + return response.status(400).json({ error: 'UserId missing or not valid' }) + } + const note = new Note({ content: body.content, - important: body.important === undefined ? false : body.important, - date: new Date(), + important: body.important || false, user: user._id }) @@ -185,89 +210,148 @@ notesRouter.post('/', async (request, response) => { user.notes = user.notes.concat(savedNote._id) await user.save() - response.json(savedNote.toJSON()) + response.status(201).json(savedNote) }) ``` -The helper function _getTokenFrom_ isolates the token from the authorization header. The validity of the token is checked with _jwt.verify_. The method also decodes the token, or returns the Object which the token was based on: +The helper function _getTokenFrom_ isolates the token from the authorization header. The validity of the token is checked with _jwt.verify_. The method also decodes the token, or returns the Object which the token was based on. ```js const decodedToken = jwt.verify(token, process.env.SECRET) ``` -The object decoded from the token contains the username and id fields, which tells the server who made the request. +If the token is missing or it is invalid, the exception JsonWebTokenError is raised. We need to extend the error handling middleware to take care of this particular case: -If there is no token, or the object decoded from the token does not contain the users identity (_decodedToken.id_ is undefined), error status code [401 unauthorized](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.2) is returned and the reason for the failure is explained in the response body. +```js +const errorHandler = (error, request, response, next) => { + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } else if (error.name === 'ValidationError') { + return response.status(400).json({ error: error.message }) + } else if (error.name === 'MongoServerError' && error.message.includes('E11000 duplicate key error')) { + return response.status(400).json({ error: 'expected `username` to be unique' }) + } else if (error.name === 'JsonWebTokenError') { // highlight-line + return response.status(401).json({ error: 'token invalid' }) // highlight-line + } + + next(error) +} +``` + +The object decoded from the token contains the username and id fields, which tell the server who made the request. + +If the object decoded from the token does not contain the user's identity (_decodedToken.id_ is undefined), error status code [401 unauthorized](https://www.rfc-editor.org/rfc/rfc9110.html#name-401-unauthorized) is returned and the reason for the failure is explained in the response body. ```js -if (!token || !decodedToken.id) { +if (!decodedToken.id) { return response.status(401).json({ - error: 'token missing or invalid' + error: 'token invalid' }) } ``` -When the identity of the maker of the request is resolved, the execution continues as before. +When the identity of the maker of the request is resolved, the execution continues as before. -A new note can now be created using Postman if the authorization header is given the correct value, the string bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ, where the second value is the token returned by the login operation. +A new note can now be created using Postman if the authorization header is given the correct value, the string Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ, where the second value is the token returned by the login operation. -Using Postman this looks as follows: +Using Postman this looks as follows: -![](../../images/4/20e.png) +![postman adding bearer token](../../images/4/20new.png) and with Visual Studio Code REST client -![](../../images/4/21e.png) +![vscode adding bearer token example](../../images/4/21new.png) -### Error handling +Current application code can be found on [GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-9), branch part4-9. -Token verification can also cause a JsonWebTokenError. If we for example remove a few characters from the token and try creating a new note, this happens: +If the application has multiple interfaces requiring identification, JWT's validation should be separated into its own middleware. An existing library like [express-jwt](https://www.npmjs.com/package/express-jwt) could also be used. -```bash -JsonWebTokenError: invalid signature - at /Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:126:19 - at getSecret (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:80:14) - at Object.module.exports [as verify] (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:84:10) - at notesRouter.post (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/controllers/notes.js:40:30) -``` +### Problems of Token-based authentication -There are many possible reasons for a decoding error. The token can be faulty (like in our example), falsified, or expired. Let's extend our errorHandler middleware to take into account the different decoding errors. +Token authentication is pretty easy to implement, but it contains one problem. Once the API user, eg. a React app gets a token, the API has a blind trust to the token holder. What if the access rights of the token holder should be revoked? + +There are two solutions to the problem. The easier one is to limit the validity period of a token: ```js -const unknownEndpoint = (request, response) => { - response.status(404).send({ error: 'unknown endpoint' }) -} +loginRouter.post('/', async (request, response) => { + const { username, password } = request.body + + const user = await User.findOne({ username }) + const passwordCorrect = user === null + ? false + : await bcrypt.compare(password, user.passwordHash) + + if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) + } + + const userForToken = { + username: user.username, + id: user._id, + } + + // token expires in 60*60 seconds, that is, in one hour + // highlight-start + const token = jwt.sign( + userForToken, + process.env.SECRET, + { expiresIn: 60*60 } + ) + // highlight-end + + response + .status(200) + .send({ token, username: user.username, name: user.name }) +}) +``` + +Once the token expires, the client app needs to get a new token. Usually, this happens by forcing the user to re-login to the app. +The error handling middleware should be extended to give a proper error in the case of an expired token: + +```js const errorHandler = (error, request, response, next) => { + logger.error(error.message) + if (error.name === 'CastError') { - return response.status(400).send({ - error: 'malformatted id' - }) + return response.status(400).send({ error: 'malformatted id' }) } else if (error.name === 'ValidationError') { + return response.status(400).json({ error: error.message }) + } else if (error.name === 'MongoServerError' && error.message.includes('E11000 duplicate key error')) { return response.status(400).json({ - error: error.message + error: 'expected `username` to be unique' + }) + } else if (error.name === 'JsonWebTokenError') { + return response.status(401).json({ + error: 'invalid token' + }) + // highlight-start + } else if (error.name === 'TokenExpiredError') { + return response.status(401).json({ + error: 'token expired' }) - } else if (error.name === 'JsonWebTokenError') { // highlight-line - return response.status(401).json({ // highlight-line - error: 'invalid token' // highlight-line - }) // highlight-line } - - logger.error(error.message) + // highlight-end next(error) } ``` -Current application code can be found on [Github](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-9), branch part4-9. +The shorter the expiration time, the safer the solution is. If the token falls into the wrong hands or user access to the system needs to be revoked, the token is only usable for a limited amount of time. However, a short expiration time is a potential pain point for the user, as it requires them to log in more frequently. + +The other solution is to save info about each token to the backend database and to check for each API request if the access rights corresponding to the tokens are still valid. With this scheme, access rights can be revoked at any time. This kind of solution is often called a server-side session. -If the application has multiple interfaces requiring identification, JWT's validation should be separated into its own middleware. Some existing library like [express-jwt](https://www.npmjs.com/package/express-jwt) could also be used. +The negative aspect of server-side sessions is the increased complexity in the backend and also the effect on performance since the token validity needs to be checked for each API request to the database. Database access is considerably slower compared to checking the validity of the token itself. That is why it is quite common to save the session corresponding to a token to a key-value database such as [Redis](https://redis.io/), that is limited in functionality compared to eg. MongoDB or a relational database, but extremely fast in some usage scenarios. + +When server-side sessions are used, the token is quite often just a random string, that does not include any information about the user as it is quite often the case when jwt-tokens are used. For each API request, the server fetches the relevant information about the identity of the user from the database. It is also quite usual that instead of using Authorization-header, cookies are used as the mechanism for transferring the token between the client and the server. ### End notes -There have been many changes to the code which have caused a typical problem for a fast-paced software project: most of the tests have broken. Because this part of the course is already jammed with new information, we will leave fixing the tests to a non compulsory exercise. +There have been many changes to the code which have caused a typical problem for a fast-paced software project: most of the tests have broken. Because this part of the course is already jammed with new information, we will leave fixing the tests to a non-compulsory exercise. -Usernames, passwords and applications using token authentication must always be used over [HTTPS](https://en.wikipedia.org/wiki/HTTPS). We could use a Node [HTTPS](https://nodejs.org/api/https.html) server in our application instead of the [HTTP](https://nodejs.org/docs/latest-v8.x/api/http.html) server (it requires more configuration). On the other hand, the production version of our application is in Heroku, so our applications stays secure: Heroku routes all traffic between a browser and the Heroku server over HTTPS. +Usernames, passwords and applications using token authentication must always be used over [HTTPS](https://en.wikipedia.org/wiki/HTTPS). We could use a Node [HTTPS](https://nodejs.org/docs/latest-v18.x/api/https.html) server in our application instead of the [HTTP](https://nodejs.org/docs/latest-v18.x/api/http.html) server (it requires more configuration). On the other hand, the production version of our application is in Fly.io, so our application stays secure: Fly.io routes all traffic between a browser and the Fly.io server over HTTPS. We will implement login to the frontend in the [next part](/en/part5). @@ -275,71 +359,72 @@ We will implement login to the frontend in the [next part](/en/part5).
    -### Exercises 4.15.-4.22. +### Exercises 4.15.-4.23. -In the next exercises, basics of user management will be implemented for the Bloglist application. The safest way is to follow the story from part 4 chapter [User administration](/en/part4/user_administration) to the chapter [Token-based authentication](/en/part4/token_authentication). You can of course also use your creativity. +In the next exercises, the basics of user management will be implemented for the Bloglist application. The safest way is to follow the course material from part 4 chapter [User administration](/en/part4/user_administration) to the chapter [Token authentication](/en/part4/token_authentication). You can of course also use your creativity. -**One more warning:** If you notice you are mixing async/await and _then_ calls, it is 99% certain you are doing something wrong. Use either or, never both. +**One more warning:** If you notice you are mixing async/await and _then_ calls, it is 99% certain you are doing something wrong. Use either or, never both. -#### 4.15: bloglist expansion, step3 +#### 4.15: Blog List Expansion, step 3 -Implement a way to create new users by doing a HTTP POST-request to address api/users. Users have username -, password and name. +Implement a way to create new users by doing an HTTP POST request to address api/users. Users have a username, password and name. -Do not save passwords to the database as clear text, but use the bcrypt library like we did in part 4 chapter [Creating new users](/en/part4/user_administration#creating-users). +Do not save passwords to the database as clear text, but use the bcrypt library like we did in part 4 chapter [Creating users](/en/part4/user_administration#creating-users). -**NB** Some Windows users have had problems with bcrypt. If you run into problems, remove the library with command +**NB** Some Windows users have had problems with bcrypt. If you run into problems, remove the library with command ```bash -npm uninstall bcrypt --save +npm uninstall bcrypt ``` -and install [bcryptjs](https://www.npmjs.com/package/bcryptjs) instead. +and install [bcryptjs](https://www.npmjs.com/package/bcryptjs) instead. + +Implement a way to see the details of all users by doing a suitable HTTP request. -Implement a way to see the details of all users by doing a suitable HTTP request. +The list of users can, for example, look as follows: -List of users can for example, look as follows: +![browser api/users shows JSON data of two users](../../images/4/22.png) -![](../../images/4/22.png) +#### 4.16*: Blog List Expansion, step 4 -#### 4.16*: bloglist expansion, step4 +Add a feature which adds the following restrictions to creating new users: Both username and password must be given and both must be at least 3 characters long. The username must be unique. -Add a feature which adds the following restrictions to creating new users: Both username and password must be given. Both username and password must be at least 3 characters long. The username must be unique. +The operation must respond with a suitable status code and some kind of an error message if an invalid user is created. -The operation must respond with a suitable status code and some kind of an error message if invalid user is created. +**NB** Do not test password restrictions with Mongoose validations. It is not a good idea because the password received by the backend and the password hash saved to the database are not the same thing. The password length should be validated in the controller as we did in [part 3](/en/part3/validation_and_es_lint) before using Mongoose validation. -**NB** Do not test password restrictions with Mongoose validations. It is not a good idea because the password received by the backend and the password hash saved to the database are not the same thing. The password length should be validated in the controller like we did in [part 3](/en/part3/validation_and_es_lint) before using Mongoose validation. +Also, **implement tests** that ensure invalid users are not created and that an invalid add user operation returns a suitable status code and error message. -Also, implement tests which check that invalid users are not created and invalid add user operation returns a suitable status code and error message. +**NB** if you decide to define tests on multiple files, you should note that by default each test file is executed in its own process (see _Test execution model_ in the [documentation](https://nodejs.org/api/test.html#test-runner-execution-model)). The consequence of this is that different test files are executed at the same time. Since the tests share the same database, simultaneous execution may cause problems, which can be avoided by executing the tests with the option _--test-concurrency=1_, i.e. defining them to be executed sequentially. -#### 4.17: bloglist expansion, step5 +#### 4.17: Blog List Expansion, step 5 -Expand blogs so that each blog contains information on the creator of the blog. +Expand blogs so that each blog contains information on the creator of the blog. -Modify adding new blogs so that when a new blog is created, any user from the database is designated as its creator (for example the one found first). Implement this according to part 4 chapter [populate](/en/part4/user_administration#populate). -Which user is designated as the creator does not matter just yet. The functionality is finished in exercise 4.19. +Modify adding new blogs so that when a new blog is created, any user from the database is designated as its creator (for example the one found first). Implement this according to part 4 chapter [populate](/en/part4/user_administration#populate). +Which user is designated as the creator does not matter just yet. The functionality is finished in exercise 4.19. -Modify listing all blogs so that the creator's user information is displayed with the blog: +Modify listing all blogs so that the creator's user information is displayed with the blog: -![](../../images/4/23e.png) +![api/blogs embeds creators user information in JSON data](../../images/4/23e.png) -and listing all users also displays the blogs created by each user: +and listing all users also displays the blogs created by each user: -![](../../images/4/24e.png) +![api/users embeds blogs in JSON data](../../images/4/24e.png) -#### 4.18: bloglist expansion, step6 +#### 4.18: Blog List Expansion, step 6 Implement token-based authentication according to part 4 chapter [Token authentication](/en/part4/token_authentication). -#### 4.19: bloglist expansion, step7 +#### 4.19: Blog List Expansion, step 7 -Modify adding new blogs so that it is only possible if a valid token is sent with the HTTP POST request. The user identified by the token is designated as the creator of the blog. +Modify adding new blogs so that it is only possible if a valid token is sent with the HTTP POST request. The user identified by the token is designated as the creator of the blog. -#### 4.20*: bloglist expansion, step8 +#### 4.20*: Blog List Expansion, step 8 -[This example](/en/part4/token_authentication) from part 4 shows taking the token from the header with the _getTokenFrom_ helper function. +[This example](/en/part4/token_authentication#limiting-creating-new-notes-to-logged-in-users) from part 4 shows taking the token from the header with the _getTokenFrom_ helper function in controllers/blogs.js. -If you used the same solution, refactor taking the token to a [middleware](/en/part3/node_js_and_express#middleware). The middleware should take the token from the Authorization header and place it to the token field of the request object. +If you used the same solution, refactor taking the token to a [middleware](/en/part3/node_js_and_express#middleware). The middleware should take the token from the Authorization header and assign it to the token field of the request object. In other words, if you register this middleware in the app.js file before all routes @@ -347,7 +432,8 @@ In other words, if you register this middleware in the app.js file before app.use(middleware.tokenExtractor) ``` -routes can access the token with _request.token_: +Routes can access the token with _request.token_: + ```js blogsRouter.post('/', async (request, response) => { // .. @@ -356,7 +442,7 @@ blogsRouter.post('/', async (request, response) => { }) ``` -Remember that a normal [middleware](/en/part3/node_js_and_express#middleware) is a function with three parameters, that at the end calls the last parameter next in order to move the control to next middleware: +Remember that a normal [middleware function](/en/part3/node_js_and_express#middleware) is a function with three parameters, that at the end calls the last parameter next to move the control to the next middleware: ```js const tokenExtractor = (request, response, next) => { @@ -366,11 +452,11 @@ const tokenExtractor = (request, response, next) => { } ``` -#### 4.21*: bloglist expansion, step9 +#### 4.21*: Blog List Expansion, step 9 -Change the delete blog operation so that a blog can be deleted only by the user who added the blog. Therefore, deleting a blog is possible only if the token sent with the request is the same as that of the blog's creator. +Change the delete blog operation so that a blog can be deleted only by the user who added it. Therefore, deleting a blog is possible only if the token sent with the request is the same as that of the blog's creator. -If deleting a blog is attempted without a token or by a wrong user, the operation should return a suitable status code. +If deleting a blog is attempted without a token or by an invalid user, the operation should return a suitable status code. Note that if you fetch a blog from the database, @@ -378,47 +464,68 @@ Note that if you fetch a blog from the database, const blog = await Blog.findById(...) ``` -the field blog.user does not contain a string, but an Object. So if you want to compare the id of the object fetched from the database and a string id, normal comparison operation does not work. The id fetched from the database must be parsed into a string first. +the field blog.user does not contain a string, but an object. So if you want to compare the ID of the object fetched from the database and a string ID, a normal comparison operation does not work. The ID fetched from the database must be parsed into a string first. ```js if ( blog.user.toString() === userid.toString() ) ... ``` -#### 4.22*: bloglist expansion, step10 +#### 4.22*: Blog List Expansion, step 10 -After adding token based authentication the tests for adding a new blog broke down. Fix now the tests. Write also a new test that ensures that adding a blog fails with proper status code 401 Unauthorized if token is not provided. +Both the new blog creation and blog deletion need to find out the identity of the user who is doing the operation. The middleware _tokenExtractor_ that we did in exercise 4.20 helps but still both the handlers of post and delete operations need to find out who the user holding a specific token is. + +Now create a new middleware called userExtractor that identifies the user related to the request and attaches it to the request object. After registering the middleware, the post and delete handlers should be able to access the user directly by referencing request.user: + +```js +blogsRouter.post('/', userExtractor, async (request, response) => { + // get user from request object + const user = request.user + // .. +}) + +blogsRouter.delete('/:id', userExtractor, async (request, response) => { + // get user from request object + const user = request.user + // .. +}) +``` + +Note that in this case, the userExtractor middleware has been registered with individual routes, so it is only executed in certain cases. So instead of using _userExtractor_ with all the routes, + +```js +// use the middleware in all routes +app.use(middleware.userExtractor) // highlight-line + +app.use('/api/blogs', blogsRouter) +app.use('/api/users', usersRouter) +app.use('/api/login', loginRouter) +``` + +we could register it to be only executed with path /api/blogs routes: + +```js +// use the middleware only in /api/blogs routes +app.use('/api/blogs', middleware.userExtractor, blogsRouter) // highlight-line +app.use('/api/users', usersRouter) +app.use('/api/login', loginRouter) +``` + +This is done by chaining multiple middleware functions as parameters to the use function. In the same way, middleware can also be registered only for individual routes: + +```js +router.post('/', userExtractor, async (request, response) => { + // ... +}) +``` + +Make sure that fetching all blogs with a GET request still works without a token. + +#### 4.23*: Blog List Expansion, step 11 + +After adding token-based authentication the tests for adding a new blog broke down. Fix them. Also, write a new test to ensure adding a blog fails with the proper status code 401 Unauthorized if a token is not provided. [This](https://github.com/visionmedia/supertest/issues/398) is most likely useful when doing the fix. This is the last exercise for this part of the course and it's time to push your code to GitHub and mark all of your finished exercises to the [exercise submission system](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). - -
    diff --git a/src/content/4/es/part4.md b/src/content/4/es/part4.md new file mode 100644 index 00000000000..9b1ac696fb7 --- /dev/null +++ b/src/content/4/es/part4.md @@ -0,0 +1,14 @@ +--- +mainImage: ../../../images/part-4.svg +part: 4 +lang: es +--- + +
    + +En esta parte, continuaremos nuestro trabajo en el backend. Nuestro primer tema principal será escribir pruebas de unidad e integración para el backend. Una vez que hayamos cubierto las pruebas, analizaremos la implementación de la autenticación y autorización de usuario. + +Parte actualizada el 13 de Febrero de 2024 +- Jest reemplazado con el test runner integrado de Node + +
    diff --git a/src/content/4/es/part4a.md b/src/content/4/es/part4a.md new file mode 100644 index 00000000000..6e4ed9e1794 --- /dev/null +++ b/src/content/4/es/part4a.md @@ -0,0 +1,789 @@ +--- +mainImage: ../../../images/part-4.svg +part: 4 +letter: a +lang: es +--- + +
    + +Continuemos nuestro trabajo en el backend de la aplicación de notas que comenzamos en la [parte 3](/es/part3). + +### Estructura del proyecto + +**Nota**: el material de este curso fue escrito con la version v20.11.0. Por favor asegúrate de que tu version de Node es al menos tan nueva como la version utilizada en el material (puedes chequear la version al ejecutar _node -v_ en la linea de comandos). + +Antes de pasar al tema de las pruebas, modificaremos la estructura de nuestro proyecto para cumplir con las mejores prácticas de Node.js. + +Después de realizar los cambios que explicaremos a continuación, terminaremos con la siguiente estructura: + +```bash +├── index.js +├── app.js +├── dist +│ └── ... +├── controllers +│ └── notes.js +├── models +│ └── note.js +├── package-lock.json +├── package.json +├── utils +│ ├── config.js +│ ├── logger.js +│ └── middleware.js +``` + +Hasta ahora hemos estado usando console.log y console.error para imprimir diferente información del código. +Sin embargo, esta no es una buena forma de hacer las cosas. +Separemos todas las impresiones a la consola en su propio módulo utils/logger.js: + +```js +const info = (...params) => { + console.log(...params) +} + +const error = (...params) => { + console.error(...params) +} + +module.exports = { + info, error +} +``` + +El logger tiene dos funciones, __info__ para imprimir mensajes de registro normales y __error__ para todos los mensajes de error. + +Extraer registros en su propio módulo es una buena idea por varios motivos. Si quisiéramos comenzar a escribir registros en un archivo o enviarlos a un servicio de registro externo como [graylog](https://www.graylog.org/) o [papertrail](https://papertrailapp.com) solo tendríamos que hacer cambios en un solo lugar. + +El manejo de las variables de entorno se extrae a un archivo utils/config.js separado: + +```js +require('dotenv').config() + +const PORT = process.env.PORT +const MONGODB_URI = process.env.MONGODB_URI + +module.exports = { + MONGODB_URI, + PORT +} +``` + +Las otras partes de la aplicación pueden acceder a las variables de entorno importando el módulo de configuración: + +```js +const config = require('./utils/config') + +logger.info(`Server running on port ${config.PORT}`) +``` + +El contenido del archivo index.js utilizado para iniciar la aplicación se simplifica de la siguiente manera: + +```js +const app = require('./app') // la aplicación Express real +const config = require('./utils/config') +const logger = require('./utils/logger') + +app.listen(config.PORT, () => { + logger.info(`Server running on port ${config.PORT}`) +}) +``` + +El archivo index.js solo importa la aplicación real desde el archivo app.js y luego inicia la aplicación. La función _info_ del módulo de registro se utiliza para la impresión de la consola que indica que la aplicación se está ejecutando. + +Ahora, la aplicación Express y el código que se encarga del servidor web están separados siguiendo las [mejores](https://dev.to/nermineslimane/always-separate-app-and-server-files--1nc7) prácticas. Una de las ventajas de este método es que ahora la aplicación se puede probar a nivel de llamadas a la API HTTP sin realizar llamadas a través de HTTP por la red, lo que hace que la ejecución de las pruebas sea más rápida. + +Los controladores de ruta también se han movido a un módulo dedicado. Los controladores de eventos de las rutas se conocen comúnmente como controladores, y por esta razón hemos creado un nuevo directorio de controllers. Todas las rutas relacionadas con las notas están ahora en el módulo notes.js bajo el directorio controllers. + +El contenido del módulo notes.js es el siguiente: + +```js +const notesRouter = require('express').Router() +const Note = require('../models/note') + +notesRouter.get('/', (request, response) => { + Note.find({}).then(notes => { + response.json(notes) + }) +}) + +notesRouter.get('/:id', (request, response, next) => { + Note.findById(request.params.id) + .then(note => { + if (note) { + response.json(note) + } else { + response.status(404).end() + } + }) + .catch(error => next(error)) +}) + +notesRouter.post('/', (request, response, next) => { + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + + note.save() + .then(savedNote => { + response.json(savedNote) + }) + .catch(error => next(error)) +}) + +notesRouter.delete('/:id', (request, response, next) => { + Note.findByIdAndDelete(request.params.id) + .then(() => { + response.status(204).end() + }) + .catch(error => next(error)) +}) + +notesRouter.put('/:id', (request, response, next) => { + const body = request.body + + const note = { + content: body.content, + important: body.important, + } + + Note.findByIdAndUpdate(request.params.id, note, { new: true }) + .then(updatedNote => { + response.json(updatedNote) + }) + .catch(error => next(error)) +}) + +module.exports = notesRouter +``` + +Esto es casi una copia exacta de nuestro archivo index.js anterior. + +Sin embargo, hay algunos cambios importantes. Al principio del archivo, creamos un nuevo objeto [router](http://expressjs.com/en/api.html#router): + +```js +const notesRouter = require('express').Router() + +//... + +module.exports = notesRouter +``` + +El módulo exporta el enrutador para que esté disponible para todos los consumidores del módulo. + +Todas las rutas están ahora definidas para el objeto enrutador, de manera similar a lo que habíamos hecho anteriormente con el objeto que representa la aplicación completa. + +Vale la pena señalar que las rutas en los controladores de ruta se han acortado. En la versión anterior teníamos: + +```js +app.delete('/api/notes/:id', (request, response, next) => { +``` + +Y en la versión actual, tenemos: + +```js +notesRouter.delete('/:id', (request, response, next) => { +``` + +Entonces, ¿qué son exactamente estos objetos de enrutador? El manual de Express proporciona la siguiente explicación: + +> Un objeto de enrutador es un instancia aislada de middleware y rutas. Puedes pensar en ella como una "mini-aplicación", capaz solo de realizar funciones de middleware y enrutamiento. Cada aplicación Express tiene un enrutador de aplicación incorporado. + +El enrutador es de hecho un middleware, que se puede utilizar para definir "rutas relacionadas" en un solo lugar, que normalmente se coloca en su propio módulo. + +El archivo app.js que crea la aplicación real , toma el enrutador como se muestra a continuación: + +```js +const notesRouter = require('./controllers/notes') +app.use('/api/notes', notesRouter) +``` + +El enrutador que definimos anteriormente se usa si la URL de la solicitud comienza con /api/notes. Por esta razón, el objeto notesRouter solo debe definir las partes relativas de las rutas, es decir, la ruta vacía / o solo el parámetro /:id. + +Después de realizar estos cambios, nuestro archivo app.js se ve así: + +```js +const config = require('./utils/config') +const express = require('express') +const app = express() +const cors = require('cors') +const notesRouter = require('./controllers/notes') +const middleware = require('./utils/middleware') +const logger = require('./utils/logger') +const mongoose = require('mongoose') + +mongoose.set('strictQuery', false) + +logger.info('connecting to', config.MONGODB_URI) + +mongoose.connect(config.MONGODB_URI) + .then(() => { + logger.info('connected to MongoDB') + }) + .catch((error) => { + logger.error('error connecting to MongoDB:', error.message) + }) + +app.use(cors()) +app.use(express.static('dist')) +app.use(express.json()) +app.use(middleware.requestLogger) + +app.use('/api/notes', notesRouter) + +app.use(middleware.unknownEndpoint) +app.use(middleware.errorHandler) + +module.exports = app +``` + +El archivo utiliza un middleware diferente, y uno de ellos es el notesRouter que se adjunta a la ruta /api/notes. + +Nuestro middleware personalizado se ha movido a un nuevo módulo utils/middleware.js: + +```js +const logger = require('./logger') + +const requestLogger = (request, response, next) => { + logger.info('Method:', request.method) + logger.info('Path: ', request.path) + logger.info('Body: ', request.body) + logger.info('---') + next() +} + +const unknownEndpoint = (request, response) => { + response.status(404).send({ error: 'unknown endpoint' }) +} + +const errorHandler = (error, request, response, next) => { + logger.error(error.message) + + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } else if (error.name === 'ValidationError') { + return response.status(400).json({ error: error.message }) + } + + next(error) +} + +module.exports = { + requestLogger, + unknownEndpoint, + errorHandler +} +``` + +La responsabilidad de establecer la conexión con la base de datos se ha entregado al módulo app.js. El archivo note.js del directorio models solo define el esquema de Mongoose para las notas. + +```js +const mongoose = require('mongoose') + +const noteSchema = new mongoose.Schema({ + content: { + type: String, + required: true, + minlength: 5 + }, + important: Boolean, +}) + +noteSchema.set('toJSON', { + transform: (document, returnedObject) => { + returnedObject.id = returnedObject._id.toString() + delete returnedObject._id + delete returnedObject.__v + } +}) + +module.exports = mongoose.model('Note', noteSchema) +``` + +Para recapitular, la estructura del directorio se ve así después de que se hayan realizado los cambios: + +```bash +├── index.js +├── app.js +├── dist +│ └── ... +├── controllers +│ └── notes.js +├── models +│ └── note.js +├── package-lock.json +├── package.json +├── utils +│ ├── config.js +│ ├── logger.js +│ └── middleware.js +``` + +Para aplicaciones más pequeñas, la estructura no importa mucho. Una vez que la aplicación comienza a crecer en tamaño, tendrá que establecer algún tipo de estructura y separar las diferentes responsabilidades de la aplicación en módulos separados. Esto facilitará mucho el desarrollo de la aplicación. + +No existe una estructura de directorio estricta o una convención de nomenclatura de archivos que se requiera para las aplicaciones Express. Para contrastar esto, Ruby on Rails requiere una estructura específica. Nuestra estructura actual simplemente sigue algunas de las mejores prácticas que puedes encontrar en Internet. + +Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part4-1 de [este repositorio de GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-1). + +Si clonas el proyecto para ti mismo, ejecuta el comando _npm install_ antes de iniciar la aplicación con _npm run dev_. + +### Nota sobre las exportaciones + +Hemos utilizado dos tipos diferentes de exportaciones en esta parte. En primer lugar, por ejemplo, el archivo utils/logger.js realiza la exportación de la siguiente manera: + +```js +const info = (...params) => { + console.log(...params) +} + +const error = (...params) => { + console.error(...params) +} + +// highlight-start +module.exports = { + info, error +} +// highlight-end +``` + +El archivo exporta un objeto que tiene dos campos, ambos son funciones. Las funciones pueden ser utilizadas de dos maneras diferentes. La primera opción es requerir todo el objeto y hacer referencia a las funciones a través del objeto utilizando la notación de punto: + +```js +const logger = require('./utils/logger') + +logger.info('message') + +logger.error('error message') +``` + +La otra opción es desestructurar las funciones en sus propias variables en la declaración de require: + +```js +const { info, error } = require('./utils/logger') + +info('message') +error('error message') +``` + +La segunda forma de exportar puede ser preferible si solo se utiliza una pequeña parte de las funciones exportadas en un archivo. Por ejemplo, en el archivo controller/notes.js, la exportación se realiza de la siguiente manera: + +```js +const notesRouter = require('express').Router() +const Note = require('../models/note') + +// ... + +module.exports = notesRouter // highlight-line +``` + +En este caso, solo se exporta una "cosa", por lo que la única forma de usarla es la siguiente: + +```js +const notesRouter = require('./controllers/notes') + +// ... + +app.use('/api/notes', notesRouter) +``` + +Ahora, la "cosa" exportada (en este caso, un objeto de router) se asigna a una variable y se utiliza como tal. + +#### Encontrar los usos de tus exportaciones con VS Code + +VS Code tiene una característica útil que te permite ver dónde se han exportado tus módulos. Esto puede ser muy útil para refactorizar. Por ejemplo, si decides dividir una función en dos funciones separadas, tu código podría romperse si no modificas todos los usos. Esto es difícil si no sabes dónde están. Sin embargo, necesitas definir tus exportaciones de una manera particular para que esto funcione. + +Si haces clic derecho en una variable en el lugar donde se exporta y seleccionas "Buscar todas las referencias", te mostrará todos los lugares donde se importa la variable. Sin embargo, si asignas un objeto directamente a module.exports, no funcionará. Una solución es asignar el objeto que deseas exportar a una variable con nombre y luego exportar la variable con nombre. Tampoco funcionará si haces una desestructuración al importar; debes importar la variable con nombre y luego desestructurar, o simplemente utilizar la notación de punto para usar las funciones contenidas en la variable con nombre. + +Esta característica de VS Code afectando la forma en que escribes tu código probablemente no sea ideal, así que debes decidir por ti mismo si seguir estas reglas vale la pena. + +
    + +
    + +### Ejercicios 4.1.-4.2. + +**Nota**: el material de este curso fue escrito con la version v20.11.0. Por favor asegúrate de que tu version de Node es al menos tan nueva como la version utilizada en el material (puedes chequear la version al ejecutar _node -v_ en la linea de comandos). + +En los ejercicios de esta parte, crearemos una aplicación de lista de blogs, que permite a los usuarios guardar información sobre blogs interesantes con los que se han encontrado en Internet. Para cada blog listado, guardaremos el autor, el título, la URL y la cantidad de votos positivos de los usuarios de la aplicación. + +#### 4.1 Lista de Blogs, paso 1 + +Imaginemos una situación en la que recibes un correo electrónico que contiene el siguiente cuerpo de la aplicación e instrucciones: + +```js +const express = require('express') +const app = express() +const cors = require('cors') +const mongoose = require('mongoose') + +const blogSchema = new mongoose.Schema({ + title: String, + author: String, + url: String, + likes: Number +}) + +const Blog = mongoose.model('Blog', blogSchema) + +const mongoUrl = 'mongodb://localhost/bloglist' +mongoose.connect(mongoUrl) + +app.use(cors()) +app.use(express.json()) + +app.get('/api/blogs', (request, response) => { + Blog + .find({}) + .then(blogs => { + response.json(blogs) + }) +}) + +app.post('/api/blogs', (request, response) => { + const blog = new Blog(request.body) + + blog + .save() + .then(result => { + response.status(201).json(result) + }) +}) + +const PORT = 3003 +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +``` + +Convierte la aplicación en un proyecto npm funcional. Para mantener tu desarrollo productivo, configura la aplicación para ejecutarse con nodemon. Puedes crear una nueva base de datos para tu aplicación con MongoDB Atlas o utilizar la misma base de datos de los ejercicios de la parte anterior. + +Verifica que sea posible agregar blogs a la lista con Postman o el cliente REST de VS Code y que la aplicación devuelva los blogs añadidos en el endpoint correcto. + +#### 4.2 Lista de Blogs, paso 2 + +Refactoriza la aplicación en módulos separados como se mostró anteriormente en esta parte del material del curso. + +**NB** refactoriza tu aplicación en pequeños pasos y verifica que funcione después de cada cambio que realices. Si intentas tomar un "atajo" refactorizando muchas cosas a la vez, entonces [la ley de Murphy](https://es.wikipedia.org/wiki/Ley_de_Murphy) se activará y es casi seguro que algo se romperá en tu aplicación. El "atajo" terminará tomando más tiempo que avanzar lenta y sistemáticamente. + +Una de las mejores prácticas es hacer un commit de tu código cada vez que está en un estado estable. Esto facilita retroceder a una situación donde la aplicación aún funciona. + +Si estás teniendo problemas con content.body siendo undefined sin razón aparente, asegúrate de no haber olvidado agregar app.use(express.json()) cerca de la parte superior del archivo. + +
    + +
    + +### Testing de aplicaciones Node + +Hemos descuidado por completo un área esencial del desarrollo de software, y es la prueba automatizada. + +Comencemos nuestro viaje de prueba mirando las pruebas unitarias. La lógica de nuestra aplicación es tan simple, que no hay mucho que tenga sentido probar con pruebas unitarias. Creemos un nuevo archivo utils/for_testing.js y escribamos un par de funciones simples que podamos usar para practicar escribir pruebas: + +```js +const reverse = (string) => { + return string + .split('') + .reverse() + .join('') +} + +const average = (array) => { + const reducer = (sum, item) => { + return sum + item + } + + return array.reduce(reducer, 0) / array.length +} + +module.exports = { + reverse, + average, +} +``` + +> La función _average_ usa el método de array [reduce](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce). Si el método aún no te resulta familiar, ahora es un buen momento para ver los primeros tres videos de la serie [Functional Javascript](https://www.youtube.com/watch?v=BMUiFMZr7vk&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84) en Youtube. + +Hay un gran número de librerías de pruebas, o test runners, disponibles para JavaScript. +El antiguo rey de las librerías de pruebas es [Mocha](https://mochajs.org/), que fue reemplazado hace unos años por [Jest](https://jestjs.io/). Un recién llegado a las librerías es [Vitest](https://vitest.dev/), que se presenta como una nueva generación de librerías de pruebas. + +Hoy en día, Node también tiene una librería de pruebas integrada [node:test](https://nodejs.org/docs/latest/api/test.html), que se adapta bien a las necesidades del curso. + +Definamos el script npm _test_ para ejecutar pruebas: + +```bash +{ + //... + "scripts": { + "start": "node index.js", + "dev": "nodemon index.js", + "build:ui": "rm -rf build && cd ../frontend/ && npm run build && cp -r build ../backend", + "deploy": "fly deploy", + "deploy:full": "npm run build:ui && npm run deploy", + "logs:prod": "fly logs", + "lint": "eslint .", + "test": "node --test" // highlight-line + }, + //... +} +``` + +Creemos un directorio separado para nuestras pruebas llamado tests y creemos un nuevo archivo llamado reverse.test.js con el siguiente contenido: + +```js +const { test } = require('node:test') +const assert = require('node:assert') + +const reverse = require('../utils/for_testing').reverse + +test('reverse of a', () => { + const result = reverse('a') + + assert.strictEqual(result, 'a') +}) + +test('reverse of react', () => { + const result = reverse('react') + + assert.strictEqual(result, 'tcaer') +}) + +test('reverse of saippuakauppias', () => { + const result = reverse('saippuakauppias') + + assert.strictEqual(result, 'saippuakauppias') +}) +``` + +En la primera linea, el archivo de prueba importa la función a ser probada y la asigna a una variable llamada _reverse_: + +La prueba define la palabra clave _test_ y la librería [assert](https://nodejs.org/docs/latest/api/assert.html), que es utilizada por las pruebas para verificar los resultados de las funciones bajo prueba. + +En la siguiente fila, el archivo de prueba importa la función a ser probada y la asigna a una variable llamada _reverse_: + +```js +const reverse = require('../utils/for_testing').reverse +``` + +Los casos de prueba individual se definen con la función _test_. El primer argumento de la función es la descripción de la prueba como una cadena. El segundo argumento es una función, que define la funcionalidad para el caso de prueba. La funcionalidad para el segundo caso de prueba se ve así: + +```js +() => { + const result = reverse('react') + + assert.strictEqual(result, 'tcaer') +} +``` + +Primero, ejecutamos el código que se va a probar, es decir, generamos un reverso para el string react. Luego, verificamos los resultados con el método [strictEqual](https://nodejs.org/docs/latest/api/assert.html#assertstrictequalactual-expected-message) de la librería [assert](https://nodejs.org/docs/latest/api/assert.html). + +Como se esperaba, todas las pruebas pasan: + +![salida de terminal para npm test con todas las pruebas pasando](../../images/4/1new.png) + +La librería node:test espera por defecto que los nombres de los archivos de prueba contengan .test. En este curso, seguiremos la convención de nombrar nuestros archivos de prueba con la extensión .test.js. + +Vamos a romper el test: + +```js +test('reverse of react', () => { + const result = reverse('react') + + assert.strictEqual(result, 'tkaer') +}) +``` + +Ejecutar esta prueba da como resultado el siguiente mensaje de error: + +![salida de terminal muestra error de npm test](../../images/4/2new.png) + +Pongamos las pruebas para la función _average_, en un nuevo archivo llamado tests/average.test.js. + +```js +const { test, describe } = require('node:test') + +// ... + +const average = require('../utils/for_testing').average + +describe('average', () => { + test('of one value is the value itself', () => { + assert.strictEqual(average([1]), 1) + }) + + test('of many is calculated right', () => { + assert.strictEqual(average([1, 2, 3, 4, 5, 6]), 3.5) + }) + + test('of empty array is zero', () => { + assert.strictEqual(average([]), 0) + }) +}) +``` + +La prueba revela que la función no funciona correctamente con un array vacío (esto se debe a que en JavaScript dividir por cero da como resultado NaN) + +![salida de terminal mostrando array vacío falla](../../images/4/3new.png) + +Arreglar la función es bastante fácil: + +```js +const average = array => { + const reducer = (sum, item) => { + return sum + item + } + + return array.length === 0 + ? 0 + : array.reduce(reducer, 0) / array.length +} +``` + +Si la longitud del array es 0, devolvemos 0, y en todos los demás casos usamos el método _reduce_ para calcular el promedio. + +Hay algunas cosas a tener en cuenta sobre las pruebas que acabamos de escribir. Definimos un bloque describe alrededor de las pruebas al que se le dio el nombre _average_: + +```js +describe('average', () => { + // tests +}) +``` + +Se pueden usar bloques de descripción para agrupar pruebas en colecciones lógicas. La salida de prueba también usa el nombre del bloque describe: + +![npm test mostrando bloques describe](../../images/4/4new.png) + +Como veremos más adelante, los bloques describe son necesarios cuando queremos ejecutar algunas operaciones de instalación o desmontaje compartidas para un grupo de pruebas. + +Otra cosa a tener en cuenta es que escribimos las pruebas de una manera bastante compacta, sin asignar la salida de la función que se está probando a una variable: + +```js +test('of empty array is zero', () => { + assert.strictEqual(average([]), 0) +}) +``` + +
    + +
    + +### Ejercicios 4.3.-4.7. + +Creemos una colección de funciones auxiliares que estén destinadas a trabajar con las secciones describe de la lista de blogs. Crea las funciones en un archivo llamado utils/list_helper.js. Escribe tus pruebas en un archivo de prueba con el nombre apropiado en el directorio tests. + +#### 4.3: Funciones Auxiliares y Pruebas Unitarias, paso 1 + +Primero define una función _dummy_ que reciba un array de publicaciones de blog como parámetro y siempre devuelva el valor 1. El contenido del archivo list_helper.js en este punto debe ser el siguiente: + +```js +const dummy = (blogs) => { + // ... +} + +module.exports = { + dummy +} +``` + +Verifica que tu configuración de prueba funcione con la siguiente prueba: + +```js +const { test, describe } = require('node:test') +const assert = require('node:assert') +const listHelper = require('../utils/list_helper') + +test('dummy returns one', () => { + const blogs = [] + + const result = listHelper.dummy(blogs) + assert.strictEqual(result, 1) +}) +``` + +#### 4.4: Funciones Auxiliares y Pruebas Unitarias, paso 2 + +Define una nueva función _totalLikes_ que recibe una lista de publicaciones de blogs como parámetro. La función devuelve la suma total de likes en todas las publicaciones del blog. + +Escribe pruebas apropiadas para la función. Se recomienda poner las pruebas dentro de un bloque describe, para que la salida del informe de prueba se agrupe bien: + +![npm test pasando para list_helper_test](../../images/4/5.png) + +Definir datos de prueba para la función se puede hacer así: + +```js +describe('total likes', () => { + const listWithOneBlog = [ + { + _id: '5a422aa71b54a676234d17f8', + title: 'Go To Statement Considered Harmful', + author: 'Edsger W. Dijkstra', + url: 'https://homepages.cwi.nl/~storm/teaching/reader/Dijkstra68.pdf', + likes: 5, + __v: 0 + } + ] + + test('when list has only one blog, equals the likes of that', () => { + const result = listHelper.totalLikes(listWithOneBlog) + assert.strictEqual(result, 5) + }) +}) +``` + +Si definir tu propia lista de datos de prueba de blogs es demasiado trabajo, puedes usar la lista ya hecha [aquí](https://github.com/fullstack-hy2020/misc/blob/master/blogs_for_test.md). + +Es probable que tengas problemas al escribir pruebas. Recuerda las cosas que aprendimos sobre [depuración](/es/part3/guardando_datos_en_mongo_db#depuracion-en-aplicaciones-de-node) en la parte 3. Puedes imprimir cosas en la consola con _console.log_ incluso durante la ejecución de la prueba. + +#### 4.5*: Funciones Auxiliares y Pruebas Unitarias, paso 3 + +Define una nueva función _favoriteBlog_ que recibe una lista de blogs como parámetro. La función descubre qué blog tiene más me gusta. Si hay muchos favoritos, basta con devolver uno de ellos. + +El valor devuelto por la función podría tener el siguiente formato: + +```js +{ + title: "Canonical string reduction", + author: "Edsger W. Dijkstra", + likes: 12 +} +``` + +**NB** cuando estás comparando objetos, el método [deepStrictEqual](https://nodejs.org/api/assert.html#assertdeepstrictequalactual-expected-message) es probablemente lo que debas usar, [strictEqual](https://nodejs.org/api/assert.html#assertstrictequalactual-expected-message) intenta verificar que los dos valores sean el mismo valor, y no solo que contengan las mismas propiedades. Para conocer las diferencias entre las distintas funciones del módulo _assert_, puedes consultar [esta respuesta Stack Overflow](https://stackoverflow.com/a/73937068/15291501). + +Escribe las pruebas para este ejercicio dentro de un nuevo bloque describe. Haz lo mismo con los ejercicios restantes también. + +#### 4.6*: Funciones Auxiliares y Pruebas Unitarias, paso 4 + +Este y el siguiente ejercicio son un poco más desafiantes. No es necesario completar estos dos ejercicios para avanzar en el material del curso, por lo que puede ser una buena idea volver a estos una vez que haya terminado de leer el material de esta parte en su totalidad. + +Se puede terminar este ejercicio sin el uso de librerías adicionales. Sin embargo, este ejercicio es una gran oportunidad para aprender a usar la librería [Lodash](https://lodash.com/). + +Define una función llamada _mostBlogs_ que reciba una lista de blogs como parámetro. La función devuelve el author que tiene la mayor cantidad de blogs. El valor de retorno también contiene el número de blogs que tiene el autor principal: + +```js +{ + author: "Robert C. Martin", + blogs: 3 +} +``` + +Si hay muchos blogueros importantes, entonces es suficiente con devolver uno de ellos. + +#### 4.7*: Funciones Auxiliares y Pruebas Unitarias, paso 5 + +Define una función llamada _mostLikes_ que reciba una lista de blogs como parámetro. La función devuelve el autor, cuyas publicaciones de blog tienen la mayor cantidad de me gusta. El valor de retorno también contiene el número total de likes que el autor ha recibido: + +```js +{ + author: "Edsger W. Dijkstra", + likes: 17 +} +``` + +Si hay muchos bloggers importantes, entonces es suficiente para mostrar cualquiera de ellos. + +
    diff --git a/src/content/4/es/part4b.md b/src/content/4/es/part4b.md new file mode 100644 index 00000000000..f7638962222 --- /dev/null +++ b/src/content/4/es/part4b.md @@ -0,0 +1,1218 @@ +--- +mainImage: ../../../images/part-4.svg +part: 4 +letter: b +lang: es +--- + +
    + +Ahora comenzaremos a escribir pruebas para el backend. Dado que el backend no contiene ninguna lógica complicada, no tiene sentido escribir [pruebas unitarias](https://es.wikipedia.org/wiki/Prueba_unitaria) para él. Lo único que podríamos probar unitariamente es el método _toJSON_ que se utiliza para formatear notas. + +En algunas situaciones, puede ser beneficioso implementar algunas de las pruebas de backend simulando la base de datos en lugar de usar una base de datos real. Una librería que podría usarse para esto es [mongo-mock](https://github.com/williamkapke/mongo-mock). + +Dado que el backend de nuestra aplicación todavía es relativamente simple, tomaremos la decisión de probar toda la aplicación a través de su API REST, de modo que la base de datos también esté incluida. Este tipo de prueba, en la que se prueban varios componentes del sistema como un grupo, se denomina [prueba de integración](https://en.wikipedia.org/wiki/Integration_testing). + +### Entorno de prueba + +En uno de los capítulos anteriores del material del curso, mencionamos que cuando su servidor backend se ejecuta en Fly.io o Render, está en modo producción. + +La convención en Node es definir el modo de ejecución de la aplicación con la variable de entorno NODE_ENV. En nuestra aplicación actual, solo cargamos las variables de entorno definidas en el archivo .env si la aplicación no esta en modo producción. + +Es una práctica común definir modos separados para desarrollo y prueba. + +A continuación, cambiemos los scripts en nuestro package.json para que cuando se ejecuten las pruebas, NODE_ENV obtenga el valor test: + +```json +{ + // ... + "scripts": { + "start": "NODE_ENV=production node index.js", // highlight-line + "dev": "NODE_ENV=development nodemon index.js", // highlight-line + "test": "NODE_ENV=test node --test", // highlight-line + "build:ui": "rm -rf build && cd ../frontend/ && npm run build && cp -r build ../backend", + "deploy": "fly deploy", + "deploy:full": "npm run build:ui && npm run deploy", + "logs:prod": "fly logs", + "lint": "eslint .", + }, + // ... +} +``` + +Especificamos el modo de la aplicación para que sea development en el script _npm run dev_ que usa nodemon. También especificamos que el comando predeterminado _npm start_ definirá el modo como production. + +Hay un pequeño problema en la forma en que hemos especificado el modo de la aplicación en nuestros scripts: no funcionará en Windows. Podemos corregir esto instalando el paquete [cross-env](https://www.npmjs.com/package/cross-env) como una dependencia de desarrollo con el comando: + +```bash +npm install --save-dev cross-env +``` + +Entonces podemos lograr la compatibilidad multiplataforma utilizando la librería cross-env en nuestros scripts npm definidos en package.json: + +```json +{ + // ... + "scripts": { + "start": "cross-env NODE_ENV=production node index.js", + "dev": "cross-env NODE_ENV=development nodemon index.js", + // ... + "test": "cross-env NODE_ENV=test node --test", + }, + // ... +} +``` + +**Nota**: Si estás desplegando esta aplicación en Fly.io/Render, ten en cuenta que si cross-env se guarda como una dependencia de desarrollo, podría causar un error en tu servidor web. Para solucionarlo, cambia cross-env a una dependencia de producción ejecutando lo siguiente en la línea de comandos: + +```bash +npm install cross-env +``` + +Ahora podemos modificar la forma en que se ejecuta nuestra aplicación en diferentes modos. Como ejemplo de esto, podríamos definir la aplicación para usar una base de datos de prueba separada cuando esté ejecutando pruebas. + +Podemos crear nuestra base de datos de prueba separada en MongoDB Atlas. Esta no es una solución óptima en situaciones en las que muchas personas desarrollan la misma aplicación. La ejecución de pruebas, en particular, generalmente requiere que las pruebas que se ejecutan simultáneamente no utilicen una sola instancia de base de datos. + +Sería mejor ejecutar nuestras pruebas usando una base de datos que esté instalada y ejecutándose en la máquina local del desarrollador. La solución óptima sería que cada ejecución de prueba use su propia base de datos separada. Esto es "relativamente simple" de lograr [ejecutando Mongo en memoria](https://docs.mongodb.com/manual/core/inmemory/) o usando contenedores [Docker](https://www.docker.com ). No complicaremos las cosas y en su lugar continuaremos usando la base de datos MongoDB Atlas. + +Hagamos algunos cambios en el módulo que define la configuración de la aplicación en _utils/config.js_: + +```js +require('dotenv').config() + +const PORT = process.env.PORT + +// highlight-start +const MONGODB_URI = process.env.NODE_ENV === 'test' + ? process.env.TEST_MONGODB_URI + : process.env.MONGODB_URI +// highlight-end + +module.exports = { + MONGODB_URI, + PORT +} +``` + +El archivo .env tiene variables independientes para las direcciones de la base de datos de desarrollo y prueba: + +```bash +MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority +PORT=3001 + +// highlight-start +TEST_MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/testNoteApp?retryWrites=true&w=majority +// highlight-end +``` + +El módulo _config_ que hemos implementado se parece ligeramente al paquete [node-config](https://github.com/lorenwest/node-config). Escribir nuestra propia implementación está justificado porque nuestra aplicación es simple, y también porque nos enseña lecciones valiosas. + +Estos son los únicos cambios que debemos realizar en el código de nuestra aplicación. + +Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part4-2 de [este repositorio de GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-2). + +### supertest + +Usemos el paquete [supertest](https://github.com/visionmedia/supertest) para ayudarnos a escribir nuestras pruebas para probar la API. + +Instalaremos el paquete como una dependencia de desarrollo: + +```bash +npm install --save-dev supertest +``` + +Escribamos nuestra primera prueba en el archivo tests/note_api.test.js: + +```js +const { test, after } = require('node:test') +const mongoose = require('mongoose') +const supertest = require('supertest') +const app = require('../app') + +const api = supertest(app) + +test('notes are returned as json', async () => { + await api + .get('/api/notes') + .expect(200) + .expect('Content-Type', /application\/json/) +}) + +after(async () => { + await mongoose.connection.close() +}) +``` + +La prueba importa la aplicación Express del módulo app.js y la envuelve con la función supertest en un objeto llamado [superagent](https://github.com/visionmedia/superagent). Este objeto se asigna a la variable api y las pruebas pueden usarlo para realizar solicitudes HTTP al backend. + +Nuestra prueba realiza una solicitud HTTP GET a la URL api/notes y verifica que se responda a la solicitud con el código de estado 200. La prueba también verifica que el encabezado Content-Type se establece en application/json, lo que indica que los datos están en el formato deseado. + +La verificación del valor en el encabezado usa una sintaxis un poco extraña: + +```js +.expect('Content-Type', /application\/json/) +``` + +El valor lo definimos como una [expresión regular](https://developer.mozilla.org/es/docs/Web/JavaScript/Guide/Regular_Expressions) o en palabras cortas: regex. Las expresiones regulares en JavaScript inician y finalizan con un slash /. Dado que la cadena deseada application/json también contiene el mismo slash en el medio, entonces se precede por un \ de tal manera que no se interprete como un caracter de terminación. + +En principio, el test podría también ser definido simplemente como una cadena: + +```js +.expect('Content-Type', 'application/json') +``` + +El problema, es que si usamos cadenas el valor del encabezado debe ser exactamente el mismo. Para la expresión que definimos, es suficiente que el encabezado contenga la cadena en cuestión. Por ejemplo, el valor actual del encabezado puede ser application/json; charset=utf-8 ya que también tiene información de la codificación de caracteres (utf-8). Sin embargo, nuestra prueba no está interesada en esto y, por lo tanto, es mejor definir la prueba como una expresión regular en lugar verificar una cadena exacta. + +La prueba contiene algunos detalles que exploraremos [un poco más adelante](/es/part4/probando_el_backend#async-await). La función de flecha que define la prueba está precedida por la palabra clave async y la llamada al método para el objeto api está precedida por la palabra clave await. Escribiremos algunas pruebas y luego echaremos un vistazo más de cerca a esta magia de async/await. No te preocupes por esto por ahora, solo ten la seguridad de que las pruebas de ejemplo funcionan correctamente. La sintaxis async/await está relacionada con el hecho de que hacer una solicitud a la API es una operación asíncrona. La sintaxis async/await se puede utilizar para escribir código asíncrono con la apariencia de código síncrono. + +Una vez que todas las pruebas (actualmente solo hay una) hayan terminado de ejecutarse, tenemos que cerrar la conexión a la base de datos utilizada por Mongoose. Esto se puede lograr fácilmente con el método [after](https://nodejs.org/api/test.html#afterfn-options): + +```js +after(() => { + await mongoose.connection.close() +}) +``` + +Un pequeño pero importante detalle: al [principio](/es/part4/estructura_de_la_aplicacion_backend_introduccion_a_las_pruebas#estructura-del-proyecto) de esta parte extrajimos la aplicación Express en el archivo app.js, y el rol del archivo index.js se cambió para iniciar la aplicación en el puerto especificado a través de `app.listen`: + +```js +const app = require('./app') // la aplicación Express +const config = require('./utils/config') +const logger = require('./utils/logger') + +server.listen(config.PORT, () => { + logger.info(`Server running on port ${config.PORT}`) +}) +``` + +Las pruebas solo usan la aplicación express definida en el archivo app.js: + +```js +const mongoose = require('mongoose') +const supertest = require('supertest') +const app = require('../app') // highlight-line + +const api = supertest(app) // highlight-line + +// ... +``` + +La documentación de supertest dice lo siguiente: + +> *si el servidor aún no está escuchando conexiones, entonces se vincula a un puerto efímero automáticamente, por lo que no es necesario hacer un seguimiento de los puertos.* + +En otras palabras, supertest se encarga de que la aplicación que se está probando se inicie en el puerto que utiliza internamente. + +Agreguemos dos notas a la base de datos de prueba utilizando el programa _mongo.js_ (aquí debemos recordar cambiar a la URL correcta de la base de datos). + +Escribamos algunas pruebas más: + +```js +test('there are two notes', async () => { + const response = await api.get('/api/notes') + + assert.strictEqual(response.body.length, 2) +}) + +test('the first note is about HTTP methods', async () => { + const response = await api.get('/api/notes') + + const contents = response.body.map(e => e.content) + assert.strictEqual(contents.includes('HTML is easy'), true) +}) +``` + +Ambas pruebas almacenan la respuesta de la solicitud en la variable _response_, y a diferencia de la prueba anterior que utilizó los métodos proporcionados por _supertest_ para verificar el código de estado y los encabezados, esta vez estamos inspeccionando los datos de respuesta almacenados en la propiedad response.body. Nuestras pruebas verifican el formato y el contenido de los datos de respuesta con el método [strictEqual](https://nodejs.org/docs/latest/api/assert.html#assertstrictequalactual-expected-message) de la librería assert. + +Podríamos simplificar un poco la segunda prueba, utilizando solo a [assert](https://nodejs.org/docs/latest/api/assert.html#assertokvalue-message) para verificar que la nota esta entre las que fueron devueltas por el backend: + +```js +test('the first note is about HTTP methods', async () => { + const response = await api.get('/api/notes') + + const contents = response.body.map(e => e.content) + // es el argumento truthy + assert(contents.includes('HTML is easy')) +}) +``` + +El beneficio de usar la sintaxis async/await está comenzando a ser evidente. Normalmente tendríamos que usar funciones callback para acceder a los datos devueltos por las promesas, pero con la nueva sintaxis las cosas son mucho más cómodas: + +```js +const response = await api.get('/api/notes') + +// la ejecución llega aquí solo después de que se completa la solicitud HTTP +// el resultado de la solicitud HTTP se guarda en la variable response +expect(response.body).toHaveLength(2) +assert.strictEqual(response.body.length, 2) +``` + +El middleware que genera información sobre las solicitudes HTTP está obstruyendo la salida de ejecución de la prueba. Modifiquemos el logger para que no imprima en la consola en modo de prueba: + +```js +const info = (...params) => { + // highlight-start + if (process.env.NODE_ENV !== 'test') { + console.log(...params) + } + // highlight-end +} + +const error = (...params) => { + // highlight-start + if (process.env.NODE_ENV !== 'test') { + console.error(...params) + } + // highlight-end +} + +module.exports = { + info, error +} +``` + +### Inicializando la base de datos antes de las pruebas + +Testing parece ser fácil y actualmente nuestras pruebas están pasando. Sin embargo, nuestras pruebas son malas ya que dependen del estado de la base de datos, que ahora tiene dos notas. Para hacerlas más robustas, debemos resetear la base de datos y generar los datos de prueba necesarios de manera controlada antes de ejecutar las pruebas. + +Nuestras pruebas ya están usando la función [after](https://nodejs.org/api/test.html#afterfn-options) para cerrar la conexión a la base de datos después de que las pruebas hayan terminado de ejecutarse. La librería node:test ofrece muchas otras funciones que se pueden usar para ejecutar operaciones una vez antes de que se ejecute cualquier prueba, o cada vez antes de que se ejecuta una prueba. + +Inicialicemos la base de datos antes de cada prueba con la función [beforeEach](https://nodejs.org/api/test.html#beforeeachfn-options): + +```js +// highlight-start +const { test, after, beforeEach } = require('node:test') +const Note = require('../models/note') +// highlight-end + +// highlight-start +const initialNotes = [ + { + content: 'HTML is easy', + important: false, + }, + { + content: 'Browser can execute only JavaScript', + important: true, + }, +] +// highlight-end + +// ... + +// highlight-start +beforeEach(async () => { + await Note.deleteMany({}) + + let noteObject = new Note(initialNotes[0]) + await noteObject.save() + + noteObject = new Note(initialNotes[1]) + await noteObject.save() +}) +// highlight-end +// ... +``` + +La base de datos se borra al principio, y luego guardamos las dos notas almacenadas en el array _initialNotes_ en la base de datos. Al hacer esto, nos aseguramos de que la base de datos esté en el mismo estado antes de ejecutar cada prueba. + +También hagamos los siguientes cambios en las dos últimas pruebas: + +```js +test('there are two notes', async () => { + const response = await api.get('/api/notes') + + assert.strictEqual(response.body.length, initialNotes.length) +}) + +test('the first note is about HTTP methods', async () => { + const response = await api.get('/api/notes') + + const contents = response.body.map(e => e.content) + assert(contents.includes('HTML is easy')) +}) +``` + +### Ejecución de pruebas una por una + +El comando _npm test_ ejecuta todas las pruebas de la aplicación. Cuando escribimos pruebas, generalmente es aconsejable ejecutar solo una o dos pruebas. + +Hay diferentes formas de lograr esto, una de las cuales es el método [only](https://nodejs.org/api/test.html#testonlyname-options-fn). Con este método podemos definir en el código que pruebas deben ser ejecutadas: + +```js +test.only('notes are returned as json', async () => { + await api + .get('/api/notes') + .expect(200) + .expect('Content-Type', /application\/json/) +}) + +test.only('there are two notes', async () => { + const response = await api.get('/api/notes') + + assert.strictEqual(response.body.length, 2) +}) +``` + +Cuando las pruebas son ejecutadas con la opción _--test-only_, eso es, con el comando: + +``` +npm test -- --test-only +``` + +solo los tests marcados con _only_ son ejecutados. + +El peligro de _only_ es que uno olvida quitarlos del código. + +Otra opción es especificar las pruebas que necesitan ser ejecutadas como argumentos del comando npm test. + +El siguiente comando solo ejecuta los tests encontrados en el archivo tests/note_api.test.js: + +```js +npm test -- tests/note_api.test.js +``` + +La opción [--tests-by-name-pattern](https://nodejs.org/api/test.html#filtering-tests-by-name) puede usarse para ejecutar pruebas con un nombre especifico: + +```js +npm test -- --test-name-pattern="the first note is about HTTP methods" +``` + +El argumento dado puede referirse al nombre del test o del bloque describe. También puede contener solo una parte del nombre. El siguiente comando va a ejecutar todos los tests que contengan notes en su nombre: + +```js +npm run test -- --test-name-pattern="notes" +``` + +### async/await + +Antes de escribir más pruebas, echemos un vistazo a las palabras clave _async_ y _await_. + +La sintaxis async/await que se introdujo en ES7 hace posible el uso de funciones asíncronas que devuelven una promesa de una manera que hace que el código parezca síncrono. + +Como ejemplo, la obtención de notas de la base de datos con promesas se ve así: + +```js +Note.find({}).then(notes => { + console.log('operation returned the following notes', notes) +}) +``` + +El método _Note.find()_ devuelve una promesa y podemos acceder al resultado de la operación registrando una función callback con el método _then_. + +Todo el código que queremos ejecutar una vez que finalice la operación está escrito en la función callback. Si quisiéramos realizar varias llamadas a funciones asíncronas en secuencia, la situación pronto se volvería dolorosa. Las llamadas asíncronas deberían realizarse en el callback. Esto probablemente conduciría a un código complicado y podría potencialmente dar lugar a un llamado [infierno de callbacks](http://callbackhell.com/). + +Al [encadenar promesas](https://es.javascript.info/promise-chaining) podríamos mantener la situación un poco bajo control y evitar el infierno de callbacks creando una cadena bastante limpia de llamadas a métodos _then_. Hemos visto algunos de estos durante el curso. Para ilustrar esto, puedes ver un ejemplo artificial de una función que recupera todas las notas y luego elimina la primera: + +```js +Note.find({}) + .then(notes => { + return notes[0].remove() + }) + .then(response => { + console.log('the first note is removed') + // más código aquí + }) +``` + +La cadena de then está bien, pero podemos hacerlo mejor. Las [funciones de generadores](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Generator) introducidas en ES6 proporcionaron una [forma inteligente](https://github.com/getify/You-Dont-Know-JS/blob/1st-ed/async%20%26%20performance/ch4.md#iterating-generators-asynchronously) de escribir código asíncrono de una manera que "parezca síncrona". La sintaxis es un poco torpe y no se usa mucho. + +Las palabras clave _async_ y _await_ introducidas en ES7 traen la misma funcionalidad que los generadores, pero de una manera comprensible y sintácticamente más limpia a las manos de todos los ciudadanos del mundo JavaScript. + +Podríamos obtener todas las notas en la base de datos utilizando un operador [await](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Operators/await) cómo este: + +```js +const notes = await Note.find({}) + +console.log('operation returned the following notes', notes) +``` + +El código se ve exactamente como el código síncrono. La ejecución del código se detiene en const notes = await Note.find({}) y espera hasta que se cumpla la promesa relacionada, y luego continúa su ejecución a la siguiente línea. Cuando la ejecución continúa, el resultado de la operación que devolvió una promesa se asigna a la variable _notes_. + +El ejemplo que era un poco complicado presentado anteriormente podría implementarse usando await así: + +```js +const notes = await Note.find({}) +const response = await notes[0].remove() + +console.log('the first note is removed') +``` + +Gracias a la nueva sintaxis, el código es mucho más simple que la cadena then anterior. + +Hay algunos detalles importantes a los que se debe prestar atención cuando se usa la sintaxis async/await. Para utilizar el operador await con operaciones asíncronas, estas deben devolver una promesa. Esto no es un problema como tal, ya que las funciones asíncronas regulares que utilizan callbacks son fáciles de envolver en promesas. + +La palabra clave await no se puede usar en cualquier parte del código JavaScript. El uso de await solo es posible dentro de una función [async](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Statements/async_function). + +Esto significa que para que los ejemplos anteriores funcionen, deben utilizar funciones asíncronas. Observa la primera línea en la definición de la función de flecha: + +```js +const main = async () => { // highlight-line + const notes = await Note.find({}) + console.log('operation returned the following notes', notes) + + const response = await notes[0].remove() + console.log('the first note is removed') +} + +main() // highlight-line +``` + +El código declara que la función asignada a _main_ es asíncrona. Después de esto, el código llama a la función con main(). + +### async/await en el backend + +Cambiemos el backend a async y await. Como todas las operaciones asíncronas se realizan actualmente dentro de una función, es suficiente cambiar las funciones de los controladores de ruta a funciones asíncronas. + +La ruta para obtener todas las notas se cambia a lo siguiente: + +```js +notesRouter.get('/', async (request, response) => { + const notes = await Note.find({}) + response.json(notes) +}) +``` + +Podemos verificar que nuestra refactorización fue exitosa probando el endpoint a través del navegador y ejecutando las pruebas que escribimos anteriormente. + +Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part4-3 de [este repositorio de GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-3). + +### Más pruebas y refactorización del backend + +Cuando el código se refactoriza, siempre existe el riesgo de [regresión](https://es.wikipedia.org/wiki/Pruebas_de_regresi%C3%B3n), lo que significa que la funcionalidad existente puede romperse. Refactoricemos las operaciones restantes escribiendo primero una prueba para cada ruta de la API. + +Comencemos con la operación para agregar una nueva nota. Escribamos una prueba que agregue una nueva nota y verifique que la cantidad de notas devueltas por la API aumenta y que la nota recién agregada esté en la lista. + +```js +test('a valid note can be added ', async () => { + const newNote = { + content: 'async/await simplifies making async calls', + important: true, + } + + await api + .post('/api/notes') + .send(newNote) + .expect(201) + .expect('Content-Type', /application\/json/) + + const response = await api.get('/api/notes') + + const contents = response.body.map(r => r.content) + + assert.strictEqual(response.body.length, initialNotes.length + 1) + + assert(contents.includes('async/await simplifies making async calls')) +}) +``` + +La prueba falla porque accidentalmente estamos devolviendo el código de estado 200 OK cuando se crea una nueva nota. Cambiémoslo para que devuelva 201 CREATED: + +```js +notesRouter.post('/', (request, response, next) => { + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + + note.save() + .then(savedNote => { + response.status(201).json(savedNote) // highlight-line + }) + .catch(error => next(error)) +}) +``` + +Escribamos también una prueba que verifique que una nota sin contenido no se guardará en la base de datos. + +```js +test('note without content is not added', async () => { + const newNote = { + important: true + } + + await api + .post('/api/notes') + .send(newNote) + .expect(400) + + const response = await api.get('/api/notes') + + assert.strictEqual(response.body.length, initialNotes.length) +}) +``` + +Ambas pruebas verifican el estado almacenado en la base de datos después de la operación de guardado, obteniendo todas las notas de la aplicación. + +```js +const response = await api.get('/api/notes') +``` + +Los mismos pasos de verificación se repetirán en otras pruebas más adelante, y es una buena idea extraer estos pasos en funciones auxiliares. Agreguemos la función en un nuevo archivo llamado tests/test_helper.js que está en el mismo directorio que el archivo de prueba. + +```js +const Note = require('../models/note') + +const initialNotes = [ + { + content: 'HTML is easy', + important: false + }, + { + content: 'Browser can execute only JavaScript', + important: true + } +] + +const nonExistingId = async () => { + const note = new Note({ content: 'willremovethissoon' }) + await note.save() + await note.deleteOne() + + return note._id.toString() +} + +const notesInDb = async () => { + const notes = await Note.find({}) + return notes.map(note => note.toJSON()) +} + +module.exports = { + initialNotes, nonExistingId, notesInDb +} +``` + +El módulo define la función _notesInDb_ que se puede usar para verificar las notas almacenadas en la base de datos. El array _initialNotes_ que contiene el estado inicial de la base de datos también está en el módulo. También definimos la función _nonExistingId_ con anticipación, que se puede usar para crear un ID de objeto de base de datos que no pertenezca a ningún objeto de nota en la base de datos. + +Nuestras pruebas ahora pueden usar el módulo auxiliar y cambiarse así: + +```js +const { test, after, beforeEach } = require('node:test') +const assert = require('node:assert') +const supertest = require('supertest') +const mongoose = require('mongoose') +const helper = require('./test_helper') // highlight-line +const app = require('../app') +const api = supertest(app) + +const Note = require('../models/note') + +beforeEach(async () => { + await Note.deleteMany({}) + + let noteObject = new Note(helper.initialNotes[0]) // highlight-line + await noteObject.save() + + noteObject = new Note(helper.initialNotes[1]) // highlight-line + await noteObject.save() +}) + +test('notes are returned as json', async () => { + await api + .get('/api/notes') + .expect(200) + .expect('Content-Type', /application\/json/) +}) + +test('all notes are returned', async () => { + const response = await api.get('/api/notes') + + assert.strictEqual(response.body.length, helper.initialNotes.length) // highlight-line +}) + +test('a specific note is within the returned notes', async () => { + const response = await api.get('/api/notes') + + const contents = response.body.map(r => r.content) + + assert(contents.includes('Browser can execute only JavaScript')) +}) + +test('a valid note can be added ', async () => { + const newNote = { + content: 'async/await simplifies making async calls', + important: true, + } + + await api + .post('/api/notes') + .send(newNote) + .expect(201) + .expect('Content-Type', /application\/json/) + + const notesAtEnd = await helper.notesInDb() // highlight-line + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length + 1) // highlight-line + + const contents = notesAtEnd.map(n => n.content) // highlight-line + assert(contents.includes('async/await simplifies making async calls')) +}) + +test('note without content is not added', async () => { + const newNote = { + important: true + } + + await api + .post('/api/notes') + .send(newNote) + .expect(400) + + const notesAtEnd = await helper.notesInDb() // highlight-line + + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length) // highlight-line +}) + +after(async () => { + await mongoose.connection.close() +}) +``` + +El código que usa promesas funciona y las pruebas pasan. Estamos listos para refactorizar nuestro código para usar la sintaxis async/await. + +Realizamos los siguientes cambios en el código que se encarga de agregar una nueva nota (observa que la definición del controlador de ruta está precedida por la palabra clave _async_): + +```js +notesRouter.post('/', async (request, response, next) => { + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + + const savedNote = await note.save() + response.status(201).json(savedNote) +}) +``` + +Hay un pequeño problema con nuestro código: no manejamos situaciones de error. ¿Cómo debemos lidiar con ellos? + +### Manejo de errores y async/await + +Si hay una excepción al manejar la solicitud POST terminamos en una situación familiar: + +![terminal mostrando advertencia de promesa rechazada sin gestionar](../../images/4/6.png) + +En otras palabras, terminamos con un rechazo de promesa no gestionado, y la solicitud nunca recibe una respuesta. + +Con async/await, la forma recomendada de lidiar con las excepciones es el viejo y familiar mecanismo _try/catch_: + +```js +notesRouter.post('/', async (request, response, next) => { + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + // highlight-start + try { + const savedNote = await note.save() + response.status(201).json(savedNote) + } catch(exception) { + next(exception) + } + // highlight-end +}) +``` + +El bloque catch simplemente llama la función _next_, que pasa el manejo de solicitudes al middleware de manejo de errores. + +Después de realizar el cambio, todas nuestras pruebas pasarán una vez más. + +A continuación, escribamos pruebas para obtener y eliminar una nota individual: + +```js +test('a specific note can be viewed', async () => { + const notesAtStart = await helper.notesInDb() + + const noteToView = notesAtStart[0] + +// highlight-start + const resultNote = await api + .get(`/api/notes/${noteToView.id}`) + .expect(200) + .expect('Content-Type', /application\/json/) +// highlight-end + + assert.deepStrictEqual(resultNote.body, noteToView) +}) + +test('a note can be deleted', async () => { + const notesAtStart = await helper.notesInDb() + const noteToDelete = notesAtStart[0] + +// highlight-start + await api + .delete(`/api/notes/${noteToDelete.id}`) + .expect(204) +// highlight-end + + const notesAtEnd = await helper.notesInDb() + + const contents = notesAtEnd.map(r => r.content) + assert(!contents.includes(noteToDelete.content)) + + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length - 1) +}) +``` + +Ambas pruebas comparten una estructura similar. En la fase de inicialización, obtienen una nota de la base de datos. Después de esto, las pruebas llaman a la operación que se está probando, que se resalta en el bloque de código. Por último, las pruebas verifican que el resultado de la operación sea el esperado. + +Hay un punto que vale la pena mencionar en la primera prueba. En lugar del método previamente utilizado [strictEqual](https://nodejs.org/api/assert.html#assertstrictequalactual-expected-message), se utiliza el método [deepStrictEqual](https://nodejs.org/api/assert.html#assertdeepstrictequalactual-expected-message): + +```js +assert.deepStrictEqual(resultNote.body, noteToView) +``` + +La razón de esto es que _strictEqual_ utiliza el método [Object.is](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Object/is) para comparar similitud, es decir, compara si los objetos son los mismos. En nuestro caso, es suficiente comprobar que los contenidos de los objetos, es decir, los valores de sus campos, son iguales. Para este propósito, _deepStrictEqual_ es adecuado. + +Las pruebas pasan y podemos refactorizar con seguridad las rutas probadas para usar async/await: + +```js +notesRouter.get('/:id', async (request, response, next) => { + try { + const note = await Note.findById(request.params.id) + if (note) { + response.json(note) + } else { + response.status(404).end() + } + } catch(exception) { + next(exception) + } +}) + +notesRouter.delete('/:id', async (request, response, next) => { + try { + await Note.findByIdAndDelete(request.params.id) + response.status(204).end() + } catch(exception) { + next(exception) + } +}) +``` + +Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part4-4 de [este repositorio de GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-4). + +### Eliminando el try-catch + +Async/await despeja un poco el código, pero el 'precio' es la estructura try/catch necesaria para detectar excepciones. +Todos los controladores de ruta siguen la misma estructura + +```js +try { + // realiza las operaciones asíncronas aquí +} catch(exception) { + next(exception) +} +``` + +Uno comienza a preguntarse, ¿sería posible refactorizar el código para eliminar el catch de los métodos? + +La librería [express-async-errors](https://github.com/davidbanham/express-async-errors) tiene una solución para esto. + +Vamos a instalarla + +```bash +npm install express-async-errors +``` + +Usarla es muy fácil. +Introduce la librería en app.js, _antes_ de que importes tus rutas: + +```js +const config = require('./utils/config') +const express = require('express') +require('express-async-errors') // highlight-line +const app = express() +const cors = require('cors') +const notesRouter = require('./controllers/notes') +const middleware = require('./utils/middleware') +const logger = require('./utils/logger') +const mongoose = require('mongoose') + +// ... + +module.exports = app +``` + +La 'magia' de esta librería nos permite eliminar por completo los bloques try-catch. +Por ejemplo, la ruta para eliminar una nota + +```js +notesRouter.delete('/:id', async (request, response, next) => { + try { + await Note.findByIdAndDelete(request.params.id) + response.status(204).end() + } catch (exception) { + next(exception) + } +}) +``` + +se convierte en + +```js +notesRouter.delete('/:id', async (request, response) => { + await Note.findByIdAndDelete(request.params.id) + response.status(204).end() +}) +``` + +Debido a la librería, ya no necesitamos la llamada a _next(exception)_. +La librería se encarga de todo lo que hay debajo del capó. Si ocurre una excepción en una ruta async, la ejecución se pasa automáticamente al middleware de manejo de errores. + +Las otras rutas se convierten en: + +```js +notesRouter.post('/', async (request, response) => { + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + + const savedNote = await note.save() + response.status(201).json(savedNote) +}) + +notesRouter.get('/:id', async (request, response) => { + const note = await Note.findById(request.params.id) + if (note) { + response.json(note) + } else { + response.status(404).end() + } +}) +``` + +### Optimización de la función beforeEach + +Volvamos a escribir nuestras pruebas y echemos un vistazo más de cerca a la función _beforeEach_ que las configura: + +```js +beforeEach(async () => { + await Note.deleteMany({}) + + let noteObject = new Note(helper.initialNotes[0]) + await noteObject.save() + + noteObject = new Note(helper.initialNotes[1]) + await noteObject.save() +}) +``` + +La función guarda las dos primeras notas del array _helper.initialNotes_ en la base de datos con dos operaciones separadas. La solución está bien, pero hay una mejor manera de guardar varios objetos en la base de datos: + +```js +beforeEach(async () => { + await Note.deleteMany({}) + console.log('cleared') + + helper.initialNotes.forEach(async (note) => { + let noteObject = new Note(note) + await noteObject.save() + console.log('saved') + }) + console.log('done') +}) + +test('notes are returned as json', async () => { + console.log('entered test') + // ... +} +``` + +Guardamos las notas almacenadas dentro del array en la base de datos dentro de un loop _forEach_. Sin embargo, las pruebas no parecen funcionar del todo, por lo que hemos agregado algunos registros de la consola para ayudarnos a encontrar el problema. + +La consola muestra el siguiente resultado: + +``` +cleared +done +entered test +saved +saved +``` + +A pesar de nuestra uso de la sintaxis async/await, nuestra solución no funciona como esperábamos. ¡La ejecución de la prueba comienza antes de que se inicialice la base de datos! + +El problema es que cada iteración del bucle forEach genera su propia operación asíncrona, y _beforeEach_ no esperará a que terminen de ejecutarse. En otras palabras, los comandos _await_ definidos dentro del bucle _forEach_ no están en la función _beforeEach_, sino en funciones separadas que _beforeEach_ no esperará. + +Dado que la ejecución de las pruebas comienza inmediatamente después de que _beforeEach_ haya terminado de ejecutarse, la ejecución de las pruebas comienza antes de que se inicialice el estado de la base de datos. + +Una forma de arreglar esto es esperar a que todas las operaciones asíncronas terminen de ejecutarse con el método [Promise.all](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Promise/all): + +```js +beforeEach(async () => { + await Note.deleteMany({}) + + const noteObjects = helper.initialNotes + .map(note => new Note(note)) + const promiseArray = noteObjects.map(note => note.save()) + await Promise.all(promiseArray) +}) +``` + +La solución es bastante avanzada a pesar de su apariencia compacta. La variable _noteObjects_ se asigna a un array de objetos Mongoose que se crean con el constructor _Note_ para cada una de las notas en el array _helper.initialNotes_. La siguiente línea de código crea un nuevo array que consiste en promesas, que se crean llamando al método _save_ de cada elemento en el array _noteObjects_. En otras palabras, es una serie de promesas para guardar cada uno de los elementos en la base de datos. + +El método [Promise.all](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) se puede utilizar para transformar una serie de promesas en una única promesa, que se cumplirá una vez que se resuelva cada promesa en el array que se le pasa como argumento. La última línea de código await Promise.all(promiseArray) espera a que finalice cada promesa de guardar una nota, lo que significa que la base de datos se ha inicializado. + +> Aún se puede acceder a los valores devueltos de cada promesa en el array cuando se usa el método Promise.all. Si esperamos a que se resuelvan las promesas con la sintaxis _await_ const results = await Promise.all(promiseArray), la operación devolverá un array que contiene los valores resueltos para cada promesa en _promiseArray_, y aparecen en el mismo orden que las promesas en el array. + +Promise.all ejecuta las promesas que recibe en paralelo. Si las promesas deben ejecutarse en un orden particular, esto será problemático. En situaciones como esta, las operaciones se pueden ejecutar dentro de un [for...of](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Statements/for...of), que garantiza un orden de ejecución especifico. + +```js +beforeEach(async () => { + await Note.deleteMany({}) + + for (let note of helper.initialNotes) { + let noteObject = new Note(note) + await noteObject.save() + } +}) +``` + +La naturaleza asíncrona de JavaScript puede llevar a un comportamiento sorprendente y, por esta razón, es importante prestar mucha atención al usar la sintaxis async/await. Aunque la sintaxis hace que sea más fácil lidiar con las promesas, ¡es necesario entender cómo funcionan las promesas! + +El código de nuestra aplicación se puede encontrar en [GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-5), en la rama part4-5. + +### El juramento de un verdadero desarrollador full stack + +Realizar pruebas añade otro nivel de desafío a la programación. Debemos actualizar nuestro juramento como desarrolladores full stack para recordar que la sistematicidad también es clave al desarrollar pruebas. + +Por lo tanto, debemos extender nuestro juramento una vez más: + +El desarrollo full stack es extremadamente difícil , por eso usaré todos los medios posibles para hacerlo más fácil: + +- Mantendré la consola de desarrollador del navegador abierta todo el tiempo +- Usaré la pestaña "Network" dentro de las herramientas de desarrollo del navegador, para asegurarme que el frontend y el backend se comuniquen como espero +- Mantendré constantemente un ojo en el estado del servidor, para asegurarme de que los datos enviados allí por el frontend se guarden como espero +- Vigilaré la base de datos para confirmar que los datos enviados por el backend se guarden en el formato correcto +- Progresaré en pequeños pasos +- Escribiré muchos console.log para asegurarme de que entiendo cómo se comporta el código y las pruebas; y para ayudarme a identificar problemas +- Si mi código no funciona, no escribiré más código. En su lugar, comenzaré a eliminar código hasta que funcione o simplemente volveré a un estado en el que todo aún funciona +- Si una prueba no pasa, me aseguraré de que la funcionalidad probada funcione correctamente en la aplicación +- Cuando pido ayuda en el canal Discorddel curso, o en otro lugar, formularé mis preguntas correctamente, ve [aquí](/es/part0/informacion_general#como-obtener-ayuda-en-discord) cómo pedir ayuda + +
    + +
    + +### Ejercicios 4.8.-4.12. + +**Advertencia:** Si te encuentras utilizando los métodos async/await y then en el mismo código, es casi seguro que estás haciendo algo mal. Usa uno u otro y no mezcles los dos. + +#### 4.8: Pruebas de Lista de Blogs, paso 1 + +Utiliza la librería SuperTest para escribir una prueba que realice una solicitud HTTP GET a la URL /api/blogs. Verifica que la aplicación de la lista de blogs devuelva la cantidad correcta de publicaciones de blog en formato JSON. + +Una vez finalizada la prueba, refactoriza el controlador de ruta para usar la sintaxis async/await en lugar de promesas. + +Ten en cuenta que tendrás que realizar cambios similares en el código a los que fueron hechos [en el material](/es/part4/probando_el_backend#entorno-de-prueba), como definir el entorno de prueba para que puedas escribir pruebas que usan una base de datos separada. + +**NB:** cuando estás escribiendo tus pruebas **es mejor no ejecutarlas todas**, solo ejecuta aquellas en las que estás trabajando. Lee más sobre esto [aquí](/es/part4/probando_el_backend#ejecucion-de-pruebas-una-por-una). + +#### 4.9: Pruebas de Lista de Blogs, paso 2 + +Escribe una prueba que verifique que la propiedad de identificador único de las publicaciones del blog se llame id, de manera predeterminada, la base de datos nombra la propiedad _id. + +Realiza los cambios necesarios en el código para que pase la prueba. El método [toJSON](/es/part3/guardando_datos_en_mongo_db#backend-conectado-a-una-base-de-datos) discutido en la parte 3 es un lugar apropiado para definir el parámetro id. + +#### 4.10: Pruebas de Lista de Blogs, paso 3 + +Escribe una prueba que verifique que al realizar una solicitud HTTP POST a la URL /api/blogs se crea correctamente una nueva publicación de blog. Como mínimo, verifica que el número total de blogs en el sistema se incrementa en uno. También puedes verificar que el contenido de la publicación del blog se guarde correctamente en la base de datos. + +Una vez finalizada la prueba, refactoriza la operación para usar async/await en lugar de promesas. + +#### 4.11*: Pruebas de Lista de Blogs, paso 4 + +Escribe una prueba que verifique que si la propiedad likes falta en la solicitud, tendrá el valor 0 por defecto. No pruebes las otras propiedades de los blogs creados todavía. + +Realiza los cambios necesarios en el código para que pase la prueba. + +#### 4.12*: Pruebas de lista de blogs, paso 5 + +Escribe una prueba relacionada con la creación de blogs nuevos a través del endpoint /api/blogs, que verifique que si faltan las propiedades title o url de los datos solicitados, el backend responde a la solicitud con el código de estado 400 Bad Request. + +Realiza los cambios necesarios en el código para que pase la prueba. + +
    + +
    + +### Refactorizando pruebas + +Actualmente, a nuestras pruebas les falta cobertura. Algunas solicitudes como GET /api/notes/:id y DELETE /api/notes/:id no se prueban cuando la solicitud se envía con una identificación no válida. La agrupación y organización de las pruebas también podría mejorar, ya que todas las pruebas existen en el mismo "nivel superior" en el archivo de prueba. La legibilidad de la prueba mejoraría si agrupamos las pruebas relacionadas en bloques describe. + +A continuación se muestra un ejemplo del archivo de prueba después de realizar algunas mejoras menores: + +```js +const { test, after, beforeEach, describe } = require('node:test') +const assert = require('node:assert') +const mongoose = require('mongoose') +const supertest = require('supertest') +const app = require('../app') +const api = supertest(app) + +const helper = require('./test_helper') + +const Note = require('../models/note') + +describe('when there is initially some notes saved', () => { + beforeEach(async () => { + await Note.deleteMany({}) + await Note.insertMany(helper.initialNotes) + }) + + test('notes are returned as json', async () => { + await api + .get('/api/notes') + .expect(200) + .expect('Content-Type', /application\/json/) + }) + + test('all notes are returned', async () => { + const response = await api.get('/api/notes') + + assert.strictEqual(response.body.length, helper.initialNotes.length) + }) + + test('a specific note is within the returned notes', async () => { + const response = await api.get('/api/notes') + + const contents = response.body.map(r => r.content) + assert(contents.includes('Browser can execute only JavaScript')) + }) + + describe('viewing a specific note', () => { + + test('succeeds with a valid id', async () => { + const notesAtStart = await helper.notesInDb() + + const noteToView = notesAtStart[0] + + const resultNote = await api + .get(`/api/notes/${noteToView.id}`) + .expect(200) + .expect('Content-Type', /application\/json/) + + assert.deepStrictEqual(resultNote.body, noteToView) + }) + + test('fails with statuscode 404 if note does not exist', async () => { + const validNonexistingId = await helper.nonExistingId() + + await api + .get(`/api/notes/${validNonexistingId}`) + .expect(404) + }) + + test('fails with statuscode 400 id is invalid', async () => { + const invalidId = '5a3d5da59070081a82a3445' + + await api + .get(`/api/notes/${invalidId}`) + .expect(400) + }) + }) + + describe('addition of a new note', () => { + test('succeeds with valid data', async () => { + const newNote = { + content: 'async/await simplifies making async calls', + important: true, + } + + await api + .post('/api/notes') + .send(newNote) + .expect(201) + .expect('Content-Type', /application\/json/) + + const notesAtEnd = await helper.notesInDb() + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length + 1) + + const contents = notesAtEnd.map(n => n.content) + assert(contents.includes('async/await simplifies making async calls')) + }) + + test('fails with status code 400 if data invalid', async () => { + const newNote = { + important: true + } + + await api + .post('/api/notes') + .send(newNote) + .expect(400) + + const notesAtEnd = await helper.notesInDb() + + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length) + }) + }) + + describe('deletion of a note', () => { + test('succeeds with status code 204 if id is valid', async () => { + const notesAtStart = await helper.notesInDb() + const noteToDelete = notesAtStart[0] + + await api + .delete(`/api/notes/${noteToDelete.id}`) + .expect(204) + + const notesAtEnd = await helper.notesInDb() + + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length - 1) + + const contents = notesAtEnd.map(r => r.content) + assert(!contents.includes(noteToDelete.content)) + }) + }) +}) + +after(async () => { + await mongoose.connection.close() +}) +``` + +La salida de las pruebas en la consola se agrupa de acuerdo con los bloques describe: + +![salida de node:test mostrando bloques describe agrupados](../../images/4/7new.png) + +Todavía hay margen de mejora, pero es hora de seguir adelante. + +Esta forma de probar la API, al realizar solicitudes HTTP e inspeccionar la base de datos con Mongoose, no es de ninguna manera la única ni la mejor forma de realizar pruebas de integración a nivel de API para aplicaciones de servidor. No existe una mejor forma universal de escribir pruebas, ya que todo depende de la aplicación que se esté probando y de los recursos disponibles. + +Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part4-6 de [este repositorio de GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-6). + +
    + +
    + +### Ejercicios 4.13.-4.14. + +#### 4.13 Expansiones de la Lista de Blogs, paso 1 + +Implementa la funcionalidad para eliminar un solo recurso de publicación de blog. + +Utiliza la sintaxis async/await. Sigue las convenciones de [RESTful](/es/part3/node_js_y_express#rest) al definir la API HTTP. + +Implementa pruebas para esta funcionalidad. + +#### 4.14 Expansiones de Listas de Blogs, paso 2 + +Implementa la funcionalidad para actualizar la información de una publicación de blog individual. + +Utiliza async/await. + +La aplicación principalmente necesita actualizar la cantidad de likes para una publicación de blog. Puedes implementar esta funcionalidad de la misma manera que implementamos actualizar notas en la [parte 3](/es/part3/guardando_datos_en_mongo_db#otras-operaciones). + +Implementa pruebas para esta funcionalidad. + +
    diff --git a/src/content/4/es/part4c.md b/src/content/4/es/part4c.md new file mode 100644 index 00000000000..4a86efd58e2 --- /dev/null +++ b/src/content/4/es/part4c.md @@ -0,0 +1,555 @@ +--- +mainImage: ../../../images/part-4.svg +part: 4 +letter: c +lang: es +--- + +
    + +Queremos agregar autenticación y autorización de usuarios a nuestra aplicación. Los usuarios deben almacenarse en la base de datos y cada nota debe estar vinculada al usuario que la creó. La eliminación y edición de una nota solo debe permitirse para el usuario que la creó. + +Comencemos agregando información sobre los usuarios a la base de datos. Existe una relación de uno a varios entre el usuario (user) y las notas (Note): + +![diagrama conectando user y notes](https://yuml.me/a187045b.png) + +Si estuviéramos trabajando con una base de datos relacional, la implementación sería sencilla. Ambos recursos tendrían sus tablas de base de datos separadas, y la identificación del usuario que creó una nota se almacenaría en la tabla de notas como una clave externa. + +Cuando se trabaja con bases de datos de documentos, la situación es un poco diferente, ya que hay muchas formas diferentes de modelar la situación. + +La solución existente guarda todas las notas de la colección de notas en la base de datos. Si no queremos cambiar esta colección existente, entonces la opción natural es guardar a los usuarios en su propia colección, users por ejemplo. + +Al igual que con todas las bases de datos de documentos, podemos usar ID de objeto en Mongo para hacer referencia a documentos en otras colecciones. Esto es similar al uso de claves externas en bases de datos relacionales. + +Tradicionalmente, las bases de datos de documentos como Mongo no admiten consultas de unión que están disponibles en bases de datos relacionales, utilizadas para agregar datos de varias tablas. Sin embargo, a partir de la versión 3.2. Mongo ha admitido [consultas de agregación de búsqueda](https://docs.mongodb.com/manual/reference/operator/aggregation/lookup/). No examinaremos esta funcionalidad en este curso. + +Si necesitamos una funcionalidad similar a las consultas de unión, la implementaremos en el código de nuestra aplicación realizando múltiples consultas. En determinadas situaciones, Mongoose puede encargarse de unir y agregar datos, lo que da la apariencia de una consulta de unión. Sin embargo, incluso en estas situaciones, Mongoose realiza varias consultas a la base de datos en segundo plano. + +### Referencias entre colecciones + +Si estuviéramos usando una base de datos relacional, la nota contendría una clave de referencia para el usuario que la creó. En las bases de datos de documentos podemos hacer lo mismo. + +Supongamos que la colección de users contiene dos usuarios: + +```js +[ + { + username: 'mluukkai', + _id: 123456, + }, + { + username: 'hellas', + _id: 141414, + }, +]; +``` + +La colección notes contiene tres notas que tienen un campo user que hace referencia a un usuario en la colección users: + +```js +[ + { + content: 'HTML is easy', + important: false, + _id: 221212, + user: 123456, + }, + { + content: 'The most important operations of HTTP protocol are GET and POST', + important: true, + _id: 221255, + user: 123456, + }, + { + content: 'A proper dinosaur codes with Java', + important: false, + _id: 221244, + user: 141414, + }, +] +``` + +Las bases de datos de documentos no exigen que la clave externa se almacene en los recursos de notas, podría también almacenarse en la colección de usuarios, o incluso ambos: + +```js +[ + { + username: 'mluukkai', + _id: 123456, + notes: [221212, 221255], + }, + { + username: 'hellas', + _id: 141414, + notes: [221244], + }, +] +``` + +Dado que los usuarios pueden tener muchas notas, los identificadores relacionados se almacenan en una matriz en el campo notes. + +Las bases de datos de documentos también ofrecen una forma radicalmente diferente de organizar los datos: en algunas situaciones, podría ser beneficioso anidar todo el conjunto de notas como parte de los documentos en la colección de usuarios: + +```js +[ + { + username: 'mluukkai', + _id: 123456, + notes: [ + { + content: 'HTML is easy', + important: false, + }, + { + content: 'The most important operations of HTTP protocol are GET and POST', + important: true, + }, + ], + }, + { + username: 'hellas', + _id: 141414, + notes: [ + { + content: + 'A proper dinosaur codes with Java', + important: false, + }, + ], + }, +] +``` + +En este esquema, las notas estarían estrechamente anidadas debajo de los usuarios y la base de datos no generaría identificadores para ellos. + +La estructura y el esquema de la base de datos no es tan evidente como lo era con las bases de datos relacionales. El esquema elegido debe ser uno que admita mejor los casos de uso de la aplicación. Esta no es una decisión de diseño simple, ya que no se conocen todos los casos de uso de las aplicaciones cuando se toma la decisión de diseño. + +Paradójicamente, las bases de datos sin esquema como Mongo requieren que los desarrolladores tomen decisiones de diseño mucho más radicales sobre la organización de datos al comienzo del proyecto que las bases de datos relacionales con esquemas. En promedio, las bases de datos relacionales ofrecen una forma más o menos adecuada de organizar datos para muchas aplicaciones. + +### Esquema de Mongoose para usuarios + +En este caso, tomamos la decisión de almacenar los ID de las notas creadas por el usuario en el documento de usuario. Definamos el modelo para representar a un usuario en el archivo models/user.js : + +```js +const mongoose = require('mongoose') + +const userSchema = new mongoose.Schema({ + username: String, + name: String, + passwordHash: String, + notes: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: 'Note' + } + ], +}) + +userSchema.set('toJSON', { + transform: (document, returnedObject) => { + returnedObject.id = returnedObject._id.toString() + delete returnedObject._id + delete returnedObject.__v + // el passwordHash no debe mostrarse + delete returnedObject.passwordHash + } +}) + +const User = mongoose.model('User', userSchema) + +module.exports = User +``` + +Los identificadores de las notas se almacenan dentro del documento del usuario como una matriz de IDs de Mongo. La definición es la siguiente: + +```js +{ + type: mongoose.Schema.Types.ObjectId, + ref: 'Note' +} +``` + +El tipo de campo es ObjectId que hace referencia a documentos de tipo note. Mongo no sabe de manera inherente que este es un campo que hace referencia a notas, la sintaxis está puramente relacionada y definida por Mongoose. + +Expandamos el esquema de la nota definida en el archivo model/note.js para que la nota contenga información sobre el usuario que la creó: + +```js +const noteSchema = new mongoose.Schema({ + content: { + type: String, + required: true, + minlength: 5 + }, + important: Boolean, + // highlight-start + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + } + // highlight-end +}) +``` + +En marcado contraste con las convenciones de las bases de datos relacionales, las referencias ahora se almacenan en ambos documentos: la nota hace referencia al usuario que la creó, y el usuario tiene una serie de referencias a todas las notas creadas por ellos. + +### Creando usuarios + +Implementemos una ruta para crear nuevos usuarios. Los usuarios tienen un nombre de usuario único, un nombre y algo llamado passwordHash. El hash de la contraseña es el resultado de una [función hash unidireccional](https://es.wikipedia.org/wiki/Funci%C3%B3n_hash_criptogr%C3%A1fica) aplicada a la contraseña del usuario. ¡Nunca es aconsejable almacenar contraseñas de texto plano sin cifrar en la base de datos! + +Instalemos el paquete [bcrypt](https://github.com/kelektiv/node.bcrypt.js) para generar los hashes de contraseña: + +```bash +npm install bcrypt +``` + +La creación de nuevos usuarios ocurre de acuerdo con las convenciones RESTful discutidas en la [parte 3](/es/part3/node_js_y_express#rest), al realizar una solicitud HTTP POST a la ruta users. + +Definamos un enrutador separado para tratar con los usuarios en un nuevo archivo controllers/users.js. Usemos el enrutador en nuestra aplicación en el archivo app.js, de modo que maneje las solicitudes hechas a la URL /api/users: + +```js +const usersRouter = require('./controllers/users') + +// ... + +app.use('/api/users', usersRouter) +``` + +El contenido del archivo, controllers/users.js, que define el enrutador es el siguiente: + +```js +const bcrypt = require('bcrypt') +const usersRouter = require('express').Router() +const User = require('../models/user') + +usersRouter.post('/', async (request, response) => { + const { username, name, password } = request.body + + const saltRounds = 10 + const passwordHash = await bcrypt.hash(password, saltRounds) + + const user = new User({ + username, + name, + passwordHash, + }) + + const savedUser = await user.save() + + response.status(201).json(savedUser) +}) + +module.exports = usersRouter +``` + +La contraseña enviada en la solicitud no se almacena en la base de datos. Almacenamos el hash de la contraseña que se genera con la función _bcrypt.hash_. + +Los fundamentos de [almacenar contraseñas](https://codahale.com/how-to-safely-store-a-password/) están fuera del alcance de este material del curso. No discutiremos qué significa el número mágico 10 asignado a la variable [saltRounds](https://github.com/kelektiv/node.bcrypt.js/#a-note-on-rounds), pero puedes leer más sobre ello en el material vinculado. + +Nuestro código actual no contiene ningún manejo de errores o validación de input para verificar que el nombre de usuario y la contraseña están en el formato deseado. + +La nueva funcionalidad, inicialmente puede y debe probarse manualmente con una herramienta como Postman. Sin embargo, probar las cosas manualmente se volverá demasiado engorroso rápidamente, especialmente una vez que implementemos la funcionalidad que obliga a los nombres de usuario a ser únicos. + +Se necesita mucho menos esfuerzo para escribir pruebas automatizadas y esto hará que el desarrollo de nuestra aplicación sea mucho más fácil. + +Nuestras pruebas iniciales podrían verse así: + +```js +const bcrypt = require('bcrypt') +const User = require('../models/user') + +//... + +describe('when there is initially one user in db', () => { + beforeEach(async () => { + await User.deleteMany({}) + + const passwordHash = await bcrypt.hash('sekret', 10) + const user = new User({ username: 'root', passwordHash }) + + await user.save() + }) + + test('creation succeeds with a fresh username', async () => { + const usersAtStart = await helper.usersInDb() + + const newUser = { + username: 'mluukkai', + name: 'Matti Luukkainen', + password: 'salainen', + } + + await api + .post('/api/users') + .send(newUser) + .expect(201) + .expect('Content-Type', /application\/json/) + + const usersAtEnd = await helper.usersInDb() + assert.strictEqual(usersAtEnd.length, usersAtStart.length + 1) + + const usernames = usersAtEnd.map(u => u.username) + assert(usernames.includes(newUser.username)) + }) +}) +``` + +Las pruebas utilizan la función auxiliar usersInDb() que implementamos en el archivo tests/test_helper.js. La función se utiliza para ayudarnos a verificar el estado de la base de datos después de que se crea un usuario: + +```js +const User = require('../models/user') + +// ... + +const usersInDb = async () => { + const users = await User.find({}) + return users.map(u => u.toJSON()) +} + +module.exports = { + initialNotes, + nonExistingId, + notesInDb, + usersInDb, +} +``` + +El bloque beforeEach agrega un usuario con el nombre de usuario root a la base de datos. La función se utiliza para ayudarnos a verificar el estado de la base de datos después de que se crea un usuario: + +```js +describe('when there is initially one user in db', () => { + // ... + + test('creation fails with proper statuscode and message if username already taken', async () => { + const usersAtStart = await helper.usersInDb() + + const newUser = { + username: 'root', + name: 'Superuser', + password: 'salainen', + } + + const result = await api + .post('/api/users') + .send(newUser) + .expect(400) + .expect('Content-Type', /application\/json/) + + const usersAtEnd = await helper.usersInDb() + assert(result.body.error.includes('expected `username` to be unique')) + + assert.strictEqual(usersAtEnd.length, usersAtStart.length) + }) +}) +``` + +El caso de prueba obviamente no pasará en este punto. Básicamente, estamos practicando [desarrollo guiado por pruebas (TDD)](https://es.wikipedia.org/wiki/Desarrollo_guiado_por_pruebas), donde las pruebas para la nueva funcionalidad se escriben antes de implementar la funcionalidad. + +Las validaciones de Mongoose no proporcionan una manera directa de verificar la unicidad del valor de un campo. Sin embargo, es posible lograr la unicidad definiendo un [índice de unicidad](https://mongoosejs.com/docs/schematypes.html) para un campo. La definición se realiza de la siguiente manera: + +```js +const mongoose = require('mongoose') + +const userSchema = mongoose.Schema({ + // highlight-start + username: { + type: String, + required: true, + unique: true // esto asegura la unicidad de username + }, + // highlight-end + name: String, + passwordHash: String, + notes: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: 'Note' + } + ], +}) + +// ... +``` + +Sin embargo, queremos tener cuidado al usar el índice de unicidad. Si ya hay documentos en la base de datos que violan la condición de unicidad, [no se creará ningún índice](https://dev.to/akshatsinghania/mongoose-unique-not-working-16bf). Por lo tanto, al agregar un índice de unicidad, ¡asegúrate de que la base de datos esté en un estado saludable! La prueba anterior agregó al usuario con username _root_ a la base de datos dos veces, y estos deben ser eliminados para que el índice se forme y el código funcione. + +Las validaciones de Mongoose no detectan la violación del índice, y en lugar de _ValidationError_ devuelven un error del tipo _MongoServerError_. Por lo tanto, necesitamos extender el controlador de errores para ese caso: + +```js +const errorHandler = (error, request, response, next) => { + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } else if (error.name === 'ValidationError') { + return response.status(400).json({ error: error.message }) +// highlight-start + } else if (error.name === 'MongoServerError' && error.message.includes('E11000 duplicate key error')) { + return response.status(400).json({ error: 'expected `username` to be unique' }) + } + // highlight-end + + next(error) +} +``` + +Luego de estos cambios, las pruebas pasaran. + +También podríamos implementar otras validaciones en la creación de usuarios. Podríamos comprobar que el nombre de usuario es lo suficientemente largo, que el nombre de usuario solo consta de caracteres permitidos o que la contraseña es lo suficientemente segura. La implementación de estas funcionalidades se deja como ejercicio opcional. + +Antes de continuar, agreguemos una implementación inicial de un controlador de ruta que devuelva todos los usuarios en la base de datos: + +```js +usersRouter.get('/', async (request, response) => { + const users = await User.find({}) + response.json(users) +}) +``` + +Para crear nuevos usuarios en un entorno de producción o desarrollo, puedes enviar una solicitud POST a ```/api/users/``` mediante Postman o REST Client en el siguiente formato: + +```js +{ + "username": "root", + "name": "Superuser", + "password": "salainen" +} +``` + +La lista se ve así: + +![navegador en api/users mostrando matriz de notas en formato JSON](../../images/4/9.png) + +Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part4-7 de [este repositorio de GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-7). + +### Creación de una nueva nota + +El código para crear una nueva nota debe actualizarse para que la nota se asigne al usuario que la creó. + +Expandamos nuestra implementación actual para que la información sobre el usuario que creó una nota se envíe en el campo userId del cuerpo de la solicitud: + +```js +const User = require('../models/user') //highlight-line + +//... + +notesRouter.post('/', async (request, response) => { + const body = request.body + + const user = await User.findById(body.userId) //highlight-line + + const note = new Note({ + content: body.content, + important: body.important === undefined ? false : body.important, + user: user.id //highlight-line + }) + + const savedNote = await note.save() + user.notes = user.notes.concat(savedNote._id) //highlight-line + await user.save() //highlight-line + + response.status(201).json(savedNote) +}) +``` + +Vale la pena notar que el objeto user también cambia. El id de la nota se almacena en el campo notes del objeto user: + +```js +const user = await User.findById(body.userId) + +// ... + +user.notes = user.notes.concat(savedNote._id) +await user.save() +``` + +Intentemos crear una nueva nota: + +![Postman creando una nueva nota](../../images/4/10e.png) + +La operación parece funcionar. Agreguemos una nota más y luego visitemos la ruta para buscar todos los usuarios: + +![api/users devuelve JSON con usuarios y su matriz de notas](../../images/4/11e.png) + +Podemos ver que el usuario tiene dos notas. + +Asimismo, los IDs de los usuarios que crearon las notas se pueden ver cuando visitamos la ruta para buscar todas las notas: + +![api/notes muestra ids de usuarios en JSON](../../images/4/12e.png) + +### Populate + +Nos gustaría que nuestra API funcione de tal manera que cuando se realiza una solicitud HTTP GET a la ruta /api/users, los objetos de usuario también contengan el contenido de las notas del usuario, y no solo su identificación. En una base de datos relacional, esta funcionalidad se implementaría con una consulta de unión. + +Como se mencionó anteriormente, las bases de datos de documentos no admiten adecuadamente las consultas de unión entre colecciones, pero la librería Mongoose puede hacer algunas de estas uniones por nosotros. Mongoose logra la unión haciendo múltiples consultas, lo cual es diferente de las consultas de unión en bases de datos relacionales que son transaccionales, lo que significa que el estado de la base de datos no cambia durante el tiempo que se realiza la consulta. Con las consultas de unión en Mongoose, nada puede garantizar que el estado entre las colecciones que se están uniendo sea coherente, lo que significa que si hacemos una consulta que une al usuario y las colecciones de notas, el estado de las colecciones puede cambiar durante la consulta. + +La unión de Mongoose se realiza con el método [populate](http://mongoosejs.com/docs/populate.html). Actualicemos la ruta que devuelve todos los usuarios primero: + +```js +usersRouter.get('/', async (request, response) => { + const users = await User // highlight-line + .find({}).populate('notes') // highlight-line + + response.json(users) +}) +``` + +El método [populate](http://mongoosejs.com/docs/populate.html) se encadena después de que el método find realiza la consulta inicial. El argumento dado al método populate define que los ids que hacen referencia a objetos note en el campo notes del documento user serán reemplazados por los documentos de note referenciados. + +El resultado es casi exactamente lo que queríamos: + +![datos JSON en el navegador mostrando datos de notas y usuarios repetidos](../../images/4/13new.png) + +Podemos usar el método populate para elegir los campos que queremos incluir de los documentos. Además del campo *id*, ahora solo nos interesan *content* e *important*. + +La selección de campos se realiza con la [sintaxis](https://www.mongodb.com/docs/manual/tutorial/project-fields-from-query-results/#return-the-specified-fields-and-the-_id-field-only) de Mongo: + +```js +usersRouter.get('/', async (request, response) => { + const users = await User + .find({}).populate('notes', { content: 1, important: 1 }) + + response.json(users) +}) +``` + +El resultado es ahora exactamente como queremos que sea: + +![datos combinados sin repeticiones](../../images/4/14new.png) + +También usemos populate en notes para mostrar información de usuario adecuada en las notas: + +```js +notesRouter.get('/', async (request, response) => { + const notes = await Note + .find({}).populate('user', { username: 1, name: 1 }) + + response.json(notes) +}); +``` + +Ahora la información del usuario se agrega al campo user de los objetos de nota. + +![JSON de notes ahora también tiene información de usuarios](../../images/4/15new.png) + +Es importante entender que la base de datos en realidad no sabe que los IDs almacenados en el campo user de la colección de notas hacen referencia a documentos en la colección de usuarios. + +La funcionalidad del método populate de Mongoose se basa en el hecho de que hemos definido "tipos" para las referencias en el esquema de Mongoose con la opción ref: + +```js +const noteSchema = new mongoose.Schema({ + content: { + type: String, + required: true, + minlength: 5 + }, + important: Boolean, + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + } +}) +``` + +Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part4-8 de [este repositorio de GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-8). + +**NOTA:** En esta etapa, en primer lugar, algunos tests fallarán. Dejaremos la corrección de los tests como un ejercicio no obligatorio. En segundo lugar, en la aplicación de notas desplegada, la función de crear una nota dejará de funcionar ya que el usuario aún no está vinculado al frontend. + +
    diff --git a/src/content/4/es/part4d.md b/src/content/4/es/part4d.md new file mode 100644 index 00000000000..25d101ab828 --- /dev/null +++ b/src/content/4/es/part4d.md @@ -0,0 +1,542 @@ +--- +mainImage: ../../../images/part-4.svg +part: 4 +letter: d +lang: es +--- + +
    + +Los usuarios deben poder iniciar sesión en nuestra aplicación, y cuando un usuario inicia sesión, su información de usuario debe adjuntarse automáticamente a cualquier nota nueva que cree. + +Ahora implementaremos soporte para [autenticación basada en token](https://www.digitalocean.com/community/tutorials/the-ins-and-outs-of-token-based-authentication#how-token-based-works) para el backend. + +Los principios de la autenticación basada en tokens se describen en el siguiente diagrama de secuencia: + +![diagrama de secuencia de autenticación basada en tokens](../../images/4/16new.png) + +- El usuario comienza iniciando sesión usando un formulario de inicio de sesión implementado con React + - Agregaremos el formulario de inicio de sesión a la interfaz en la [parte 5](/es/part5) +- Esto hace que el código React envíe el nombre de usuario y la contraseña a la dirección del servidor /api/login como una solicitud HTTP POST. +- Si el nombre de usuario y la contraseña son correctos, el servidor genera un token que identifica de alguna manera al usuario que inició sesión. + - El token está firmado digitalmente, por lo que es imposible falsificarlo (con medios criptográficos) +- El backend responde con un código de estado que indica que la operación fue exitosa y devuelve el token con la respuesta. +- El navegador guarda el token, por ejemplo, en el estado de una aplicación React. +- Cuando el usuario crea una nueva nota (o realiza alguna otra operación que requiera identificación), el código React envía el token al servidor con la solicitud. +- El servidor usa el token para identificar al usuario + +Primero implementemos la funcionalidad para iniciar sesión. Instala la librería [jsonwebtoken](https://github.com/auth0/node-jsonwebtoken), que nos permite generar [tokens web JSON](https://jwt.io/). + +```bash +npm install jsonwebtoken +``` + +El código para la funcionalidad de inicio de sesión va a los controladores de controllers/login.js. + +```js +const jwt = require('jsonwebtoken') +const bcrypt = require('bcrypt') +const loginRouter = require('express').Router() +const User = require('../models/user') + +loginRouter.post('/', async (request, response) => { + const { username, password } = request.body + + const user = await User.findOne({ username }) + const passwordCorrect = user === null + ? false + : await bcrypt.compare(password, user.passwordHash) + + if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) + } + + const userForToken = { + username: user.username, + id: user._id, + } + + const token = jwt.sign(userForToken, process.env.SECRET) + + response + .status(200) + .send({ token, username: user.username, name: user.name }) +}) + +module.exports = loginRouter +``` + +El código comienza buscando al usuario en la base de datos por el username adjunto a la solicitud. + +```js +const user = await User.findOne({ username }) +``` + +A continuación, verifica la password, también adjunta a la solicitud. + +```js +const passwordCorrect = user === null + ? false + : await bcrypt.compare(password, user.passwordHash) +``` + +Debido a que las contraseñas en sí no se guardan en la base de datos, sino que se guardan hashes calculados a partir de las contraseñas, el método _bcrypt.compare_ se usa para verificar si la contraseña es correcta: + +```js +await bcrypt.compare(password, user.passwordHash) +``` + +Si no se encuentra el usuario o la contraseña es incorrecta, se responde a la solicitud con el código de estado [401 unauthorized](https://www.rfc-editor.org/rfc/rfc9110.html#name-401-unauthorized). El motivo del error se explica en el cuerpo de la respuesta. + +```js +if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) +} +``` + +Si la contraseña es correcta, se crea un token con el método _jwt.sign_. El token contiene el nombre de usuario y la ID de usuario en un formato firmado digitalmente. + +```js +const userForToken = { + username: user.username, + id: user._id, +} + +const token = jwt.sign(userForToken, process.env.SECRET) +``` + +El token ha sido firmado digitalmente usando una cadena de variable de entorno SECRET como el secreto. +La firma digital garantiza que solo las partes que conocen el secreto puedan generar un token válido. +El valor de la variable de entorno debe establecerse en el archivo .env. + +Una solicitud exitosa se responde con el código de estado 200 OK. El token generado y el username del usuario se devuelven al cuerpo de la respuesta. + +```js +response + .status(200) + .send({ token, username: user.username, name: user.name }) +``` + +Ahora, el código de inicio de sesión solo debe agregarse a la aplicación agregando el nuevo enrutador a app.js. + +```js +const loginRouter = require('./controllers/login') + +//... + +app.use('/api/login', loginRouter) +``` + +Vamos a intentar logearnos usando el cliente REST de VS Code: + +![vscode rest post a api/login con username/password](../../images/4/17e.png) + +No funciona. Se imprime lo siguiente en la consola: + +```bash +(node:32911) UnhandledPromiseRejectionWarning: Error: secretOrPrivateKey must have a value + at Object.module.exports [as sign] (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/sign.js:101:20) + at loginRouter.post (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/controllers/login.js:26:21) +(node:32911) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 2) +``` + +El comando _jwt.sign(userForToken, process.env.SECRET) _ falla. Olvidamos establecer un valor para la variable de entorno SECRET. Puede ser cualquier string. Cuando establecemos el valor en el archivo .env, el inicio de sesión funciona. + +Un inicio de sesión exitoso devuelve los detalles del usuario y el token: + +![vs code rest, respuesta mostrando detalles y token](../../images/4/18ea.png) + +Un nombre de usuario o contraseña incorrectos devuelve un mensaje de error y el código de estado correcto: + +![vs code rest, respuesta para credenciales de login incorrectos](../../images/4/19ea.png) + +### Limitación de la creación de nuevas notas a los usuarios registrados + +Cambiemos la creación de nuevas notas para que solo sea posible si la solicitud de publicación tiene un token válido adjunto. Luego, la nota se guarda en la lista de notas del usuario identificado por el token. + +Hay varias formas de enviar el token desde el navegador al servidor. Usaremos el encabezado [Authorization](https://developer.mozilla.org/es/docs/Web/HTTP/Headers/Authorization). El encabezado también indica qué [esquema de autenticación](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#Authentication_schemes) se utiliza. Esto puede ser necesario si el servidor ofrece varias formas de autenticación. +La identificación del esquema le dice al servidor cómo se deben interpretar las credenciales adjuntas. + +El esquema Bearer se adapta a nuestras necesidades. + +En la práctica, esto significa que si el token es, por ejemplo, la cadena eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW, el encabezado de autorización tendrá el valor: + +``` +Bearer eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW +``` + +Creación de nuevas notas cambiará de este modo (controllers/notes.js): + +```js +const jwt = require('jsonwebtoken') //highlight-line + +// ... + //highlight-start +const getTokenFrom = request => { + const authorization = request.get('authorization') + if (authorization && authorization.startsWith('Bearer ')) { + return authorization.replace('Bearer ', '') + } + return null +} + //highlight-end + +notesRouter.post('/', async (request, response) => { + const body = request.body +//highlight-start + const decodedToken = jwt.verify(getTokenFrom(request), process.env.SECRET) + if (!decodedToken.id) { + return response.status(401).json({ error: 'token invalid' }) + } + + const user = await User.findById(decodedToken.id) +//highlight-end + + const note = new Note({ + content: body.content, + important: body.important === undefined ? false : body.important, + user: user._id + }) + + const savedNote = await note.save() + user.notes = user.notes.concat(savedNote._id) + await user.save() + + response.json(savedNote) +}) +``` + +La función auxiliar _getTokenFrom_ aísla el token del encabezado authorization. La validez del token se comprueba con _jwt.verify_. El método también decodifica el token, o devuelve el objeto en el que se basó el token: + +```js +const decodedToken = jwt.verify(token, process.env.SECRET) +``` + +Si el token es invalido o está ausente, la excepción JsonWebTokenError es generada. Tenemos que extender nuestro middleware de manejo de errors para tener en cuenta este caso particular: + +```js +const errorHandler = (error, request, response, next) => { + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } else if (error.name === 'ValidationError') { + return response.status(400).json({ error: error.message }) + } else if (error.name === 'MongoServerError' && error.message.includes('E11000 duplicate key error')) { + return response.status(400).json({ error: 'expected `username` to be unique' }) + } else if (error.name === 'JsonWebTokenError') { // highlight-line + return response.status(401).json({ error: 'token invalid' }) // highlight-line + } + + next(error) +} +``` + +El objeto decodificado del token contiene los campos username y id, que le dice al servidor quién hizo la solicitud. + +Si el objeto decodificado del token no contiene la identidad del usuario (_decodedToken.id_ es undefined), el código de estado de error [401 unauthorized](https://www.rfc-editor.org/rfc/rfc9110.html#name-401-unauthorized) es devuelto y el motivo del error se explica en el cuerpo de la respuesta. + +```js +if (!decodedToken.id) { + return response.status(401).json({ + error: 'token invalid' + }) +} +``` + +Cuando se resuelve la identidad del autor de la solicitud, la ejecución continúa como antes. + +Ahora se puede crear una nueva nota usando Postman si el encabezado authorization tiene el valor correcto, la cadena Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ, donde el segundo valor es el token devuelto por la operación login. + +Usando Postman, esto se ve de la siguiente manera: + +![postman agregando bearer token](../../images/4/20new.png) + +y con el cliente REST de Visual Studio Code + +![vscode rest agregando bearer token](../../images/4/21new.png) + +El código de la aplicación actual se puede encontrar en [GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-9), rama part4-9. + +Si la aplicación tiene múltiples interfaces que requieren identificación, la validación de JWT debe separarse en su propio middleware. También se podría utilizar alguna librería existente como [express-jwt](https://www.npmjs.com/package/express-jwt). + +### Problemas de la autenticación basada en Tokens + +La autenticación basada en tokens es muy fácil de implementar, pero tiene un problema. Una vez que el cliente de la API, por ejemplo una aplicación React, obtiene un token, la API tiene una confianza ciega en el titular del token. ¿Qué sucede si necesitamos revocar los derechos de acceso del titular del token? + +Hay dos soluciones al problema. La más fácil es limitar el período de validez de un token: + +```js +loginRouter.post('/', async (request, response) => { + const { username, password } = request.body + + const user = await User.findOne({ username }) + const passwordCorrect = user === null + ? false + : await bcrypt.compare(password, user.passwordHash) + + if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) + } + + const userForToken = { + username: user.username, + id: user._id, + } + + // el token expira in 60*60 segundos, eso es, en una hora + // highlight-start + const token = jwt.sign( + userForToken, + process.env.SECRET, + { expiresIn: 60*60 } + ) + // highlight-end + + response + .status(200) + .send({ token, username: user.username, name: user.name }) +}) +``` + +Una vez que el token caduca, la aplicación cliente necesita obtener un nuevo token. Por lo general, esto sucede al obligar al usuario a volver a iniciar sesión en la aplicación. + +El middleware de manejo de errores debe extenderse para dar un error adecuado en el caso de un token caducado: + +```js +const errorHandler = (error, request, response, next) => { + logger.error(error.message) + + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } else if (error.name === 'ValidationError') { + return response.status(400).json({ error: error.message }) + } else if (error.name === 'MongoServerError' && error.message.includes('E11000 duplicate key error')) { + return response.status(400).json({ + error: 'expected `username` to be unique' + }) + } else if (error.name === 'JsonWebTokenError') { + return response.status(401).json({ + error: 'invalid token' + }) + // highlight-start + } else if (error.name === 'TokenExpiredError') { + return response.status(401).json({ + error: 'token expired' + }) + } + // highlight-end + + next(error) +} +``` + +Cuanto más corto sea el tiempo de caducidad, más segura será la solución. Por lo tanto, si el token cae en las manos equivocadas o es necesario revocar el acceso del usuario al sistema, el token solo se puede utilizar durante un período de tiempo limitado. Por otro lado, un tiempo de caducidad corto genera un dolor potencial para el usuario, ya que implica iniciar sesión en el sistema con más frecuencia. + +La otra solución es guardar información sobre cada token en la base de datos y verificar en cada solicitud de API si el derecho de acceso correspondiente al token sigue siendo válido. Con este esquema, los derechos de acceso pueden ser revocados en cualquier momento. Este tipo de solución a menudo se denomina server-side session. + +El aspecto negativo de las sesiones del lado del servidor es la mayor complejidad en el backend y también el efecto en el rendimiento, ya que se debe verificar la validez del token para cada solicitud de API a la base de datos. El acceso a la base de datos es considerablemente más lento en comparación con la verificación de la validez del token en sí. Es por eso que es bastante común guardar la sesión correspondiente a un token en una base de datos de llave-valor como [Redis](https://redis.io/), que tiene una funcionalidad limitada en comparación con MongoDB o una base de datos relacional, pero es extremadamente rápida en algunos escenarios de uso. + +Cuando se utilizan sesiones del lado del servidor, el token suele ser solo una cadena aleatoria, que no incluye ninguna información sobre el usuario, como suele ser el caso cuando se utilizan jwt-tokens. Para cada solicitud de API, el servidor obtiene la información relevante sobre la identidad del usuario de la base de datos. También es bastante habitual que, en lugar de utilizar el encabezado de autorización, se utilicen cookies como mecanismo para transferir el token entre el cliente y el servidor. + +### Notas finales + +Ha habido muchos cambios en el código que han causado un problema típico para un proyecto de software de rápido desarrollo: la mayoría de las pruebas se han roto. Debido a que esta parte del curso ya está repleta de nueva información, dejaremos el arreglo de las pruebas cómo un ejercicio no obligatorio. + +Los nombres de usuario, las contraseñas y las aplicaciones que utilizan la autenticación basada en token siempre deben usarse en [HTTPS](https://es.wikipedia.org/wiki/Protocolo_seguro_de_transferencia_de_hipertexto). Podríamos usar un servidor Node [HTTPS](https://nodejs.org/docs/latest-v18.x/api/https.html) en nuestra aplicación en lugar del servidor [HTTP](https://nodejs.org/docs/latest-v18.x/api/http.html) (requiere más configuración). Por otro lado, la versión de producción de nuestra aplicación está en Fly.io, por lo que nuestra aplicación permanece segura: Fly.io enruta todo el tráfico entre un navegador y el servidor Fly.io a través de HTTPS. + +Implementaremos el inicio de sesión en la interfaz en la [siguiente parte](/es/part5). + +**NOTA:** En esta etapa, en la aplicación de notas desplegada, se espera que la función de crear una nota deje de funcionar ya que la funcionalidad de inicio de sesión del backend aún no está vinculada al frontend. + +
    + +
    + +### Ejercicios 4.15.-4.23. + +En los próximos ejercicios, se implementarán los conceptos básicos de la gestión de usuarios para la aplicación de lista de blogs. La forma más segura es seguir el material del curso desde el capítulo de la parte 4 [Administración de usuarios](/es/part4/administracion_de_usuarios) hasta el capítulo [Autenticación basada en token](/es/part4/autenticacion_basada_en_token). Por supuesto, también puedes utilizar tu creatividad. + +**Una advertencia más:** Si notas que estás mezclando llamadas async/await y _then_, es 99% seguro que estás haciendo algo mal. Utiliza uno u otro, nunca ambos. + +#### 4.15: Expansión de la Lista de Blogs, paso 3 + +Implementa una forma de crear nuevos usuarios realizando una solicitud POST HTTP a la dirección api/users. Los usuarios tienen username, password y name. + +No guardes las contraseñas en la base de datos como texto sin cifrar, utiliza la librería bcrypt como hicimos en el capítulo de la parte 4 [Creando usuarios](/es/part4/administracion_de_usuarios#creando-usuarios). + +**NB** Algunos usuarios de Windows han tenido problemas con bcrypt. Si tienes problemas, elimina la librería con el comando + +```bash +npm uninstall bcrypt +``` + +e instala [bcryptjs](https://www.npmjs.com/package/bcryptjs) en su lugar. + +Implementa una forma de ver los detalles de todos los usuarios realizando una solicitud HTTP adecuada. + +La lista de usuarios puede, por ejemplo, tener el siguiente aspecto: + +![navegador en api/users mostrando datos en formato JSON de dos usuarios](../../images/4/22.png) + +#### 4.16*: Expansión de la Lista de Blogs, paso 4 + +Agrega una funcionalidad que agregue las siguientes restricciones para la creación de nuevos usuarios: Deben proporcionarse tanto el username como password y ambos deben tener al menos 3 caracteres. El username debe ser único. + +La operación debe responder con un código de estado adecuado y algún tipo de mensaje de error si se crea un usuario no válido. + +**NB** No pruebes las restricciones de password con las validaciones de Mongoose. No es una buena idea porque la password recibida por el backend y el hash de password guardado en la base de datos no son lo mismo. La longitud de la contraseña debe validarse en el controlador como hicimos en la [parte 3](/es/part3/validacion_y_es_lint) antes de usar la validación de Mongoose. + +Además, **implementa pruebas** que verifiquen que no se creen usuarios no válidos y que una operación de agregar usuario que sea no válida devuelva un código de estado adecuado y un mensaje de error. + +**NB** si decides definir pruebas en múltiples archivos, debes notar que por defecto cada archivo de prueba se ejecuta en su propio proceso (ver _Modelo de ejecución de pruebas_ en la [documentación](https://nodejs.org/api/test.html#test-runner-execution-model)). La consecuencia de esto es que diferentes archivos de prueba se ejecutan al mismo tiempo. Dado que las pruebas comparten la misma base de datos, la ejecución simultánea puede causar problemas, que pueden evitarse ejecutando las pruebas con la opción _--test-concurrency=1_, es decir, definiéndolas para que se ejecuten secuencialmente. + +#### 4.17: Expansión de la Lista de Blogs, paso 5 + +Expande los blogs para que cada blog contenga información sobre el creador del blog. + +Modifica la adición de nuevos blogs para que cuando se cree un nuevo blog, cualquier usuario de la base de datos sea designado como su creador (por ejemplo, el que se encontró primero). Implementa esto de acuerdo con el capítulo de la parte 4 [populate](/es/part4/administracion_de_usuarios#populate). +El usuario designado como creador no importa todavía. La funcionalidad se termina en el ejercicio 4.19. + +Modifica la lista de todos los blogs para que la información de usuario del creador se muestre con el blog: + +![api/blogs con información de usuario en formato JSON](../../images/4/23e.png) + +y la lista de todos los usuarios también muestra los blogs creados por cada usuario: + +![api/users con información de blogs en formato JSON](../../images/4/24e.png) + +#### 4.18: Expansión de la Lista de Blogs, paso 6 + +Implementar la autenticación basada en token según la parte 4 [Autenticación basada en token](/es/part4/autenticacion_basada_en_token). + +#### 4.19: Expansión de la Lista de Blogs, paso 7 + +Modifica la adición de nuevos blogs para que solo sea posible si se envía un token válido con la solicitud HTTP POST. El usuario identificado por el token se designa como el creador del blog. + +#### 4.20*: Expansión de la Lista de Blogs, paso 8 + +[Este ejemplo](/es/part4/autenticacion_basada_en_token#limitacion-de-la-creacion-de-nuevas-notas-a-los-usuarios-registrados) de la parte 4 muestra cómo tomar el token del encabezado con la función auxiliar _getTokenFrom_. + +Si usaste la misma solución, refactoriza para llevar el token a un [middleware](/es/part3/node_js_y_express#middleware). El middleware debe tomar el token del encabezado Authorization y debe asignarlo al campo token del objeto request. + +En otras palabras, si registras este middleware en el archivo app.js antes de todas las rutas + +```js +app.use(middleware.tokenExtractor) +``` + +Las rutas pueden acceder al token con _request.token_: + +```js +blogsRouter.post('/', async (request, response) => { + // .. + const decodedToken = jwt.verify(request.token, process.env.SECRET) + // .. +}) +``` + +Recuerda que una [función middleware](/es/part3/node_js_y_express#middleware) es una función con tres parámetros, que al final llama al último parámetro next para mover el control al siguiente middleware: + +```js +const tokenExtractor = (request, response, next) => { + // código que extrae el token + + next() +} +``` + +#### 4.21*: Expansión de la Lista de Blogs, paso 9 + +Cambia la operación de eliminar blogs para que el blog solo pueda ser eliminado por el usuario que lo agregó. Por lo tanto, eliminar un blog solo es posible si el token enviado con la solicitud es el mismo que el del creador del blog. + +Si se intenta eliminar un blog sin un token o por un usuario incorrecto, la operación debe devolver un código de estado adecuado. + +Ten en cuenta que si obtienes un blog de la base de datos, + +```js +const blog = await Blog.findById(...) +``` + +el campo blog.user no contiene una cadena, sino un objeto. Entonces, si deseas comparar el ID del objeto obtenido de la base de datos y un ID de cadena, la operación de comparación normal no funciona. El ID obtenido de la base de datos debe primero convertirse en una cadena. + +```js +if ( blog.user.toString() === userid.toString() ) ... +``` + +#### 4.22*: Expansión de la Lista de Blogs, paso 10 + +Tanto la creación de un nuevo blog como su eliminación necesitan averiguar la identidad del usuario que está realizando la operación. El middleware _tokenExtractor_ que hicimos en el ejercicio 4.20 ayuda, pero los controladores de las operaciones post y delete necesitan averiguar quién es el usuario que posee un token específico. + +Ahora cree un nuevo middleware _userExtractor_, que encuentre al usuario y lo guarde en el objeto de solicitud. Cuando registras el middleware en app.js + +```js +app.use(middleware.userExtractor) +``` + +el usuario se guardara en el campo _request.user_: + +```js +blogsRouter.post('/', async (request, response) => { + // obtén usuario desde el objeto de solicitud + const user = request.user + // .. +}) + +blogsRouter.delete('/:id', async (request, response) => { + // obtén usuario desde el objeto de solicitud + const user = request.user + // .. +}) +``` + +Ten en cuenta que es posible registrar un middleware solo para un conjunto específico de rutas. Entonces, en lugar de usar _userExtractor_ con todas las rutas, + +```js +const middleware = require('../utils/middleware'); +// ... + +// usa el middleware en todas las rutas +app.use(middleware.userExtractor) // highlight-line + +app.use('/api/blogs', blogsRouter) +app.use('/api/users', usersRouter) +app.use('/api/login', loginRouter) +``` + +podríamos registrarlo para que solo se ejecute con las rutas de /api/blogs: + +```js +const middleware = require('../utils/middleware'); +// ... + +// usa el middleware solo en las rutas de api/blogs +app.use('/api/blogs', middleware.userExtractor, blogsRouter) // highlight-line +app.use('/api/users', usersRouter) +app.use('/api/login', loginRouter) +``` + +Como puede verse, esto sucede al encadenarse múltiples middlewares como argumentos de la función use. También sería posible registrar un middleware solo para una operación específica: + +```js +const middleware = require('../utils/middleware'); +// ... + +router.post('/', middleware.userExtractor, async (request, response) => { + // ... +} +``` + +#### 4.23*: Expansión de la Lista de Blogs, paso 11 + +Después de agregar la autenticación basada en token, las pruebas para agregar un nuevo blog se rompieron. Arréglalas. También escribe una nueva prueba para asegurarte de que la adición de un blog falla con el código de estado adecuado 401 Unauthorized si no se proporciona un token. + +[Esto](https://github.com/visionmedia/supertest/issues/398) probablemente sea útil al hacer la corrección. + +Este es el último ejercicio de esta parte del curso y es hora de enviar tu código a GitHub y marcar todos sus ejercicios terminados en el [sistema de envío de ejercicios](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    diff --git a/src/content/4/fi/osa4.md b/src/content/4/fi/osa4.md index 015a885bd67..7818a7bcb8d 100644 --- a/src/content/4/fi/osa4.md +++ b/src/content/4/fi/osa4.md @@ -8,4 +8,9 @@ lang: fi Jatkamme tämän osan backendin parissa. Osan ensimmäinen iso teema on backendin yksikkö- ja integraatiotestaus. Testauksen jälkeen toteutetaan backendin logiikka käyttäjienhallintaan ja kirjautumiseen. +Osa päivitetty 13.8.2025 +- Node päivitetty versioon v22.3.0 +- Express päivitetty versioon 5 ja kirjasto express-async-errors poistettu osasta 4b +- Pieniä korjauksia ja parannuksia +
    diff --git a/src/content/4/fi/osa4a.md b/src/content/4/fi/osa4a.md index 8d7735f6b33..2a4f0589006 100644 --- a/src/content/4/fi/osa4a.md +++ b/src/content/4/fi/osa4a.md @@ -11,28 +11,30 @@ Jatketaan [osassa 3](/osa3) tehdyn muistiinpanosovelluksen backendin kehittämis ### Sovelluksen rakenne -Ennen osan ensimmäistä isoa teemaa eli testaamista, muutetaan sovelluksen rakennetta noudattamaan paremmin Noden yleisiä konventioita. +**HUOM**: Kurssimateriaalia tehtäessä on ollut käytössä Node.js:n versio v22.3.0. Suosittelen, että omasi on vähintään yhtä tuore (ks. komentoriviltä _node -v_). -Seuraavassa läpikäytävien muutosten jälkeen sovelluksemme hakemistorakenne näyttää seuraavalta +Ennen osan ensimmäistä isoa teemaa eli testaamista muutetaan sovelluksen rakennetta noudattamaan paremmin Noden yleisiä konventioita. + +Seuraavassa läpikäytävien muutosten jälkeen sovelluksemme hakemistorakenne näyttää seuraavalta: ```bash -├── index.js -├── app.js -├── build -│   ├── ... ├── controllers -│   └── notes.js +│ └── notes.js +├── dist +│ └── ... ├── models -│   └── note.js -├── package-lock.json -├── package.json +│ └── note.js ├── utils -│ ├── logger.js │ ├── config.js +│ ├── logger.js │ └── middleware.js +├── app.js +├── index.js +├── package-lock.json +├── package.json ``` -Olemme toistaiseksi tulostelleet koodista erilaista logaustietoa komennoilla console.log ja console.error, tämä ei ole kovin järkevä käytäntö. Eristetään kaikki konsoliin tulostelu omaan moduliinsa utils/logger.js: +Olemme toistaiseksi tulostelleet koodista erilaista loggaustietoa komennoilla console.log ja console.error, mutta tämä ei ole kovin järkevä käytäntö. Eristetään kaikki konsoliin tulostelu omaan moduliinsa utils/logger.js: ```js const info = (...params) => { @@ -43,57 +45,33 @@ const error = (...params) => { console.error(...params) } -module.exports = { - info, error -} +module.exports = { info, error } ``` -Loggeri tarjoaa kaksi funktiota, normaalien logiviesteihin tarkoitetun funktion _info_ sekä virhetilanteisiin tarkoitetun funktion _error_. - -Logauksen eristäminen omaan moduulinsa vastuulle on monellakin tapaa järkevää. Jos esim. päätämme ruveta kirjoittamaan logeja tiedostoon tai keräämään ne johonkin ulkoiseen palveuun kuten [graylog](https://www.graylog.org/) tai [papertrail](https://papertrailapp.com), on muutos helppo tehdä yhteen paikkaan. - -Sovelluksen käynnistystiedosto index.js pelkistyy seuraavaan muotoon: - -```js -const app = require('./app') // varsinainen Express-sovellus -const http = require('http') -const config = require('./utils/config') -const logger = require('./utils/logger') +Loggeri tarjoaa kaksi funktiota: normaaleihin logiviesteihin tarkoitetun funktion _info_ sekä virhetilanteisiin tarkoitetun funktion _error_. -const server = http.createServer(app) - -server.listen(config.PORT, () => { - logger.info(`Server running on port ${config.PORT}`) -}) -``` - -index.js ainoastaan importaa tiedostossa app.js olevan varsinaisen sovelluksen ja käynnistää sen. Sovelluksen käynnistäminen tapahtuu nyt server-muuttujassa olevan olion kautta. Käynnistymisestä kertova konsolitulostus tehtään logger-moduulin funktion _info_ avulla. +Loggauksen eristäminen omaan moduuliinsa on monellakin tapaa järkevää. Jos esim. päätämme ruveta kirjoittamaan logeja tiedostoon tai keräämään niitä johonkin ulkoiseen palveluun kuten [Graylog](https://www.graylog.org/) tai [Papertrail](https://papertrailapp.com), on muutos helppo tehdä yhteen paikkaan. Ympäristömuuttujien käsittely on eriytetty moduulin utils/config.js vastuulle: ```js require('dotenv').config() -let PORT = process.env.PORT -let MONGODB_URI = process.env.MONGODB_URI +const PORT = process.env.PORT +const MONGODB_URI = process.env.MONGODB_URI -module.exports = { - MONGODB_URI, - PORT -} +module.exports = { MONGODB_URI, PORT } ``` -Sovelluksen muut osat pääsevät ympäristömuuttujiin käsiksi importtaamalla konfiguraatiomoduulin +Sovelluksen muut osat pääsevät ympäristömuuttujiin käsiksi importtaamalla konfiguraatiomoduulin: ```js const config = require('./utils/config') -console.log(`Server running on port ${config.PORT}`) +logger.info(`Server running on port ${config.PORT}`) ``` -Routejen määrittely siirretään omaan tiedostoonsa, eli myös siitä tehdään moduuli. Routejen tapahtumankäsittelijöitä kutsutaan usein kontrollereiksi. Sovellukselle onkin luotu hakemisto controllers ja sinne tiedosto notes.js, johon kaikki muistiinpanoihin liittyvien reittien määrittelyt on siirretty. - -Tiedoston sisältö on seuraava: +Routejen määrittely siirretään omaan tiedostoonsa, eli myös siitä tehdään moduuli. Routejen tapahtumankäsittelijöitä kutsutaan usein kontrollereiksi. Sovellukselle onkin luotu hakemisto controllers ja sinne tiedosto notes.js, johon kaikki muistiinpanoihin liittyvien reittien määrittelyt on siirretty: ```js const notesRouter = require('express').Router() @@ -101,7 +79,7 @@ const Note = require('../models/note') notesRouter.get('/', (request, response) => { Note.find({}).then(notes => { - response.json(notes.map(note => note.toJSON())) + response.json(notes) }) }) @@ -109,7 +87,7 @@ notesRouter.get('/:id', (request, response, next) => { Note.findById(request.params.id) .then(note => { if (note) { - response.json(note.toJSON()) + response.json(note) } else { response.status(404).end() } @@ -123,18 +101,17 @@ notesRouter.post('/', (request, response, next) => { const note = new Note({ content: body.content, important: body.important || false, - date: new Date(), }) note.save() .then(savedNote => { - response.json(savedNote.toJSON()) + response.json(savedNote) }) .catch(error => next(error)) }) notesRouter.delete('/:id', (request, response, next) => { - Note.findByIdAndRemove(request.params.id) + Note.findByIdAndDelete(request.params.id) .then(() => { response.status(204).end() }) @@ -142,16 +119,20 @@ notesRouter.delete('/:id', (request, response, next) => { }) notesRouter.put('/:id', (request, response, next) => { - const body = request.body + const { content, important } = request.body - const note = { - content: body.content, - important: body.important, - } + Note.findById(request.params.id) + .then(note => { + if (!note) { + return response.status(404).end() + } + + note.content = content + note.important = important - Note.findByIdAndUpdate(request.params.id, note, { new: true }) - .then(updatedNote => { - response.json(updatedNote.toJSON()) + return note.save().then((updatedNote) => { + response.json(updatedNote) + }) }) .catch(error => next(error)) }) @@ -159,7 +140,7 @@ notesRouter.put('/:id', (request, response, next) => { module.exports = notesRouter ``` -Kyseessä on käytännössä melkein suora copypaste edellisen osan materiaalin tiedostosta index.js. +Kyseessä on käytännössä melkein suora copy-paste edellisen osan materiaalin tiedostosta index.js. Muutoksia on muutama. Tiedoston alussa luodaan [router](http://expressjs.com/en/api.html#router)-olio: @@ -175,19 +156,19 @@ Tiedosto eksporttaa moduulin käyttäjille määritellyn routerin. Kaikki määriteltävät routet liitetään router-olioon, samaan tapaan kuin aiemmassa versiossa routet liitettiin sovellusta edustavaan olioon. -Huomioinarvoinen seikka routejen määrittelyssä on se, että polut ovat typistyneet, aiemmin määrittelimme esim. +Huomionarvoinen seikka routejen määrittelyssä on se, että polut ovat typistyneet. Aiemmin määrittelimme esim. ```js app.delete('/api/notes/:id', (request, response) => { ``` -nyt riittää määritellä +Nyt riittää määritellä ```js notesRouter.delete('/:id', (request, response) => { ``` -Mistä routereissa oikeastaan on kyse? Expressin manuaalin sanoin +Mistä routereissa oikeastaan on kyse? Expressin manuaalin sanoin: > A router object is an isolated instance of middleware and routes. You can think of it as a “mini-application,” capable only of performing middleware and routing functions. Every Express application has a built-in app router. @@ -200,23 +181,24 @@ const notesRouter = require('./controllers/notes') app.use('/api/notes', notesRouter) ``` -Näin määrittelemäämme routeria käytetään jos polun alkuosa on /api/notes. notesRouter-olion sisällä täytyy tämän takia käyttää ainoastaan polun loppuosia, eli tyhjää polkua / tai pelkkää parametria /:id. +Näin määrittelemäämme routeria käytetään jos polun alkuosa on /api/notes. notesRouter-olion sisällä täytyy tämän takia käyttää ainoastaan polun loppuosia eli tyhjää polkua / tai pelkkää parametria /:id. -Sovelluksen määrittelevä app.js näyttää muutosten jälkeen seuraavalta: +Repositorion juureen on luotu sovelluksen määrittelevä tiedosto app.js: ```js -const config = require('./utils/config') const express = require('express') -const app = express() -const cors = require('cors') -const notesRouter = require('./controllers/notes') -const middleware = require('./utils/middleware') -const logger = require('./utils/logger') const mongoose = require('mongoose') +const config = require('./utils/config') +const logger = require('./utils/logger') +const middleware = require('./utils/middleware') +const notesRouter = require('./controllers/notes') + +const app = express() logger.info('connecting to', config.MONGODB_URI) -mongoose.connect(config.MONGODB_URI, { useNewUrlParser: true, useUnifiedTopology: true }) +mongoose + .connect(config.MONGODB_URI) .then(() => { logger.info('connected to MongoDB') }) @@ -224,8 +206,7 @@ mongoose.connect(config.MONGODB_URI, { useNewUrlParser: true, useUnifiedTopology logger.error('error connection to MongoDB:', error.message) }) -app.use(cors()) -app.use(express.static('build')) +app.use(express.static('dist')) app.use(express.json()) app.use(middleware.requestLogger) @@ -237,9 +218,9 @@ app.use(middleware.errorHandler) module.exports = app ``` -Tiedostossa siis otetaan käyttöön joukko middlewareja, näistä yksi on polkuun /api/notes kiinnitettävä notesRouter (tai notes-kontrolleri niin kuin jotkut sitä kutsuisivat). +Tiedostossa siis otetaan käyttöön joukko middlewareja, joista yksi on polkuun /api/notes kiinnitettävä notesRouter (tai notes-kontrolleri niin kuin jotkut sitä kutsuisivat). -Itse toteutettujen middlewarejen määritelty on siirretty tiedostoon utils/middleware.js: +Itse toteutettujen middlewarejen määrittely on siirretty tiedostoon utils/middleware.js: ```js const logger = require('./logger') @@ -275,7 +256,7 @@ module.exports = { } ``` -Koska tietokantayhteyden muodostaminen on siirretty tiedoston app.js:n vastuulle. Hakemistossa models oleva tiedosto note.js sisältää nyt ainoastaan muistiinpanojen skeeman määrittelyn. +Tietokantayhteyden muodostaminen on siirretty tiedoston app.js:n vastuulle. Hakemistossa models oleva tiedosto note.js sisältää nyt ainoastaan muistiinpanojen skeeman määrittelyn. ```js const mongoose = require('mongoose') @@ -286,7 +267,6 @@ const noteSchema = new mongoose.Schema({ required: true, minlength: 5 }, - date: Date, important: Boolean, }) @@ -301,32 +281,109 @@ noteSchema.set('toJSON', { module.exports = mongoose.model('Note', noteSchema) ``` -Sovelluksen hakemistorakenne siis näyttää refaktoroinnin jälkeen seuraavalta: +Sovelluksen käynnistystiedosto index.js pelkistyy seuraavasti: + +```js +const app = require('./app') // varsinainen Express-sovellus +const config = require('./utils/config') +const logger = require('./utils/logger') + +app.listen(config.PORT, () => { + logger.info(`Server running on port ${config.PORT}`) +}) +``` + +index.js ainoastaan importtaa tiedostossa app.js olevan varsinaisen sovelluksen ja käynnistää sen. Käynnistymisestä kertova konsolitulostus tehdään logger-moduulin funktion _info_ avulla. + +Nyt Express-sovellus sekä sen käynnistymisestä ja verkkoasetuksista huolehtiva koodi on eriytetty toisistaan [parhaita](https://dev.to/nermineslimane/always-separate-app-and-server-files--1nc7) käytänteitä noudattaen. Eräs tämän tavan eduista on se, että sovelluksen toimintaa voi nyt testata API-tasolle tehtävien HTTP-kutsujen tasolla kuitenkaan tekemättä kutsuja varsinaisesti HTTP:llä verkon yli. Tämä tekee testien suorittamisesta nopeampaa. + +Sovelluksen hakemistorakenne näyttää siis refaktoroinnin jälkeen seuraavalta: ```bash -├── index.js -├── app.js -├── build -│   ├── ... ├── controllers -│   └── notes.js +│ └── notes.js +├── dist +│ └── ... ├── models -│   └── note.js -├── package-lock.json -├── package.json +│ └── note.js ├── utils │ ├── config.js -│ └── logger.js +│ ├── logger.js │ └── middleware.js +├── app.js +├── index.js +├── package-lock.json +├── package.json +``` + +Jos sovellus on pieni, ei rakenteella ole kovin suurta merkitystä. Sovelluksen kasvaessa sille kannattaa muodostaa jonkinlainen rakenne eli arkkitehtuuri ja jakaa erilaiset vastuut omiin moduuleihinsa. Tämä helpottaa huomattavasti ohjelman jatkokehitystä. + +Express-sovelluksien rakenteelle eli hakemistojen ja tiedostojen nimeämiselle ei ole olemassa mitään yleismaailmallista standardia samaan tapaan kuin esim. Ruby on Railsissa. Tässä käyttämämme malli noudattaa eräitä Internetissä vastaan tulevia hyviä käytäntöjä. + +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-1) branchissa part4-1. + +Jos kloonaat projektin itsellesi, suorita komento _npm install_ ennen käynnistämistä eli komentoa _npm run dev_. + +### Huomio eksporteista + +Olemme käyttäneet tässä osassa kahta eri tapaa eksporttaukseen. Esimerkiksi tiedostossa utils/logger.js eksporttaus tapahtuu seuraavasti: + +```js +const info = (...params) => { + console.log(...params) +} + +const error = (...params) => { + console.error(...params) +} + +module.exports = { info, error } // highlight-line + +``` + +Tiedosto eksporttaa olion, joka sisältää kenttinään kaksi funktiota. Funktioihin päästään käsiksi kahdella vaihtoehtoisella tavalla. Voidaan joko ottaa käyttöön koko eksportoitava olio, jolloin funktioihin viitataan olion kautta: + +```js +const logger = require('./utils/logger') + +logger.info('message') + +logger.error('error message') ``` -Jos sovellus on pieni, ei rakenteella ole kovin suurta merkitystä. Sovelluksen kasvaessa kannattaa sille muodostaa jonkinlainen rakenne eli arkkitehtuuri, ja jakaa erilaisten vastuut omiin moduuleihin. Tämä helpottaa huomattavasti ohjelman jatkokehitystä. +Toinen vaihtoehto on destrukturoida funktiot omiin muuttujiin require-kutsun yhteydessä: + +```js +const { info, error } = require('./utils/logger') -Express-sovelluksien rakenteelle, eli hakemistojen ja tiedostojen nimennälle ei ole olemassa mitään yleismaailmallista standardia samaan tapaan kuin esim. Ruby on Railsissa. Tässä käyttämämme malli noudattaa eräitä internetissä vastaan tulevia hyviä käytäntöjä. +info('message') +error('error message') +``` -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-1), branchissa part4-1: +Jälkimäinen tapa voi olla selkeämpi erityisesti jos ollaan kiinnostunut ainoastaan joistain eksportatuista funktioista. -Jos kloonaat projektin itsellesi, suorita komento _npm install_ ennen käynnistämistä eli komentoa _npm start_. +Tiedostossa controller/notes.js eksporttaus taas tapahtuu seuraavasti + +```js +const notesRouter = require('express').Router() +const Note = require('../models/note') + +// ... + +module.exports = notesRouter // highlight-line +``` + +Tässä tapauksessa exportataan ainoastaan yksi "asia", joten mahdollisia käyttötapojakin on vain yksi: + +```js +const notesRouter = require('./controllers/notes') + +// ... + +app.use('/api/notes', notesRouter) +``` + +Eli eksportoitava asia (tässä tilanteessa router-olio) sijoitetaan muuttujaan ja käytetään sitä sellaisenaan.
    @@ -334,70 +391,66 @@ Jos kloonaat projektin itsellesi, suorita komento _npm install_ ennen käynnist ### Tehtävät 4.1.-4.2. -Rakennamme tämän osan tehtävissä blogilistasovellusta, jonka avulla käyttäjien on mahdollista tallettaa tietoja internetistä löytämistään mielenkiintoisista blogeista. Kustakin blogista talletetaan sen kirjoittaja (author), aihe (title), url sekä blogilistasovelluksen käyttäjien antamien äänien määrä. +**HUOM**: Kurssimateriaalia tehtäessä on ollut käytössä Node.js:n versio v22.3.0. Suosittelen, että omasi on vähintään yhtä tuore (ks. komentoriviltä _node -v_). +Rakennamme tämän osan tehtävissä blogilistasovellusta, jonka avulla käyttäjien on mahdollista tallettaa tietoja Internetistä löytämistään mielenkiintoisista blogeista. Kustakin blogista talletetaan sen kirjoittaja (author), aihe (title), url sekä blogilistasovelluksen käyttäjien antamien äänien määrä. #### 4.1 blogilista, step1 Kuvitellaan tilanne, jossa saat sähköpostitse seuraavan, yhteen tiedostoon koodatun sovellusrungon: ```js -const http = require('http') const express = require('express') -const app = express() -const cors = require('cors') const mongoose = require('mongoose') +const app = express() + const blogSchema = mongoose.Schema({ title: String, author: String, url: String, - likes: Number + likes: Number, }) const Blog = mongoose.model('Blog', blogSchema) const mongoUrl = 'mongodb://localhost/bloglist' -mongoose.connect(mongoUrl, { useNewUrlParser: true, useUnifiedTopology: true}) +mongoose.connect(mongoUrl) -app.use(cors()) app.use(express.json()) app.get('/api/blogs', (request, response) => { - Blog - .find({}) - .then(blogs => { - response.json(blogs) - }) + Blog.find({}).then((blogs) => { + response.json(blogs) + }) }) app.post('/api/blogs', (request, response) => { const blog = new Blog(request.body) - blog - .save() - .then(result => { - response.status(201).json(result) - }) + blog.save().then((result) => { + response.status(201).json(result) + }) }) const PORT = 3003 app.listen(PORT, () => { console.log(`Server running on port ${PORT}`) }) + ``` -Tee sovelluksesta toimiva npm-projekti. Jotta sovelluskehitys olisi sujuvaa, konfiguroi sovellus suoritettavaksi nodemonilla. Voit luoda sovellukselle uuden tietokannan MongoDB Atlasiin tai käyttää edellisen osan sovelluksen tietokantaa. +Tee sovelluksesta toimiva npm-projekti. Jotta sovelluskehitys olisi sujuvaa, konfiguroi sovellus suoritettavaksi komennolla node --watch. Voit luoda sovellukselle uuden tietokannan MongoDB Atlasiin tai käyttää edellisen osan sovelluksen tietokantaa. -Varmista, että sovellukseen on mahdollista lisätä blogeja Postmanilla tai VS Code REST clientilla, ja että sovellus näyttää lisätyt blogit. +Varmista, että sovellukseen on mahdollista lisätä blogeja Postmanilla tai VS Code REST Clientilla ja että sovellus näyttää lisätyt blogit. #### 4.2 blogilista, step2 Jaa sovelluksen koodi tämän osan alun tapaan useaan moduuliin. -**HUOM** etene todella pienin askelin, varmistaen että kaikki toimii koko ajan. Jos yrität "oikaista" tekemällä monta asiaa kerralla, on [Murphyn lain](https://fi.wikipedia.org/wiki/Murphyn_laki) perusteella käytännössä varmaa, että jokin menee pahasti pieleen ja "oikotien" takia maaliin päästään paljon myöhemmin kuin systemaattisin pienin askelin. +**HUOM:** Etene todella pienin askelin ja varmistaen, että kaikki toimii koko ajan. Jos yrität "oikaista" tekemällä monta asiaa kerralla, on [Murphyn lain](https://fi.wikipedia.org/wiki/Murphyn_laki) perusteella käytännössä varmaa, että jokin menee pahasti pieleen ja "oikotien" takia maaliin päästään paljon myöhemmin kuin systemaattisin pienin askelin. -Paras käytänne on commitoida koodi aina stabiilissa tilanteessa, tällöin on helppo palata aina toimivaan tilanteeseen jos koodi menee liian solmuun. +Paras käytänne on commitoida koodi aina stabiilissa tilanteessa. Tällöin on helppo palata aina toimivaan tilanteeseen jos koodi menee liian solmuun.
    @@ -410,7 +463,7 @@ Olemme laiminlyöneet ikävästi yhtä oleellista ohjelmistokehityksen osa-aluet Aloitamme yksikkötestauksesta. Sovelluksemme logiikka on sen verran yksinkertaista, että siinä ei ole juurikaan mielekästä yksikkötestattavaa. Luodaan tiedosto utils/for_testing.js ja määritellään sinne pari yksinkertaista funktiota testattavaksi: ```js -const palindrome = (string) => { +const reverse = (string) => { return string .split('') .reverse() @@ -426,167 +479,129 @@ const average = (array) => { } module.exports = { - palindrome, + reverse, average, } ``` -> Metodi _average_ käyttää taulukoiden metodia [reduce](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce). Jos metodi ei ole vieläkään tuttu, on korkea aika katsoa Youtubesta [Functional Javascript](https://www.youtube.com/watch?v=BMUiFMZr7vk&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84) -sarjasta ainakin kolme ensimmäistä videoa. - -Javascriptiin on tarjolla runsaasti erilaisia testikirjastoja eli test runnereita. Käytämme tällä kurssilla Facebookin kehittämää ja sisäisesti käyttämää [jest](https://jestjs.io/):iä, joka on toiminnaltaan ja syntaksiltaankin hyvin samankaltainen kuin testikirjastojen entinen kuningas [Mocha](https://mochajs.org/). Muitakin mahdollisuuksia olisi, esim. eräissä piireissä suosiota nopeasti saavuttanut [ava](https://github.com/avajs/ava). - -Jest on tälle kurssille luonteva valinta, sillä se sopii hyvin backendien testaamiseen, mutta suorastaan loistaa Reactilla tehtyjen frontendien testauksessa. - -> **Huomio Windows-käyttäjille:** jest ei välttämättä toimi, jos projektin hakemistopolulla on hakemisto, jonka nimessä on välilyöntejä. +> Funktio _average_ käyttää taulukoiden metodia [reduce](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce). Jos metodi ei ole vieläkään tuttu, on korkea aika katsoa YouTubesta [Functional JavaScript](https://www.youtube.com/watch?v=BMUiFMZr7vk&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84) ‑sarjasta ainakin kolme ensimmäistä videoa. -Koska testejä on tarkoitus suorittaa ainoastaan sovellusta kehitettäessä, asennetaan jest kehitysaikaiseksi riippuvuudeksi komennolla +JavaScriptiin on tarjolla runsaasti erilaisia testikirjastoja eli test runnereita. +Testikirjastojen vanha kuningas on [Mocha](https://mochajs.org/), jolta kruunun muutamia vuosia sitten peri [Jest](https://jestjs.io/). Uusi tulokas kirjastojen joukossa on uuden generaation testikirjastoksi itseään mainostava [Vitest](https://vitest.dev/). -```bash -npm install --save-dev jest -``` +Nykyään myös Nodessa on sisäänrakennettu testikirjasto [node:test](https://nodejs.org/docs/latest/api/test.html), ja se sopii oikein mainiosti kurssin tarpeisiin. -määritellään npm-skripti test suorittamaan testaus jestillä ja raportoimaan testien suorituksesta verbose-tyylillä: +Määritellään npm-skripti test testien suorittamiseen: -```bash +```js { - //... + // ... "scripts": { "start": "node index.js", - "dev": "nodemon index.js", - "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend", - "deploy": "git push heroku master", - "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy", - "logs:prod": "heroku logs --tail", - "lint": "eslint .", - "test": "jest --verbose" // highlight-line + "dev": "node --watch index.js", + "test": "node --test", // highlight-line + "lint": "eslint ." }, - //... + // ... } ``` -Jestille pitää vielä kertoa, että suoritusympäristönä on käytössä Node. Tämä tapahtuu esim. lisäämällä package.json tiedoston loppuun: - -```js -{ - //... - "jest": { - "testEnvironment": "node" - } -} -``` -Tai vaihtoehtoisesti Jest löytää myös oletuksena asetustiedoston nimellä jest.config.js, jonne suoritusympäristön määrittely tapahtuu seuraavasti: +Tehdään testejä varten hakemisto tests ja sinne tiedosto reverse.test.js, jonka sisältö on seuraava: ```js -module.exports = { - testEnvironment: 'node', -}; -``` +const { test } = require('node:test') +const assert = require('node:assert') -Tehdään testejä varten hakemisto tests ja sinne tiedosto palindrome.test.js, jonka sisältö on seuraava - -```js -const palindrome = require('../utils/for_testing').palindrome +const reverse = require('../utils/for_testing').reverse -test('palindrome of a', () => { - const result = palindrome('a') +test('reverse of a', () => { + const result = reverse('a') - expect(result).toBe('a') + assert.strictEqual(result, 'a') }) -test('palindrome of react', () => { - const result = palindrome('react') +test('reverse of react', () => { + const result = reverse('react') - expect(result).toBe('tcaer') + assert.strictEqual(result, 'tcaer') }) -test('palindrome of saippuakauppias', () => { - const result = palindrome('saippuakauppias') +test('reverse of saippuakauppias', () => { + const result = reverse('saippuakauppias') - expect(result).toBe('saippuakauppias') + assert.strictEqual(result, 'saippuakauppias') }) ``` -Edellisessä osassa käyttöön ottamamme ESlint valittaa testien käyttämistä komennoista _test_ ja _expect_ sillä käyttämämme konfiguraatio kieltää globaalina määriteltyjen asioiden käytön. Poistetaan valitus lisäämällä .eslintrc.js-tiedoston kenttään env arvo "jest": true. Näin kerromme ESlintille, että käytämme projektissamme Jestiä ja sen globaaleja muuttujia. +Testi ottaa käyttöönsä avainsanan _test_ sekä kirjaston [assert](https://nodejs.org/docs/latest/api/assert.html), jonka avulla testit suorittavat testattavien funktioiden tulosten tarkistamisen. -```js -module.exports = { - "env": { - "commonjs": true - "es6": true, - "node": true, - "jest": true, // highlight-line - }, - "extends": "eslint:recommended", - "rules": { - // ... - }, -}; -``` - -Testi ottaa ensimmäisellä rivillä käyttöön testattavan funktion sijoittaen sen muuttujaan _palindrome_: +Seuraavaksi testi ottaa käyttöön testattavan funktion sijoittaen sen muuttujaan _reverse_: ```js -const palindrome = require('../utils/for_testing').palindrome +const reverse = require('../utils/for_testing').reverse ``` Yksittäiset testitapaukset määritellään funktion _test_ avulla. Ensimmäisenä parametrina on merkkijonomuotoinen testin kuvaus. Toisena parametrina on funktio, joka määrittelee testitapauksen toiminnallisuuden. Esim. toisen testitapauksen toiminnallisuus näyttää seuraavalta: ```js () => { - const result = palindrome('react') + const result = reverse('react') - expect(result).toBe('tcaer') + assert.strictEqual(result, 'tcaer') } ``` -Ensin suoritetaan testattava koodi, eli generoidaan merkkijonon react palindromi. Seuraavaksi varmistetaan tulos metodin [expect](https://facebook.github.io/jest/docs/en/expect.html#content) avulla. Expect käärii tuloksena olevan arvon olioon, joka tarjoaa joukon matcher-funktioita, joiden avulla tuloksen oikeellisuutta voidaan tarkastella. Koska kyse on kahden merkkijonon samuuden vertailusta, sopii tilanteeseen matcheri [toBe](https://facebook.github.io/jest/docs/en/expect.html#tobevalue). +Ensin suoritetaan testattava koodi eli generoidaan merkkijonon react palindromi. Seuraavaksi varmistetaan tulos [assert](https://nodejs.org/docs/latest/api/assert.html) kirjaston metodin [strictEqual](https://nodejs.org/docs/latest/api/assert.html#assertstrictequalactual-expected-message) avulla. Kuten odotettua, testit menevät läpi: -![](../../images/4/1.png) +![Konsolin tuloste kertoo että 3 testiä kolmesta meni läpi](../../images/4/1new.png) -Jest olettaa oletusarvoisesti, että testitiedoston nimessä on merkkijono .test. Käytetään kurssilla konventiota, millä testitiedostojen nimen loppu on .test.js +Käytetään kurssilla konventiota, jossa testitiedostojen nimen loppu on .test.js, sillä testikirjasto node:test suorittaa näin nimetyt testitiedostot automaattisesti. -Jestin antamat virheilmoitukset ovat hyviä, rikotaan testi +Node:testin antamat virheilmoitukset ovat hyviä. Rikotaan testi: ```js -test('palindrome of react', () => { - const result = palindrome('react') +test('reverse of react', () => { + const result = reverse('react') - expect(result).toBe('tkaer') + assert.strictEqual(result, 'tkaer') }) ``` -seurauksena on seuraava virheilmoitus +Seurauksena on seuraava virheilmoitus: -![](../../images/4/2e.png) +![Konsolin tuloste kertoo että testin odottama merkkijono poikkesi tuloksena olevasta merkkijonosta](../../images/4/2new.png) -Lisätään muutama testi metodille _average_, tiedostoon tests/average.test.js. +Lisätään muutama testi myös funktiolle _average_. Luodaan uusi tiedosto tests/average.test.js ja lisätään sille seuraava sisältö: ```js +const { test, describe } = require('node:test') +const assert = require('node:assert') + const average = require('../utils/for_testing').average describe('average', () => { test('of one value is the value itself', () => { - expect(average([1])).toBe(1) + assert.strictEqual(average([1]), 1) }) test('of many is calculated right', () => { - expect(average([1, 2, 3, 4, 5, 6])).toBe(3.5) + assert.strictEqual(average([1, 2, 3, 4, 5, 6]), 3.5) }) test('of empty array is zero', () => { - expect(average([])).toBe(0) + assert.strictEqual(average([]), 0) }) }) ``` -Testi paljastaa, että metodi toimii väärin tyhjällä taulukolla (sillä nollallajaon tulos on Javascriptissä NaN): +Testi paljastaa, että funktio toimii väärin tyhjällä taulukolla (sillä nollalla jaon tulos on JavaScriptissä NaN): -![](../../images/4/3.png) +![Konsolin tuloste kertoo että odotetun arvon 0 sijaan tuloksena on NaN](../../images/4/3new.png) -Metodi on helppo korjata +Funktio on helppo korjata: ```js const average = array => { @@ -601,7 +616,7 @@ const average = array => { Eli jos taulukon pituus on 0, palautetaan 0 ja muussa tapauksessa palautetaan metodin _reduce_ avulla laskettu keskiarvo. -Pari huomiota keskiarvon testeistä. Määrittelimme testien ympärille nimellä _average_ varustetun describe-lohkon. +Pari huomiota keskiarvon testeistä. Määrittelimme testien ympärille nimellä _average_ varustetun describe-lohkon: ```js describe('average', () => { @@ -611,15 +626,15 @@ describe('average', () => { Describejen avulla yksittäisessä tiedostossa olevat testit voidaan jaotella loogisiin kokonaisuuksiin. Testituloste hyödyntää myös describe-lohkon nimeä: -![](../../images/4/4.png) +![Testitapausten tulokset on ryhmitelty describe-lohkojen mukaan](../../images/4/4new.png) -Kuten myöhemmin tulemme näkemään, describe-lohkot ovat tarpeellisia siinä vaiheessa, jos haluamme osalle yksittäisen testitiedoston testitapauksista jotain yhteisiä alustus- tai lopetustoimenpiteitä. +Kuten myöhemmin tulemme näkemään, describe-lohkot ovat tarpeellisia, jos haluamme osalle yksittäisen testitiedoston testitapauksista joitain yhteisiä alustus- tai lopetustoimenpiteitä. -Toisena huomiona se, että kirjoitimme testit aavistuksen tiiviimmässä muodossa, ottamatta testattavan metodin tulosta erikseen apumuuttujaan: +Toisena huomiona se, että kirjoitimme testit aavistuksen tiiviimmässä muodossa, ottamatta testattavan funktion tulosta erikseen apumuuttujaan: ```js test('of empty array is zero', () => { - expect(average([])).toBe(0) + assert.strictEqual(average([]), 0) }) ``` @@ -633,7 +648,7 @@ Tehdään joukko blogilistan käsittelyyn tarkoitettuja apufunktioita. Tee funkt #### 4.3: apufunktioita ja yksikkötestejä, step1 -Määrittele ensin funktio _dummy_ joka saa parametrikseen taulukollisen blogeja ja palauttaa aina luvun 1. Tiedoston list_helper.js sisällöksi siis tulee tässä vaiheessa +Määrittele ensin funktio _dummy_, joka saa parametrikseen taulukollisen blogeja ja palauttaa aina luvun 1. Tiedoston list_helper.js sisällöksi siis tulee tässä vaiheessa: ```js const dummy = (blogs) => { @@ -648,19 +663,21 @@ module.exports = { Varmista testikonfiguraatiosi toimivuus seuraavalla testillä: ```js +const { test, describe } = require('node:test') +const assert = require('node:assert') const listHelper = require('../utils/list_helper') test('dummy returns one', () => { const blogs = [] const result = listHelper.dummy(blogs) - expect(result).toBe(1) + assert.strictEqual(result, 1) }) ``` #### 4.4: apufunktioita ja yksikkötestejä, step2 -Määrittele funktio _totalLikes_ joka saa parametrikseen taulukollisen blogeja. Funktio palauttaa blogien yhteenlaskettujen tykkäysten eli likejen määrän. +Määrittele funktio _totalLikes_, joka saa parametrikseen taulukollisen blogeja. Funktio palauttaa blogien yhteenlaskettujen tykkäysten eli likejen määrän. Määrittele funktiolle sopivat testit. Funktion testit kannattaa laittaa describe-lohkoon jolloin testien tulostus ryhmittyy miellyttävästi: @@ -683,40 +700,22 @@ describe('total likes', () => { test('when list has only one blog equals the likes of that', () => { const result = listHelper.totalLikes(listWithOneBlog) - expect(result).toBe(5) + assert.strictEqual(result, 5) }) }) ``` -Jos et viitsi itse määritellä testisyötteenä käytettäviä blogeja, saat valmiin listan [täältä.](https://github.com/fullstack-hy2020/misc/blob/master/blogs_for_test.md) - -Törmäät varmasti testien tekemisen yhteydessä erinäisiin ongelmiin. Pidä mielessä osassa 3 käsitellyt [debuggaukseen](osa3/tietojen_tallettaminen_mongo_db_tietokantaan#node-sovellusten-debuggaaminen) liittyvät asiat, voit testejäkin suorittaessasi printtailla konsoliin komennolla _console.log_. Myös debuggerin käyttö testejä suorittaessa on mahdollista, ohje [täällä](https://jestjs.io/docs/en/troubleshooting). +Jos et viitsi itse määritellä testisyötteenä käytettäviä blogeja, saat valmiin listan [täältä](https://raw.githubusercontent.com/fullstack-hy2020/misc/master/blogs_for_test.md). -**HUOM:** jos jokin testi ei mene läpi, ei ongelmaa korjatessa kannata suorittaa kaikkia testejä, vaan ainoastaan rikkinäistä testiä hyödyntäen [only](https://facebook.github.io/jest/docs/en/api.html#testonlyname-fn-timeout)-metodia. - -Toinen tapa suorittaa yksittäinen testi (tai describe-lohko) on määritellä suoritettava testi argumentin [-t](https://jestjs.io/docs/en/cli.html) avulla: - -```js -npm test -- -t 'when list has only one blog, equals the likes of that' -``` +Törmäät testien tekemisen yhteydessä varmasti erinäisiin ongelmiin. Pidä mielessä osassa 3 käsitellyt [debuggaukseen](/osa3/tietojen_tallettaminen_mongo_db_tietokantaan#node-sovellusten-debuggaaminen) liittyvät asiat. #### 4.5*: apufunktioita ja yksikkötestejä, step3 -Määrittele funktio _favoriteBlog_ joka saa parametrikseen taulukollisen blogeja. Funktio selvittää millä blogilla on eniten tykkäyksiä. Jos suosikkeja on monta, riittää että funktio palauttaa niistä jonkun. - -Paluuarvo voi olla esim. seuraavassa muodossa: - -```js -{ - title: "Canonical string reduction", - author: "Edsger W. Dijkstra", - likes: 12 -} -``` +Määrittele funktio _favoriteBlog_, joka saa parametrikseen taulukollisen blogeja. Funktio palauttaa blogin, jolla on eniten tykkäyksiä. Jos suosikkeja on monta, riittää että funktio palauttaa niistä jonkun. -**Huom**, että kun vertailet olioita, metodi [toEqual](https://jestjs.io/docs/en/expect#toequalvalue) on todennäköisesti se mitä haluat käyttää sillä [toBe](https://jestjs.io/docs/en/expect#tobevalue)-vertailu, joka sopii esim. lukujen ja merkkijonojen vertailuun vaatisi olioiden vertailussa, että oliot ovat samat, pelkkä sama sisältöisyys ei riitä. +**HUOM.** Kun vertailet olioita, haluat luultavimmin käyttää [deepStrictEqual](https://nodejs.org/api/assert.html#assertdeepstrictequalactual-expected-message)-metodia, sillä se tarkistaa, että olioilla on samat attribuutit. Assert-moduulin eri metodeista voit lukea lisää esimerkiksi [tästä Stack Overflow -vastauksesta](https://stackoverflow.com/a/73937068/15291501). -Tee myös tämän ja seuraavien kohtien testit kukin oman describe-lohkon sisälle. +Tee myös tämän ja seuraavien kohtien testit kukin oman describe-lohkonsa sisälle. #### 4.6*: apufunktioita ja yksikkötestejä, step4 @@ -724,7 +723,7 @@ Tämä ja seuraava tehtävä ovat jo hieman haastavampia. Tehtävien tekeminen e Tehtävän tekeminen onnistuu hyvin ilman mitään kirjastojakin, mutta tämä saattaa olla hyvä paikka tutustua kokoelmien käsittelyä suuresti helpottavaan [Lodash](https://lodash.com/)-kirjastoon. -Määrittele funktio _mostBlogs_ joka saa parametrikseen taulukollisen blogeja. Funktio selvittää kirjoittajan, kenellä on eniten blogeja. Funktion paluuarvo kertoo myös ennätysblogaajan blogien määrän: +Määrittele funktio _mostBlogs_, joka saa parametrikseen taulukollisen blogeja. Funktio selvittää kirjoittajan, jolla on eniten blogeja. Funktion paluuarvo kertoo myös ennätysbloggaajan blogien määrän: ```js { @@ -733,11 +732,11 @@ Määrittele funktio _mostBlogs_ joka saa parametrikseen taulukollisen blogeja. } ``` -Jos ennätysblogaajia on monta, riittää että funktio palauttaa niistä jonkun. +Jos ennätysbloggaajia on monta, riittää että funktio palauttaa niistä jonkun. #### 4.7*: apufunktioita ja yksikkötestejä, step5 -Määrittele funktio _mostLikes_ joka saa parametrikseen taulukollisen blogeja. Funktio selvittää kirjoittajan, kenen blogeilla on eniten tykkäyksiä. Funktion paluuarvo kertoo myös suosikkiblogaajan likejen yhteenlasketun määrän: +Määrittele funktio _mostLikes_, joka saa parametrikseen taulukollisen blogeja. Funktio selvittää kirjoittajan, jonka blogeilla on eniten tykkäyksiä. Funktion paluuarvo kertoo myös suosikkibloggaajan likejen yhteenlasketun määrän: ```js { @@ -746,6 +745,6 @@ Määrittele funktio _mostLikes_ joka saa parametrikseen taulukollisen blogeja. } ``` -Jos suosikkiblogaajia on monta, riittää että funktio palauttaa niistä jonkun. +Jos suosikkibloggaajia on monta, riittää että funktio palauttaa niistä jonkun. diff --git a/src/content/4/fi/osa4b.md b/src/content/4/fi/osa4b.md index 3e3fa466ede..4e60ddd6506 100644 --- a/src/content/4/fi/osa4b.md +++ b/src/content/4/fi/osa4b.md @@ -9,15 +9,15 @@ lang: fi Ruvetaan nyt tekemään testejä backendille. Koska backend ei sisällä monimutkaista laskentaa, ei yksittäisiä funktioita testaavia [yksikkötestejä](https://en.wikipedia.org/wiki/Unit_testing) oikeastaan kannata tehdä. Ainoa potentiaalinen yksikkötestattava asia olisi muistiinpanojen metodi _toJSON_. -Joissain tilanteissa voisi olla mielekästä suorittaa ainakin osa backendin testauksesta siten, että oikea tietokanta eristettäisiin testeistä ja korvattaisiin "valekomponentilla" eli mockilla. Eräs tähän sopiva ratkaisu olisi [mongo-mock](https://github.com/williamkapke/mongo-mock). +Joissain tilanteissa voisi olla mielekästä suorittaa ainakin osa backendin testauksesta siten, että oikea tietokanta eristettäisiin testeistä ja korvattaisiin "valekomponentilla" eli mockilla. Eräs tähän sopiva ratkaisu olisi [mongodb-memory-server](https://github.com/nodkz/mongodb-memory-server). -Koska sovelluksemme backend on koodiltaan kuitenkin suhteellisen yksinkertainen, päätämme testata sitä kokonaisuudessaan sen tarjoaman REST-apin tasolta, siten että myös testeissä käytetään tietokantaa. Tämän kaltaisia, useita sovelluksen komponentteja yhtäaikaa käyttäviä testejä voi luonnehtia [integraatiotesteiksi](https://en.wikipedia.org/wiki/Integration_testing). +Koska sovelluksemme backend on koodiltaan kuitenkin suhteellisen yksinkertainen, päätämme testata sitä kokonaisuudessaan sen tarjoaman REST API:n tasolta ja siten, että myös testeissä käytetään tietokantaa. Tämän kaltaisia, useita sovelluksen komponentteja yhtä aikaa käyttäviä testejä voi luonnehtia [integraatiotesteiksi](https://en.wikipedia.org/wiki/Integration_testing). ### test-ympäristö -Edellisen osan luvussa [Tietokantaa käyttävän version vieminen tuotantoon](/osa3/validointi_ja_es_lint#tietokantaa-kayttavan-version-vieminen-tuotantoon) mainitsimme, että kun sovellusta suoritetaan Herokussa, on se production-moodissa. +Edellisen osan luvussa [Tietokantaa käyttävän version vieminen tuotantoon](/osa3/validointi_ja_es_lint#tietokantaa-kayttavan-version-vieminen-tuotantoon) mainitsimme, että kun sovellusta suoritetaan tuotantopalvelimella eli esim. Fly.io:ssa tai Renderissä, on se production-moodissa. -Noden konventiona on määritellä projektin suoritusmoodi ympäristömuuttujan NODE\_ENV avulla. Yleinen käytäntö on määritellä sovelluksille omat moodinsa tuotantokäyttöön, sovelluskehitykseen ja testaukseen. +Noden konventiona on määritellä projektin suoritusmoodi ympäristömuuttujan NODE\_ENV avulla. Yleinen käytäntö on määritellä sovelluksille omat moodinsa tuotantokäyttöön, sovelluskehitykseen ja testaukseen. Määritellään nyt tiedostossa package.json, että testejä suoritettaessa sovelluksen NODE\_ENV saa arvokseen test: @@ -25,62 +25,55 @@ Määritellään nyt tiedostossa package.json, että testejä suoritettae { // ... "scripts": { - "start": "NODE_ENV=production node index.js",// highlight-line - "dev": "NODE_ENV=development nodemon index.js",// highlight-line - "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend", - "deploy": "git push heroku master", - "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy", - "logs:prod": "heroku logs --tail", - "lint": "eslint .", - "test": "NODE_ENV=test jest --verbose --runInBand"// highlight-line - }, + "start": "NODE_ENV=production node index.js", // highlight-line + "dev": "NODE_ENV=development node --watch index.js", // highlight-line + "test": "NODE_ENV=test node --test", // highlight-line + "lint": "eslint ." + } // ... } ``` -Lisäsimme testit suorittavaan npm-skriptiin myös määreen [runInBand](https://jestjs.io/docs/en/cli.html#runinband), joka estää testien rinnakkaisen suorituksen. Tämä tarkennus on viisainta tehdä sitten, kun testimme tulevat käyttämään tietokantaa. +Samalla määriteltiin, että suoritettaessa sovellusta komennolla _npm run dev_ sovelluksen moodi on development. Jos sovellusta suoritetaan komennolla _npm start_, on moodiksi määritelty production. -Samalla määriteltiin, että suoritettaessa sovellusta komennolla _npm run dev_ eli nodemonin avulla, on sovelluksen moodi development. Jos sovellusta suoritetaan normaalisti Nodella, on moodiksi määritelty production. - -Määrittelyssämme on kuitenkin pieni ongelma: se ei toimi Windowsilla. Tilanne korjautuu asentamalla kirjasto [cross-env](https://www.npmjs.com/package/cross-env) komennolla +Määrittelyssämme on kuitenkin pieni ongelma: se ei toimi Windowsilla. Tilanne korjautuu asentamalla kirjasto [cross-env](https://www.npmjs.com/package/cross-env) projektin riippuvuudeksi komennolla ```bash npm install cross-env ``` -ja muuttamalla package.json kaikilla käyttöjärjestelmillä toimivaan muotoon +ja muuttamalla package.json kaikilla käyttöjärjestelmillä toimivaan muotoon: ```json { // ... "scripts": { - "start": "cross-env NODE_ENV=production node index.js", - "dev": "cross-env NODE_ENV=development nodemon index.js", - // ... - "test": "cross-env NODE_ENV=test jest --verbose --runInBand", + "start": "cross-env NODE_ENV=production node index.js", // highlight-line + "dev": "cross-env NODE_ENV=development node --watch index.js", // highlight-line + "test": "cross-env NODE_ENV=test node --test", // highlight-line + "lint": "eslint ." }, // ... } ``` -Nyt sovelluksen toimintaa on mahdollista muokata sen suoritusmoodiin perustuen. Eli voimme määritellä, esim. että testejä suoritettaessa ohjelma käyttää erillistä, testejä varten luotua tietokantaa. +Nyt sovelluksen toimintaa on mahdollista muokata sen suoritusmoodiin perustuen. Eli voimme määritellä vaikkapa, että testejä suoritettaessa ohjelma käyttää erillistä, testejä varten luotua tietokantaa. -Sovelluksen testikanta voidaan luoda tuotantokäytön ja sovelluskehityksen tapaan Mongo DB Atlasiin. Ratkaisu ei ole optimaalinen erityisesti, jos sovellusta on tekemässä yhtä aikaa useita henkilöitä. Testien suoritus nimittäin yleensä edellyttää, että samaa tietokantainstanssia ei ole yhtä aikaa käyttämässä useampia testiajoja. +Sovelluksen testikanta voidaan luoda tuotantokäytön ja sovelluskehityksen tapaan Mongo DB Atlasiin. Ratkaisu ei ole optimaalinen, erityisesti jos sovellusta on tekemässä yhtä aikaa useita henkilöitä. Testien suoritus nimittäin yleensä edellyttää, että samaa tietokantainstanssia ei ole yhtä aikaa käyttämässä useampia testiajoja. -Testaukseen kannattaisikin käyttää verkossa olevan jaetun tietokannan sijaan mieluummin sovelluskehittäjän paikallisella koneella olevaa tietokantaa. Optimiratkaisu olisi tietysti se, että jokaista testiajoa varten olisi käytettävissä oma tietokanta, sekin periaatteessa onnistuu "suhteellisen helposti" mm. [keskusmuistissa toimivan Mongon](https://docs.mongodb.com/manual/core/inmemory/) ja [docker](https://www.docker.com)-kontainereiden avulla. Etenemme kuitenkin nyt lyhyemmän kaavan mukaan ja käytetään testikantana normaalia Mongoa. +Testaukseen kannattaisikin käyttää verkossa olevan jaetun tietokannan sijaan mieluummin sovelluskehittäjän paikallisella koneella olevaa tietokantaa. Optimiratkaisu olisi tietysti se, että jokaista testiajoa varten olisi käytettävissä oma tietokanta, sekin periaatteessa onnistuu "suhteellisen helposti" mm. [keskusmuistissa toimivan Mongon](https://docs.mongodb.com/manual/core/inmemory/) ja [Docker](https://www.docker.com)-kontainereiden avulla. Etenemme kuitenkin nyt lyhyemmän kaavan mukaan ja käytämme testikantana normaalia Mongoa. Muutetaan konfiguraatiot suorittavaa moduulia seuraavasti: ```js require('dotenv').config() -let PORT = process.env.PORT -let MONGODB_URI = process.env.MONGODB_URI +const PORT = process.env.PORT // highlight-start -if (process.env.NODE_ENV === 'test') { - MONGODB_URI = process.env.TEST_MONGODB_URI -} +const MONGODB_URI = process.env.NODE_ENV === 'test' + ? process.env.TEST_MONGODB_URI + : process.env.MONGODB_URI // highlight-end module.exports = { @@ -92,23 +85,23 @@ module.exports = { Tiedostossa .env on nyt määritelty erikseen sekä sovelluskehitysympäristön että testausympäristön tietokannan osoite: ```bash -MONGODB_URI=mongodb+srv://fullstack:secret@cluster0-ostce.mongodb.net/note-app?retryWrites=true +MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.a5qfl.mongodb.net/noteApp?retryWrites=true&w=majority&appName=Cluster0 PORT=3001 // highlight-start -TEST_MONGODB_URI=mongodb+srv://fullstack:secret@cluster0-ostce.mongodb.net/note-app-test?retryWrites=true +TEST_MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.a5qfl.mongodb.net/testNoteApp?retryWrites=true&w=majority&appName=Cluster0 // highlight-end ``` -Oma tekemämme eri ympäristöjen konfiguroinnista huolehtiva _config_-moduuli toimii hieman samassa hengessä kuin [node-config](https://github.com/lorenwest/node-config)-kirjasto. Oma tekemä konfigurointiympäristö sopii tarkoitukseemme, sillä sovellus on yksinkertainen ja oman konfiguraatio-moduulin tekeminen on myös jossain määrin opettavaista. Isommissa sovelluksissa kannattaa harkita valmiiden kirjastojen, kuten [node-config](https://github.com/lorenwest/node-config):in käyttöä. +Itse tekemämme eri ympäristöjen konfiguroinnista huolehtiva _config_-moduuli toimii hieman samassa hengessä kuin [node-config](https://github.com/lorenwest/node-config)-kirjasto. Itse tekemämme konfigurointiympäristö sopii tarkoitukseemme, sillä sovellus on yksinkertainen ja oman konfiguraatiomoduulin tekeminen on myös jossain määrin opettavaista. Isommissa sovelluksissa kannattaa harkita valmiiden kirjastojen, kuten [node-config](https://github.com/lorenwest/node-config):in käyttöä. Muualle koodiin ei muutoksia tarvita. -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-2), branchissä part4-2. +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-2), branchissä part4-2. -### supertest +### SuperTest -Käytetään API:n testaamiseen Jestin apuna [supertest](https://github.com/visionmedia/supertest)-kirjastoa. +Käytetään API:n testaamiseen Noden test-moduulin apuna [SuperTest](https://github.com/visionmedia/supertest)-kirjastoa. Kirjasto asennetaan kehitysaikaiseksi riippuvuudeksi komennolla @@ -116,9 +109,10 @@ Kirjasto asennetaan kehitysaikaiseksi riippuvuudeksi komennolla npm install --save-dev supertest ``` -Luodaan heti ensimmäinen testi tiedostoon tests/note_api.test.js +Luodaan heti ensimmäinen testi tiedostoon tests/note_api.test.js: ```js +const { test, after } = require('node:test') const mongoose = require('mongoose') const supertest = require('supertest') const app = require('../app') @@ -132,53 +126,55 @@ test('notes are returned as json', async () => { .expect('Content-Type', /application\/json/) }) -afterAll(() => { - mongoose.connection.close() +after(async () => { + await mongoose.connection.close() }) ``` -Testi importtaa tiedostoon app.js määritellyn Express-sovelluksen ja käärii sen funktion supertest avulla ns. [superagent](https://github.com/visionmedia/superagent)-olioksi. Tämä olio sijoitetaan muuttujaan api ja sen kautta testit voivat tehdä HTTP-pyyntöjä backendiin. +Testi importtaa tiedostoon app.js määritellyn Express-sovelluksen ja käärii sen funktion supertest avulla ns. [superagent](https://github.com/visionmedia/superagent)-olioksi. Tämä olio sijoitetaan muuttujaan api ja sen kautta testit voivat tehdä HTTP-pyyntöjä backendiin. -Testimetodi tekee HTTP GET -pyynnön osoitteeseen api/notes ja varmistaa, että pyyntöön vastataan statuskoodilla 200 ja että data palautetaan oikeassa muodossa, eli että Content-Type:n arvo on application/json. +Testimetodi tekee HTTP GET ‑pyynnön osoitteeseen api/notes ja varmistaa, että pyyntöön vastataan statuskoodilla 200 ja että data palautetaan oikeassa muodossa, eli että Content-Type:n arvo on application/json. + +Headerin arvon tarkastaminen näyttää syntaksiltaan hieman kummalliselta: + +```js +.expect('Content-Type', /application\/json/) +``` -Testissä on muutama detalji joihin tutustumme vasta [hieman myöhemmin](/osa4/backendin_testaaminen#async-await) tässä osassa. Testikoodin määrittelevä nuolifunktio alkaa sanalla async ja api-oliolle tehtyä metodikutsua edeltää sana await. Teemme ensin muutamia testejä ja tutustumme sen jälkeen async/await-magiaan. Tällä hetkellä niistä ei tarvitse välittää, kaikki toimii kun kirjoitat testimetodit esimerkin mukaan. Async/await-syntaksin käyttö liittyy siihen, että palvelimelle tehtävät pyynnöt ovat asynkronisia operaatioita. [Async/await-kikalla](https://facebook.github.io/jest/docs/en/asynchronous.html) saamme pyynnön näyttämään koodin tasolla synkroonisesti toimivalta. +Haluttu arvo on nyt määritelty [regexinä](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions) eli suomeksi säännöllisenä lausekkeena. Regex alkaa ja loppuu vinoviivaan /, koska haluttu merkkijono application/json myös sisältää saman vinoviivan, on sen eteen laitettu \ jotta sitä ei tulkita regexin lopetusmerkiksi. -Kaikkien testien (joita siis tällä kertaa on vain yksi) päätteeksi on vielä lopputoimenpiteenä katkaistava Mongoosen käyttämä tietokantayhteys. Tämä onnistuu helposti metodissa [afterAll](https://facebook.github.io/jest/docs/en/api.html#afterallfn-timeout): +Periaatteessa testi olisi voitu määritellä myös normaalina merkkijonona ```js -afterAll(() => { - mongoose.connection.close() -}) +.expect('Content-Type', 'application/json') ``` -Testejä suorittaessa saattaa tulla seuraava ilmoitus +Tässä ongelmana on kuitenkin se, että käytettäessä merkkijonoa, tulee headerin arvon olla täsmälleen sama. Määrittelemällemme regexille kelpaa että header sisältää kyseisen merkkijonon. Headerin todellinen arvo on application/json; charset=utf-8, eli se sisältää myös tiedon merkistökoodauksesta. Testimme ei kuitenkaan ole tästä kiinnostunut ja siksi testi on parempi määritellä tarkan merkkijonon sijaan regexinä. -![](../../images/4/8.png) +Testissä on muutama detalji joihin tutustumme vasta [hieman myöhemmin](/osa4/backendin_testaaminen#async-await) tässä osassa. Testikoodin määrittelevä nuolifunktio alkaa sanalla async, ja api-oliolle tehtyä metodikutsua edeltää sana await. Teemme ensin muutamia testejä ja tutustumme sen jälkeen async/await-magiaan. Tällä hetkellä niistä ei tarvitse välittää, sillä kaikki toimii kunhan kirjoitat testimetodit esimerkin mukaan. Async/await-syntaksin käyttö liittyy siihen, että palvelimelle tehtävät pyynnöt ovat asynkronisia operaatioita. Async/await-syntaksia käyttämällä saamme pyynnön näyttämään koodin tasolla synkronisesti toimivalta. -Jos näin käy, toimitaan [ohjeen](https://mongoosejs.com/docs/jest.html) mukaan ja lisätään projektin hakemiston juureen tiedosto jest.config.js jolla on seuraava sisältö: +Kaikkien testien (joita siis tällä kertaa on vain yksi) päätteeksi on vielä lopputoimenpiteenä katkaistava Mongoosen käyttämä tietokantayhteys, sillä muuten ohjelman suoritus ei pääty. Tämä onnistuu helposti metodissa [after](https://nodejs.org/api/test.html#afterfn-options): ```js -module.exports = { - testEnvironment: 'node' -} +after(async () => { + await mongoose.connection.close() +}) ``` -Pieni mutta tärkeä huomio: eristimme tämän osan [alussa](/osa4/sovelluksen_rakenne_ja_testauksen_alkeet#sovelluksen-rakenne) Express-sovelluksen tiedostoon app.js ja tiedoston index.js rooliksi jäi sovelluksen käynnistäminen määriteltyyn porttiin Noden http-olion avulla: +Pieni mutta tärkeä huomio: eristimme tämän osan [alussa](/osa4/sovelluksen_rakenne_ja_testauksen_alkeet#sovelluksen-rakenne) Express-sovelluksen tiedostoon app.js, ja tiedoston index.js rooliksi jäi sovelluksen käynnistäminen määriteltyyn porttiin http-olion avulla: ```js const app = require('./app') // varsinainen Express-sovellus -const http = require('http') const config = require('./utils/config') const logger = require('./utils/logger') -const server = http.createServer(app) - -server.listen(config.PORT, () => { +app.listen(config.PORT, () => { logger.info(`Server running on port ${config.PORT}`) }) ``` -Testit käyttävät ainoastaan tiedostossa app.js määriteltyä express-sovellusta: +Testit käyttävät ainoastaan tiedostossa app.js määriteltyä Express-sovellusta: + ```js const mongoose = require('mongoose') const supertest = require('supertest') @@ -189,29 +185,49 @@ const api = supertest(app) // highlight-line // ... ``` -Supertestin dokumentaatio toteaa seuraavasti +SuperTestin dokumentaatio toteaa seuraavaa: > if the server is not already listening for connections then it is bound to an ephemeral port for you so there is no need to keep track of ports. -eli Supertest huolehtii testattavan sovelluksen käynnistämisestä sisäisesti käyttämäänsä porttiin. +SuperTest siis huolehtii testattavan sovelluksen käynnistämisestä sisäisesti käyttämäänsä porttiin. Tämä on yksi syy siihen, miksi käytämme SuperTestiä emmekä esimerkiksi axiosia, sillä meidän ei tarvitse erikseen käynnistää palvelinta ennen testauksen aloittamista. SuperTest tarjoaa myös funktioita, kuten _expect_, jotka tekevät testauksesta helpompaa. + +Lisätään tiedoston _mongo.js_ ohjelmaa käyttämällä testitietokantaan kaksi muistiinpanoa (tässä kohtaa on muistettava vaihtaa käyttöön oikea tietokantaurl). Tehdään pari testiä lisää: ```js -test('there are two notes', async () => { +const assert = require('node:assert') +// ... + +test('all notes are returned', async () => { + const response = await api.get('/api/notes') + + assert.strictEqual(response.body.length, 2) +}) + +test('a specific note is within the returned notes', async () => { const response = await api.get('/api/notes') - expect(response.body).toHaveLength(2) + const contents = response.body.map(e => e.content) + assert.strictEqual(contents.includes('HTML is easy'), true) }) -test('the first note is about HTTP methods', async () => { +// ... +``` + +Molemmat testit sijoittavat pyynnön vastauksen muuttujaan _response_. Toisin kuin edellisessä testissä (joka käytti SuperTestin mekanismeja statuskoodin ja vastauksen headereiden oikeellisuuden varmistamiseen), tällä kertaa tutkitaan vastauksessa olevan datan eli response.body:n oikeellisuutta _assert_-kirjaston [strictEqual](https://nodejs.org/docs/latest/api/assert.html#assertstrictequalactual-expected-message) metodilla. + +Jälkimmäistä testiä on vielä mahdollista yksinkertaistaa hiukan tekemällä vertailu suoraan [assert](https://nodejs.org/docs/latest/api/assert.html#assertokvalue-message):illa: + +```js +test('a specific note is within the returned notes', async () => { const response = await api.get('/api/notes') - expect(response.body[0].content).toBe('HTML is easy') + const contents = response.body.map(e => e.content) + assert(contents.includes('HTML is easy')) }) ``` -Molemmat testit sijoittavat pyynnön vastauksen muuttujaan _response_ ja toisin kuin edellinen testi, joka käytti _supertestin_ mekanismeja statuskoodin ja vastauksen headereiden oikeellisuuden varmistamiseen, tällä kertaa tutkitaan vastauksessa olevan datan, eli response.body:n oikeellisuutta Jestin [expect](https://facebook.github.io/jest/docs/en/expect.html#content):in avulla. Async/await-kikan hyödyt tulevat nyt selkeästi esiin. Normaalisti tarvitsisimme asynkronisten pyyntöjen vastauksiin käsille pääsemiseen promiseja ja takaisinkutsuja, mutta nyt kaikki menee mukavasti: @@ -220,7 +236,7 @@ const response = await api.get('/api/notes') // tänne tullaan vasta kun edellinen komento eli HTTP-pyyntö on suoritettu // muuttujassa response on nyt HTTP-pyynnön tulos -expect(response.body).toHaveLength(2) +assert.strictEqual(response.body.length, 2) ``` HTTP-pyyntöjen tiedot konsoliin kirjoittava middleware häiritsee hiukan testien tulostusta. Muutetaan loggeria siten, että testausmoodissa lokiviestit eivät tulostu konsoliin: @@ -235,7 +251,11 @@ const info = (...params) => { } const error = (...params) => { - console.error(...params) + // highlight-start + if (process.env.NODE_ENV !== 'test') { + console.error(...params) + } + // highlight-end } module.exports = { @@ -245,31 +265,37 @@ module.exports = { ### Tietokannan alustaminen ennen testejä -Testaus vaikuttaa helpolta ja testit menevät läpi. Testimme ovat kuitenkin huonoja, niiden läpimeno riippuu tietokannan tilasta (joka sattuu omassa testikannassani olemaan sopiva). Jotta saisimme robustimmat testit, tulee tietokannan tila nollata testien alussa ja sen jälkeen laittaa kantaan hallitusti testien tarvitsema data. +Testeissämme on tällä hetkellä ongelmana, että niiden läpimeno riippuu tietokannan tilasta. Testit menevät siis läpi, jos testitietokannassa sattuu olemaan kaksi muistiinpanoa, joista toisen sisältö on 'HTML is easy'. Jotta saisimme robustimmat testit, tulee tietokannan tila nollata testien alussa ja sen jälkeen laittaa kantaan hallitusti testien tarvitsema data. -Testimme käyttää jo jestin metodia [afterAll](https://facebook.github.io/jest/docs/en/api.html#afterallfn-timeout) sulkemaan tietokannan testien suoritusten jälkeen. Jest tarjoaa joukon muitakin [funktioita](https://facebook.github.io/jest/docs/en/setup-teardown.html#content), joiden avulla voidaan suorittaa operaatioita ennen yhdenkään testin suorittamista tai ennen jokaisen testin suoritusta. +Testimme käyttää jo nyt funktiota [after](https://nodejs.org/api/test.html#afterfn-options) sulkemaan tietokannan testien suoritusten jälkeen. Kirjasto Node:test tarjoaa joukon muitakin metodeja joiden avulla voidaan suorittaa operaatioita ennen yhdenkään testin suorittamista tai ennen jokaisen testin suoritusta. -Päätetään alustaa tietokanta ennen jokaisen testin suoritusta, eli funktiossa [beforeEach](https://jestjs.io/docs/en/api.html#aftereachfn-timeout): +Päätetään alustaa tietokanta ennen jokaisen testin suoritusta, eli funktiossa [beforeEach](https://nodejs.org/api/test.html#beforeeachfn-options): ```js + +const assert = require('node:assert') +const { test, after, beforeEach } = require('node:test') // highlight-line +const mongoose = require('mongoose') const supertest = require('supertest') const app = require('../app') +const Note = require('../models/note') // highlight-line + const api = supertest(app) -const Note = require('../models/note') +// highlight-start const initialNotes = [ { content: 'HTML is easy', - date: new Date(), important: false, }, { - content: 'Browser can execute only Javascript', - date: new Date(), + content: 'Browser can execute only JavaScript', important: true, }, ] +// highlight-end +// highlight-start beforeEach(async () => { await Note.deleteMany({}) @@ -279,56 +305,79 @@ beforeEach(async () => { noteObject = new Note(initialNotes[1]) await noteObject.save() }) +// highlight-end + +// ... ``` -Tietokanta siis tyhjennetään aluksi ja sen jälkeen kantaan lisätään kaksi taulukkoon _initialNotes_ talletettua muistiinpanoa. Näin testien suoritus aloitetaan aina hallitusti samasta tilasta. +Tietokanta siis tyhjennetään aluksi, ja sen jälkeen kantaan lisätään kaksi taulukkoon _initialNotes_ talletettua muistiinpanoa. Näin testien suoritus aloitetaan aina hallitusti samasta tilasta. -Muutetaan kahta jälkimmäistä testiä vielä seuraavasti: +Muutetaan muistiinpanojen lukumäärää testaavaa testiä vielä seuraavasti: ```js +// ... + test('all notes are returned', async () => { const response = await api.get('/api/notes') - expect(response.body).toHaveLength(initialNotes.length) // highlight-line + assert.strictEqual(response.body.length, initialNotes.length) // highlight-line }) -test('a specific note is within the returned notes', async () => { - const response = await api.get('/api/notes') +// ... + +``` + +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-3), branchissa part4-3. + +### Testien suorittaminen yksitellen + +Komento _npm test_ suorittaa projektin kaikki testit. Kun olemme vasta tekemässä testejä, on useimmiten järkevämpää suorittaa kerrallaan ainoastaan yhtä tai muutamaa testiä. Test-moduuli tarjoaa tähän muutamia vaihtoehtoja. - const contents = response.body.map(r => r.content) // highlight-line +Eräs näistä on komennon [only](https://nodejs.org/api/test.html#testonlyname-options-fn) käyttö. Komennon avulla voidaan merkitä vain osa testeistä suoritettavaksi: - expect(contents).toContain( - 'Browser can execute only Javascript' // highlight-line - ) +```js +test.only('notes are returned as json', async () => { + await api + .get('/api/notes') + .expect(200) + .expect('Content-Type', /application\/json/) +}) + +test.only('all notes are returned', async () => { + const response = await api.get('/api/notes') + + assert.strictEqual(response.body.length, 2) }) ``` -Huomaa jälkimmäisen testin ekspektaatio. Komennolla response.body.map(r => r.content) muodostetaan taulukko API:n palauttamien muistiinpanojen sisällöistä. Jestin [toContain](https://facebook.github.io/jest/docs/en/expect.html#tocontainitem)-ekspektaatiometodilla tarkistetaan että parametrina oleva muistiinpano on kaikkien API:n palauttamien muistiinpanojen joukossa. +Kun testit nyt suoritetaan lisäparametrilla _--test-only_, eli komennolla -### Testien suorittaminen yksitellen +``` +npm test -- --test-only +``` + +tulevat ainoastaan merkityt suoritetuksi. -Komento _npm test_ suorittaa projektin kaikki testit. Kun olemme vasta tekemässä testejä, on useimmiten järkevämpää suorittaa kerrallaan ainoastaan yhtä tai muutamaa testiä. Jest tarjoaa tähän muutamia vaihtoehtoja. Eräs näistä on komennon [only](https://jestjs.io/docs/en/api#testonlyname-fn-timeout) käyttö. Jos testit on kirjoitettu useaan tiedostoon, ei menetelmä ole kovin hyvä. +Komennon _only_ käytön riskinä on se, että ohjelmoija unohtaa poistaa komennot testeistä... -Parempi vaihtoehto on määritellä komennon npm test yhteydessä minkä tiedoston testit halutaan suoritta. Seuraava komento suorittaa ainoastaan tiedostossa tests/note_api.test.js olevat testit +On myös mahdollista suorittaa ainoastaan yhdessä tiedostossa määritellyt testit. Seuraava komento suorittaa ainoastaan tiedostossa tests/note_api.test.js olevat testit: ```js npm test -- tests/note_api.test.js ``` -Parametrin -t avulla voidaan suorittaa testejä nimen perusteella: +Parametrin [--tests-by-name-pattern](https://nodejs.org/api/test.html#filtering-tests-by-name) avulla voidaan suorittaa testejä nimen perusteella: ```js -npm test -- -t 'a specific note is within the returned notes' +npm test -- --test-name-pattern="a specific note is within the returned notes" ``` Parametri voi viitata testin tai describe-lohkon nimeen. Parametrina voidaan antaa myös nimen osa. Seuraava komento suorittaisi kaikki testit, joiden nimessä on sana notes: ```js -npm test -- -t 'notes' +npm run test -- --test-name-pattern="notes" ``` -*HUOM*: yksittäisiä testejä suoritettaessa saattaa mongoose-yhteys jäädä auki, mikäli yhtään yhteyttä hyödyntävää testiä ei ajeta. Ongelma seurannee siitä, että supertest alustaa yhteyden, mutta jest ei suorita afterAll-osiota. - ### async/await Ennen kuin teemme lisää testejä, tarkastellaan tarkemmin mitä _async_ ja _await_ tarkoittavat. @@ -347,12 +396,12 @@ Metodikutsu _Note.find()_ palauttaa promisen, ja saamme itse operaation tuloksen Kaikki operaation suorituksen jälkeinen koodi kirjoitetaan tapahtumankäsittelijään. Jos haluaisimme tehdä peräkkäin useita asynkronisia funktiokutsuja, menisi tilanne ikävämmäksi. Joutuisimme tekemään kutsut tapahtumankäsittelijästä. Näin syntyisi potentiaalisesti monimutkaista koodia, pahimmassa tapauksessa jopa niin sanottu [callback-helvetti](http://callbackhell.com/). -[Ketjuttamalla promiseja](https://javascript.info/promise-chaining) tilanne pysyy jollain tavalla hallinnassa, callback-helvetin eli monien sisäkkäisten callbackien sijaan saadaan aikaan siistihkö _then_-kutsujen ketju. Olemmekin nähneet jo kurssin aikana muutaman sellaisen. Seuraavassa vielä erittäin keinotekoinen esimerkki, joka hakee ensin kaikki muistiinpanot ja sitten tuhoaa niistä ensimmäisen: +[Ketjuttamalla promiseja](https://javascript.info/promise-chaining) tilanne pysyy jollain tavalla hallinnassa. Callback-helvetin eli monien sisäkkäisten callbackien sijaan saadaan aikaan siistihkö _then_-kutsujen ketju. Olemmekin nähneet jo kurssin aikana muutaman sellaisen. Seuraavassa vielä erittäin keinotekoinen esimerkki, joka hakee ensin kaikki muistiinpanot ja sitten tuhoaa niistä ensimmäisen: ```js Note.find({}) .then(notes => { - return notes[0].remove() + return notes[0].deleteOne() }) .then(response => { console.log('the first note is removed') @@ -362,7 +411,7 @@ Note.find({}) Then-ketju on ok, mutta parempaankin pystytään. Jo ES6:ssa esitellyt [generaattorifunktiot](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator) mahdollistivat [ovelan tavan](https://github.com/getify/You-Dont-Know-JS/blob/1st-ed/async%20%26%20performance/ch4.md#iterating-generators-asynchronously) määritellä asynkronista koodia siten että se "näyttää synkroniselta". Syntaksi ei kuitenkaan ole täysin luonteva ja sitä ei käytetä kovin yleisesti. -ES7:ssa _async_ ja _await_ tuovat generaattoreiden tarjoaman toiminnallisuuden ymmärrettävästi ja syntaksin puolesta selkeällä tavalla koko Javascript-kansan ulottuville. +ES7:ssa _async_ ja _await_ tuovat generaattoreiden tarjoaman toiminnallisuuden ymmärrettävästi ja syntaksin puolesta selkeällä tavalla koko JavaScript-kansan ulottuville. Voisimme hakea tietokannasta kaikki muistiinpanot [await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await)-operaattoria hyödyntäen seuraavasti: @@ -378,7 +427,7 @@ Ylempänä oleva monimutkaisempi esimerkki suoritettaisiin awaitin avulla seuraa ```js const notes = await Note.find({}) -const response = await notes[0].remove() +const response = await notes[0].deleteOne() console.log('the first note is removed') ``` @@ -387,48 +436,54 @@ Koodi siis yksinkertaistuu huomattavasti verrattuna promiseja käyttävään the Awaitin käyttöön liittyy parikin tärkeää seikkaa. Jotta asynkronisia operaatioita voi kutsua awaitin avulla, niiden täytyy palauttaa promiseja. Tämä ei sinänsä ole ongelma, sillä myös "normaaleja" callbackeja käyttävä asynkroninen koodi on helppo kääriä promiseksi. -Mistä tahansa kohtaa Javascript-koodia ei awaitia kuitenkaan pysty käyttämään. Awaitin käyttö onnistuu ainoastaan jos ollaan [async](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function)-funktiossa. +Mistä tahansa kohtaa JavaScript-koodia ei awaitia kuitenkaan pysty käyttämään. Awaitin käyttö onnistuu ainoastaan jos ollaan [async](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function)-funktiossa. -Eli jotta edelliset esimerkit toimisivat, on ne suoritettava async-funktioiden sisällä, huomaa funktion määrittelevä rivi: +Eli jotta edelliset esimerkit toimisivat, on ne suoritettava async-funktioiden sisällä (huomaa funktion määrittelevä rivi): ```js const main = async () => { // highlight-line const notes = await Note.find({}) console.log('operaatio palautti seuraavat muistiinpanot', notes) - const notes = await Note.find({}) - const response = await notes[0].remove() - + const response = await notes[0].deleteOne() console.log('the first note is removed') } main() // highlight-line ``` -Koodi määrittelee ensin asynkronisen funktion, joka sijoitetaan muuttujaan _main_. Määrittelyn jälkeen koodi kutsuu metodia komennolla main() +Koodi määrittelee ensin asynkronisen funktion, joka sijoitetaan muuttujaan _main_. Määrittelyn jälkeen koodi kutsuu metodia komennolla main(). ### async/await backendissä -Muutetaan nyt backend käyttämään asyncia ja awaitia. Koska kaikki asynkroniset operaatiot tehdään joka tapauksessa funktioiden sisällä, awaitin käyttämiseen riittää, että muutamme routejen käsittelijät async-funktioiksi. +Muutetaan seuraavaksi backend käyttämään asyncia ja awaitia. Aloitetaan kaikkien muistiinpanojen hakemisesta vastaavasta routesta. -Kaikkien muistiinpanojen hakemisesta vastaava route muuttuu seuraavasti: +Koska kaikki asynkroniset operaatiot tehdään joka tapauksessa funktioiden sisällä, awaitin käyttämiseen riittää, että muutamme routejen käsittelijät async-funktioiksi. Alkuperäinen route + +```js +notesRouter.get('/', (request, response) => { + Note.find({}).then((notes) => { + response.json(notes) + }) +}) +``` + +muuttuu seuraavasti: ```js notesRouter.get('/', async (request, response) => { const notes = await Note.find({}) - response.json(notes.map(note => note.toJSON())) + response.json(notes) }) ``` -Voimme varmistaa refaktoroinnin onnistumisen selaimella, sekä suorittamalla juuri määrittelemämme testit. +Voimme varmistaa refaktoroinnin onnistumisen selaimella sekä suorittamalla juuri määrittelemämme testit. -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-3), branchissa part4-3. +### Muistiinpanon lisäämisestä vastaavan routen refaktorointi -### Lisää testejä ja backendin refaktorointia +Koodia refaktoroidessa vaanii aina [regression](https://en.wikipedia.org/wiki/Regression_testing) vaara. On olemassa riski, että jo toimineet ominaisuudet hajoavat. Tehdäänkin muiden operaatioiden refaktorointi siten, että ennen koodin muutosta tehdään jokaiselle API:n routelle sen toiminnallisuuden varmistavat testit. -Koodia refaktoroidessa vaanii aina [regression](https://en.wikipedia.org/wiki/Regression_testing) vaara, eli on olemassa riski, että jo toimineet ominaisuudet hajoavat. Tehdäänkin muiden operaatioiden refaktorointi siten, että ennen koodin muutosta tehdään jokaiselle API:n routelle sen toiminnallisuuden varmistavat testit. - -Aloitetaan lisäysoperaatiosta. Tehdään testi, joka lisää uuden muistiinpanon ja tarkistaa, että API:n palauttamien muistiinpanojen määrä kasvaa, ja että lisätty muistiinpano on palautettujen joukossa: +Aloitetaan lisäysoperaatiosta. Tehdään testi, joka lisää uuden muistiinpanon ja tarkastaa, että API:n palauttamien muistiinpanojen määrä kasvaa, ja että lisätty muistiinpano on palautettujen joukossa: ```js test('a valid note can be added ', async () => { @@ -440,23 +495,39 @@ test('a valid note can be added ', async () => { await api .post('/api/notes') .send(newNote) - .expect(200) + .expect(201) .expect('Content-Type', /application\/json/) const response = await api.get('/api/notes') const contents = response.body.map(r => r.content) - expect(response.body).toHaveLength(initialNotes.length + 1) - expect(contents).toContain( - 'async/await simplifies making async calls' - ) + assert.strictEqual(response.body.length, initialNotes.length + 1) + + assert(contents.includes('async/await simplifies making async calls')) }) ``` -Kuten odotimme ja toivoimme, menee testi läpi. +Testi ei itse asiassa mene läpi, sillä olemme vahingossa palauttaneet statuskoodin 200 OK uuden muistiinpanon luomisen yhteydessä, parempi statuskoodi on 201 CREATED. Muutetaan koodia siten että testi menee läpi: + +```js +notesRouter.post('/', (request, response, next) => { + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + + note.save() + .then(savedNote => { + response.status(201).json(savedNote) // highlight-line + }) + .catch(error => next(error)) +}) +``` -Tehdään myös testi, joka varmistaa, että muistiinpanoa, jolle ei ole asetettu sisältöä, ei talleteta +Tehdään myös testi, joka varmistaa, että muistiinpanoa, jolle ei ole asetettu sisältöä, ei talleteta: ```js test('note without content is not added', async () => { @@ -471,17 +542,17 @@ test('note without content is not added', async () => { const response = await api.get('/api/notes') - expect(response.body).toHaveLength(initialNotes.length) + assert.strictEqual(response.body.length, initialNotes.length) }) ``` -Molemmat testit tarkastavat lisäyksen jälkeen mihin tilaan tietokanta on päätynyt hakemalla kaikki sovelluksen muistiinpanot +Molemmat testit tarkastavat lisäyksen jälkeen mihin tilaan tietokanta on päätynyt hakemalla kaikki sovelluksen muistiinpanot: ```js const response = await api.get('/api/notes') ``` -Sama tulee toistumaan myöhemminkin monissa testeissä ja operaatio kannattaakin eristää apufunktioon. Sijoitetaan se testien yhteyteen tiedostoon tests/test_helper.js +Sama tulee toistumaan myöhemminkin monissa testeissä ja operaatio kannattaakin eristää apufunktioon. Sijoitetaan se testien yhteyteen tiedostoon tests/test_helper.js: ```js const Note = require('../models/note') @@ -489,12 +560,10 @@ const Note = require('../models/note') const initialNotes = [ { content: 'HTML is easy', - date: new Date(), important: false }, { - content: 'Browser can execute only Javascript', - date: new Date(), + content: 'Browser can execute only JavaScript', important: true } ] @@ -502,7 +571,7 @@ const initialNotes = [ const nonExistingId = async () => { const note = new Note({ content: 'willremovethissoon' }) await note.save() - await note.remove() + await note.deleteOne() return note._id.toString() } @@ -517,19 +586,21 @@ module.exports = { } ``` -Moduuli määrittelee funktion _notesInDb_, jonka avulla voidaan tarkastaa sovelluksen tietokannassa olevat muistiinpanot. Tietokantaan alustettava sisältö _initialNotes_ on siirretty samaan tiedostoon. Määrittelimme myös tulevan varalta funktion _nonExistingId_, jonka avulla on mahdollista luoda tietokantaid, joka ei kuulu millekään kannassa olevalle oliolle. +Moduuli määrittelee funktion _notesInDb_, jonka avulla voidaan tarkastaa sovelluksen tietokannassa olevat muistiinpanot. Tietokantaan alustettava sisältö _initialNotes_ on siirretty samaan tiedostoon. Määrittelimme myös tulevan varalta funktion _nonExistingId_, jonka avulla on mahdollista luoda tietokanta-id, joka ei kuulu millekään kannassa olevalle oliolle. Testit muuttuvat muotoon ```js -const supertest = require('supertest') +const assert = require('node:assert') +const { test, after, beforeEach } = require('node:test') const mongoose = require('mongoose') -const helper = require('./test_helper') // highlight-line +const supertest = require('supertest') const app = require('../app') -const api = supertest(app) - +const helper = require('./test_helper') // highlight-line const Note = require('../models/note') +const api = supertest(app) + beforeEach(async () => { await Note.deleteMany({}) @@ -550,16 +621,14 @@ test('notes are returned as json', async () => { test('all notes are returned', async () => { const response = await api.get('/api/notes') - expect(response.body).toHaveLength(helper.initialNotes.length) // highlight-line + assert.strictEqual(response.body.length, helper.initialNotes.length) // highlight-line }) test('a specific note is within the returned notes', async () => { const response = await api.get('/api/notes') - const contents = response.body.map(r => r.content) - expect(contents).toContain( - 'Browser can execute only Javascript' - ) + const contents = response.body.map(e => e.content) + assert(contents.includes('HTML is easy')) }) test('a valid note can be added ', async () => { @@ -571,17 +640,14 @@ test('a valid note can be added ', async () => { await api .post('/api/notes') .send(newNote) - .expect(200) + .expect(201) .expect('Content-Type', /application\/json/) - const notesAtEnd = await helper.notesInDb() // highlight-line - expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1) // highlight-line + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length + 1) // highlight-line const contents = notesAtEnd.map(n => n.content) // highlight-line - expect(contents).toContain( - 'async/await simplifies making async calls' - ) + assert(contents.includes('async/await simplifies making async calls')) }) test('note without content is not added', async () => { @@ -596,75 +662,83 @@ test('note without content is not added', async () => { const notesAtEnd = await helper.notesInDb() // highlight-line - expect(notesAtEnd).toHaveLength(helper.initialNotes.length) // highlight-line + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length) // highlight-line }) -afterAll(() => { - mongoose.connection.close() -}) +after(async () => { + await mongoose.connection.close() +}) ``` Promiseja käyttävä koodi toimii nyt ja testitkin menevät läpi. Olemme valmiit muuttamaan koodin käyttämään async/await-syntaksia. -Uuden muistiinpanon lisäämisestä huolehtiva koodi muuttuu seuraavasti (huomaa, että käsittelijän alkuun on laitettava määre _async_): +Uuden muistiinpanon lisäämisestä huolehtiva route ```js -notesRouter.post('/', async (request, response, next) => { +notesRouter.post('/', (request, response, next) => { const body = request.body const note = new Note({ content: body.content, - important: body.important === undefined ? false : body.important, - date: new Date(), + important: body.important || false, }) - const savedNote = await note.save() - response.json(savedNote.toJSON()) + note + .save() + .then((savedNote) => { + response.status(201).json(savedNote) + }) + .catch((error) => next(error)) }) ``` -Koodiin jää kuitenkin pieni ongelma: virhetilanteita ei nyt käsitellä ollenkaan. Miten niiden suhteen tulisi toimia? - -### virheiden käsittely ja async/await - -Jos sovellus POST-pyyntöä käsitellessään aiheuttaa jonkinlaisen ajonaikaisen virheen, syntyy jälleen tuttu tilanne: - -![](../../images/4/6.png) - -eli käsittelemätön promisen rejektoituminen. Pyyntöön ei vastata tilanteessa mitenkään. - -Async/awaitia käyttäessä kannattaa käyttää vanhaa kunnon _try/catch_-mekanismia virheiden käsittelyyn: +muuttuu seuraavasti: ```js -notesRouter.post('/', async (request, response, next) => { +notesRouter.post('/', async (request, response) => { // highlight-line const body = request.body const note = new Note({ content: body.content, - important: body.important === undefined ? false : body.important, - date: new Date(), + important: body.important || false, }) + // highlight-start - try { - const savedNote = await note.save() - response.json(savedNote.toJSON()) - } catch(exception) { - next(exception) - } + const savedNote = await note.save() + response.status(201).json(savedNote) // highlight-end }) ``` -Catch-lohkossa siis ainoastaan kutsutaan funktiota _next_ siirretään poikkeuksen käsittely virheidenkäsittelymiddlewarelle. +Käsittelijän alkuun on laitettava määre _async_, jotta _async/await_-syntaksia on mahdollista käyttää. Koodi yksinkertaistuu huomattavasti. + +Huomionarvioista tässä on se, että mahdollisia virheitä ei nyt erikseen tarvitse siirtää eteenpäin käsiteltäväksi. Promiseja käyttävässä koodissa mahdollinen virhe siirrettiin virheenkäsittelijämiddlevaren käsiteltäväksi näin: + +```js + note + .save() + .then((savedNote) => { + response.json(savedNote) + }) + .catch((error) => next(error)) // highlight-line +``` -Muutoksen jälkeen testit menevät läpi. -Tehdään sitten testit yksittäisen muistiinpanon tietojen katsomiselle ja muistiinpanon poistolle: + _Async/await_-syntaksia käytettäessä Express [kutsuu automaattisesti](https://expressjs.com/en/guide/error-handling.html) virheenkäsittelijämiddlewarea, jos _await_-komento päätyy virheeseen tai sen odottama promise päätyy _rejected_-tilaan. Näin lopullisesta koodista tulee entistä siistimpää. + + **HUOM.** Tämä ominaisuus on käytössä Expressin versiosta 5 alkaen. Jos olet asentanut Expressin projektisi riippuvuudeksi ennen 31.3.2025, sinulla saattaa vielä olla käytössä Expressin versio 4. Voit tarkistaa projektissasi käytössä olevan Expressin version _package.json_-tiedostosta. Jos käytössäsi on vanha versio, päivitä se nyt versioon 5 komennolla + + ```bash + npm install express@5 + ``` + +### Yksittäisen muistiinpanon hakemisesta vastaavan routen refaktorointi + +Tehdään sitten testi yksittäisen muistiinpanon tietojen katsomiselle. Koodissa on korostettu varsinainen API:in suoritettava operaatio: ```js test('a specific note can be viewed', async () => { const notesAtStart = await helper.notesInDb() - const noteToView = notesAtStart[0] // highlight-start @@ -674,156 +748,71 @@ test('a specific note can be viewed', async () => { .expect('Content-Type', /application\/json/) // highlight-end - expect(resultNote.body).toEqual(noteToView) -}) - -test('a note can be deleted', async () => { - const notesAtStart = await helper.notesInDb() - const noteToDelete = notesAtStart[0] - -// highlight-start - await api - .delete(`/api/notes/${noteToDelete.id}`) - .expect(204) -// highlight-end - - const notesAtEnd = await helper.notesInDb() - - expect(notesAtEnd).toHaveLength( - helper.initialNotes.length - 1 - ) - - const contents = notesAtEnd.map(r => r.content) - - expect(contents).not.toContain(noteToDelete.content) + assert.deepStrictEqual(resultNote.body, noteToView) }) ``` -Molemmat testit ovat rakenteeltaan samankaltaisia. Alustusvaiheessa ne hakevat kannasta yksittäisen muistiinpanon. Tämän jälkeen on itse testattava operaatio, joka on koodissa korostettuna. Lopussa tarkastetaan, että operaation tulos on haluttu. +Ensin testi hakee kannasta yksittäisen muistiinpanon. Tämän jälkeen testataan, että kyseinen muistiinpano on mahdollista hakea API:n kautta. Lopussa tarkastetaan, että haetun muistiinpanon sisältö on odotetunlainen. -Testit menevät läpi, joten voimme turvallisesti refaktoroida testatut routet käyttämään async/awaitia: +Testissä on eräs huomionarvoinen seikka. Sen sijaan, että vertailu tehtäisiin aiemmin käytetyn metodin [strictEqual](https://nodejs.org/api/assert.html#assertstrictequalactual-expected-message), käytössä on metodi [deepStrictEqual](https://nodejs.org/api/assert.html#assertdeepstrictequalactual-expected-message): ```js -notesRouter.get('/:id', async (request, response, next) => { - try{ - const note = await Note.findById(request.params.id) - if (note) { - response.json(note.toJSON()) - } else { - response.status(404).end() - } - } catch(exception) { - next(exception) - } -}) - -notesRouter.delete('/:id', async (request, response, next) => { - try { - await Note.findByIdAndRemove(request.params.id) - response.status(204).end() - } catch (exception) { - next(exception) - } -}) +assert.deepStrictEqual(resultNote.body, noteToView) ``` -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-4), haarassa part4-4. +Syynä tälle on se, että _strictEqual_ käyttää metodia [Object.is](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is) samuuden vertailuun, eli se vertaa ovatko kyseessä samat oliot. Meidän tapauksessamme taas on tarkoitus tarkistaa, että olioiden sisältö eli niiden kenttien arvot olisivat samat. Tähän tarkoitukseen sopii _deepStrictEqual_. -### Try-catchin eliminointi - -Async/await selkeyttää koodia jossain määrin, mutta sen 'hinta' on poikkeusten käsittelyn edellyttämä try/catch-rakenne. Kaikki routejen käsittelijät noudattavat samaa kaavaa +Testit menevät läpi, joten voimme turvallisesti refaktoroida testatun routen käyttämään async/awaitia: ```js -try { - // do the async operations here -} catch(exception) { - next(exception) -} +notesRouter.get('/:id', async (request, response) => { + const note = await Note.findById(request.params.id) + if (note) { + response.json(note) + } else { + response.status(404).end() + } +}) ``` -Mieleen herää kysymys, olisiko koodia mahdollista refaktoroida siten, että catch saataisiin refaktoroitua ulos metodeista? +### Muistiinpanon poistamisesta vastaavan routen refaktorointi -Kirjasto [express-async-errors](https://github.com/davidbanham/express-async-errors) tuo tilanteeseen helpotuksen. - -Asennetaan kirjasto - -```bash -npm install express-async-errors --save -``` - -Kirjaston käyttö on todella helppoa. Kirjaston koodi otetaan käyttöön tiedostossa src/app.js: +Lisätään vielä testi muistiinpanon poistamisesta vastaavalle routelle: ```js -const config = require('./utils/config') -const express = require('express') -require('express-async-errors') // highlight-line -const app = express() -const cors = require('cors') -const notesRouter = require('./controllers/notes') -const middleware = require('./utils/middleware') -const logger = require('./utils/logger') -const mongoose = require('mongoose') +test('a note can be deleted', async () => { + const notesAtStart = await helper.notesInDb() + const noteToDelete = notesAtStart[0] -// ... + await api + .delete(`/api/notes/${noteToDelete.id}`) + .expect(204) -module.exports = app -``` + const notesAtEnd = await helper.notesInDb() -Kirjaston koodiin sisällyttämän "magian" ansiosta pääsemme kokonaan eroon try-catch-lauseista. Muistiinpanon poistamisesta huolehtiva route + const contents = notesAtEnd.map(n => n.content) + assert(!contents.includes(noteToDelete.content)) -```js -notesRouter.delete('/:id', async (request, response, next) => { - try { - await Note.findByIdAndRemove(request.params.id) - response.status(204).end() - } catch (exception) { - next(exception) - } + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length - 1) }) ``` -muuttuu muotoon +Testi on rakenteeltaan samanlainen kuin yksittäisen muistiinpanon hakemista testaava testi, eli ensin kannasta haetaan yksittäinen muistiinpano, jonka jälkeen testataan sen poistamista API:n kautta. Lopuksi tarkistetaan, että kannassa ei ole enää kyseistä muistiinpanoa ja että muistiinpanojen määrä on vähentynyt yhdellä. + +Testit menevät edelleen läpi, joten voimme turvallisesti suorittaa routen refaktoroinnin: ```js notesRouter.delete('/:id', async (request, response) => { - await Note.findByIdAndRemove(request.params.id) + await Note.findByIdAndDelete(request.params.id) response.status(204).end() }) ``` -Kirjaston ansiosta kutsua _next(exception)_ ei siis enää tarvita, kirjasto hoitaa asian konepellin alla, eli jos async-funktiona määritellyn routen sisällä syntyy poikkeus, siirtyy suoritus automaattisesti virheenkäsittelijämiddlewareen. - -Muut routet yksinkertaistuvat seuraavasti: - -```js -notesRouter.post('/', async (request, response) => { - const body = request.body - - const note = new Note({ - content: body.content, - important: body.important === undefined ? false : body.important, - date: new Date(), - }) - - const savedNote = await note.save() - response.json(savedNote.toJSON()) -}) - -notesRouter.get('/:id', async (request, response) => { - const note = await Note.findById(request.params.id) - if (note) { - response.json(note.toJSON()) - } else { - response.status(404).end() - } -}) -``` - -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-5), haarassa part4-5. +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-4), haarassa part4-4. ### Testin beforeEach-metodin optimointi -Palataan takaisin testien pariin, ja tarkastellaan määrittelemäämme testit alustavaa funktiota _beforeEach_: +Palataan takaisin testien pariin ja tarkastellaan määrittelemäämme testit alustavaa funktiota _beforeEach_: ```js beforeEach(async () => { @@ -837,7 +826,7 @@ beforeEach(async () => { }) ``` -Funktio tallettaa tietokantaan taulukon _helper.initialNotes_ nollannen ja ensimmäisen alkion, kummankin erikseen taulukon alkioita indeksöiden. Ratkaisu on ok, mutta jos haluaisimme tallettaa alustuksen yhteydessä kantaan useampia alkioita, olisi toisto parempi ratkaisu: +Funktio tallettaa tietokantaan taulukon _helper.initialNotes_ nollannen ja ensimmäisen alkion, kummankin erikseen taulukon alkioita indeksoiden. Ratkaisu on ok, mutta jos haluaisimme tallettaa alustuksen yhteydessä kantaan useampia alkioita, olisi toisto parempi ratkaisu: ```js beforeEach(async () => { @@ -860,23 +849,23 @@ test('notes are returned as json', async () => { Talletamme siis taulukossa olevat muistiinpanot tietokantaan _forEach_-loopissa. Testeissä kuitenkin ilmenee jotain häikkää, ja sitä varten koodin sisään on lisätty aputulosteita. -Konsoliin tulostuu +Konsoliin tulostuu: -
    +```
     cleared
     done
     entered test
     saved
     saved
    -
    +``` -Yllättäen ratkaisu ei async/awaitista huolimatta toimi niin kuin oletamme, testin suoritus aloitetaan ennen kuin tietokannan tila on saatu alustettua! +Yllättäen ratkaisu ei async/awaitista huolimatta toimi niin kuin oletamme, vaan testin suoritus aloitetaan ennen kuin tietokannan tila on saatu alustettua! -Ongelma on siinä, että jokainen forEach-loopin läpikäynti generoi oman asynkronisen operaation ja _beforeEach_ ei odota näiden suoritusta. Eli forEach:in sisällä olevat _await_-komennot eivät ole funktiossa _beforeEach_ vaan erillisissä funktioissa, joiden päättymistä _beforeEach_ ei odota. +Ongelma on siinä, että jokainen _forEach_-loopin läpikäynti generoi oman asynkronisen operaation, eikä _beforeEach_-funktio odota näiden suoritusta. Eli forEach:in sisällä olevat _await_-komennot eivät ole funktiossa _beforeEach_ vaan erillisissä funktioissa, joiden päättymistä _beforeEach_ ei odota. Lisäksi [_forEach_-metodi odottaa saavansa parametrikseen synkronisen funktion](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach#description), joten async/await-rakenne ei toimi sen sisällä muutenkaan oikein. Koska testien suoritus alkaa heti _beforeEach_ metodin suorituksen jälkeen, testien suoritus ehditään aloittamaan ennen kuin tietokanta on alustettu toivottuun alkutilaan. -Toimiva ratkaisu tilanteessa on odottaa asynkronisten talletusoperaatioiden valmistumista _beforeEach_-funktiossa, esim. metodin [Promise.all](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) avulla: +Toimiva ratkaisu tilanteessa on odottaa asynkronisten talletusoperaatioiden valmistumista _beforeEach_-funktiossa esim. metodin [Promise.all](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) avulla: ```js beforeEach(async () => { @@ -889,14 +878,14 @@ beforeEach(async () => { }) ``` -Ratkaisu on varmasti aloittelijalle tiiviydestään huolimatta hieman haastava. Taulukkoon _noteObjects_ talletetaan taulukkossa _helper.initialNotes_ olevia Javascript-oliota vastaavat _Note_-konstruktorifunktiolla generoidut Mongoose-oliot. Seuraavalla rivillä luodaan uusi taulukko, joka muodostuu promiseista, jotka saadaan kun jokaiselle _noteObjects_ taulukon alkiolle kutsutaan metodia _save_, eli ne talletetaan kantaan. +Ratkaisu on varmasti aloittelijalle tiiviydestään huolimatta hieman haastava. Taulukkoon _noteObjects_ talletetaan taulukossa _helper.initialNotes_ olevia JavaScript-olioita vastaavat _Note_-konstruktorifunktiolla generoidut Mongoose-oliot. Seuraavalla rivillä luodaan uusi taulukko, joka muodostuu promiseista, jotka saadaan kun jokaiselle _noteObjects_-taulukon alkiolle kutsutaan metodia _save_, eli kun ne talletetaan kantaan. Metodin [Promise.all](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) avulla saadaan koostettua taulukollinen promiseja yhdeksi promiseksi, joka valmistuu, eli menee tilaan fulfilled kun kaikki sen parametrina olevan taulukon promiset ovat valmistuneet. -Siispä viimeinen rivi, await Promise.all(promiseArray) odottaa, että kaikki tietokantaan talletetusta vastaavat promiset ovat valmiina, eli alkiot on talletettu tietokantaan. +Siispä viimeinen rivi, await Promise.all(promiseArray) odottaa, että kaikki tietokantaan talletusta vastaavat promiset ovat valmiina, eli alkiot on talletettu tietokantaan. -> Promise.all-metodia käyttäessä päästään tarvittaessa käsiksi sen parametrina olevien yksittäisten promisejen arvoihin, eli promiseja vastaavien operaatioiden tuloksiin. Jos odotetaan promisejen valmistumista _await_-syntaksilla const results = await Promise.all(promiseArray) palauttaa operaatio taulukon, jonka alkioina on _promiseArray_:n promiseja vastaavat arvot samassa järjestyksessä kuin promiset ovat taulukossa. +> Promise.all-metodia käyttäessä päästään tarvittaessa käsiksi sen parametrina olevien yksittäisten promisejen arvoihin eli promiseja vastaavien operaatioiden tuloksiin. Jos odotetaan promisejen valmistumista _await_-syntaksilla const results = await Promise.all(promiseArray) palauttaa operaatio taulukon, jonka alkioina on _promiseArray_:n promiseja vastaavat arvot samassa järjestyksessä kuin promiset ovat taulukossa. -Promise.all suorittaa kaikkia syötteenä saamiaan promiseja rinnakkain. Jos operaatioiden suoritusjärjestyksellä on merkitystä, voi tämä aiheuttaa ongelmia. Tällöin asynkroniset operaatiot on mahdollista määrittää [for...of](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...of) lohkon sisällä, jonka suoritusjärjestys on taattu. +Promise.all suorittaa kaikkia syötteenä saamiaan promiseja rinnakkain. Jos operaatioiden suoritusjärjestyksellä on merkitystä, voi tämä aiheuttaa ongelmia. Tällöin asynkroniset operaatiot on mahdollista määrittää [for...of](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...of)-lohkon sisällä, jolloin suoritusjärjestys on taattu. ```js beforeEach(async () => { @@ -909,9 +898,9 @@ beforeEach(async () => { }) ``` -Javascriptin asynkroninen suoritusmalli aiheuttaakin siis helposti yllätyksiä ja myös async/await-syntaksin kanssa pitää olla koko ajan tarkkana. Vaikka async/await peittää monia promisejen käsittelyyn liittyviä seikkoja, promisejen toiminta on syytä tuntea mahdollisimman hyvin! +JavaScriptin asynkroninen suoritusmalli aiheuttaakin siis helposti yllätyksiä, ja myös async/await-syntaksin kanssa pitää olla koko ajan tarkkana. Vaikka async/await peittää monia promisejen käsittelyyn liittyviä seikkoja, promisejen toiminta on syytä tuntea mahdollisimman hyvin! -Kaikkein helpoimmalla tilanteesta selvitään hyödyntämällä mongoosen valmista metodia _insertMany_: +On kuitenkin olemassa vieläkin yksinkertaisempi tapa _beforeEach_-funktion toteuttamiseksi. Kaikkein helpoimmalla tilanteesta selvitään hyödyntämällä Mongoosen valmista metodia _insertMany_: ```js beforeEach(async () => { @@ -920,59 +909,63 @@ beforeEach(async () => { }) ``` +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-5), haarassa part4-5. + +### Testejä tekevän full stack ‑sovelluskehittäjän vala + +Testien tekeminen tuo ohjelmointiin jälleen uuden kerroksen haasteellisuutta. Joudumme päivittämään full stack ‑kehittäjän valaamme muistuttamaan siitä että systemaattisuus on myös testejä kehitettäessä avainasemassa. + +Full stack ‑ohjelmointi on todella hankalaa, ja sen takia lupaan hyödyntää kaikkia ohjelmointia helpottavia keinoja: + +- pidän selaimen konsolin koko ajan auki +- tarkkailen säännöllisesti selaimen network-välilehdeltä, että frontendin ja backendin välinen kommunikaatio tapahtuu oletusteni mukaan +- tarkkailen säännöllisesti palvelimella olevan datan tilaa, ja varmistan että frontendin lähettämä data siirtyy sinne kuten oletin +- pidän silmällä tietokannan tilaa: varmistan että backend tallentaa datan sinne oikeaan muotoon +- etenen pienin askelin +- käytän koodissa ja testeissä runsaasti _console.log_-komentoja varmistamaan sen, että varmasti ymmärrän jokaisen kirjoittamani rivin, sekä etsiessäni koodista tai testeistä mahdollisia ongelman aiheuttajia +- jos koodini ei toimi, en kirjoita enää yhtään lisää koodia, vaan alan poistamaan toiminnan rikkoneita rivejä tai palaan suosiolla tilanteeseen, missä koodi vielä toimi +- jos testit eivät mene läpi, varmistan että testien testaama toiminnallisuus varmasti toimii sovelluksessa +- kun kysyn apua kurssin Discord-kanavalla, tai muualla internetissä, muotoilen kysymyksen järkevästi, esim. [täällä](/en/part0/general_info#how-to-get-help-in-discord) esiteltyyn tapaan +
    ### Tehtävät 4.8.-4.12. -**Huom:** materiaalissa käytetään muutamaan kertaan matcheria [toContain](https://facebook.github.io/jest/docs/en/expect.html#tocontainitem) tarkastettaessa, että jokin arvo on taulukossa. Kannattaa huomata, että metodi käyttää samuuden vertailuun ===-operaattoria ja olioiden kohdalla tämä ei ole useinkaan se mitä halutaan ja parempi vaihtoehto onkin [toContainEqual](https://facebook.github.io/jest/docs/en/expect.html#tocontainequalitem). Tosin mallivastauksissa ei vertailla kertaakaan olioita matcherien avulla, joten ilmankin selviää varsin hyvin. - -**Varoitus:** Jos huomaat kirjoittavasi sekaisin async/awaitia ja then-kutsuja, on 99% varmaa, että teet jotain väärin. Käytä siis jompaa kumpaa tapaa, älä missään tapauksessa "varalta" molempia. +**Varoitus:** Jos huomaat kirjoittavasi sekaisin async/awaitia ja then-kutsuja, on 99-prosenttisen varmaa, että teet jotain väärin. Käytä siis jompaakumpaa tapaa, älä missään tapauksessa "varalta" molempia. #### 4.8: blogilistan testit, step 1 -Tee supertest-kirjastolla testit blogilistan osoitteeseen /api/blogs tapahtuvalle HTTP GET -pyynnölle. Testaa, että sovellus palauttaa oikean määrän JSON-muotoisia blogeja. +Tee SuperTest-kirjastolla testit blogilistan osoitteeseen /api/blogs tapahtuvalle HTTP GET ‑pyynnölle. Testaa, että sovellus palauttaa oikean määrän JSON-muotoisia blogeja. Kun testi on valmis, refaktoroi operaatio käyttämään promisejen sijaan async/awaitia. Huomaa, että joudut tekemään koodiin [materiaalin tapaan](/osa4/backendin_testaaminen#test-ymparisto) hieman muutoksia (mm. testausympäristön määrittely), jotta saat järkevästi tehtyä omaa tietokantaa käyttäviä API-tason testejä. -**Huom1:** Testejä suorittaessa saatat törmätä seuraavaan varoitukseen - -![](../../images/4/8a.png) - -Jos näin käy, toimi [ohjeen](https://mongoosejs.com/docs/jest.html) mukaan ja lisää projektin hakemiston juureen tiedosto jest.config.js jolla on seuraava sisältö: - -```js -module.exports = { - testEnvironment: 'node' -} -``` +**HUOM:** Testien kehitysvaiheessa yleensä **ei kannata suorittaa joka kerta kaikkia testejä**, vaan keskittyä yhteen testiin kerrallaan. Katso lisää [täältä](/osa4/backendin_testaaminen#testien-suorittaminen-yksitellen). -**Huom2:** testien kehitysvaiheessa yleensä **ei kannata suorittaa joka kerta kaikkia testejä**, vaan keskittyä yhteen testiin kerrallaan. Katso lisää [täältä](/osa4/backendin_testaaminen#testien-suorittaminen-yksitellen). +#### 4.9: blogilistan testit, step2 -#### 4.9*: blogilistan testit, step2 - -Tee testi, joka varmistaa että palautettujen blogien identifioivan kentän tulee olla nimeltään id, oletusarvoisestihan tietokantaan talletettujen olioiden tunnistekenttä on _id. Olion kentän olemassaolon tarkastaminen onnistuu jestin matcherillä [toBeDefined](https://jestjs.io/docs/en/expect#tobedefined) +Tee testi, joka varmistaa että palautettujen blogien identifioivan kentän tulee olla nimeltään id. Oletusarvoisestihan tietokantaan talletettujen olioiden tunnistekenttä on _id. Muuta koodia siten, että testi menee läpi. Osassa 3 käsitelty [toJSON](/osa3/tietojen_tallettaminen_mongo_db_tietokantaan#tietokantaa-kayttava-backend) on sopiva paikka parametrin id määrittelyyn. #### 4.10: blogilistan testit, step3 -Tee testi joka varmistaa että sovellukseen voi lisätä blogeja osoitteeseen /api/blogs tapahtuvalla HTTP POST -pyynnölle. Testaa ainakin, että blogien määrä kasvaa yhdellä. Voit myös varmistaa, että oikeansisältöinen blogi on lisätty järjestelmään. +Tee testi, joka varmistaa, että sovellukseen voi lisätä blogeja osoitteeseen /api/blogs tapahtuvalla HTTP POST ‑pyynnöllä. Testaa ainakin, että blogien määrä kasvaa yhdellä. Voit myös varmistaa, että oikeansisältöinen blogi on lisätty järjestelmään. Kun testi on valmis, refaktoroi operaatio käyttämään promisejen sijaan async/awaitia. #### 4.11*: blogilistan testit, step4 -Tee testi joka varmistaa, että jos kentälle likes ei anneta arvoa, asetetaan sen arvoksi 0. Muiden kenttien sisällöstä ei tässä tehtävässä vielä välitetä. +Tee testi, joka varmistaa, että jos kentälle likes ei anneta arvoa, asetetaan sen arvoksi 0. Muiden kenttien sisällöstä ei tässä tehtävässä vielä välitetä. Laajenna ohjelmaa siten, että testi menee läpi. #### 4.12*: blogilistan testit, step5 -Tee testit blogin lisäämiselle, eli osoitteeseen /api/blogs tapahtuvalle HTTP POST -pyynnölle, joka varmistaa, että jos uusi blogi ei sisällä kenttiä title ja url, pyyntöön vastataan statuskoodilla 400 Bad request +Tee testit blogin lisäämiselle eli osoitteeseen /api/blogs tapahtuvalle HTTP POST ‑pyynnölle jotka varmistavat, että jos uusi blogi ei sisällä kenttää title tai kenttää url, pyyntöön vastataan statuskoodilla 400 Bad Request. Laajenna toteutusta siten, että testit menevät läpi. @@ -982,27 +975,25 @@ Laajenna toteutusta siten, että testit menevät läpi. ### Testien refaktorointia -Testit ovat tällä hetkellä osittain epätäydelliset, esim. reittejä GET /api/notes/:id ja DELETE /api/notes/:id ei tällä hetkellä testata epävalidien id:iden osalta. Myös testien organisoinnissa on hieman toivomisen varaa, sillä kaikki on kirjoitettu suoraan testifunktion "päätasolle", parempaan luettavuuteen pääsisimme eritellessä loogisesti toisiinsa liittyvät testit describe-lohkoihin. +Testit ovat tällä hetkellä osittain epätäydelliset, sillä esim. reittejä GET /api/notes/:id ja DELETE /api/notes/:id ei tällä hetkellä testata epävalidien id:iden osalta. Myös testien organisoinnissa on hieman toivomisen varaa, sillä kaikki on kirjoitettu suoraan testifunktion "päätasolle". Parempaan luettavuuteen pääsisimme eritellessä loogisesti toisiinsa liittyvät testit describe-lohkoihin. -Jossain määrin parannellut testit seuraavassa: +Jossain määrin parannellut testit ovat seuraavassa: ```js -const supertest = require('supertest') +const assert = require('node:assert') +const { test, after, beforeEach, describe } = require('node:test') const mongoose = require('mongoose') -const helper = require('./test_helper') +const supertest = require('supertest') const app = require('../app') -const api = supertest(app) - +const helper = require('./test_helper') const Note = require('../models/note') +const api = supertest(app) + describe('when there is initially some notes saved', () => { beforeEach(async () => { await Note.deleteMany({}) - - const noteObjects = helper.initialNotes - .map(note => new Note(note)) - const promiseArray = noteObjects.map(note => note.save()) - await Promise.all(promiseArray) + await Note.insertMany(helper.initialNotes) }) test('notes are returned as json', async () => { @@ -1015,23 +1006,19 @@ describe('when there is initially some notes saved', () => { test('all notes are returned', async () => { const response = await api.get('/api/notes') - expect(response.body).toHaveLength(helper.initialNotes.length) + assert.strictEqual(response.body.length, helper.initialNotes.length) }) test('a specific note is within the returned notes', async () => { const response = await api.get('/api/notes') - const contents = response.body.map(r => r.content) - expect(contents).toContain( - 'Browser can execute only Javascript' - ) + const contents = response.body.map(e => e.content) + assert(contents.includes('HTML is easy')) }) describe('viewing a specific note', () => { - test('succeeds with a valid id', async () => { const notesAtStart = await helper.notesInDb() - const noteToView = notesAtStart[0] const resultNote = await api @@ -1039,25 +1026,19 @@ describe('when there is initially some notes saved', () => { .expect(200) .expect('Content-Type', /application\/json/) - expect(resultNote.body).toEqual(noteToView) + assert.deepStrictEqual(resultNote.body, noteToView) }) test('fails with statuscode 404 if note does not exist', async () => { const validNonexistingId = await helper.nonExistingId() - console.log(validNonexistingId) - - await api - .get(`/api/notes/${validNonexistingId}`) - .expect(404) + await api.get(`/api/notes/${validNonexistingId}`).expect(404) }) test('fails with statuscode 400 id is invalid', async () => { const invalidId = '5a3d5da59070081a82a3445' - await api - .get(`/api/notes/${invalidId}`) - .expect(400) + await api.get(`/api/notes/${invalidId}`).expect(400) }) }) @@ -1071,32 +1052,24 @@ describe('when there is initially some notes saved', () => { await api .post('/api/notes') .send(newNote) - .expect(200) + .expect(201) .expect('Content-Type', /application\/json/) - const notesAtEnd = await helper.notesInDb() - expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1) + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length + 1) const contents = notesAtEnd.map(n => n.content) - expect(contents).toContain( - 'async/await simplifies making async calls' - ) + assert(contents.includes('async/await simplifies making async calls')) }) test('fails with status code 400 if data invalid', async () => { - const newNote = { - important: true - } + const newNote = { important: true } - await api - .post('/api/notes') - .send(newNote) - .expect(400) + await api.post('/api/notes').send(newNote).expect(400) const notesAtEnd = await helper.notesInDb() - expect(notesAtEnd).toHaveLength(helper.initialNotes.length) + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length) }) }) @@ -1105,37 +1078,32 @@ describe('when there is initially some notes saved', () => { const notesAtStart = await helper.notesInDb() const noteToDelete = notesAtStart[0] - await api - .delete(`/api/notes/${noteToDelete.id}`) - .expect(204) + await api.delete(`/api/notes/${noteToDelete.id}`).expect(204) const notesAtEnd = await helper.notesInDb() - expect(notesAtEnd).toHaveLength( - helper.initialNotes.length - 1 - ) - - const contents = notesAtEnd.map(r => r.content) + const contents = notesAtEnd.map(n => n.content) + assert(!contents.includes(noteToDelete.content)) - expect(contents).not.toContain(noteToDelete.content) + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length - 1) }) }) }) -afterAll(() => { - mongoose.connection.close() +after(async () => { + await mongoose.connection.close() }) ``` Testien raportointi tapahtuu describe-lohkojen ryhmittelyn mukaan: -![](../../images/4/7.png) +![Tstikirjasto ryhmittelee testitulokset describe-lohkoittain](../../images/4/7new.png) -Testeihin jää vielä parannettavaa mutta on jo aika siirtyä eteenpäin. +Testeihin jää vielä parannettavaa, mutta on jo aika siirtyä eteenpäin. Käytetty tapa API:n testaamiseen, eli HTTP-pyyntöinä tehtävät operaatiot ja tietokannan tilan tarkastelu Mongoosen kautta ei ole suinkaan ainoa tai välttämättä edes paras tapa tehdä API-tason integraatiotestausta. Universaalisti parasta tapaa testien tekoon ei ole, vaan kaikki on aina suhteessa käytettäviin resursseihin ja testattavaan ohjelmistoon. -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-6), branchissa part4-6 +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-6), branchissa part4-6
    @@ -1149,7 +1117,7 @@ Toteuta sovellukseen mahdollisuus yksittäisen blogin poistoon. Käytä async/awaitia. Noudata operaation HTTP-rajapinnan suhteen [RESTful](/osa3/node_js_ja_express#rest)-käytänteitä. -Saat toteuttaa ominaisuudelle testit jos haluat. Jos et, varmista ominaisuuden toimivuus esim. Postmanilla. +Toteuta ominaisuudelle myös testit. #### 4.14* blogilistan laajennus, step2 @@ -1159,6 +1127,6 @@ Käytä async/awaitia. Tarvitsemme muokkausta lähinnä likejen lukumäärän päivittämiseen. Toiminnallisuuden voi toteuttaa samaan tapaan kuin muistiinpanon päivittäminen toteutettiin [osassa 3](/osa3/tietojen_tallettaminen_mongo_db_tietokantaan#muut-operaatiot). -Saat toteuttaa ominaisuudelle testit jos haluat. Jos et, varmista ominaisuuden toimivuus esim. Postmanilla. +Toteuta ominaisuudelle myös testit. diff --git a/src/content/4/fi/osa4c.md b/src/content/4/fi/osa4c.md index 6b163b0862c..5f2a4ab86ce 100644 --- a/src/content/4/fi/osa4c.md +++ b/src/content/4/fi/osa4c.md @@ -7,29 +7,29 @@ lang: fi
    -Haluamme toteuttaa sovellukseemme käyttäjien hallinnan. Käyttäjät tulee tallettaa tietokantaan ja jokaisesta muistiinpanosta tulee tietää sen luonut käyttäjä. Muistiinpanojen poisto ja editointi tulee olla sallittua ainoastaan muistiinpanot tehneelle käyttäjälle. +Haluamme toteuttaa sovellukseemme käyttäjien hallinnan. Käyttäjät tulee tallettaa tietokantaan, ja jokaisesta muistiinpanosta tulee tietää sen luonut käyttäjä. Muistiinpanojen poiston ja editoinnin tulee olla sallittua ainoastaan muistiinpanot tehneelle käyttäjälle. -Aloitetaan lisäämällä tietokantaan tieto käyttäjistä. Käyttäjän User ja muistiinpanojen Note välillä on yhden suhde moneen -yhteys: +Aloitetaan lisäämällä tietokantaan tieto käyttäjistä. Käyttäjän User ja muistiinpanojen Note välillä on yhden suhde moneen ‑yhteys: -![](https://yuml.me/a187045b.png) +![Yhteen käyttäjään liittyy monta muistiinpanoa eli UML:nä User 1 --- * Note](https://yuml.me/a187045b.png) -Relaatiotietokantoja käytettäessä ratkaisua ei tarvitsisi juuri miettiä. Molemmille olisi oma taulunsa ja muistiinpanoihin liitettäisiin sen luonutta käyttäjää vastaava id vierasavaimeksi (foreign key). +Relaatiotietokantoja käytettäessä ratkaisua ei tarvitsisi juuri miettiä. Molemmille olisi oma taulunsa, ja muistiinpanoihin liitettäisiin sen luonutta käyttäjää vastaava id vierasavaimeksi (foreign key). -Dokumenttitietokantoja käytettäessä tilanne on kuitenkin toinen, erilaisia tapoja mallintaa tilanne on useita. +Dokumenttitietokantoja käytettäessä tilanne on kuitenkin toinen ja erilaisia tapoja mallintaa tilanne on useita. -Olemassaoleva ratkaisumme tallentaa jokaisen luodun muistiinpanon tietokantaan notes-kokoelmaan eli collectioniin. Jos emme halua muuttaa tätä, lienee luontevinta tallettaa käyttäjät omaan kokoelmaansa, esim. nimeltään users. +Olemassaoleva ratkaisumme tallentaa jokaisen luodun muistiinpanon tietokantaan notes-kokoelmaan eli collectioniin. Jos emme halua muuttaa tätä, lienee luontevinta tallettaa käyttäjät omaan kokoelmaansa, nimeltään vaikkapa users. Mongossa voidaan kaikkien dokumenttitietokantojen tapaan käyttää olioiden id:itä viittaamaan muissa kokoelmissa talletettaviin dokumentteihin, vastaavasti kuten viiteavaimia käytetään relaatiotietokannoissa. -Dokumenttitietokannat kuten Mongo eivät kuitenkaan tue relaatiotietokantojen liitoskyselyitä vastaavaa toiminnallisuutta, joka mahdollistaisi useaan kokoelmaan kohdistuvan tietokantahaun. Tämä ei tarkalleen ottaen enää pidä paikkaansa, sillä versiosta 3.2. alkaen Mongo on tukenut useampaan kokoelmaan kohdistuvia [lookup-aggregaattikyselyitä](https://docs.mongodb.com/manual/reference/operator/aggregation/lookup/). Emme kuitenkaan käsittele niitä kurssilla. +Dokumenttitietokannat kuten Mongo eivät kuitenkaan tue relaatiotietokantojen liitoskyselyitä vastaavaa toiminnallisuutta, joka mahdollistaisi useaan kokoelmaan kohdistuvan tietokantahaun. Tämä ei tarkalleen ottaen enää pidä paikkaansa, sillä versiosta 3.2 alkaen Mongo on tukenut useampaan kokoelmaan kohdistuvia [lookup-aggregaattikyselyitä](https://docs.mongodb.com/manual/reference/operator/aggregation/lookup/). Emme kuitenkaan käsittele niitä kurssilla. -Jos tarvitsemme liitoskyselyitä vastaavaa toiminnallisuutta, tulee se toteuttaa sovelluksen tasolla, eli käytännössä tekemällä tietokantaan useita kyselyitä. Tietyissä tilanteissa mongoose-kirjasto osaa hoitaa liitosten tekemisen, jolloin kysely näyttää mongoosen käyttäjälle toimivan liitoskyselyn tapaan. Mongoose tekee kuitenkin näissä tapauksissa taustalla useamman kyselyn tietokantaan. +Jos tarvitsemme liitoskyselyitä vastaavaa toiminnallisuutta, tulee se toteuttaa sovelluksen tasolla eli käytännössä tekemällä tietokantaan useita kyselyitä. Tietyissä tilanteissa Mongoose-kirjasto osaa hoitaa liitosten tekemisen, jolloin kysely näyttää Mongoosen käyttäjälle toimivan liitoskyselyn tapaan. Mongoose tekee kuitenkin näissä tapauksissa taustalla useamman kyselyn tietokantaan. ### Viitteet kokoelmien välillä Jos käyttäisimme relaatiotietokantaa, muistiinpano sisältäisi viiteavaimen sen tehneeseen käyttäjään. Dokumenttitietokannassa voidaan toimia samoin. -Oletetaan että kokoelmassa users on kaksi käyttäjää: +Oletetaan, että kokoelmassa users on kaksi käyttäjää: ```js [ @@ -44,7 +44,7 @@ Oletetaan että kokoelmassa users on kaksi käyttäjää: ] ``` -Kokoelmassa notes on kolme muistiinpanoa, kaikkien kenttä user viittaa users-kentässä olevaan käyttäjään: +Kokoelmassa notes on kolme muistiinpanoa, joiden kaikkien kenttä user viittaa users-kokoelmassa olevaan käyttäjään: ```js [ @@ -69,7 +69,7 @@ Kokoelmassa notes on kolme muistiinpanoa, kaikkien kenttä user vi ] ``` -Mikään ei kuitenkaan määrää dokumenttitietokannoissa, että viitteet on talletettava muistiinpanoihin, ne voivat olla myös (tai ainoastaan) käyttäjien yhteydessä: +Mikään ei kuitenkaan määrää dokumenttitietokannoissa, että viitteet on talletettava muistiinpanoihin, vaan ne voivat olla myös (tai ainoastaan) käyttäjien yhteydessä: ```js [ @@ -81,7 +81,7 @@ Mikään ei kuitenkaan määrää dokumenttitietokannoissa, että viitteet on ta { username: 'hellas', _id: 141414, - notes: [141414], + notes: [221244], }, ] ``` @@ -120,15 +120,15 @@ Dokumenttitietokannat tarjoavat myös radikaalisti erilaisen tavan datan organis ] ``` -Muistiinpanot olisivat tässä skeemaratkaisussa siis yhteen käyttäjään alisteisia kenttiä, niillä ei olisi edes omaa identiteettiä, eli id:tä tietokannan tasolla. +Muistiinpanot olisivat tässä skeemaratkaisussa siis yhteen käyttäjään alisteisia kenttiä, eikä niillä olisi edes omaa identiteettiä eli id:tä tietokannan tasolla. -Dokumenttitietokantojen yhteydessä skeeman rakenne ei siis ole ollenkaan samalla tavalla ilmeinen kuin relaatiotietokannoissa, ja valittava ratkaisu kannattaa määritellä siten että se tukee parhaalla tavalla sovelluksen käyttötapauksia. Tämä ei luonnollisestikaan ole helppoa, sillä järjestelmän kaikki käyttötapaukset eivät yleensä ole selvillä kun projektin alkuvaiheissa mietitään datan organisointitapaa. +Dokumenttitietokantojen yhteydessä skeeman rakenne ei siis ole ollenkaan samalla tavalla ilmeinen kuin relaatiotietokannoissa, ja valittava ratkaisu kannattaa määritellä siten, että se tukee parhaalla tavalla sovelluksen käyttötapauksia. Tämä ei luonnollisestikaan ole helppoa, sillä järjestelmän kaikki käyttötapaukset eivät yleensä ole selvillä kun projektin alkuvaiheissa mietitään datan organisointitapaa. Hieman paradoksaalisesti tietokannan tasolla skeematon Mongo edellyttääkin projektin alkuvaiheissa jopa radikaalimpien datan organisoimiseen liittyvien ratkaisujen tekemistä kuin tietokannan tasolla skeemalliset relaatiotietokannat, jotka tarjoavat keskimäärin kaikkiin tilanteisiin melko hyvin sopivan tavan organisoida dataa. -### Käyttäjien mongoose-skeema +### Käyttäjien Mongoose-skeema -Päätetään tallettaa käyttäjän yhteyteen myös tieto käyttäjän luomista muistiinpanoista, eli käytännössä muistiinpanojen id:t. Määritellään käyttäjää edustava model tiedostoon models/user.js +Päätetään tallettaa käyttäjän yhteyteen myös tieto käyttäjän luomista muistiinpanoista eli käytännössä muistiinpanojen id:t. Määritellään käyttäjää edustava model tiedostoon models/user.js: ```js const mongoose = require('mongoose') @@ -160,7 +160,7 @@ const User = mongoose.model('User', userSchema) module.exports = User ``` -Muistiinpanojen id:t on talletettu käyttäjien sisälle taulukkona mongo-id:itä. Määrittely on seuraava +Muistiinpanojen id:t on talletettu käyttäjien sisälle taulukkona Mongo-id:itä. Määrittely on seuraava: ```js { @@ -169,9 +169,9 @@ Muistiinpanojen id:t on talletettu käyttäjien sisälle taulukkona mongo-id:it } ``` -kentän tyyppi on ObjectId joka viittaa note-tyyppisiin dokumentteihin. Mongo ei itsessään tiedä mitään siitä, että kyse on kentästä, joka viittaa nimenomaan muistiinpanoihin, kyseessä onkin puhtaasti mongoosen syntaksi. +Kentän tyyppi on ObjectId, eli se viittaa johonkin toiseen dokumenttiin. Kenttä ref määrittää sen modelin nimen, johon viitataan. Mongo ei itsessään tiedä mitään siitä, että kyse on kentästä, joka viittaa nimenomaan muistiinpanoihin, vaan kyseessä on puhtaasti Mongoosen syntaksi. -Laajennetaan tiedostossa model/note.js olevaa muistiinpanon skeemaa siten, että myös muistiinpanossa on tieto sen luoneesta käyttäjästä +Laajennetaan tiedostossa model/note.js olevaa muistiinpanon skeemaa siten, että myös muistiinpanossa on tieto sen luoneesta käyttäjästä: ```js const noteSchema = new mongoose.Schema({ @@ -180,7 +180,7 @@ const noteSchema = new mongoose.Schema({ required: true, minlength: 5 }, - date: Date, + important: Boolean, // highlight-start user: { @@ -191,31 +191,36 @@ const noteSchema = new mongoose.Schema({ }) ``` -Relaatiotietokantojen käytänteistä poiketen viitteet on nyt talletettu molempiin dokumentteihin, muistiinpano viittaa sen luoneeseen käyttäjään ja käyttäjä sisältää taulukollisen viitteitä sen luomiin muistiinpanoihin. +Relaatiotietokantojen käytänteistä poiketen viitteet on nyt talletettu molempiin dokumentteihin. Muistiinpano viittaa sen luoneeseen käyttäjään ja käyttäjä sisältää taulukollisen viitteitä sen luomiin muistiinpanoihin. ### Käyttäjien luominen -Toteutetaan seuraavaksi route käyttäjien luomista varten. Käyttäjällä on siis username jonka täytyy olla järjestelmässä yksikäsitteinen, nimi eli name sekä passwordHash, eli salasanasta [yksisuuntaisen funktion](https://en.wikipedia.org/wiki/Cryptographic_hash_function) perusteella laskettu tunniste. Salasanojahan ei ole koskaan viisasta tallentaa tietokantaan selväsanaisena! +Toteutetaan seuraavaksi route käyttäjien luomista varten. Käyttäjällä on siis username (jonka täytyy olla järjestelmässä yksikäsitteinen), nimi eli name sekä passwordHash eli salasanasta [yksisuuntaisen funktion](https://en.wikipedia.org/wiki/Cryptographic_hash_function) perusteella laskettu tunniste. Salasanojahan ei ole koskaan viisasta tallentaa tietokantaan selväsanaisena! Asennetaan salasanojen hashaamiseen käyttämämme [bcrypt](https://github.com/kelektiv/node.bcrypt.js)-kirjasto: ```bash -npm install bcrypt --save +npm install bcrypt ``` -Käyttäjien luominen tapahtuu osassa 3 läpikäytyjä [RESTful](/osa3/node_js_ja_express#rest)-periaatteita seuraten tekemällä HTTP POST -pyyntö polkuun users. +Käyttäjien luominen tapahtuu osassa 3 läpikäytyjä [RESTful](/osa3/node_js_ja_express#rest)-periaatteita seuraten tekemällä HTTP POST ‑pyyntö polkuun users. Määritellään käyttäjienhallintaa varten oma router tiedostoon controllers/users.js, ja liitetään se app.js-tiedostossa huolehtimaan polulle /api/users/ tulevista pyynnöistä: ```js -const usersRouter = require('./controllers/users') +// ... +const notesRouter = require('./controllers/notes') +const usersRouter = require('./controllers/users') // highlight-line // ... -app.use('/api/users', usersRouter) +app.use('/api/notes', notesRouter) +app.use('/api/users', usersRouter) // highlight-line + +// ... ``` -Routerin alustava sisältö on seuraava: +Routerin alustava sisältö tiedostossa controllers/users.js on seuraava: ```js const bcrypt = require('bcrypt') @@ -223,20 +228,20 @@ const usersRouter = require('express').Router() const User = require('../models/user') usersRouter.post('/', async (request, response) => { - const body = request.body + const { username, name, password } = request.body const saltRounds = 10 - const passwordHash = await bcrypt.hash(body.password, saltRounds) + const passwordHash = await bcrypt.hash(password, saltRounds) const user = new User({ - username: body.username, - name: body.name, + username, + name, passwordHash, }) const savedUser = await user.save() - response.json(savedUser) + response.status(201).json(savedUser) }) module.exports = usersRouter @@ -246,9 +251,9 @@ Tietokantaan siis ei talleteta pyynnön mukana tulevaa salasanaa, vaan fu Materiaalin tilamäärä ei valitettavasti riitä käsittelemään sen tarkemmin salasanojen [tallennuksen perusteita](https://codahale.com/how-to-safely-store-a-password/), esim. mitä maaginen luku 10 muuttujan [saltRounds](https://github.com/kelektiv/node.bcrypt.js/#a-note-on-rounds) arvona tarkoittaa. Lue linkkien takaa lisää. -Koodissa ei tällä hetkellä ole mitään virheidenkäsittelyä eikä validointeja, eli esim. käyttäjätunnuksen ja salasanan halutun muodon tarkastuksia. +Koodissa ei tällä hetkellä ole mitään virheidenkäsittelyä eikä validointeja eli esim. käyttäjätunnuksen ja salasanan muodon tarkastuksia. -Uutta ominaisuutta voidaan ja kannattaakin joskus testailla käsin esim. postmanilla. Käsin tapahtuva testailu muuttuu kuitenkin nopeasti työlääksi, etenkin kun tulemme pian vaatimaan, että samaa käyttäjätunnusta ei saa tallettaa kantaan kahteen kertaan. +Uutta ominaisuutta voi ja kannattaakin joskus testailla käsin esim. Postmanilla. Käsin tapahtuva testailu muuttuu kuitenkin nopeasti työlääksi, etenkin kun tulemme pian vaatimaan, että samaa käyttäjätunnusta ei saa tallettaa kantaan kahteen kertaan. Pienellä vaivalla voimme tehdä automaattisesti suoritettavat testit, jotka helpottavat sovelluksen kehittämistä merkittävästi. @@ -282,14 +287,14 @@ describe('when there is initially one user at db', () => { await api .post('/api/users') .send(newUser) - .expect(200) + .expect(201) .expect('Content-Type', /application\/json/) const usersAtEnd = await helper.usersInDb() - expect(usersAtEnd).toHaveLength(usersAtStart.length + 1) + assert.strictEqual(usersAtEnd.length, usersAtStart.length + 1) const usernames = usersAtEnd.map(u => u.username) - expect(usernames).toContain(newUser.username) + assert(usernames.includes(newUser.username)) }) }) ``` @@ -335,34 +340,28 @@ describe('when there is initially one user at db', () => { .expect(400) .expect('Content-Type', /application\/json/) - expect(result.body.error).toContain('`username` to be unique') - const usersAtEnd = await helper.usersInDb() - expect(usersAtEnd).toHaveLength(usersAtStart.length) + assert(result.body.error.includes('expected `username` to be unique')) + + assert.strictEqual(usersAtEnd.length, usersAtStart.length) }) }) ``` -Testi ei tietenkään mene läpi tässä vaiheessa. Toimimme nyt oleellisesti [TDD:n eli test driven developmentin](https://en.wikipedia.org/wiki/Test-driven_development) hengessä, uuden ominaisuuden testi on kirjoitettu ennen ominaisuuden ohjelmointia. - -Hoidetaan uniikkiuden tarkastaminen Mongoosen validoinnin avulla. Kuten edellisen osan tehtävässä [3.19](/osa3/validointi_ja_es_lint#tehtavat-3-19-3-21) mainittiin, Mongoose ei tarjoa valmista validaattoria kentän uniikkiuden tarkastamiseen. Tilanteeseen ratkaisun tarjoaa npm-pakettina asennettava -[mongoose-unique-validator](https://www.npmjs.com/package/mongoose-unique-validator). Suoritetaan asennus +Testi ei tietenkään mene läpi tässä vaiheessa. Toimimme nyt [TDD:n eli test driven developmentin](https://en.wikipedia.org/wiki/Test-driven_development) hengessä, eli uuden ominaisuuden testi kirjoitetaan ennen ominaisuuden ohjelmointia. -```bash -npm install --save mongoose-unique-validator -``` +Mongoosen validoinnit eivät tarjoa täysin suoraa tapaa kentän arvon uniikkiuden tarkastamiseen. Uniikkius on kuitenkin mahdollista saada aikaan määrittelemällä tietokannan kokoelmaan kentän arvon [yksikäsitteisyyden takaava indeksi](https://mongoosejs.com/docs/schematypes.html). Määrittely tapahtuu seuraavasti: -Käyttäjän skeemaa tiedostossa models/user.js tulee muuttaa seuraavasti seuraavasti: ```js -const mongoose = require('mongoose') -const uniqueValidator = require('mongoose-unique-validator') // highlight-line - const userSchema = mongoose.Schema({ + // highlight-start username: { type: String, - unique: true // highlight-line + required: true, + unique: true // username oltava yksikäsitteinen }, + // highlight-end name: String, passwordHash: String, notes: [ @@ -373,28 +372,48 @@ const userSchema = mongoose.Schema({ ], }) -userSchema.plugin(uniqueValidator) // highlight-line // ... ``` -Voisimme toteuttaa käyttäjien luomisen yhteyteen myös muita tarkistuksia, esim. onko käyttäjätunnus tarpeeksi pitkä, koostuuko se sallituista merkeistä ja onko salasana tarpeeksi hyvä. Jätämme ne kuitenkin vapaaehtoiseksi harjoitustehtäväksi. +Yksikäsitteisyyden takaavan indeksin kanssa on kuitenkin oltava tarkkana. Jos tietokannassa on jo dokumentteja jotka rikkovat yksikäsitteisyysehdon, [ei indeksiä muodosteta](https://dev.to/akshatsinghania/mongoose-unique-not-working-16bf). Eli lisätessäsi yksikäsitteisyysindeksin, varmista että tietokanta on eheässä tilassa! Yllä oleva testi lisäsi tietokantaan kahteen kertaan käyttäjän käyttäjänimellä _root_, ja nämä on poistettava jotta indeksi muodostuu ja koodi toimii. + +Mongoosen validaatiot eivät huomaa indeksin rikkoutumista, ja niistä seuraa virheen _ValidationError_ sijaan virhe, jonka tyyppi on _MongoServerError_. Joudummekin laajentamaan virheenkäsittelijää, jotta virhe saadaan asianmukaisesti hoidettua: + +```js +const errorHandler = (error, request, response, next) => { + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } else if (error.name === 'ValidationError') { + return response.status(400).json({ error: error.message }) +// highlight-start + } else if (error.name === 'MongoServerError' && error.message.includes('E11000 duplicate key error')) { + return response.status(400).json({ error: 'expected `username` to be unique' }) + } + // highlight-end + + next(error) +} +``` +Näiden muutosten jälkeen testit menevät läpi. + +Voisimme toteuttaa käyttäjien luomisen yhteyteen myös muita tarkistuksia, esim. onko käyttäjätunnus tarpeeksi pitkä, koostuuko se sallituista merkeistä ja onko salasana tarpeeksi hyvä. Jätämme ne kuitenkin vapaaehtoiseksi harjoitustehtäväksi. Ennen kuin menemme eteenpäin, lisätään sovellukseen alustava versio kaikki käyttäjät palauttavasta käsittelijäfunktiosta: ```js usersRouter.get('/', async (request, response) => { const users = await User.find({}) - response.json(users.map(u => u.toJSON())) + response.json(users) }) ``` -Lista näyttää seuraavalta +Lista näyttää seuraavalta: -![](../../images/4/9.png) +![Selain renderöi osoitteessa localhost:3001/api/users taulukollisen JSON:eja joilla kentät username, name, id ja notes, jonka arvo on tyhjä taulukko](../../images/4/9.png) -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-7), branchissä part4-7. +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-7), branchissä part4-7. ### Muistiinpanon luominen @@ -403,19 +422,26 @@ Muistiinpanot luovaa koodia on nyt mukautettava siten, että uusi muistiinpano t Laajennetaan ensin olemassaolevaa toteutusta siten, että tieto muistiinpanon luovan käyttäjän id:stä lähetetään pyynnön rungossa kentän userId arvona: ```js -const User = require('../models/user') +const notesRouter = require('express').Router() +const Note = require('../models/note') +const User = require('../models/user') //highlight-line //... -notesRouter.post('/', async (request, response, next) => { +notesRouter.post('/', async (request, response) => { const body = request.body - const user = await User.findById(body.userId) //highlight-line + const user = await User.findById(body.userId)// highlight-line + + // highlight-start + if (!user) { + return response.status(400).json({ error: 'userId missing or not valid' }) + } + // highlight-end const note = new Note({ content: body.content, - important: body.important === undefined ? false : body.important, - date: new Date(), + important: body.important || false, user: user._id //highlight-line }) @@ -423,14 +449,18 @@ notesRouter.post('/', async (request, response, next) => { user.notes = user.notes.concat(savedNote._id) //highlight-line await user.save() //highlight-line - response.json(savedNote.toJSON()) + response.status(201).json(savedNote) }) + +// ... ``` +Tietokannasta etsitään ensin käyttäjä pyynnön mukana lähetetyn userId:n avulla. Jos käyttäjää ei löydy, vastataan statuskoodilla 400 (Bad Request) ja virheviestillä "userId missing or not valid". + Huomionarvoista on nyt se, että myös user-olio muuttuu. Sen kenttään notes talletetaan luodun muistiinpanon id: ```js -const user = User.findById(userId) +const user = User.findById(body.userId) // ... @@ -438,23 +468,25 @@ user.notes = user.notes.concat(savedNote._id) await user.save() ``` -Kokeillaan nyt lisätä uusi muistiinpano +Kokeillaan nyt lisätä uusi muistiinpano: -![](../../images/4/10e.png) +![Näkymä Postmanista luotaessa muistiinpano jolla on validit kentät](../../images/4/10e.png) Operaatio vaikuttaa toimivan. Lisätään vielä yksi muistiinpano ja mennään kaikkien käyttäjien sivulle: -![](../../images/4/11e.png) +![Selain renderöi osoitteessa localhost:3001/api/users taulukollisen JSON:eja joilla kentät username, name, id ja notes, jonka arvo on taulukko muistiinpanojen id:itä](../../images/4/11e.png) Huomaamme siis, että käyttäjällä on kaksi muistiinpanoa. -Muistiinpanon luoneen käyttäjän id näkyviin muistiinpanon yhteyteen: +Muistiinpanon luoneen käyttäjän id tulee näkyviin muistiinpanon yhteyteen: -![](../../images/4/12e.png) +![Selain renderöi osoitteessa localhost:3001/api/notes taulukollisen JSON:eja joilla kentät content, important, id ja user, jonka arvo käyttäjäid](../../images/4/12e.png) + +Tekemiemme muutosten myötä testit eivät mene enää läpi, mutta jätämme testien korjaamisen nyt vapaaehtoiseksi harjoitustehtäväksi. Tekemiämme muutoksia ei ole myöskään huomioitu frontendissä, joten muistiinpanojen luomistoiminto ei enää toimi. Korjaamme frontendin kurssin osassa 5. ### populate -Haluaisimme API:n toimivan siten, että haettaessa esim. käyttäjien tiedot polulle /api/users tehtävällä HTTP GET -pyynnöllä tulisi käyttäjien tekemien muistiinpanojen id:iden lisäksi näyttää niiden sisältö. Relaatiotietokannoilla toiminnallisuus toteutettaisiin liitoskyselyn avulla. +Haluaisimme API:n toimivan siten, että haettaessa esim. käyttäjien tiedot polulle /api/users tehtävällä HTTP GET ‑pyynnöllä tulisi käyttäjien tekemien muistiinpanojen id:iden lisäksi näyttää niiden sisältö. Relaatiotietokannoilla toiminnallisuus toteutettaisiin liitoskyselyn avulla. Kuten aiemmin mainittiin, eivät dokumenttitietokannat tue (kunnolla) eri kokoelmien välisiä liitoskyselyitä. Mongoose-kirjasto osaa kuitenkin tehdä liitoksen puolestamme. Mongoose toteuttaa liitoksen tekemällä useampia tietokantakyselyitä, joten siinä mielessä kyseessä on täysin erilainen tapa kuin relaatiotietokantojen liitoskyselyt, jotka ovat transaktionaalisia, eli liitoskyselyä tehdessä tietokannan tila ei muutu. Mongoosella tehtävä liitos taas on sellainen, että mikään ei takaa sitä, että liitettävien kokoelmien tila on konsistentti, toisin sanoen jos tehdään users- ja notes-kokoelmat liittävä kysely, kokoelmien tila saattaa muuttua kesken Mongoosen liitosoperaation. @@ -465,30 +497,32 @@ usersRouter.get('/', async (request, response) => { const users = await User // highlight-line .find({}).populate('notes') // highlight-line - response.json(users.map(u => u.toJSON())) + response.json(users) }) ``` -Funktion [populate](http://mongoosejs.com/docs/populate.html) kutsu siis ketjutetaan kyselyä vastaavan metodikutsun (tässä tapauksessa find perään. Populaten parametri määrittelee, että user-dokumenttien notes-kentässä olevat note-olioihin viittaavat id:t korvataan niitä vastaavilla dokumenteilla. +Funktion [populate](http://mongoosejs.com/docs/populate.html) kutsu siis ketjutetaan kyselyä vastaavan metodikutsun (tässä tapauksessa find) perään. Populaten parametri määrittelee, että user-dokumenttien notes-kentässä olevat note-olioihin viittaavat id:t korvataan niitä vastaavilla dokumenteilla. + +Mongoose tekee ensin kyselyn users-kokoelmaan käyttäjien hakemiseksi ja sen jälkeen kyselyn notes-kokoelmaan muistiinpanojen hakemiseksi. Mongoose osaa tehdä jälkimmäisen kyselyn oikeaan kokoelmaan, koska määrittelimme aiemmin user-skeemassa notes-kentälle ref-attribuutin, joka kertoo Mongooselle, mihin kokelmaan notes-kentässä viitataan. Lopputulos on jo melkein haluamamme kaltainen: -![](../../images/4/13ea.png) +![Selain renderöi osoitteessa localhost:3001/api/users taulukollisen JSON:eja joilla kentät username, name, id ja notes. Kenttä notes on nyt olio jolla on kentät content, important, id ja user](../../images/4/13new.png) -Populaten yhteydessä on myös mahdollista rajata mitä kenttiä sisällytettävistä dokumenteista otetaan mukaan. Rajaus tapahtuu Mongon [syntaksilla](https://docs.mongodb.com/manual/tutorial/project-fields-from-query-results/#return-the-specified-fields-and-the-id-field-only): +Populaten yhteydessä on myös mahdollista rajata mitä kenttiä sisällytettävistä dokumenteista otetaan mukaan. Haluamme id:n lisäksi nyt ainoastaan kentät content ja important. Rajaus tapahtuu Mongon [syntaksilla](https://docs.mongodb.com/manual/tutorial/project-fields-from-query-results/#return-the-specified-fields-and-the-id-field-only): ```js usersRouter.get('/', async (request, response) => { const users = await User - .find({}).populate('notes', { content: 1, date: 1 }) + .find({}).populate('notes', { content: 1, important: 1 }) - response.json(users.map(u => u.toJSON())) -}); + response.json(users) +}) ``` -Tulos on täsmälleen sellainen kuin haluamme +Tulos on täsmälleen sellainen kuin haluamme: -![](../../images/4/14ea.png) +![Selain renderöi osoitteessa localhost:3001/api/users taulukollisen JSON:eja joilla kentät username, name, id ja notes. Kenttä notes on nyt olio jolla on ainoastaan halutut kentät content, important](../../images/4/14new.png) Lisätään sopiva käyttäjän tietojen populointi muistiinpanojen yhteyteen: @@ -497,15 +531,15 @@ notesRouter.get('/', async (request, response) => { const notes = await Note .find({}).populate('user', { username: 1, name: 1 }) - response.json(notes.map(note => note.toJSON())) -}); + response.json(notes) +}) ``` -Nyt käyttäjän tiedot tulevat muistiinpanon kenttään user. +Nyt käyttäjän tiedot tulevat muistiinpanon kenttään user: -![](../../images/4/15ea.png) +![Selain renderöi osoitteessa localhost:3001/api/notes taulukollisen JSON:eja joilla kentät content, important, id ja user. Kenttä user on olio jolla on kentät name, username ja id](../../images/4/15new.png) -Korostetaan vielä, että tietokannan tasolla ei siis ole mitään määrittelyä siitä, että esim. muistiinpanojen kenttään user talletetut id:t viittaavat käyttäjä-kokoelman dokumentteihin. +Korostetaan vielä, että tietokannan tasolla ei siis ole mitään määrittelyä sille, että esim. muistiinpanojen kenttään user talletetut id:t viittaavat users-kokoelman dokumentteihin. Mongoosen populate-funktion toiminnallisuus perustuu siihen, että olemme määritelleet viitteiden "tyypit" olioiden Mongoose-skeemaan ref-kentän avulla: @@ -516,7 +550,6 @@ const noteSchema = new mongoose.Schema({ required: true, minlength: 5 }, - date: Date, important: Boolean, user: { type: mongoose.Schema.Types.ObjectId, @@ -525,6 +558,6 @@ const noteSchema = new mongoose.Schema({ }) ``` -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-8), branchissä part4-8. +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-8), branchissä part4-8.
    diff --git a/src/content/4/fi/osa4d.md b/src/content/4/fi/osa4d.md index 3fcda0b0762..41710e9c3e8 100644 --- a/src/content/4/fi/osa4d.md +++ b/src/content/4/fi/osa4d.md @@ -13,11 +13,11 @@ Toteutamme nyt backendiin tuen [token-perustaiselle](https://scotch.io/tutorials Token-autentikaation periaatetta kuvaa seuraava sekvenssikaavio: -![](../../images/4/16e.png) +![Sekvensikaavio, joka sisältää saman datan kuin alla oleva bulletpoint-lista](../../images/4/16new.png) - Alussa käyttäjä kirjautuu Reactilla toteutettua kirjautumislomaketta käyttäen - lisäämme kirjautumislomakkeen frontendiin [osassa 5](/osa5) -- Tämän seurauksena selaimen React-koodi lähettää käyttäjätunnuksen ja salasanan HTTP POST -pyynnöllä palvelimen osoitteeseen /api/login +- Tämän seurauksena selaimen React-koodi lähettää käyttäjätunnuksen ja salasanan HTTP POST ‑pyynnöllä palvelimen osoitteeseen /api/login - Jos käyttäjätunnus ja salasana ovat oikein, generoi palvelin tokenin, joka yksilöi jollain tavalla kirjautumisen tehneen käyttäjän - token on digitaalisesti allekirjoitettu, joten sen väärentäminen on (kryptografisesti) mahdotonta - Backend vastaa selaimelle onnistumisesta kertovalla statuskoodilla ja palauttaa tokenin vastauksen mukana @@ -25,10 +25,10 @@ Token-autentikaation periaatetta kuvaa seuraava sekvenssikaavio: - Kun käyttäjä luo uuden muistiinpanon (tai tekee jonkin operaation, joka edellyttää tunnistautumista), lähettää React-koodi tokenin pyynnön mukana palvelimelle - Palvelin tunnistaa pyynnön tekijän tokenin perusteella -Tehdään ensin kirjautumistoiminto. Asennetaan [jsonwebtoken](https://github.com/auth0/node-jsonwebtoken)-kirjasto, jonka avulla koodimme pystyy generoimaan [JSON web token](https://jwt.io/) -muotoisia tokeneja. +Tehdään ensin kirjautumistoiminto. Asennetaan [jsonwebtoken](https://github.com/auth0/node-jsonwebtoken)-kirjasto, jonka avulla koodimme pystyy generoimaan [JSON web token](https://jwt.io/) ‑muotoisia tokeneja. ```bash -npm install jsonwebtoken --save +npm install jsonwebtoken ``` Tehdään kirjautumisesta vastaava koodi tiedostoon _controllers/login.js_ @@ -40,12 +40,12 @@ const loginRouter = require('express').Router() const User = require('../models/user') loginRouter.post('/', async (request, response) => { - const body = request.body + const { username, password } = request.body - const user = await User.findOne({ username: body.username }) + const user = await User.findOne({ username }) const passwordCorrect = user === null ? false - : await bcrypt.compare(body.password, user.passwordHash) + : await bcrypt.compare(password, user.passwordHash) if (!(user && passwordCorrect)) { return response.status(401).json({ @@ -74,7 +74,7 @@ Koodi aloittaa etsimällä pyynnön mukana olevaa usernamea vastaavan kä await bcrypt.compare(body.password, user.passwordHash) ``` -Jos käyttäjää ei ole olemassa tai salasana on väärä, vastataan kyselyyn statuskoodilla [401 unauthorized](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.2) ja kerrotaan syy vastauksen bodyssä. +Jos käyttäjää ei ole olemassa tai salasana on väärä, vastataan kyselyyn statuskoodilla [401 unauthorized](https://www.rfc-editor.org/rfc/rfc9110.html#name-401-unauthorized) ja kerrotaan syy vastauksen bodyssä. Jos salasana on oikein, luodaan metodin _jwt.sign_ avulla token, joka sisältää digitaalisesti allekirjoitetussa muodossa käyttäjätunnuksen ja käyttäjän id: @@ -103,7 +103,7 @@ app.use('/api/login', loginRouter) Kokeillaan kirjautumista, käytetään VS Coden REST-clientiä: -![](../../images/4/17e.png) +![Tehdään HTTP POST localhost:3001/api/login jossa lähetetään username ja password sopivilla arvoilla](../../images/4/17e.png) Kirjautuminen ei kuitenkaan toimi, konsoli näyttää seuraavalta: @@ -114,28 +114,28 @@ Kirjautuminen ei kuitenkaan toimi, konsoli näyttää seuraavalta: (node:32911) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 2) ``` -Ongelman aiheuttaa komento _jwt.sign(userForToken, process.env.SECRET)_ sillä ympäristömuuttujalle SECRET on unohtunut määritellä arvo. Kun arvo (joka saa olla mikä tahansa merkkijono) määritellään tiedostoon .env, alkaa kirjautuminen toimia. +Ongelman aiheuttaa komento _jwt.sign(userForToken, process.env.SECRET)_ sillä ympäristömuuttujalle SECRET on unohtunut määritellä arvo. Kun arvo (joka saa olla mikä tahansa merkkijono) määritellään tiedostoon .env (ja sovellus uudelleenkäynnistetään), alkaa kirjautuminen toimia. Onnistunut kirjautuminen palauttaa kirjautuneen käyttäjän tiedot ja tokenin: -![](../../images/4/18ea.png) +![VS coden näkymä kertoo onnistuneen HTTP statuskoodin sekä näytää palvelimen palauttaman JSON:in jolla kentät token, user ja username ](../../images/4/18ea.png) Virheellisellä käyttäjätunnuksella tai salasanalla kirjautuessa annetaan asianmukaisella statuskoodilla varustettu virheilmoitus -![](../../images/4/19ea.png) +![VS coden näkymä kertoo pyynnön epäonnistuneen statuskoodilla 401 Unauthorized. Palvelin myös palauttaa virheilmoituksen (invalid username or password) kertovan objektin](../../images/4/19ea.png) ### Muistiinpanojen luominen vain kirjautuneille -Muutetaan vielä muistiinpanojen luomista, siten että luominen onnistuu ainoastaan jos luomista vastaavan pyynnön mukana on validi token. Muistiinpano talletetaan tokenin identifioiman käyttäjän tekemien muistiinpanojen listaan. +Muutetaan vielä muistiinpanojen luomista siten, että luominen onnistuu ainoastaan jos luomista vastaavan pyynnön mukana on validi token. Muistiinpano talletetaan tokenin identifioiman käyttäjän tekemien muistiinpanojen listaan. Tapoja tokenin välittämiseen selaimesta backendiin on useita. Käytämme ratkaisussamme [Authorization](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization)-headeria. Tokenin lisäksi headerin avulla kerrotaan mistä [autentikointiskeemasta](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#Authentication_schemes) on kyse. Tämä voi olla tarpeen, jos palvelin tarjoaa useita eri tapoja autentikointiin. Skeeman ilmaiseminen kertoo näissä tapauksissa palvelimelle, miten mukana olevat kredentiaalit tulee tulkita. Meidän käyttöömme sopii Bearer-skeema. Käytännössä tämä tarkoittaa, että jos token on esimerkiksi merkkijono eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW, laitetaan pyynnöissä headerin Authorization arvoksi merkkijono -
    +```
     Bearer eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW
    -
    +``` Muistiinpanojen luominen muuttuu seuraavasti: @@ -146,8 +146,8 @@ const jwt = require('jsonwebtoken') //highlight-line //highlight-start const getTokenFrom = request => { const authorization = request.get('authorization') - if (authorization && authorization.toLowerCase().startsWith('bearer ')) { - return authorization.substring(7) + if (authorization && authorization.startsWith('Bearer ')) { + return authorization.replace('Bearer ', '') } return null } @@ -155,21 +155,22 @@ const getTokenFrom = request => { notesRouter.post('/', async (request, response) => { const body = request.body - const token = getTokenFrom(request) - //highlight-start - const decodedToken = jwt.verify(token, process.env.SECRET) - if (!token || !decodedToken.id) { - return response.status(401).json({ error: 'token missing or invalid' }) + const decodedToken = jwt.verify(getTokenFrom(request), process.env.SECRET) + if (!decodedToken.id) { + return response.status(401).json({ error: 'token invalid' }) } const user = await User.findById(decodedToken.id) //highlight-end + if (!user) { + return response.status(400).json({ error: 'UserId missing or not valid' }) + } + const note = new Note({ content: body.content, - important: body.important === undefined ? false : body.important, - date: new Date(), + important: body.important || false, user: user._id }) @@ -177,89 +178,144 @@ notesRouter.post('/', async (request, response) => { user.notes = user.notes.concat(savedNote._id) await user.save() - response.json(savedNote.toJSON()) + response.status(201).json(savedNote) }) ``` Apufunktio _getTokenFrom_ eristää tokenin headerista authorization. Tokenin oikeellisuus varmistetaan metodilla _jwt.verify_. Metodi myös dekoodaa tokenin, eli palauttaa olion, jonka perusteella token on laadittu: ```js -const decodedToken = jwt.verify(token, process.env.SECRET) +const decodedToken = jwt.verify(getTokenFrom(request), process.env.SECRET) ``` Tokenista dekoodatun olion sisällä on kentät username ja id eli se kertoo palvelimelle kuka pyynnön on tehnyt. -Jos tokenia ei ole tai tokenista dekoodattu olio ei sisällä käyttäjän identiteettiä (eli _decodedToken.id_ ei ole määritelty), palautetaan virheestä kertova statuskoodi [401 unauthorized](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.2) ja kerrotaan syy vastauksen bodyssä: +Jos tokenia ei ole tai se on epävalidi, syntyy poikkeus JsonWebTokenError. Laajennetaan virheidenkäsittelijämiddleware huomioimaan tilanne: ```js -if (!token || !decodedToken.id) { - return response.status(401).json({ - error: 'token missing or invalid' - }) +const errorHandler = (error, request, response, next) => { + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } else if (error.name === 'ValidationError') { + return response.status(400).json({ error: error.message }) + } else if (error.name === 'MongoServerError' && error.message.includes('E11000 duplicate key error')) { + return response.status(400).json({ error: 'expected `username` to be unique' }) + } else if (error.name === 'JsonWebTokenError') { // highlight-line + return response.status(401).json({ error: 'token missing or invalid' }) // highlight-line + } + + next(error) } ``` +Jos token on muuten kunnossa, mutta tokenista dekoodattu olio ei sisällä käyttäjän identiteettiä (eli _decodedToken.id_ ei ole määritelty), palautetaan virheestä kertova statuskoodi [401 unauthorized](https://www.rfc-editor.org/rfc/rfc9110.html#name-401-unauthorized) ja kerrotaan syy vastauksen bodyssä: + +```js + if (!decodedToken.id) { + return response.status(401).json({ error: 'token invalid' }) + } +``` + Kun pyynnön tekijän identiteetti on selvillä, jatkuu suoritus entiseen tapaan. -Uuden muistiinpanon luominen onnistuu nyt postmanilla jos authorization-headerille asetetaan oikeanlainen arvo, eli merkkijono bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ, missä loppuosa on login-operaation palauttama token. +Uuden muistiinpanon luominen onnistuu nyt Postmanilla jos Authorization-headerille asetetaan oikeanlainen arvo, eli merkkijono Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ, missä loppuosa on login-operaation palauttama token. Postmanilla luominen näyttää seuraavalta -![](../../images/4/20e.png) +![Postmanin näkymä, joka kertoo että POST localhost:3001/api/notes pyyntöön mukaan on liitetty Authorization-headeri jonka arvo on bearer tokeninarvo](../../images/4/20new.png) ja Visual Studio Coden REST clientillä -![](../../images/4/21ea.png) +![VS coden näkymä, joka kertoo että POST localhost:3001/api/notes pyyntöön mukaan on liitetty Authorization-headeri jonka arvo on bearer tokeninarvo](../../images/4/21new.png) -### Poikkeusten käsittely +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-9), branchissä part4-9. -Tokenin verifiointi voi myös aiheuttaa poikkeuksen JsonWebTokenError. Jos esim. poistetaan tokenista pari merkkiä, ja yritetään luoda muistiinpano, tapahtuu seuraavasti +Jos sovelluksessa on useampia rajapintoja jotka vaativat kirjautumisen, kannattaa JWT:n validointi eriyttää omaksi middlewarekseen, tai käyttää jotain jo olemassa olevaa kirjastoa kuten [express-jwt](https://github.com/auth0/express-jwt). -```bash -JsonWebTokenError: invalid signature - at /Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:126:19 - at getSecret (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:80:14) - at Object.module.exports [as verify] (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:84:10) - at notesRouter.post (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/controllers/notes.js:40:30) -``` +### Token-perustaisen kirjautumisen ongelmat -Syynä tokenin dekoodaamisen aiheuttamalle virheelle on monia. Token voi olla viallinen, kuten esimerkissämme, väärennetty tai eliniältään vanhentunut. Laajennetaan virheidenkäsittelymiddlewarea huomioimaan tokenin dekoodaamisen aiheuttamat virheet +Token-kirjautuminen on helppo toteuttaa, mutta se sisältää yhden ongelman. Kun API:n asiakas, esim. webselaimessa toimiva React-sovellus saa tokenin, luottaa API tämän jälkeen tokeniin sokeasti. Entä jos tokenin haltijalta tulisi poistaa käyttöoikeus? + +Ratkaisuja tähän on kaksi. Yksinkertaisempi on asettaa tokenille voimassaoloaika: ```js -const unknownEndpoint = (request, response) => { - response.status(404).send({ error: 'unknown endpoint' }) -} +loginRouter.post('/', async (request, response) => { + const { username, password } = request.body + + const user = await User.findOne({ username }) + const passwordCorrect = user === null + ? false + : await bcrypt.compare(password, user.passwordHash) + + if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) + } + + const userForToken = { + username: user.username, + id: user._id, + } + + // token expires in 60*60 seconds, that is, in one hour + // highlight-start + const token = jwt.sign( + userForToken, + process.env.SECRET, + { expiresIn: 60*60 } + ) + // highlight-end + + response + .status(200) + .send({ token, username: user.username, name: user.name }) +}) +``` +Kun tokenin voimassaoloaika päättyy, on asiakassovelluksen hankittava uusi token esim. pakottamalla käyttäjä kirjaantumaan uudelleen sovellukseen. + +Virheenkäsittelijämiddleware tulee laajentaa siten, että se antaa vanhentuneen tokenin tapauksessa asianmukaisen virheilmoituksen: + +```js const errorHandler = (error, request, response, next) => { + logger.error(error.message) + if (error.name === 'CastError') { - return response.status(400).send({ - error: 'malformatted id' - }) + return response.status(400).send({ error: 'malformatted id' }) } else if (error.name === 'ValidationError') { - return response.status(400).json({ - error: error.message + return response.status(400).json({ error: error.message }) + } else if (error.name === 'MongoServerError' && error.message.includes('E11000 duplicate key error')) { + return response.status(400).json({ + error: 'expected `username` to be unique' + }) + } else if (error.name === 'JsonWebTokenError') { + return response.status(401).json({ error: 'invalid token' }) + // highlight-start + } else if (error.name === 'TokenExpiredError') { + return response.status(401).json({ + error: 'token expired' }) - } else if (error.name === 'JsonWebTokenError') { // highlight-line - return response.status(401).json({ // highlight-line - error: 'invalid token' // highlight-line - }) // highlight-line } - - logger.error(error.message) + // highlight-end next(error) } ``` -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-9), branchissä part4-9. +Mitä lyhemmäksi tokenin voimassaolo asetetaan, sitä turvallisempi ratkaisu on. Eli jos token päätyy vääriin käsiin, tai käyttäjän pääsy järjestelmään tulee estää, on token käytettävissä ainoastaan rajallisen ajan. Toisaalta tokenin lyhyt voimassaolo aiheuttaa vaivaa API:n käyttäjälle. Kirjaantuminen pitää tehdä useammin. + +Toinen ratkaisu on tallettaa API:ssa tietokantaan tieto jokaisesta asiakkaalle myönnetystä tokenista, ja tarkastaa jokaisen API-pyynnön yhteydessä onko käyttöoikeus edelleen voimassa. Tällöin tokenin voimassaolo voidaan tarvittaessa poistaa välittömästi. Tällaista ratkaisua kutsutaan usein palvelinpuolen sessioksi (engl. server side session). -Jos sovelluksessa on useampia rajapintoja jotka vaativat kirjautumisen, kannattaa JWT:n validointi eriyttää omaksi middlewarekseen, tai käyttää jotain jo olemassa olevaa kirjastoa kuten [express-jwt](https://www.npmjs.com/package/express-jwt). +Tämän ratkaisun negatiivinen puoli on sen backendiin lisäämä monimutkaisuus sekä hienoinen vaikutus suorituskykyyn. Jos tokenin voimassaolo joudutaan tarkastamaan tietokannasta, on se hitaampaa kuin tokenista itsestään tarkastettava voimassaolo. Usein tokeneita vastaava sessio, eli tieto tokenia vastaavasta käyttäjästä, talletetaankin esim. avain-arvo-periaattella toimivaan [Redis](https://redis.io/)-tietokantaan, joka on toiminnallisuudeltaan esim MongoDB:tä tai relaatiotietokantoja rajoittuneempi, mutta toimii tietynlaisissa käyttöskenaarioissa todella nopeasti. + +Käytettäessä palvelinpuolen sessioita, token ei useinkaan sisällä jwt-tokenien tapaan mitään tietoa käyttäjästä (esim. käyttäjätunnusta), sen sijaan token on ainoastaan satunnainen merkkijono, jota vastaava käyttäjä haetaan palvelimella sessiot tallettavasta tietokannasta. On myös yleistä, että palvelinpuolen sessiota käytettäessä tieto käyttäjän identiteetistä välitetään Authorization-headerin sijaan evästeiden (engl. cookie) välityksellä. ### Loppuhuomioita Koodissa on tapahtunut paljon muutoksia ja matkan varrella on tapahtunut tyypillinen kiivaasti etenevän ohjelmistoprojektin ilmiö: suuri osa testeistä on hajonnut. Koska kurssin tämä osa on jo muutenkin täynnä uutta asiaa, jätämme testien korjailun vapaaehtoiseksi harjoitustehtäväksi. -Käyttäjätunnuksia, salasanoja ja tokenautentikaatiota hyödyntäviä sovelluksia tulee aina käyttää salatun [HTTPS](https://en.wikipedia.org/wiki/HTTPS)-yhteyden yli. Voimme käyttää sovelluksissamme Noden [HTTP](https://nodejs.org/docs/latest-v8.x/api/http.html)-serverin sijaan [HTTPS](https://nodejs.org/api/https.html)-serveriä (se vaatii lisää konfiguraatiota). Toisaalta koska sovelluksemme tuotantoversio on Herokussa, sovelluksemme pysyy käyttäjien kannalta suojattuna sen ansiosta, että Heroku reitittää kaiken liikenteen selaimen ja Herokun palvelimien välillä HTTPS:n yli. +Käyttäjätunnuksia, salasanoja ja tokenautentikaatiota hyödyntäviä sovelluksia tulee aina käyttää salatun [HTTPS](https://en.wikipedia.org/wiki/HTTPS)-yhteyden yli. Voimme käyttää sovelluksissamme Noden [HTTP](https://nodejs.org/docs/latest-v8.x/api/http.html)-serverin sijaan [HTTPS](https://nodejs.org/api/https.html)-serveriä (se vaatii lisää konfiguraatiota). Toisaalta koska sovelluksemme tuotantoversio on Fly.io:ssa tai Renderissä, sovelluksemme pysyy käyttäjien kannalta suojattuna sen ansiosta, että käyttämämme pilvipalvelu reitittää kaiken liikenteen selaimen ja pilvipalvelun palvelimien välillä HTTPS:n yli. Toteutamme kirjautumisen frontendin puolelle kurssin [seuraavassa osassa](/osa5). @@ -267,22 +323,22 @@ Toteutamme kirjautumisen frontendin puolelle kurssin [seuraavassa osassa](/osa5)
    -### Tehtävät 4.15.-4.22. +### Tehtävät 4.15.-4.23. Seuraavien tehtävien myötä Blogilistalle luodaan käyttäjienhallinnan perusteet. Varminta on seurata melko tarkkaan osan 4 luvusta [Käyttäjien hallinta](/osa4/kayttajien_hallinta) ja [Token-perustainen kirjautuminen](/osa4/token_perustainen_kirjautuminen) etenevää tarinaa. Toki luovuus on sallittua. **Varoitus vielä kerran:** jos huomaat kirjoittavasi sekaisin async/awaitia ja _then_-kutsuja, on 99% varmaa, että teet jotain väärin. Käytä siis jompaa kumpaa tapaa, älä missään tapauksessa "varalta" molempia. -#### 4.15: blogilistan laajennus, step4 +#### 4.15: blogilistan laajennus, step3 -Tee sovellukseen mahdollisuus luoda käyttäjiä tekemällä HTTP POST -pyyntö osoitteeseen api/users. Käyttäjillä on käyttäjätunnus, salasana ja nimi. +Tee sovellukseen mahdollisuus luoda käyttäjiä tekemällä HTTP POST ‑pyyntö osoitteeseen api/users. Käyttäjillä on käyttäjätunnus, salasana ja nimi. Älä talleta tietokantaan salasanoja selväkielisenä vaan käytä osan 4 luvun [Käyttäjien luominen](/osa4/kayttajien_hallinta#kayttajien-luominen) tapaan bcrypt-kirjastoa. **HUOM** joillain windows-käyttäjillä on ollut ongelmia bcryptin kanssa. Jos törmäät ongelmiin, poista kirjasto komennolla ```bash -npm uninstall bcrypt --save +npm uninstall bcrypt ``` ja asenna sen sijaan [bcryptjs](https://www.npmjs.com/package/bcryptjs) @@ -293,23 +349,25 @@ Käyttäjien lista voi näyttää esim. seuraavalta: ![](../../images/4/22.png) -#### 4.16*: blogilistan laajennus, step5 +#### 4.16*: blogilistan laajennus, step4 Laajenna käyttäjätunnusten luomista siten, että käyttäjätunnuksen sekä salasanan tulee olla olemassa ja vähintään 3 merkkiä pitkiä. Käyttäjätunnuksen on oltava järjestelmässä uniikki. Luomisoperaation tulee palauttaa sopiva statuskoodi ja jonkinlainen virheilmoitus, jos yritetään luoda epävalidi käyttäjä. -**HUOM** älä testaa salasanaan liittyviä ehtoja Mongoosen validointien avulla, se ei ole hyvä idea, sillä backendin vastaanottama salasana ja kantaan tallennettu salasanan tiiviste eivät ole sama asia. Salasanan oikeellisuus kannattaa testata kontrollerissa samoin kun teimme [osassa 3](/osa3/validointi_ja_es_lint) ennen validointien käyttöönottoa. +**HUOM** älä testaa salasanan oikeellisuutta Mongoosen validointien avulla, se ei ole hyvä idea, sillä backendin vastaanottama salasana ja kantaan tallennettu salasanan tiiviste eivät ole sama asia. Salasanan oikeellisuus kannattaa testata kontrollerissa samoin kun teimme [osassa 3](/osa3/validointi_ja_es_lint) ennen validointien käyttöönottoa. + +**Tee myös testit**, jotka varmistavat, että virheellisiä käyttäjiä ei luoda, ja että virheellisen käyttäjän luomisoperaatioon vastaus on järkevä statuskoodin ja virheilmoituksen osalta. -Tee myös testit, jotka varmistavat, että virheellisiä käyttäjiä ei luoda, ja että virheellisen käyttäjän luomisoperaatioon vastaus on järkevä statuskoodin ja virheilmoituksen osalta. +**HUOM** jos päätät tehdä testejä useaan eri tiedostoon, on syytä huomioida se, että oletusarvoisesti jokainen testitiedosto suoritetaan omassa prosessissaan (ks. kohta _Test execution model_ [dokumentaatiosta](https://nodejs.org/api/test.html)). Seurauksena tästä on se, että eri testitiedostoja suoritetaan yhtä aikaa. Koska testit käyttävät samaa tietokantaa, saattaa yhtäaikaisesta suorituksesta aiheutua ongelmia. Ongelmat vältetään kun testit suoritetaan optiolla _--test-concurrency=1_, eli määritellään ne suoritettavaksi peräkkäin. -#### 4.17: blogilistan laajennus, step6 +#### 4.17: blogilistan laajennus, step5 Laajenna blogia siten, että blogiin tulee tieto sen lisänneestä käyttäjästä. Muokkaa blogien lisäystä osan 4 luvun [populate](/osa4/kayttajien_hallinta#populate) tapaan siten, että blogin lisäämisen yhteydessä määritellään blogin lisääjäksi joku järjestelmän tietokannassa olevista käyttäjistä (esim. ensimmäisenä löytyvä). Tässä vaiheessa ei ole väliä kuka käyttäjistä määritellään lisääväksi. Toiminnallisuus viimeistellään tehtävässä 4.19. -Muokaa kaikkien blogien listausta siten, että blogien yhteydessä näytetään lisääjän tiedot: +Muokkaa kaikkien blogien listausta siten, että blogien yhteydessä näytetään lisääjän tiedot: ![](../../images/4/23e.png) @@ -317,17 +375,17 @@ ja käyttäjien listausta siten että käyttäjien lisäämät blogit ovat näky ![](../../images/4/24e.png) -#### 4.18: blogilistan laajennus, step7 +#### 4.18: blogilistan laajennus, step6 -Toteuta osan 4 luvun [Token-perustainen kirjautuminen](/osa4#/token_perustainen_kirjautuminen) tapaan järjestelmään token-perustainen autentikointi. +Toteuta osan 4 luvun [Token-perustainen kirjautuminen](/osa4/token_perustainen_kirjautuminen) tapaan järjestelmään token-perustainen autentikointi. -#### 4.19: blogilistan laajennus, step8 +#### 4.19: blogilistan laajennus, step7 -Muuta blogien lisäämistä siten, että se on mahdollista vain, jos lisäyksen tekevässä HTTP POST -pyynnössä on mukana validi token. Tokenin haltija määritellään blogin lisääjäksi. +Muuta blogien lisäämistä siten, että se on mahdollista vain, jos lisäyksen tekevässä HTTP POST ‑pyynnössä on mukana validi token. Tokenin haltija määritellään blogin lisääjäksi. -#### 4.20*: blogilistan laajennus, step9 +#### 4.20*: blogilistan laajennus, step8 -Osan 4 [esimerkissä](/osa4#/token_perustainen_kirjautuminen) token otetaan headereista apufunktion _getTokenFrom_ avulla. +Osan 4 [esimerkissä](/osa4/token_perustainen_kirjautuminen#muistiinpanojen-luominen-vain-kirjautuneille) token otetaan headereista apufunktion _getTokenFrom_ avulla. Jos käytit samaa ratkaisua, refaktoroi tokenin erottaminen [middlewareksi](/osa3/node_js_ja_express#middlewaret), joka ottaa tokenin Authorization-headerista ja sijoittaa sen request-olion kenttään token. @@ -347,7 +405,7 @@ blogsRouter.post('/', async (request, response) => { }) ``` -Muista, että normaali [middleware](/osa3/node_js_ja_express#middlewaret) on funktio, jolla on kolme parametria, ja joka kutsuu lopuksi parametrina next olevaa funktiota: +Muista, että normaali [middleware](/osa3/node_js_ja_express#middlewaret) on funktio, jolla on kolme parametria, ja joka kutsuu lopuksi parametrina next olevaa funktiota: ```js const tokenExtractor = (request, response, next) => { @@ -357,7 +415,7 @@ const tokenExtractor = (request, response, next) => { } ``` -#### 4.21*: blogilistan laajennus, step10 +#### 4.21*: blogilistan laajennus, step9 Muuta blogin poistavaa operaatiota siten, että poisto onnistuu ainoastaan jos poisto-operaation tekijä (eli se kenen token on pyynnön mukana) on sama kuin blogin lisääjä. @@ -404,12 +462,64 @@ backend -> selain: 201 created kayttaja -> kayttaja: --> -#### 4.22*: blogilistan laajennus, step11 + +#### 4.22*: blogilistan laajennus, step10 + +Sekä uuden blogin luonnin että blogin poistamisen yhteydessä on selvitettävä operaation tekevän käyttäjän identiteetti. Tätä auttaa jo tehtävässä 4.20 tehty middleware _tokenExtractor_. Tästä huolimatta post- ja delete-käsittelijöissä tulee vielä selvittää tokenia vastaava käyttäjä. + +Tee nyt uusi middleware _userExtractor_, joka selvittää pyyntöön liittyvän käyttäjän ja sijoittaa sen request-olioon. Middlewaren rekisteröinnin jälkeen _post-_ ja _delete-_-käsittelijöiden tulee päästä käyttäjään käsiksi suoraan viittaamalla _request.user_: + + +```js +blogsRouter.post('/', userExtractor, async (request, response) => { + // get user from request object + const user = request.user + // .. +}) + +blogsRouter.delete('/:id', userExtractor, async (request, response) => { + // get user from request object + const user = request.user + // .. +}) +``` + +Huomaa, että tässä middleware _userExtractor_ on rekisteröity yksittäisten routejen yhteyteen eli se suoritetaan vain osassa tapauksista. Eli sen sijaan, että _userExtractor_-middlewarea käytettäisiin aina + +```js +// use the middleware in all routes +app.use(middleware.userExtractor) // highlight-line + +app.use('/api/blogs', blogsRouter) +app.use('/api/users', usersRouter) +app.use('/api/login', loginRouter) +``` + +voitaisiin määritellä, että se suoritetaan ainoastaan polun /api/blogs routeissa: + +```js +// use the middleware only in /api/blogs routes +app.use('/api/blogs', middleware.userExtractor, blogsRouter) // highlight-line +app.use('/api/users', usersRouter) +app.use('/api/login', loginRouter) +``` + +Tämä siis tapahtuu ketjuttamalla useampi middleware funktion use parametriksi. Middlewareja voidaan samaan tapaan rekisteröidä myös ainoastaan yksittäisten routejen yhteyteen: + +```js +router.post('/', userExtractor, async (request, response) => { // highlight-line + // ... +} +``` + +Huolehdi, että kaikkien blogien hakeminen GET-pyynnöllä onnistuu edelleen ilman tokenia. + +#### 4.23*: blogilistan laajennus, step11 Token-kirjautumisen lisääminen valitettavasti hajotti blogien lisäämiseen liittyvät testit. Korjaa testit. Tee myös testi, joka varmistaa että uuden blogin lisäys ei onnistu, ja pyyntö palauttaa oikean statuskoodin 401 Unauthorized jos pyynnön mukana ei ole tokenia. Tarvitset luultavasti [tätä](https://github.com/visionmedia/supertest/issues/398) tietoa tehtävää tehdessä. -Tämä oli osan viimeinen tehtävä ja on aika pushata koodi githubiin sekä merkata tehdyt tehtävät [palautussovellukseen](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). +Tämä oli osan viimeinen tehtävä ja on aika pushata koodi GitHubiin sekä merkata tehdyt tehtävät [palautussovellukseen](https://studies.cs.helsinki.fi/stats/courses/fullstackopen).
    diff --git a/src/content/4/fr/part4.md b/src/content/4/fr/part4.md new file mode 100644 index 00000000000..89d89ad25b3 --- /dev/null +++ b/src/content/4/fr/part4.md @@ -0,0 +1,14 @@ +--- +mainImage: ../../../images/part-4.svg +part: 4 +lang: fr +--- + +
    + +Dans cette partie, nous allons poursuivre notre travail sur le backend. Notre premier grand thème sera la rédaction de tests unitaires et d'intégration pour le backend. Après avoir abordé les tests, nous examinerons la mise en oeuvre de l'authentification et de l'autorisation des utilisateurs. + +Partie mise à jour le 22 janvier 2023 +- Pas de changements majeurs + +
    \ No newline at end of file diff --git a/src/content/4/fr/part4a.md b/src/content/4/fr/part4a.md new file mode 100644 index 00000000000..3cebbc761f6 --- /dev/null +++ b/src/content/4/fr/part4a.md @@ -0,0 +1,812 @@ +--- +mainImage: ../../../images/part-4.svg +part: 4 +letter: a +lang: fr +--- + +
    + +Poursuivons notre travail sur le backend de l'application de notes que nous avons commencé dans la [partie 3](/fr/part3). + +### Structure du projet + +Avant de nous plonger dans le sujet des tests, nous allons modifier la structure de notre projet pour nous conformer aux meilleures pratiques de Node.js. + +Une fois que nous aurons apporté les modifications à la structure de répertoire de notre projet, nous obtiendrons la structure suivante: + +```bash +├── index.js +├── app.js +├── dist +│ └── ... +├── controllers +│ └── notes.js +├── models +│ └── note.js +├── package-lock.json +├── package.json +├── utils +│ ├── config.js +│ ├── logger.js +│ └── middleware.js +``` + +Jusqu'à présent, nous avons utilisé console.log et console.error pour afficher différentes informations provenant du code. Cependant, ce n'est pas une très bonne manière de procéder. Séparons toute impression sur la console dans son propre module utils/logger.js: + +```js +const info = (...params) => { + console.log(...params) +} + +const error = (...params) => { + console.error(...params) +} + +module.exports = { + info, error +} +``` + +Le logger possède deux fonctions, __info__ pour imprimer les messages de log normaux, et __error__ pour tous les messages d'erreur. + +Extraire le logging dans son propre module est une bonne idée à plus d'un titre. Si nous voulions commencer à écrire des logs dans un fichier ou les envoyer à un service de logging externe comme [graylog](https://www.graylog.org/) ou [papertrail](https://papertrailapp.com), nous n'aurions à faire des modifications qu'à un seul endroit. + +La gestion des variables d'environnement est extraite dans un fichier séparé utils/config.js: + +```js +require('dotenv').config() + +const PORT = process.env.PORT +const MONGODB_URI = process.env.MONGODB_URI + +module.exports = { + MONGODB_URI, + PORT +} +``` + +Les autres parties de l'application peuvent accéder aux variables d'environnement en important le module de configuration: + +```js +const config = require('./utils/config') + +logger.info(`Server running on port ${config.PORT}`) +``` + +Le contenu du fichier index.js utilisé pour démarrer l'application est simplifié comme suit: + +```js +const app = require('./app') // the actual Express application +const config = require('./utils/config') +const logger = require('./utils/logger') + +app.listen(config.PORT, () => { + logger.info(`Server running on port ${config.PORT}`) +}) +``` + +Le fichier index.js importe seulement l'application réelle du fichier app.js et lance ensuite l'application. La fonction _info_ du module logger est utilisée pour l'affichage dans la console indiquant que l'application fonctionne. + +Maintenant, l'application Express et le code s'occupant du serveur web sont séparés l'un de l'autre, suivant les [meilleures](https://dev.to/nermineslimane/always-separate-app-and-server-files--1nc7) [pratiques](https://nodejsbestpractices.com/sections/projectstructre/separateexpress). L'un des avantages de cette méthode est que l'application peut maintenant être testée au niveau des appels API HTTP sans faire réellement des appels via HTTP sur le réseau, ce qui rend l'exécution des tests plus rapide. + +Les gestionnaires de route ont également été déplacés dans un module dédié. Les gestionnaires d'événements des routes sont communément appelés contrôleurs, et pour cette raison, nous avons créé un nouveau répertoire controllers. Toutes les routes liées aux notes se trouvent maintenant dans le module notes.js sous le répertoire controllers. + +Le contenu du module notes.js est le suivant: + +```js +const notesRouter = require('express').Router() +const Note = require('../models/note') + +notesRouter.get('/', (request, response) => { + Note.find({}).then(notes => { + response.json(notes) + }) +}) + +notesRouter.get('/:id', (request, response, next) => { + Note.findById(request.params.id) + .then(note => { + if (note) { + response.json(note) + } else { + response.status(404).end() + } + }) + .catch(error => next(error)) +}) + +notesRouter.post('/', (request, response, next) => { + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + + note.save() + .then(savedNote => { + response.json(savedNote) + }) + .catch(error => next(error)) +}) + +notesRouter.delete('/:id', (request, response, next) => { + Note.findByIdAndDelete(request.params.id) + .then(() => { + response.status(204).end() + }) + .catch(error => next(error)) +}) + +notesRouter.put('/:id', (request, response, next) => { + const body = request.body + + const note = { + content: body.content, + important: body.important, + } + + Note.findByIdAndUpdate(request.params.id, note, { new: true }) + .then(updatedNote => { + response.json(updatedNote) + }) + .catch(error => next(error)) +}) + +module.exports = notesRouter +``` + +Ceci est presque une copie exacte de notre précédent fichier index.js. + +Cependant, il y a quelques changements significatifs. Au tout début du fichier, nous créons un nouvel objet [router](http://expressjs.com/en/api.html#router): + +```js +const notesRouter = require('express').Router() + +//... + +module.exports = notesRouter +``` + +Le module exporte le routeur pour qu'il soit disponible pour tous les consommateurs du module. + +Toutes les routes sont maintenant définies pour l'objet routeur, de manière similaire à ce que j'ai fait auparavant avec l'objet représentant l'ensemble de l'application. + +Il est important de noter que les chemins dans les gestionnaires de route ont été raccourcis. Dans la version précédente, nous avions: + +```js +app.delete('/api/notes/:id', (request, response) => { +``` + +And in the current version, we have: + +```js +notesRouter.delete('/:id', (request, response) => { +``` + +Alors, que sont exactement ces objets routeur? Le manuel Express fournit l'explication suivante : + +> Un objet routeur est une instance isolée de middleware et de routes. Vous pouvez le considérer comme une “mini-application”, capable uniquement de réaliser des fonctions de middleware et de routage. Chaque application Express possède un routeur intégré à l'application. + +Le routeur est en fait un middleware, qui peut être utilisé pour définir des "routes connexes" en un seul endroit, généralement placé dans son propre module. + +Le fichier app.js qui crée l'application réelle prend le routeur en utilisation comme montré ci-dessous: + + +```js +const notesRouter = require('./controllers/notes') +app.use('/api/notes', notesRouter) +``` + +Le routeur que nous avons défini plus tôt est utilisé si l'URL de la requête commence par /api/notes. Pour cette raison, l'objet notesRouter doit uniquement définir les parties relatives des routes, c'est-à-dire le chemin vide / ou simplement le paramètre /:id. + +Après avoir apporté ces modifications, notre fichier app.js ressemble à ceci: + + +```js +const config = require('./utils/config') +const express = require('express') +const app = express() +const cors = require('cors') +const notesRouter = require('./controllers/notes') +const middleware = require('./utils/middleware') +const logger = require('./utils/logger') +const mongoose = require('mongoose') + +mongoose.set('strictQuery', false) + +logger.info('connecting to', config.MONGODB_URI) + +mongoose.connect(config.MONGODB_URI) + .then(() => { + logger.info('connected to MongoDB') + }) + .catch((error) => { + logger.error('error connecting to MongoDB:', error.message) + }) + +app.use(cors()) +app.use(express.static('dist')) +app.use(express.json()) +app.use(middleware.requestLogger) + +app.use('/api/notes', notesRouter) + +app.use(middleware.unknownEndpoint) +app.use(middleware.errorHandler) + +module.exports = app +``` + +Le fichier utilise différents middleware, et l'un d'entre eux est le notesRouter qui est attaché à la route /api/notes. + +Notre middleware personnalisé a été déplacé dans un nouveau module utils/middleware.js: + +```js +const logger = require('./logger') + +const requestLogger = (request, response, next) => { + logger.info('Method:', request.method) + logger.info('Path: ', request.path) + logger.info('Body: ', request.body) + logger.info('---') + next() +} + +const unknownEndpoint = (request, response) => { + response.status(404).send({ error: 'unknown endpoint' }) +} + +const errorHandler = (error, request, response, next) => { + logger.error(error.message) + + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } else if (error.name === 'ValidationError') { + return response.status(400).json({ error: error.message }) + } + + next(error) +} + +module.exports = { + requestLogger, + unknownEndpoint, + errorHandler +} +``` + +La responsabilité d'établir la connexion à la base de données a été attribuée au module app.js. Le fichier note.js situé dans le répertoire models définit uniquement le schéma Mongoose pour les notes. + +```js +const mongoose = require('mongoose') + +const noteSchema = new mongoose.Schema({ + content: { + type: String, + required: true, + minlength: 5 + }, + important: Boolean, +}) + +noteSchema.set('toJSON', { + transform: (document, returnedObject) => { + returnedObject.id = returnedObject._id.toString() + delete returnedObject._id + delete returnedObject.__v + } +}) + +module.exports = mongoose.model('Note', noteSchema) +``` + +Pour récapituler, la structure de répertoire ressemble à ceci après que les modifications ont été apportées: + + +```bash +├── index.js +├── app.js +├── dist +│ └── ... +├── controllers +│ └── notes.js +├── models +│ └── note.js +├── package-lock.json +├── package.json +├── utils +│ ├── config.js +│ ├── logger.js +│ └── middleware.js +``` + +Pour les applications plus petites, la structure n'a pas beaucoup d'importance. Une fois que l'application commence à grandir en taille, vous allez devoir établir une sorte de structure et séparer les différentes responsabilités de l'application en modules distincts. Cela rendra le développement de l'application beaucoup plus facile. + +Il n'y a pas de structure de répertoire stricte ou de convention de nommage de fichiers requise pour les applications Express. En contraste, Ruby on Rails exige une structure spécifique. Notre structure actuelle suit simplement certaines des meilleures pratiques que vous pouvez trouver sur Internet. + +Vous pouvez trouver le code de notre application actuelle dans son intégralité dans la branche part4-1 de [ce dépôt GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-1). + +Si vous clonez le projet pour vous-même, exécutez la commande _npm install_ avant de démarrer l'application avec _npm run dev_. + +### Note sur les exports + +Nous avons utilisé deux types différents d'exports dans cette partie. Tout d'abord, par exemple, le fichier utils/logger.js fait l'exportation comme suit: + +```js +const info = (...params) => { + console.log(...params) +} + +const error = (...params) => { + console.error(...params) +} + +// highlight-start +module.exports = { + info, error +} +// highlight-end +``` + +Le fichier exporte un objet qui a deux champs, qui sont tous les deux des fonctions. Les fonctions peuvent être utilisées de deux manières différentes. La première option est d'importer l'objet entier et de se référer aux fonctions à travers l'objet en utilisant la notation par point: + +```js +const logger = require('./utils/logger') + +logger.info('message') + +logger.error('error message') +``` + +L'autre option est de décomposer les fonctions en leurs propres variables dans l'instruction require: + +```js +const { info, error } = require('./utils/logger') + +info('message') +error('error message') +``` + +La deuxième manière d'exporter peut être préférable si seulement une petite partie des fonctions exportées est utilisée dans un fichier. Par exemple, dans le fichier controller/notes.js, l'exportation se fait comme suit: + +```js +const notesRouter = require('express').Router() +const Note = require('../models/note') + +// ... + +module.exports = notesRouter // highlight-line +``` + +Dans ce cas, il n'y a qu'une seule "chose" exportée, donc la seule façon de l'utiliser est la suivante: + +```js +const notesRouter = require('./controllers/notes') + +// ... + +app.use('/api/notes', notesRouter) +``` + +Maintenant, la "chose" exportée (dans ce cas, un objet routeur) est assignée à une variable et utilisée comme telle. + +#### Trouver les utilisations de vos exports avec VS Code + +VS Code dispose d'une fonctionnalité pratique qui vous permet de voir où vos modules ont été exportés. Cela peut être très utile pour le refactoring. Par exemple, si vous décidez de diviser une fonction en deux fonctions séparées, votre code pourrait se casser si vous ne modifiez pas toutes les utilisations. C'est difficile si vous ne savez pas où elles se trouvent. Cependant, vous devez définir vos exports d'une manière particulière pour que cela fonctionne. + +Si vous faites un clic droit sur une variable à l'endroit où elle est exportée et que vous sélectionnez "Trouver toutes les références", cela vous montrera partout où la variable est importée. Cependant, si vous assignez directement un objet à module.exports, cela ne fonctionnera pas. Une solution consiste à assigner l'objet que vous souhaitez exporter à une variable nommée, puis à exporter la variable nommée. Cela ne fonctionnera pas non plus si vous déstructurez là où vous importez ; vous devez importer la variable nommée puis la déstructurer, ou simplement utiliser la notation par point pour utiliser les fonctions contenues dans la variable nommée. + +Le fait que la nature de VS Code influe sur la manière dont vous écrivez votre code n'est probablement pas idéal, donc vous devez décider par vous-même si le compromis en vaut la peine. + +
    + +
    + +### Exercices 4.1.-4.2. + +Dans les exercices de cette partie, nous allons construire une application de liste de blogs, qui permet aux utilisateurs de sauvegarder des informations sur des blogs intéressants qu'ils ont trouvés sur Internet. Pour chaque blog listé, nous sauvegarderons l'auteur, le titre, l'URL et le nombre de votes positifs des utilisateurs de l'application. + +#### 4.1 Liste de blogs, étape 1 + +Imaginons une situation où vous recevez un email contenant le corps de l'application suivant: + +```js +const express = require('express') +const app = express() +const cors = require('cors') +const mongoose = require('mongoose') + +const blogSchema = new mongoose.Schema({ + title: String, + author: String, + url: String, + likes: Number +}) + +const Blog = mongoose.model('Blog', blogSchema) + +const mongoUrl = 'mongodb://localhost/bloglist' +mongoose.connect(mongoUrl) + +app.use(cors()) +app.use(express.json()) + +app.get('/api/blogs', (request, response) => { + Blog + .find({}) + .then(blogs => { + response.json(blogs) + }) +}) + +app.post('/api/blogs', (request, response) => { + const blog = new Blog(request.body) + + blog + .save() + .then(result => { + response.status(201).json(result) + }) +}) + +const PORT = 3003 +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +``` + +Transformez l'application en un projet npm fonctionnel. Pour maintenir votre productivité de développement, configurez l'application pour qu'elle soit exécutée avec nodemon. Vous pouvez créer une nouvelle base de données pour votre application avec MongoDB Atlas, ou utiliser la même base de données que celle des exercices de la partie précédente. + +Vérifiez qu'il est possible d'ajouter des blogs à la liste avec Postman ou le client REST de VS Code et que l'application renvoie les blogs ajoutés au bon point de terminaison. + +#### 4.2 Liste de blogs, étape 2 + +Refactorisez l'application en modules séparés comme montré précédemment dans cette partie du matériel de cours. + +**NB** refactorisez votre application par petites étapes et vérifiez que l'application fonctionne après chaque changement que vous effectuez. Si vous essayez de prendre un "raccourci" en refactorisant plusieurs choses à la fois, alors la [loi de Murphy](https://en.wikipedia.org/wiki/Murphy%27s_law) entrera en jeu et il est presque certain que quelque chose se cassera dans votre application. Le "raccourci" finira par prendre plus de temps que d'avancer lentement et systématiquement. + +Une bonne pratique est de commettre votre code chaque fois qu'il est dans un état stable. Cela facilite le retour à une situation où l'application fonctionne encore. + +Si vous rencontrez des problèmes avec content.body étant indéfini sans raison apparente, assurez-vous de ne pas avoir oublié d'ajouter app.use(express.json()) près du haut du fichier. + +
    + +
    + +### Tester les applications Node + +Nous avons complètement négligé un domaine essentiel du développement logiciel, à savoir les tests automatisés. + +Commençons notre parcours de test en examinant les tests unitaires. La logique de notre application est si simple qu'il n'y a pas grand-chose qui a du sens à tester avec des tests unitaires. Créons un nouveau fichier utils/for_testing.js et écrivons quelques fonctions simples que nous pouvons utiliser pour pratiquer l'écriture de tests : + +```js +const reverse = (string) => { + return string + .split('') + .reverse() + .join('') +} + +const average = (array) => { + const reducer = (sum, item) => { + return sum + item + } + + return array.reduce(reducer, 0) / array.length +} + +module.exports = { + reverse, + average, +} +``` + +> La fonction _average_ utilise la méthode [reduce](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce) de l'array. Si cette méthode ne vous est pas encore familière, c'est le moment idéal pour regarder les trois premières vidéos de la série [Functional Javascript](https://www.youtube.com/watch?v=BMUiFMZr7vk&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84) sur YouTube. + +Il existe de nombreuses bibliothèques de test différentes ou des test runners disponibles pour JavaScript. Dans ce cours, nous utiliserons une bibliothèque de test développée et utilisée en interne par Facebook appelée [jest](https://jestjs.io/), qui ressemble à l'ancien roi des bibliothèques de test JavaScript [Mocha](https://mochajs.org/). + +Jest est un choix naturel pour ce cours, car il fonctionne bien pour tester les backends, et il brille lorsqu'il s'agit de tester des applications React. + +> **Utilisateurs Windows:** Jest peut ne pas fonctionner si le chemin du répertoire du projet contient un répertoire ayant des espaces dans son nom. + +Puisque les tests ne sont exécutés que pendant le développement de notre application, nous installerons jest comme une dépendance de développement avec la commande: + +```bash +npm install --save-dev jest +``` + +Définissons le script npm _test_ pour exécuter les tests avec Jest et pour rapporter sur l'exécution des tests avec le style verbose: + +```bash +{ + //... + "scripts": { + "start": "node index.js", + "dev": "nodemon index.js", + "build:ui": "rm -rf build && cd ../frontend/ && npm run build && cp -r build ../backend", + "deploy": "fly deploy", + "deploy:full": "npm run build:ui && npm run deploy", + "logs:prod": "fly logs", + "lint": "eslint .", + "test": "jest --verbose" // highlight-line + }, + //... +} +``` + +Jest nécessite de spécifier que l'environnement d'exécution est Node. Cela peut être fait en ajoutant ce qui suit à la fin de package.json: + +```js +{ + //... + "jest": { + "testEnvironment": "node" + } +} +``` + +Créons un répertoire séparé pour nos tests appelé tests et créons un nouveau fichier appelé reverse.test.js avec le contenu suivant: + +```js +const reverse = require('../utils/for_testing').reverse + +test('reverse of a', () => { + const result = reverse('a') + + expect(result).toBe('a') +}) + +test('reverse of react', () => { + const result = reverse('react') + + expect(result).toBe('tcaer') +}) + +test('reverse of releveler', () => { + const result = reverse('releveler') + + expect(result).toBe('releveler') +}) +``` + +La configuration ESLint que nous avons ajoutée au projet dans la partie précédente se plaint des commandes _test_ et _expect_ dans notre fichier de test puisque la configuration ne permet pas les globals. Débarrassons-nous de ces plaintes en ajoutant "jest": true à la propriété env dans le fichier .eslintrc.js. + +```js +module.exports = { + 'env': { + 'commonjs': true, + 'es2021': true, + 'node': true, + 'jest': true, // highlight-line + }, + // ... +} +``` +Dans la première ligne, le fichier de test importe la fonction à tester et l'assigne à une variable appelée _reverse_: + +```js +const reverse = require('../utils/for_testing').reverse +``` + +Les cas de test individuels sont définis avec la fonction _test_. Le premier paramètre de la fonction est la description du test sous forme de chaîne de caractères. Le deuxième paramètre est une fonction qui définit la fonctionnalité du cas de test. La fonctionnalité du deuxième cas de test ressemble à ceci: + +```js +() => { + const result = reverse('react') + + expect(result).toBe('tcaer') +} +``` + +Tout d'abord, nous exécutons le code à tester, ce qui signifie que nous générons une inversion pour la chaîne de caractères react. Ensuite, nous vérifions les résultats avec la fonction [expect](https://jestjs.io/docs/expect#expectvalue). Expect encapsule la valeur résultante dans un objet qui offre une collection de fonctions de comparaison (matcher), qui peuvent être utilisées pour vérifier la correction du résultat. Étant donné que dans ce cas de test, nous comparons deux chaînes, nous pouvons utiliser le comparateur [toBe](https://jestjs.io/docs/expect#tobevalue). + +Comme prévu, tous les tests passent : + +![Sortie du terminal de npm test](../../images/4/1x.png) + +Par défaut, Jest s'attend à ce que les noms des fichiers de test contiennent .test. Dans ce cours, nous suivrons la convention de nommer nos fichiers de test avec l'extension .test.js. + +Jest offre d'excellents messages d'erreur. Intentionnellement, échouons le test pour illustrer: + +```js +test('palindrome of react', () => { + const result = reverse('react') + + expect(result).toBe('tkaer') +}) +``` + +Exécuter les tests ci-dessus génère le message d'erreur suivant: + +![La sortie du terminal montre un échec de npm test](../../images/4/2x.png) + +Ajoutons quelques tests pour la fonction _average_ dans un nouveau fichier tests/average.test.js. + +```js +const average = require('../utils/for_testing').average + +describe('average', () => { + test('of one value is the value itself', () => { + expect(average([1])).toBe(1) + }) + + test('of many is calculated right', () => { + expect(average([1, 2, 3, 4, 5, 6])).toBe(3.5) + }) + + test('of empty array is zero', () => { + expect(average([])).toBe(0) + }) +}) +``` + +Le test révèle que la fonction ne fonctionne pas correctement avec un tableau vide (cela est dû au fait qu'en JavaScript, diviser par zéro donne NaN): + +![Sortie du terminal montrant que le tableau vide échoue avec jest](../../images/4/3.png) + +Corriger la fonction est assez simple: + +```js +const average = array => { + const reducer = (sum, item) => { + return sum + item + } + + return array.length === 0 + ? 0 + : array.reduce(reducer, 0) / array.length +} +``` + +Si la longueur du tableau est de 0, nous renvoyons 0, et dans tous les autres cas, nous utilisons la méthode _reduce_ pour calculer la moyenne. + +Il y a quelques choses à noter à propos des tests que nous venons d'écrire. Nous avons défini un bloc describe autour des tests qui ont été nommés _average_: + +```js +describe('average', () => { + // tests +}) +``` + +Les blocs describe peuvent être utilisés pour regrouper des tests en collections logiques. La sortie des tests de Jest utilise également le nom du bloc describe : + +![Capture d'écran de npm test montrant les blocs describe](../../images/4/4x.png) + +Comme nous le verrons plus tard, les blocs describe sont nécessaires lorsque nous voulons exécuter des opérations de configuration ou de nettoyage partagées pour un groupe de tests. + +Une autre chose à noter est que nous avons écrit les tests de manière assez compacte, sans attribuer la sortie de la fonction testée à une variable: + +```js +test('of empty array is zero', () => { + expect(average([])).toBe(0) +}) +``` + +
    + +
    + +### Exercices 4.3 à 4.7. + +Dans cette série d'exercices, nous allons créer une collection de fonctions d'aide destinées à faciliter la gestion de la liste de blogs. Créez ces fonctions dans un fichier appelé utils/list_helper.js et écrivez les tests correspondants dans un fichier de test approprié sous le répertoire tests. + +### 4.3 : fonctions d'aide et tests unitaires, étape 1 + +Tout d'abord, définissez une fonction _dummy_ qui reçoit un tableau de billets de blog en tant que paramètre et renvoie toujours la valeur 1. Le contenu du fichier list_helper.js à ce stade devrait être le suivant : + +```js +const dummy = (blogs) => { + // ... +} + +module.exports = { + dummy +} +``` + +Vérifiez que votre configuration de test fonctionne avec le test suivant: + +```js +const listHelper = require('../utils/list_helper') + +test('dummy returns one', () => { + const blogs = [] + + const result = listHelper.dummy(blogs) + expect(result).toBe(1) +}) +``` + +### 4.4 : fonctions d'aide et tests unitaires, étape 2 + +Définissez une nouvelle fonction _totalLikes_ qui reçoit une liste de billets de blog en tant que paramètre. La fonction renvoie la somme totale des likes dans tous les billets de blog. + +Écrivez des tests appropriés pour la fonction. Il est recommandé de mettre les tests à l'intérieur d'un bloc describe pour que le rapport de test soit bien regroupé : + +![npm test passing for list_helper_test](../../images/4/5.png) + +La définition des entrées de test pour la fonction peut être faite de la manière suivante: + +```js +describe('total likes', () => { + const listWithOneBlog = [ + { + _id: '5a422aa71b54a676234d17f8', + title: 'Go To Statement Considered Harmful', + author: 'Edsger W. Dijkstra', + url: 'http://www.u.arizona.edu/~rubinson/copyright_violations/Go_To_Considered_Harmful.html', + likes: 5, + __v: 0 + } + ] + + test('when list has only one blog, equals the likes of that', () => { + const result = listHelper.totalLikes(listWithOneBlog) + expect(result).toBe(5) + }) +}) +``` + +Si définir votre propre liste d'entrée de test pour les blogs est trop compliqué, vous pouvez utiliser la liste toute faite disponible [ici](https://raw.githubusercontent.com/fullstack-hy2020/misc/master/blogs_for_test.md). + +Vous risquez de rencontrer des problèmes lors de l'écriture des tests. Rappelez-vous des choses que nous avons apprises sur le [débogage](/fr/part3/saving_data_to_mongo_db#debugging-node-applications) dans la partie 3. Vous pouvez afficher des choses dans la console avec console.log même pendant l'exécution des tests. Il est même possible d'utiliser le débogueur pendant l'exécution des tests, vous pouvez trouver des instructions à ce sujet [ici](https://jestjs.io/docs/en/troubleshooting). + +**NB** : si un test échoue, il est recommandé de ne lancer que ce test pendant que vous corrigez le problème. Vous pouvez exécuter un seul test avec la méthode [only](https://jestjs.io/docs/api#testonlyname-fn-timeout). + +Une autre façon d'exécuter un seul test (ou un bloc de description) consiste à spécifier le nom du test à exécuter avec le drapeau [-t](https://jestjs.io/docs/en/cli.html): + +```js +npm test -- -t 'when list has only one blog, equals the likes of that' +``` + +#### 4.5*: Fonctions d'aide et tests unitaires, étape 3 + +Définissez une nouvelle fonction _favoriteBlog_ qui reçoit en paramètre une liste de blogs. La fonction détermine quel blog a le plus de likes. S'il y a plusieurs favoris principaux, il suffit d'en retourner un. + +La valeur renvoyée par la fonction pourrait être dans le format suivant : + +```js +{ + title: "Canonical string reduction", + author: "Edsger W. Dijkstra", + likes: 12 +} +``` + +**NB** lorsque vous comparez des objets, la méthode [toEqual](https://jestjs.io/docs/en/expect#toequalvalue) est probablement ce que vous voulez utiliser, car la méthode [toBe](https://jestjs.io/docs/en/expect#tobevalue) tente de vérifier que les deux valeurs sont la même valeur, et non seulement qu'elles contiennent les mêmes propriétés. + +Écrivez les tests pour cet exercice à l'intérieur d'un nouveau bloc describe. Faites de même pour les exercices suivants. + +#### 4.6*: Fonctions d'aide et tests unitaires, étape 4 + +Cet exercice et le suivant sont un peu plus difficiles. Terminer ces deux exercices n'est pas nécessaire pour avancer dans le matériel du cours, il peut donc être judicieux de revenir à ceux-ci une fois que vous avez terminé de parcourir l'intégralité du matériel de cette partie. + +Vous pouvez terminer cet exercice sans utiliser de bibliothèques supplémentaires. Cependant, cet exercice est une excellente occasion d'apprendre à utiliser la bibliothèque [Lodash](https://lodash.com/). + +Définissez une fonction appelée _mostBlogs_ qui reçoit un tableau de blogs en tant que paramètre. La fonction renvoie l'auteur qui a le plus grand nombre de blogs. La valeur renvoyée contient également le nombre de blogs que l'auteur principal possède: + +```js +{ + author: "Robert C. Martin", + blogs: 3 +} +``` + +S'il y a plusieurs blogueurs en tête, il suffit d'en renvoyer un. + +#### 4.7*: Fonctions d'aide et tests unitaires, étape 5 + +Définissez une fonction appelée _mostLikes_ qui reçoit un tableau de blogs en tant que paramètre. La fonction renvoie l'auteur dont les articles de blog ont reçu le plus grand nombre de "j'aime". La valeur renvoyée contient également le nombre total de "j'aime" que l'auteur a reçus : + +```js +{ + author: "Edsger W. Dijkstra", + likes: 17 +} +``` + +S'il y a plusieurs blogueurs en tête, il suffit d'en montrer un quelconque. + +
    diff --git a/src/content/4/fr/part4b.md b/src/content/4/fr/part4b.md new file mode 100644 index 00000000000..04b0348f9e0 --- /dev/null +++ b/src/content/4/fr/part4b.md @@ -0,0 +1,1260 @@ +--- +mainImage: ../../../images/part-4.svg +part: 4 +letter: b +lang: fr +--- + +
    + +Nous allons maintenant commencer à écrire des tests pour le backend. Étant donné que le backend ne contient pas de logique compliquée, il n'est pas judicieux d'écrire des [tests unitaires](https://en.wikipedia.org/wiki/Unit_testing) pour cela. La seule chose que nous pourrions potentiellement tester unitairement est la méthode _toJSON_ utilisée pour formater les notes. + +Dans certaines situations, il peut être bénéfique d'implémenter certains tests du backend en simulant la base de données plutôt qu'en utilisant une vraie base de données. Une bibliothèque qui pourrait être utilisée pour cela est [mongodb-memory-server](https://github.com/nodkz/mongodb-memory-server). + +Comme le backend de notre application est encore relativement simple, nous déciderons de tester l'ensemble de l'application via son API REST, de sorte que la base de données soit également incluse. Ce type de test, où plusieurs composants du système sont testés en groupe, est appelé [test d'intégration](https://en.wikipedia.org/wiki/Integration_testing). + + +## Environnement de test + +Dans l'un des chapitres précédents du matériel de cours, nous avons mentionné que lorsque votre serveur backend fonctionne sur Fly.io ou Render, il est en mode production. + +La convention en Node est de définir le mode d'exécution de l'application avec la variable d'environnement NODE\_ENV. Dans notre application actuelle, nous chargeons uniquement les variables d'environnement définies dans le fichier .env si l'application n'est pas en mode production. + +Il est courant de définir des modes séparés pour le développement et les tests. + +Ensuite, modifions les scripts dans le fichier package.json de notre application de notes, de sorte que lorsque les tests sont exécutés, NODE\_ENV reçoive la valeur test: + +```json +{ + // ... + "scripts": { + "start": "NODE_ENV=production node index.js",// highlight-line + "dev": "NODE_ENV=development nodemon index.js",// highlight-line + "build:ui": "rm -rf build && cd ../frontend/ && npm run build && cp -r build ../backend", + "deploy": "fly deploy", + "deploy:full": "npm run build:ui && npm run deploy", + "logs:prod": "fly logs", + "lint": "eslint .", + "test": "NODE_ENV=test jest --verbose --runInBand"// highlight-line + }, + // ... +} +``` + +Nous avons également ajouté l'option [runInBand](https://jestjs.io/docs/cli#--runinband) au script npm qui exécute les tests. Cette option empêchera Jest d'exécuter les tests en parallèle; nous discuterons de son importance une fois que nos tests commenceront à utiliser la base de données. + +Nous avons spécifié le mode de l'application comme étant développement dans le script _npm run dev_ qui utilise nodemon. Nous avons également précisé que la commande par défaut _npm start_ définira le mode comme production. + +Il y a un léger problème dans la façon dont nous avons spécifié le mode de l'application dans nos scripts: cela ne fonctionnera pas sur Windows. Nous pouvons corriger cela en installant le package [cross-env](https://www.npmjs.com/package/cross-env) en tant que dépendance de développement avec la commande: + +```bash +npm install --save-dev cross-env +``` + +Nous pouvons ensuite obtenir une compatibilité multi-plateformes en utilisant la bibliothèque cross-env dans nos scripts npm définis dans le fichier package.json: + +```json +{ + // ... + "scripts": { + "start": "cross-env NODE_ENV=production node index.js", + "dev": "cross-env NODE_ENV=development nodemon index.js", + // ... + "test": "cross-env NODE_ENV=test jest --verbose --runInBand", + }, + // ... +} +``` + +**NB:** Si vous déployez cette application sur Fly.io/Render, gardez à l'esprit que si cross-env est enregistré en tant que dépendance de développement, cela pourrait provoquer une erreur d'application sur votre serveur web. Pour résoudre ce problème, changez cross-env en une dépendance de production en exécutant ceci dans la ligne de commande: + +```bash +npm install cross-env +``` + + +Nous pouvons maintenant modifier la manière dont notre application fonctionne dans différents modes. Par exemple, nous pourrions définir l'application pour utiliser une base de données de test séparée lorsqu'elle exécute des tests. + +Nous pouvons créer notre base de données de test séparée dans MongoDB Atlas. Ce n'est pas une solution optimale dans les situations où de nombreuses personnes développent la même application. L'exécution de tests nécessite en particulier une seule instance de base de données qui n'est pas utilisée par des tests s'exécutant simultanément. + +Il serait préférable d'exécuter nos tests en utilisant une base de données installée et fonctionnant sur la machine locale du développeur. La solution optimale serait que chaque exécution de test utilise une base de données séparée. Cela est "relativement simple" à réaliser en exécutant [Mongo en mémoire](https://docs.mongodb.com/manual/core/inmemory/) ou en utilisant des conteneurs [Docker](https://www.docker.com). Nous ne compliquerons pas les choses et continuerons à utiliser la base de données MongoDB Atlas. + +Faisons quelques modifications au module qui définit la configuration de l'application: + +```js +require('dotenv').config() + +const PORT = process.env.PORT + +// highlight-start +const MONGODB_URI = process.env.NODE_ENV === 'test' + ? process.env.TEST_MONGODB_URI + : process.env.MONGODB_URI +// highlight-end + +module.exports = { + MONGODB_URI, + PORT +} +``` + +Le fichier .env contient des variables distinctes pour les adresses de la base de données de développement et de test: + +```bash +MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority +PORT=3001 + +// highlight-start +TEST_MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/testNoteApp?retryWrites=true&w=majority +// highlight-end +``` + +Le module _config_ que nous avons implémenté ressemble un peu au package [node-config](https://github.com/lorenwest/node-config). Écrire notre propre implémentation est justifié puisque notre application est simple, et cela nous apprend également des leçons précieuses. + +Ce sont les seuls changements que nous devons apporter au code de notre application. + +Vous pouvez trouver le code complet de notre application actuelle dans la branche part4-2 de ce [dépôt GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-2). + +### supertest + +Utilisons le package [supertest](https://github.com/visionmedia/supertest) pour nous aider à écrire nos tests pour tester l'API. + +Nous installerons le package en tant que dépendance de développement: + +```bash +npm install --save-dev supertest +``` + +Écrivons notre premier test dans le fichier tests/note_api.test.js: + +```js +const mongoose = require('mongoose') +const supertest = require('supertest') +const app = require('../app') + +const api = supertest(app) + +test('notes are returned as json', async () => { + await api + .get('/api/notes') + .expect(200) + .expect('Content-Type', /application\/json/) +}) + +afterAll(async () => { + await mongoose.connection.close() +}) +``` + +Le test importe l'application Express depuis le module app.js et l'enveloppe avec la fonction supertest en un objet dit [superagent](https://github.com/visionmedia/superagent). Cet objet est assigné à la variable api et les tests peuvent l'utiliser pour faire des requêtes HTTP vers le backend. + +Notre test effectue une requête HTTP GET vers l'URL api/notes et vérifie que la requête reçoit une réponse avec le code de statut 200. Le test vérifie également que l'en-tête Content-Type est défini sur application/json, indiquant que les données sont dans le format souhaité. + +La vérification de la valeur de l'en-tête utilise une syntaxe un peu étrange: + +```js +.expect('Content-Type', /application\/json/) +``` + +La valeur souhaitée est maintenant définie comme une [expression régulière](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions) ou, en abrégé, regex. La regex commence et se termine par un slash /, car la chaîne souhaitée application/json contient également le même slash, elle est précédée d'un \ de sorte qu'elle ne soit pas interprétée comme un caractère de fin de regex. + +En principe, le test aurait également pu être défini comme une chaîne + +```js +.expect('Content-Type', 'application/json') +``` + +Le problème ici, cependant, est que lorsqu'on utilise une chaîne, la valeur de l'en-tête doit être exactement la même. Pour la regex que nous avons définie, il est acceptable que l'en-tête contienne la chaîne en question. La valeur réelle de l'en-tête est application/json; charset=utf-8, c'est-à-dire qu'elle contient également des informations sur l'encodage des caractères. Cependant, notre test n'est pas intéressé par cela et il est donc préférable de définir le test comme une regex au lieu d'une chaîne exacte. + +Le test contient certains détails que nous explorerons [un peu plus tard](/en/part4/testing_the_backend#async-await). La fonction fléchée qui définit le test est précédée du mot-clé async et l'appel de méthode pour l'objet api est précédé du mot-clé await. Nous allons écrire quelques tests puis examiner de plus près cette magie async/await. Ne vous en préoccupez pas pour l'instant, soyez simplement assuré que les tests d'exemple fonctionnent correctement. La syntaxe async/await est liée au fait que faire une requête à l'API est une opération asynchrone. La syntaxe [async/await](https://jestjs.io/docs/asynchronous) peut être utilisée pour écrire du code asynchrone avec l'apparence de code synchrone. + +Une fois que tous les tests (il n'y en a actuellement qu'un) ont fini de s'exécuter, nous devons fermer la connexion à la base de données utilisée par Mongoose. Cela peut être facilement réalisé avec la méthode [afterAll](https://jestjs.io/docs/api#afterallfn-timeout): + +```js +afterAll(async () => { + await mongoose.connection.close() +}) +``` + +Lorsque vous exécutez vos tests, vous pouvez rencontrer l'avertissement suivant dans la console: + +![avertissement de la console jest sur la non-sortie](../../images/4/8.png) + +Le problème est très probablement causé par la version 6.x de Mongoose, le problème n'apparaît pas avec les versions 5.x ou 7.x. La [documentation de Mongoose](https://mongoosejs.com/docs/jest.html) ne recommande pas de tester des applications Mongoose avec Jest. + +[Une manière](https://stackoverflow.com/questions/50687592/jest-and-mongoose-jest-has-detected-opened-handles) de se débarrasser de cela est d'ajouter dans le répertoire tests un fichier teardown.js avec le contenu suivant + +```js +module.exports = () => { + process.exit(0) +} +``` + +et en étendant les définitions de Jest dans le fichier package.json comme suit + +```js +{ + //... + "jest": { + "testEnvironment": "node", + "globalTeardown": "./tests/teardown.js" // highlight-line + } +} +``` + +Une autre erreur que vous pourriez rencontrer est que votre test prend plus de temps que le délai d'attente par défaut de Jest de 5000 ms. Cela peut être résolu en ajoutant un troisième paramètre à la fonction de test: + +```js +test('notes are returned as json', async () => { + await api + .get('/api/notes') + .expect(200) + .expect('Content-Type', /application\/json/) +}, 100000) +``` + +Ce troisième paramètre définit le délai d'attente à 100 000 ms. Un long délai d'attente garantit que notre test ne échouera pas à cause du temps nécessaire pour s'exécuter. (Un long délai d'attente n'est peut-être pas ce que vous souhaitez pour des tests basés sur la performance ou la vitesse, mais cela convient pour nos tests d'exemple). + +Si vous rencontrez toujours des problèmes avec les délais d'attente de mongoose, définissez la variable `bufferTimeoutMS` à une valeur significativement supérieure à 10 000 (10 secondes). Vous pourriez la définir ainsi en haut, juste après les déclarations `require`. `mongoose.set("bufferTimeoutMS", 30000)` + +Un petit détail mais important: au [début](/en/part4/structure_of_backend_application_introduction_to_testing#project-structure) de cette partie, nous avons extrait l'application Express dans le fichier app.js, et le rôle du fichier index.js a été modifié pour lancer l'application sur le port spécifié via `app.listen`: + +```js +const app = require('./app') // the actual Express app +const config = require('./utils/config') +const logger = require('./utils/logger') + +app.listen(config.PORT, () => { + logger.info(`Server running on port ${config.PORT}`) +}) +``` + +Les tests utilisent uniquement l'application Express définie dans le fichier app.js, qui n'écoute aucun port: + +```js +const mongoose = require('mongoose') +const supertest = require('supertest') +const app = require('../app') // highlight-line + +const api = supertest(app) // highlight-line + +// ... +``` + +La documentation de supertest dit ce qui suit: + +> si le serveur n'écoute pas déjà les connexions, il est lié à un port éphémère pour vous, il n'est donc pas nécessaire de suivre les ports. + +En d'autres termes, supertest s'assure que l'application testée est lancée sur le port qu'il utilise en interne. + +Ajoutons deux notes à la base de données de test à l'aide du programme _mongo.js_ (ici, nous devons nous rappeler de passer à l'url correcte de la base de données). + +Écrivons quelques tests supplémentaires: + +```js +test('there are two notes', async () => { + const response = await api.get('/api/notes') + + expect(response.body).toHaveLength(2) +}) + +test('the first note is about HTTP methods', async () => { + const response = await api.get('/api/notes') + + expect(response.body[0].content).toBe('HTML is easy') +}) +``` + +Les deux tests stockent la réponse de la requête dans la variable _response_, et contrairement au test précédent qui utilisait les méthodes fournies par _supertest_ pour vérifier le code de statut et les en-têtes, cette fois-ci nous inspectons les données de réponse stockées dans la propriété response.body. Nos tests vérifient le format et le contenu des données de réponse avec la méthode [expect](https://jestjs.io/docs/expect#expectvalue) de Jest. + +L'avantage de l'utilisation de la syntaxe async/await commence à devenir évident. Normalement, nous devrions utiliser des fonctions de rappel pour accéder aux données renvoyées par les promesses, mais avec la nouvelle syntaxe, les choses sont beaucoup plus confortables: + +```js +const response = await api.get('/api/notes') + +// execution gets here only after the HTTP request is complete +// the result of HTTP request is saved in variable response +expect(response.body).toHaveLength(2) +``` + +Le middleware qui affiche des informations sur les requêtes HTTP gêne l'affichage de l'exécution des tests. Modifions le logger pour qu'il n'imprime pas dans la console en mode test: + +```js +const info = (...params) => { + // highlight-start + if (process.env.NODE_ENV !== 'test') { + console.log(...params) + } + // highlight-end +} + +const error = (...params) => { + // highlight-start + if (process.env.NODE_ENV !== 'test') { + console.error(...params) + } + // highlight-end +} + +module.exports = { + info, error +} +``` + +### Initialisation de la base de données avant les tests + +Les tests semblent faciles et nos tests passent actuellement. Cependant, nos tests ne sont pas fiables car ils dépendent de l'état de la base de données, qui contient actuellement deux notes. Pour rendre nos tests plus robustes, nous devons réinitialiser la base de données et générer les données de test nécessaires de manière contrôlée avant d'exécuter les tests. + +Nos tests utilisent déjà la fonction [afterAll](https://jestjs.io/docs/api#afterallfn-timeout) de Jest pour fermer la connexion à la base de données après l'exécution des tests. Jest offre de nombreuses autres [fonctions](https://jestjs.io/docs/setup-teardown) qui peuvent être utilisées pour exécuter des opérations une fois avant l'exécution de n'importe quel test ou à chaque fois avant un test. + +Initialisons la base de données avant chaque test avec la fonction [beforeEach](https://jestjs.io/docs/en/api.html#beforeeachfn-timeout): + +```js +const mongoose = require('mongoose') +const supertest = require('supertest') +const app = require('../app') +const api = supertest(app) +// highlight-start +const Note = require('../models/note') +// highlight-end + +// highlight-start +const initialNotes = [ + { + content: 'HTML is easy', + important: false, + }, + { + content: 'Browser can execute only JavaScript', + important: true, + }, +] +// highlight-end + +// highlight-start +beforeEach(async () => { + await Note.deleteMany({}) + + let noteObject = new Note(initialNotes[0]) + await noteObject.save() + + noteObject = new Note(initialNotes[1]) + await noteObject.save() +}) +// highlight-end +// ... +``` + +La base de données est vidée au début, et après cela, nous enregistrons les deux notes stockées dans le tableau _initialNotes_ dans la base de données. En faisant cela, nous nous assurons que la base de données est dans le même état avant l'exécution de chaque test. + +Faisons également les modifications suivantes aux deux derniers tests: + +```js +test('all notes are returned', async () => { // highlight-line + const response = await api.get('/api/notes') + + expect(response.body).toHaveLength(initialNotes.length) // highlight-line +}) + +test('a specific note is within the returned notes', async () => { // highlight-line + const response = await api.get('/api/notes') + + // highlight-start + const contents = response.body.map(r => r.content) + + expect(contents).toContain( + 'Browser can execute only JavaScript' + ) + // highlight-end +}) +``` + +Portez une attention particulière à l'expectation dans le dernier test. La commande response.body.map(r => r.content) est utilisée pour créer un tableau contenant le contenu de chaque note renvoyée par l'API. La méthode [toContain](https://jestjs.io/docs/expect#tocontainitem) est utilisée pour vérifier que la note donnée en paramètre se trouve dans la liste des notes renvoyées par l'API. + +### Exécution des tests un par un + +La commande _npm test_ exécute tous les tests de l'application. Lorsque nous écrivons des tests, il est généralement judicieux de n'en exécuter qu'un ou deux. Jest offre plusieurs manières de le faire, dont une est la méthode [only](https://jestjs.io/docs/en/api#testonlyname-fn-timeout). Si les tests sont répartis sur plusieurs fichiers, cette méthode n'est pas idéale. + +Une meilleure option est de spécifier les tests à exécuter en tant que paramètres de la commande npm test. + +La commande suivante exécute uniquement les tests trouvés dans le fichier tests/note_api.test.js: + +```js +npm test -- tests/note_api.test.js +``` + +L'option -t peut être utilisée pour exécuter des tests ayant un nom spécifique: + +```js +npm test -- -t "a specific note is within the returned notes" +``` + +Le paramètre fourni peut faire référence au nom du test ou du bloc describe. Le paramètre peut également contenir juste une partie du nom. La commande suivante exécutera tous les tests qui contiennent notes dans leur nom: + +```js +npm test -- -t 'notes' +``` + +**NB:** Lors de l'exécution d'un seul test, la connexion mongoose peut rester ouverte si aucun test utilisant la connexion n'est exécuté. +Le problème peut être dû au fait que supertest initialise la connexion, mais Jest n'exécute pas la partie afterAll du code. + +### async/await + +Avant d'écrire plus de tests, examinons les mots-clés _async_ et _await_. + +La syntaxe async/await introduite dans ES7 permet d'utiliser des fonctions asynchrones qui retournent une promesse d'une manière qui rend le code apparemment synchrone. + +Par exemple, la récupération de notes depuis la base de données avec des promesses ressemble à ceci: + +```js +Note.find({}).then(notes => { + console.log('operation returned the following notes', notes) +}) +``` + +La méthode _Note.find()_ renvoie une promesse et nous pouvons accéder au résultat de l'opération en enregistrant une fonction de rappel avec la méthode _then_. + +Tout le code que nous voulons exécuter une fois l'opération terminée est écrit dans la fonction de rappel. Si nous voulions effectuer plusieurs appels de fonction asynchrones en séquence, la situation deviendrait rapidement pénible. Les appels asynchrones devraient être faits dans le rappel. Cela pourrait probablement conduire à un code compliqué et pourrait potentiellement donner naissance à ce qu'on appelle un [enfer de rappels](http://callbackhell.com/). + +En [chaînant les promesses](https://javascript.info/promise-chaining), nous pourrions garder la situation quelque peu sous contrôle, et éviter l'enfer de rappels en créant une chaîne assez propre d'appels de méthode _then_. Nous avons vu quelques exemples de cela au cours de la formation. Pour illustrer cela, vous pouvez voir un exemple artificiel d'une fonction qui récupère toutes les notes puis supprime la première: + +```js +Note.find({}) + .then(notes => { + return notes[0].deleteOne() + }) + .then(response => { + console.log('the first note is removed') + // more code here + }) +``` + +La chaîne de then est correcte, mais nous pouvons faire mieux. Les [fonctions générateur](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator) introduites dans ES6 ont fourni une [méthode astucieuse](https://github.com/getify/You-Dont-Know-JS/blob/1st-ed/async%20%26%20performance/ch4.md#iterating-generators-asynchronously) d'écrire du code asynchrone d'une manière qui "semble synchrone". La syntaxe est un peu lourde et n'est pas largement utilisée. + +Les mots-clés _async_ et _await_ introduits dans ES7 apportent la même fonctionnalité que les générateurs, mais d'une manière compréhensible et syntaxiquement plus propre à la portée de tous les citoyens du monde JavaScript. + +Nous pourrions récupérer toutes les notes dans la base de données en utilisant l'opérateur [await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await) de cette façon: + +```js +const notes = await Note.find({}) + +console.log('operation returned the following notes', notes) +``` + +Le code ressemble exactement à du code synchrone. L'exécution du code s'arrête à const notes = await Note.find({}) et attend jusqu'à ce que la promesse associée soit remplie, puis continue son exécution jusqu'à la ligne suivante. Lorsque l'exécution se poursuit, le résultat de l'opération qui a renvoyé une promesse est attribué à la variable _notes_. + +L'exemple légèrement compliqué présenté ci-dessus pourrait être mis en oeuvre en utilisant await de cette façon: + +```js +const notes = await Note.find({}) +const response = await notes[0].deleteOne() + +console.log('the first note is removed') +``` + +Grâce à la nouvelle syntaxe, le code est beaucoup plus simple que la chaîne de then précédente. + +Il y a quelques détails importants à prendre en compte lors de l'utilisation de la syntaxe async/await. Pour utiliser l'opérateur await avec des opérations asynchrones, elles doivent retourner une promesse. Ce n'est pas un problème en soi, car les fonctions asynchrones régulières utilisant des callbacks sont faciles à envelopper dans des promesses. + +Le mot-clé await ne peut pas être utilisé n'importe où dans le code JavaScript. L'utilisation de await est possible uniquement à l'intérieur d'une fonction [async](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function). + +Cela signifie que pour que les exemples précédents fonctionnent, ils doivent utiliser des fonctions async. Remarquez la première ligne dans la définition de la fonction fléchée: + +```js +const main = async () => { // highlight-line + const notes = await Note.find({}) + console.log('operation returned the following notes', notes) + + const response = await notes[0].deleteOne() + console.log('the first note is removed') +} + +main() // highlight-line +``` + +Le code déclare que la fonction assignée à _main_ est asynchrone. Après cela, le code appelle la fonction avec main(). + +### async/await dans le backend + +Commençons à changer le backend pour utiliser async et await. Comme toutes les opérations asynchrones sont actuellement effectuées à l'intérieur d'une fonction, il suffit de changer les fonctions de gestionnaire de route en fonctions asynchrones. + +La route pour récupérer toutes les notes est modifiée comme suit: + +```js +notesRouter.get('/', async (request, response) => { + const notes = await Note.find({}) + response.json(notes) +}) +``` + +Nous pouvons vérifier que notre refactoring a été réussi en testant le point de terminaison via le navigateur et en exécutant les tests que nous avons écrits précédemment. + +Vous pouvez trouver le code de notre application actuelle dans son intégralité dans la branche part4-3 de ce [dépôt GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-3). + +### Plus de tests et refactoring du backend + +Lorsque le code est refactorisé, il y a toujours le risque de [régression](https://en.wikipedia.org/wiki/Regression_testing), ce qui signifie que la fonctionnalité existante peut se briser. Refactorisons les opérations restantes en écrivant d'abord un test pour chaque route de l'API. + +Commençons par l'opération d'ajout d'une nouvelle note. Écrivons un test qui ajoute une nouvelle note et vérifie que le nombre de notes renvoyées par l'API augmente et que la nouvelle note ajoutée est dans la liste. + +```js +test('a valid note can be added', async () => { + const newNote = { + content: 'async/await simplifies making async calls', + important: true, + } + + await api + .post('/api/notes') + .send(newNote) + .expect(201) + .expect('Content-Type', /application\/json/) + + const response = await api.get('/api/notes') + + const contents = response.body.map(r => r.content) + + expect(response.body).toHaveLength(initialNotes.length + 1) + expect(contents).toContain( + 'async/await simplifies making async calls' + ) +}) +``` + +Le test échoue car nous retournons par erreur le code d'état 200 OK lorsqu'une nouvelle note est créée. Modifions cela pour 201 CREATED: + +```js +notesRouter.post('/', (request, response, next) => { + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + + note.save() + .then(savedNote => { + response.status(201).json(savedNote) // highlight-line + }) + .catch(error => next(error)) +}) +``` + +Écrivons également un test qui vérifie qu'une note sans contenu ne sera pas enregistrée dans la base de données. + +```js +test('note without content is not added', async () => { + const newNote = { + important: true + } + + await api + .post('/api/notes') + .send(newNote) + .expect(400) + + const response = await api.get('/api/notes') + + expect(response.body).toHaveLength(initialNotes.length) +}) +``` + +Les deux tests vérifient l'état stocké dans la base de données après l'opération d'enregistrement, en récupérant toutes les notes de l'application. + +```js +const response = await api.get('/api/notes') +``` + +Les mêmes étapes de vérification se répéteront dans d'autres tests ultérieurement, et il est judicieux d'extraire ces étapes en fonctions d'aide. Ajoutons la fonction dans un nouveau fichier appelé tests/test_helper.js qui se trouve dans le même répertoire que le fichier de test. + +```js +const Note = require('../models/note') + +const initialNotes = [ + { + content: 'HTML is easy', + important: false + }, + { + content: 'Browser can execute only JavaScript', + important: true + } +] + +const nonExistingId = async () => { + const note = new Note({ content: 'willremovethissoon' }) + await note.save() + await note.deleteOne() + + return note._id.toString() +} + +const notesInDb = async () => { + const notes = await Note.find({}) + return notes.map(note => note.toJSON()) +} + +module.exports = { + initialNotes, nonExistingId, notesInDb +} +``` + +Le module définit la fonction _notesInDb_ qui peut être utilisée pour vérifier les notes stockées dans la base de données. Le tableau _initialNotes_ contenant l'état initial de la base de données est également présent dans le module. Nous définissons également la fonction _nonExistingId_ à l'avance, qui peut être utilisée pour créer un ID d'objet de base de données qui n'appartient à aucun objet de note dans la base de données. + +Nos tests peuvent maintenant utiliser le module d'aide et être modifiés comme suit: + +```js +const supertest = require('supertest') +const mongoose = require('mongoose') +const helper = require('./test_helper') // highlight-line +const app = require('../app') +const api = supertest(app) + +const Note = require('../models/note') + +beforeEach(async () => { + await Note.deleteMany({}) + + let noteObject = new Note(helper.initialNotes[0]) // highlight-line + await noteObject.save() + + noteObject = new Note(helper.initialNotes[1]) // highlight-line + await noteObject.save() +}) + +test('notes are returned as json', async () => { + await api + .get('/api/notes') + .expect(200) + .expect('Content-Type', /application\/json/) +}) + +test('all notes are returned', async () => { + const response = await api.get('/api/notes') + + expect(response.body).toHaveLength(helper.initialNotes.length) // highlight-line +}) + +test('a specific note is within the returned notes', async () => { + const response = await api.get('/api/notes') + + const contents = response.body.map(r => r.content) + + expect(contents).toContain( + 'Browser can execute only JavaScript' + ) +}) + +test('a valid note can be added ', async () => { + const newNote = { + content: 'async/await simplifies making async calls', + important: true, + } + + await api + .post('/api/notes') + .send(newNote) + .expect(201) + .expect('Content-Type', /application\/json/) + + const notesAtEnd = await helper.notesInDb() // highlight-line + expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1) // highlight-line + + const contents = notesAtEnd.map(n => n.content) // highlight-line + expect(contents).toContain( + 'async/await simplifies making async calls' + ) +}) + +test('note without content is not added', async () => { + const newNote = { + important: true + } + + await api + .post('/api/notes') + .send(newNote) + .expect(400) + + const notesAtEnd = await helper.notesInDb() // highlight-line + + expect(notesAtEnd).toHaveLength(helper.initialNotes.length) // highlight-line +}) + +afterAll(async () => { + await mongoose.connection.close() +}) +``` + +Le code utilisant des promesses fonctionne et les tests passent. Nous sommes prêts à refactorer notre code pour utiliser la syntaxe async/await. + +Nous apportons les modifications suivantes au code qui gère l'ajout d'une nouvelle note (remarquez que la définition du gestionnaire de route est précédée du mot-clé _async_): + +```js +notesRouter.post('/', async (request, response, next) => { + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + + const savedNote = await note.save() + response.status(201).json(savedNote) +}) +``` + +Il y a un léger problème avec notre code: nous ne gérons pas les situations d'erreur. Comment devrions-nous les traiter? + +### Gestion des erreurs et async/await + +Si une exception se produit lors de la gestion de la requête POST, nous nous retrouvons dans une situation familière: + +![terminal montrant un avertissement de rejet de promesse non géré](../../images/4/6.png) + +En d'autres termes, nous nous retrouvons avec un rejet de promesse non géré, et la requête ne reçoit jamais de réponse. + +Avec async/await, la manière recommandée de gérer les exceptions est le mécanisme ancien et familier _try/catch_ : + +```js +notesRouter.post('/', async (request, response, next) => { + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + // highlight-start + try { + const savedNote = await note.save() + response.status(201).json(savedNote) + } catch(exception) { + next(exception) + } + // highlight-end +}) +``` + +Le bloc catch appelle simplement la fonction _next_, qui transmet la gestion de la requête au middleware de gestion des erreurs. + +Après avoir apporté cette modification, tous nos tests passeront à nouveau. + +Ensuite, écrivons des tests pour récupérer et supprimer une note individuelle: + +```js +test('a specific note can be viewed', async () => { + const notesAtStart = await helper.notesInDb() + + const noteToView = notesAtStart[0] + +// highlight-start + const resultNote = await api + .get(`/api/notes/${noteToView.id}`) + .expect(200) + .expect('Content-Type', /application\/json/) +// highlight-end + + expect(resultNote.body).toEqual(noteToView) +}) + +test('a note can be deleted', async () => { + const notesAtStart = await helper.notesInDb() + const noteToDelete = notesAtStart[0] + +// highlight-start + await api + .delete(`/api/notes/${noteToDelete.id}`) + .expect(204) +// highlight-end + + const notesAtEnd = await helper.notesInDb() + + expect(notesAtEnd).toHaveLength( + helper.initialNotes.length - 1 + ) + + const contents = notesAtEnd.map(r => r.content) + + expect(contents).not.toContain(noteToDelete.content) +}) +``` + +Les deux tests partagent une structure similaire. Dans la phase d'initialisation, ils récupèrent une note de la base de données. Ensuite, les tests appellent l'opération réelle qui est testée, comme indiqué dans le bloc de code. Enfin, les tests vérifient que le résultat de l'opération est conforme aux attentes. + +Les tests réussissent et nous pouvons en toute sécurité refactorer les routes testées pour utiliser async/await: + +```js +notesRouter.get('/:id', async (request, response, next) => { + try { + const note = await Note.findById(request.params.id) + if (note) { + response.json(note) + } else { + response.status(404).end() + } + } catch(exception) { + next(exception) + } +}) + +notesRouter.delete('/:id', async (request, response, next) => { + try { + await Note.findByIdAndDelete(request.params.id) + response.status(204).end() + } catch(exception) { + next(exception) + } +}) +``` + +Vous pouvez trouver le code de notre application actuelle dans son intégralité dans la branche part4-4 de ce [dépôt GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-4). + +### Élimination du try-catch + +Async/await simplifie quelque peu le code, mais la contrepartie est l'utilisation de la structure try/catch nécessaire pour gérer les exceptions. Tous les gestionnaires de routes suivent la même structure. + +```js +try { + // do the async operations here +} catch(exception) { + next(exception) +} +``` + +On commence à se demander s'il serait possible de refactorer le code pour éliminer la clause catch des méthodes? + +La bibliothèque [express-async-errors](https://github.com/davidbanham/express-async-errors) propose une solution à ce problème. + +Installons la bibliothèque + +```bash +npm install express-async-errors +``` + +L'utilisation de la bibliothèque est très facile. +Vous introduisez la bibliothèque dans app.js, avant d'importer vos routes: + +```js +const config = require('./utils/config') +const express = require('express') +require('express-async-errors') // highlight-line +const app = express() +const cors = require('cors') +const notesRouter = require('./controllers/notes') +const middleware = require('./utils/middleware') +const logger = require('./utils/logger') +const mongoose = require('mongoose') + +// ... + +module.exports = app +``` + +La "magie" de la bibliothèque nous permet d'éliminer complètement les blocs try-catch. +Par exemple, la route pour supprimer une note: + +```js +notesRouter.delete('/:id', async (request, response, next) => { + try { + await Note.findByIdAndDelete(request.params.id) + response.status(204).end() + } catch (exception) { + next(exception) + } +}) +``` + +devient + +```js +notesRouter.delete('/:id', async (request, response) => { + await Note.findByIdAndDelete(request.params.id) + response.status(204).end() +}) +``` + +Grâce à la bibliothèque, nous n'avons plus besoin de l'appel _next(exception)_. +La bibliothèque gère tout en interne. Si une exception se produit dans une route asynchrone, l'exécution est automatiquement transmise au middleware de gestion des erreurs. + +Les autres routes deviennent: + +```js +notesRouter.post('/', async (request, response) => { + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + + const savedNote = await note.save() + response.status(201).json(savedNote) +}) + +notesRouter.get('/:id', async (request, response) => { + const note = await Note.findById(request.params.id) + if (note) { + response.json(note) + } else { + response.status(404).end() + } +}) +``` + +### Optimisation de la fonction beforeEach + +Revenons à l'écriture de nos tests et examinons de plus près la fonction _beforeEach_ qui configure les tests: + +```js +beforeEach(async () => { + await Note.deleteMany({}) + + let noteObject = new Note(helper.initialNotes[0]) + await noteObject.save() + + noteObject = new Note(helper.initialNotes[1]) + await noteObject.save() +}) +``` + +La fonction enregistre les deux premières notes du tableau _helper.initialNotes_ dans la base de données avec deux opérations distinctes. La solution est correcte, mais il existe un moyen plus efficace d'enregistrer plusieurs objets dans la base de données: + + +```js +beforeEach(async () => { + await Note.deleteMany({}) + console.log('cleared') + + helper.initialNotes.forEach(async (note) => { + let noteObject = new Note(note) + await noteObject.save() + console.log('saved') + }) + console.log('done') +}) + +test('notes are returned as json', async () => { + console.log('entered test') + // ... +} +``` + +Nous enregistrons les notes stockées dans le tableau dans la base de données à l'intérieur d'une boucle _forEach_. Cependant, les tests ne semblent pas fonctionner comme prévu, alors nous avons ajouté quelques journaux de console pour nous aider à trouver le problème. + +La console affiche la sortie suivante: + +``` +cleared +done +entered test +saved +saved +``` + +Malgré l'utilisation de la syntaxe async/await, notre solution ne fonctionne pas comme nous l'espérions. L'exécution des tests commence avant que la base de données ne soit initialisée! + +Le problème réside dans le fait que chaque itération de la boucle forEach génère une opération asynchrone, et _beforeEach_ n'attend pas leur achèvement. En d'autres termes, les commandes _await_ définies à l'intérieur de la boucle _forEach_ ne sont pas dans la fonction _beforeEach_, mais dans des fonctions séparées auxquelles _beforeEach_ n'attend pas. + +Comme l'exécution des tests commence immédiatement après que _beforeEach_ a terminé son exécution, l'exécution des tests commence avant que l'état de la base de données ne soit initialisé. + +Une façon de résoudre ce problème est d'attendre que toutes les opérations asynchrones se terminent avec la méthode [Promise.all](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all): + +```js +beforeEach(async () => { + await Note.deleteMany({}) + + const noteObjects = helper.initialNotes + .map(note => new Note(note)) + const promiseArray = noteObjects.map(note => note.save()) + await Promise.all(promiseArray) +}) +``` + +La solution est assez avancée malgré son apparence compacte. La variable _noteObjects_ est assignée à un tableau d'objets Mongoose qui sont créés avec le constructeur _Note_ pour chacune des notes du tableau _helper.initialNotes_. La ligne suivante de code crée un nouveau tableau qui consiste en des promesses, créées en appelant la méthode _save_ pour chaque élément du tableau _noteObjects_. En d'autres termes, il s'agit d'un tableau de promesses pour sauvegarder chacun des éléments dans la base de données. + +La méthode [Promise.all](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) peut être utilisée pour transformer un tableau de promesses en une seule promesse, qui sera accomplie une fois que chaque promesse du tableau passé en paramètre sera résolue. La dernière ligne de code await Promise.all(promiseArray) attend que chaque promesse de sauvegarde d'une note soit terminée, ce qui signifie que la base de données a été initialisée. + +> Les valeurs renvoyées par chaque promesse du tableau peuvent toujours être consultées lors de l'utilisation de la méthode Promise.all. Si nous attendons que les promesses soient résolues avec la syntaxe _await_ const results = await Promise.all(promiseArray), l'opération renverra un tableau contenant les valeurs résolues pour chaque promesse du _promiseArray_, et elles apparaissent dans le même ordre que les promesses dans le tableau. + +Promise.all exécute les promesses qu'il reçoit en parallèle. Si les promesses doivent être exécutées dans un ordre particulier, cela posera problème. Dans de telles situations, les opérations peuvent être exécutées à l'intérieur d'une boucle [for...of](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...of), qui garantit un ordre d'exécution spécifique. + +```js +beforeEach(async () => { + await Note.deleteMany({}) + + for (let note of helper.initialNotes) { + let noteObject = new Note(note) + await noteObject.save() + } +}) +``` + +L'aspect asynchrone de JavaScript peut en effet entraîner un comportement inattendu, et il est essentiel de comprendre comment fonctionnent les promesses lorsque l'on utilise la syntaxe async/await. Bien que async/await simplifie le travail avec les promesses, une compréhension solide des promesses est essentielle. + +Vous pouvez trouver le code de notre application sur [GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-5), dans la branche part4-5. + +### Le serment d'un véritable développeur full stack + +L'ajout de tests apporte un autre niveau de complexité à la programmation. Nous devons mettre à jour notre serment de développeur full stack pour vous rappeler que la systématicité est également essentielle lors du développement de tests. + +Nous devrions donc étendre une fois de plus notre serment comme suit: + +Le développement full stack est extrêmement difficile, c'est pourquoi j'utiliserai tous les moyens possibles pour le rendre plus facile : + +- J'aurai toujours ma console de développement du navigateur ouverte +- J'utiliserai l'onglet réseau des outils de développement du navigateur pour m'assurer que le frontend et le backend communiquent comme prévu +- Je garderai constamment un oeil sur l'état du serveur pour m'assurer que les données envoyées par le frontend sont enregistrées conformément à mes attentes +- Je surveillerai la base de données : est-ce que le backend enregistre les données au bon format? +- J'avancerai par petites étapes +- J'écrirai de nombreuses instructions console.log pour m'assurer de comprendre le comportement du code et des tests, et pour m'aider à repérer les problèmes +- Si mon code ne fonctionne pas, je n'écrirai pas davantage de code. Au lieu de cela, je commencerai par supprimer le code jusqu'à ce qu'il fonctionne ou que je revienne à un état où tout fonctionnait encore +- Si un test ne réussit pas, je m'assurerai que la fonctionnalité testée fonctionne certainement dans l'application +- Lorsque je demande de l'aide sur le Discord du cours, je formulerai mes questions correctement. Consultez [ici](https://fullstackopen.com/en/part0/general_info#how-to-get-help-in-discord) comment demander de l'aide de manière appropriée + +
    + +
    + +### Exercices 4.8.-4.12. + +**NB:** Le matériel utilise le matcher [toContain](https://jestjs.io/docs/expect#tocontainitem) à plusieurs endroits pour vérifier qu'un tableau contient un élément spécifique. Il est important de noter que cette méthode utilise l'opérateur === pour comparer et faire correspondre les éléments, ce qui signifie qu'elle n'est souvent pas adaptée à la correspondance d'objets. Dans la plupart des cas, la méthode appropriée pour vérifier les objets dans les tableaux est le matcher [toContainEqual](https://jestjs.io/docs/expect#tocontainequalitem). Cependant, les solutions modèles ne vérifient pas les objets dans les tableaux avec des matchers, donc l'utilisation de cette méthode n'est pas nécessaire pour résoudre les exercices. + +**Avertissement** : Si vous vous retrouvez à utiliser à la fois async/await et les méthodes then dans le même code, il est presque garanti que vous faites quelque chose de mal. Utilisez l'une ou l'autre, ne les mélangez pas. + +#### 4.8: Tests de la liste de blogs, étape 1 + +Utilisez le package supertest pour écrire un test qui effectue une requête HTTP GET vers l'URL /api/blogs. Vérifiez que l'application de la liste de blogs renvoie le bon nombre d'articles de blog au format JSON. + +Une fois le test terminé, refactorez le gestionnaire de route pour utiliser la syntaxe async/await au lieu des promesses. + +Notez que vous devrez apporter des modifications similaires à celles qui ont été apportées [dans le matériel](/en/part4/testing_the_backend#test-environment), comme la définition de l'environnement de test afin de pouvoir écrire des tests qui utilisent des bases de données distinctes. + +**NB:** Lors de l'exécution des tests, vous pouvez rencontrer l'avertissement suivant: + +![Avertissement de lire la documentation sur la connexion de mongoose à jest](../../images/4/8a.png) + +[Une façon](https://stackoverflow.com/questions/50687592/jest-and-mongoose-jest-has-detected-opened-handles) de s'en débarrasser consiste à ajouter dans le répertoire tests un fichier teardown.js avec le contenu suivant + +```js +module.exports = () => { + process.exit(0) +} +``` + +et en étendant les définitions Jest dans le fichier package.json comme suit: + +```js +{ + //... + "jest": { + "testEnvironment": "node", + "globalTeardown": "./tests/teardown.js" // highlight-line + } +} +``` + +**NB:** Lorsque vous rédigez vos tests, **il est préférable de ne pas exécuter tous vos tests**, exécutez uniquement ceux sur lesquels vous travaillez. En savoir plus à ce sujet [ici](/en/part4/testing_the_backend#running-tests-one-by-one). + +#### 4.9: Tests de la liste de blogs, étape 2 + +Écrivez un test qui vérifie que la propriété d'identifiant unique des articles de blog est nommée id, par défaut, la base de données nomme la propriété _id. La vérification de l'existence d'une propriété se fait facilement avec le matcher [toBeDefined](https://jestjs.io/docs/en/expect#tobedefined) de Jest. + +Apportez les modifications nécessaires au code pour qu'il passe le test. La méthode [toJSON](/en/part3/saving_data_to_mongo_db#connecting-the-backend-to-a-database) discutée dans la partie 3 est un endroit approprié pour définir le paramètre id. + +#### 4.10: Tests de la liste de blogs, étape 3 + +Écrivez un test qui vérifie qu'une requête HTTP POST à l'URL /api/blogs crée avec succès un nouvel article de blog. Au moins, vérifiez que le nombre total de blogs dans le système augmente d'un. Vous pouvez également vérifier que le contenu de l'article de blog est correctement enregistré dans la base de données. + +Une fois le test terminé, refactorisez l'opération pour utiliser async/await au lieu des promises. + +#### 4.11*: Tests de la liste de blogs, étape 4 + +Écrivez un test qui vérifie que si la propriété likes est manquante dans la requête, elle prendra par défaut la valeur 0. Ne testez pas encore les autres propriétés des blogs créés. + +Apportez les modifications nécessaires au code pour qu'il passe le test. + +#### 4.12*: Tests de la liste de blogs, étape 5 + +Écrivez des tests liés à la création de nouveaux blogs via l'endpoint /api/blogs, qui vérifient que si les propriétés title ou url sont manquantes dans les données de la requête, le backend répondra à la requête avec le code d'état 400 Bad Request. + +Apportez les modifications nécessaires au code pour qu'il passe le test. + +
    + +
    + +### Refactoring des tests + +Notre couverture de tests est actuellement insuffisante. Certaines requêtes comme GET /api/notes/:id et DELETE /api/notes/:id ne sont pas testées lorsque la requête est envoyée avec un id invalide. Le regroupement et l'organisation des tests pourraient également être améliorés, car tous les tests se trouvent sur le même "niveau supérieur" dans le fichier de test. La lisibilité des tests s'améliorerait si nous regroupions les tests connexes avec des blocs describe. + +Voici un exemple du fichier de test après quelques améliorations mineures: + +```js +const supertest = require('supertest') +const mongoose = require('mongoose') +const helper = require('./test_helper') +const app = require('../app') +const api = supertest(app) + +const Note = require('../models/note') + +beforeEach(async () => { + await Note.deleteMany({}) + await Note.insertMany(helper.initialNotes) +}) + +describe('when there is initially some notes saved', () => { + test('notes are returned as json', async () => { + await api + .get('/api/notes') + .expect(200) + .expect('Content-Type', /application\/json/) + }) + + test('all notes are returned', async () => { + const response = await api.get('/api/notes') + + expect(response.body).toHaveLength(helper.initialNotes.length) + }) + + test('a specific note is within the returned notes', async () => { + const response = await api.get('/api/notes') + + const contents = response.body.map(r => r.content) + + expect(contents).toContain( + 'Browser can execute only JavaScript' + ) + }) +}) + +describe('viewing a specific note', () => { + test('succeeds with a valid id', async () => { + const notesAtStart = await helper.notesInDb() + + const noteToView = notesAtStart[0] + + const resultNote = await api + .get(`/api/notes/${noteToView.id}`) + .expect(200) + .expect('Content-Type', /application\/json/) + + expect(resultNote.body).toEqual(noteToView) + }) + + test('fails with statuscode 404 if note does not exist', async () => { + const validNonexistingId = await helper.nonExistingId() + + await api + .get(`/api/notes/${validNonexistingId}`) + .expect(404) + }) + + test('fails with statuscode 400 if id is invalid', async () => { + const invalidId = '5a3d5da59070081a82a3445' + + await api + .get(`/api/notes/${invalidId}`) + .expect(400) + }) +}) + +describe('addition of a new note', () => { + test('succeeds with valid data', async () => { + const newNote = { + content: 'async/await simplifies making async calls', + important: true, + } + + await api + .post('/api/notes') + .send(newNote) + .expect(201) + .expect('Content-Type', /application\/json/) + + const notesAtEnd = await helper.notesInDb() + expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1) + + const contents = notesAtEnd.map(n => n.content) + expect(contents).toContain( + 'async/await simplifies making async calls' + ) + }) + + test('fails with status code 400 if data invalid', async () => { + const newNote = { + important: true + } + + await api + .post('/api/notes') + .send(newNote) + .expect(400) + + const notesAtEnd = await helper.notesInDb() + + expect(notesAtEnd).toHaveLength(helper.initialNotes.length) + }) +}) + +describe('deletion of a note', () => { + test('succeeds with status code 204 if id is valid', async () => { + const notesAtStart = await helper.notesInDb() + const noteToDelete = notesAtStart[0] + + await api + .delete(`/api/notes/${noteToDelete.id}`) + .expect(204) + + const notesAtEnd = await helper.notesInDb() + + expect(notesAtEnd).toHaveLength( + helper.initialNotes.length - 1 + ) + + const contents = notesAtEnd.map(r => r.content) + + expect(contents).not.toContain(noteToDelete.content) + }) +}) + +afterAll(async () => { + await mongoose.connection.close() +}) +``` + +La sortie des tests est regroupée en fonction des blocs describe : + +![jest output showing grouped describe blocks](../../images/4/7.png) + +Il y a encore de la place pour des améliorations, mais il est temps de continuer. + +Cette façon de tester l'API, en effectuant des requêtes HTTP et en inspectant la base de données avec Mongoose, n'est en aucun cas la seule ni la meilleure façon de réaliser des tests d'intégration au niveau de l'API pour les applications serveur. Il n'y a pas de meilleure façon universelle d'écrire des tests, car tout dépend de l'application testée et des ressources disponibles. + +Vous pouvez trouver le code de notre application actuelle dans sa totalité dans la branche part4-6 de [ce dépôt GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-6). + +
    + +
    + +### Exercices 4.13.-4.14. + +#### 4.13 Blog list expansions, step1 + +Mettez en oeuvre la fonctionnalité permettant de supprimer une seule ressource de billet de blog. + +Utilisez la syntaxe async/await. Suivez les conventions [RESTful](/en/part3/node_js_and_express#rest) lors de la définition de l'API HTTP. + +Implémentez des tests pour la fonctionnalité. + +#### 4.14 Blog list expansions, step2 + +Mettez en oeuvre la fonctionnalité permettant de mettre à jour les informations d'un billet de blog individuel. + +Utilisez async/await. + +L'application a principalement besoin de mettre à jour le nombre de likes pour un billet de blog. Vous pouvez mettre en oeuvre cette fonctionnalité de la même manière que nous avons mis en oeuvre la mise à jour des notes dans la [partie 3](/en/part3/saving_data_to_mongo_db#other-operations). + +Implémentez des tests pour la fonctionnalité. + +
    \ No newline at end of file diff --git a/src/content/4/fr/part4c.md b/src/content/4/fr/part4c.md new file mode 100644 index 00000000000..efaf952a727 --- /dev/null +++ b/src/content/4/fr/part4c.md @@ -0,0 +1,563 @@ +--- +mainImage: ../../../images/part-4.svg +part: 4 +letter: c +lang: fr +--- + +
    + +Nous souhaitons ajouter l'authentification et l'autorisation des utilisateurs à notre application. Les utilisateurs doivent être stockés dans la base de données et chaque note doit être liée à l'utilisateur qui l'a créée. La suppression et l'édition d'une note ne devraient être autorisées que pour l'utilisateur qui l'a créée. + +Commençons par ajouter des informations sur les utilisateurs dans la base de données. Il existe une relation un-à-plusieurs entre l'utilisateur (User) et les notes (Note): + +![diagramme liant user et notes](https://yuml.me/a187045b.png) + +Si nous travaillions avec une base de données relationnelle, la mise en oeuvre serait simple. Les deux ressources auraient leurs propres tables de base de données séparées, et l'identifiant de l'utilisateur qui a créé une note serait stocké dans la table des notes comme une clé étrangère. + +Lorsqu'on travaille avec des bases de données de documents, la situation est un peu différente, car il existe de nombreuses manières différentes de modéliser la situation. + +La solution existante enregistre chaque note dans la collection de notes dans la base de données. Si nous ne voulons pas modifier cette collection existante, alors le choix naturel est de sauvegarder les utilisateurs dans leur propre collection, users par exemple. + +Comme avec toutes les bases de données de documents, nous pouvons utiliser des identifiants d'objets dans Mongo pour référencer des documents dans d'autres collections. Cela est similaire à l'utilisation de clés étrangères dans les bases de données relationnelles. + +Traditionnellement, les bases de données de documents comme Mongo ne prennent pas en charge les requêtes jointes disponibles dans les bases de données relationnelles, utilisées pour agréger des données de plusieurs tables. Cependant, à partir de la version 3.2, Mongo a pris en [charge les requêtes d'agrégation](https://docs.mongodb.com/manual/reference/operator/aggregation/lookup/) de recherche. Nous n'allons pas examiner cette fonctionnalité dans ce cours. + +Si nous avons besoin d'une fonctionnalité similaire aux requêtes jointes, nous l'implémenterons dans notre code d'application en effectuant plusieurs requêtes. Dans certaines situations, Mongoose peut s'occuper de joindre et d'agréger des données, ce qui donne l'apparence d'une requête jointe. Cependant, même dans ces situations, Mongoose effectue plusieurs requêtes à la base de données en arrière-plan. + +### Références entre collections + +Si nous utilisions une base de données relationnelle, la note contiendrait une clé de référence à l'utilisateur qui l'a créée. Dans les bases de données de documents, nous pouvons faire la même chose. + +Supposons que la collection users contienne deux utilisateurs: + +```js +[ + { + username: 'mluukkai', + _id: 123456, + }, + { + username: 'hellas', + _id: 141414, + }, +] +``` + +La collection notes contient trois notes qui ont toutes un champ user qui fait référence à un utilisateur dans la collection users: + +```js +[ + { + content: 'HTML is easy', + important: false, + _id: 221212, + user: 123456, + }, + { + content: 'The most important operations of HTTP protocol are GET and POST', + important: true, + _id: 221255, + user: 123456, + }, + { + content: 'A proper dinosaur codes with Java', + important: false, + _id: 221244, + user: 141414, + }, +] +``` + +Les bases de données de documents n'exigent pas que la clé étrangère soit stockée dans les ressources de note, elle pourrait aussi être stockée dans la collection des utilisateurs, ou même dans les deux: + +```js +[ + { + username: 'mluukkai', + _id: 123456, + notes: [221212, 221255], + }, + { + username: 'hellas', + _id: 141414, + notes: [221244], + }, +] +``` + +Puisque les utilisateurs peuvent avoir de nombreuses notes, les identifiants associés sont stockés dans un tableau dans le champ notes. + +Les bases de données de documents offrent également une manière radicalement différente d'organiser les données : Dans certaines situations, il pourrait être avantageux d'imbriquer le tableau entier de notes comme une partie des documents dans la collection des utilisateurs: + +```js +[ + { + username: 'mluukkai', + _id: 123456, + notes: [ + { + content: 'HTML is easy', + important: false, + }, + { + content: 'The most important operations of HTTP protocol are GET and POST', + important: true, + }, + ], + }, + { + username: 'hellas', + _id: 141414, + notes: [ + { + content: + 'A proper dinosaur codes with Java', + important: false, + }, + ], + }, +] +``` + +Dans ce schéma, les notes seraient étroitement imbriquées sous les utilisateurs et la base de données ne générerait pas d'identifiants pour elles. + +La structure et le schéma de la base de données ne sont pas aussi évidents qu'avec les bases de données relationnelles. Le schéma choisi doit soutenir au mieux les cas d'utilisation de l'application. Ce n'est pas une décision de conception simple à prendre, car tous les cas d'utilisation des applications ne sont pas connus lorsque la décision de conception est prise. + +Paradoxalement, les bases de données sans schéma comme Mongo exigent des développeurs qu'ils prennent des décisions de conception bien plus radicales sur l'organisation des données au début du projet que les bases de données relationnelles avec des schémas. En moyenne, les bases de données relationnelles offrent une manière plus ou moins adaptée d'organiser les données pour de nombreuses applications. + +### Schéma Mongoose pour les utilisateurs + +Dans ce cas, nous décidons de stocker les identifiants des notes créées par l'utilisateur dans le document utilisateur. Définissons le modèle pour représenter un utilisateur dans le fichier models/user.js: + +```js +const mongoose = require('mongoose') + +const userSchema = new mongoose.Schema({ + username: String, + name: String, + passwordHash: String, + notes: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: 'Note' + } + ], +}) + +userSchema.set('toJSON', { + transform: (document, returnedObject) => { + returnedObject.id = returnedObject._id.toString() + delete returnedObject._id + delete returnedObject.__v + // the passwordHash should not be revealed + delete returnedObject.passwordHash + } +}) + +const User = mongoose.model('User', userSchema) + +module.exports = User +``` + +Les identifiants des notes sont stockés dans le document utilisateur sous forme de tableau d'identifiants Mongo. La définition est la suivante: + +```js +{ + type: mongoose.Schema.Types.ObjectId, + ref: 'Note' +} +``` + +Le type du champ est ObjectId qui fait référence à des documents de type note. Mongo ne sait pas intrinsèquement qu'il s'agit d'un champ qui fait référence à des notes, la syntaxe est purement liée à et définie par Mongoose. + +Étendons le schéma de la note définie dans le fichier models/note.js pour que la note contienne des informations sur l'utilisateur qui l'a créée: + +```js +const noteSchema = new mongoose.Schema({ + content: { + type: String, + required: true, + minlength: 5 + }, + important: Boolean, + // highlight-start + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + } + // highlight-end +}) +``` + +En net contraste avec les conventions des bases de données relationnelles, les références sont maintenant stockées dans les deux documents : la note fait référence à l'utilisateur qui l'a créée, et l'utilisateur possède un tableau de références à toutes les notes qu'il a créées. + +### Création d'utilisateurs + +Implémentons une route pour créer de nouveaux utilisateurs. Les utilisateurs ont un nom d'utilisateur unique, un nom et quelque chose appelé passwordHash. Le hachage de mot de passe est le résultat d'une [fonction de hachage à sens unique](https://en.wikipedia.org/wiki/Cryptographic_hash_function) appliquée au mot de passe de l'utilisateur. Il n'est jamais judicieux de stocker des mots de passe en texte clair non crypté dans la base de données! + +Installons le paquet [bcrypt](https://github.com/kelektiv/node.bcrypt.js) pour générer les hachages de mot de passe: + +```bash +npm install bcrypt +``` + +La création de nouveaux utilisateurs se fait conformément aux conventions REST discutées dans la [partie 3](/en/part3/node_js_and_express#rest), en effectuant une requête HTTP POST vers le chemin users. + +Définissons un routeur séparé pour gérer les utilisateurs dans un nouveau fichier controllers/users.js. Prenons ce routeur en compte dans notre application dans le fichier app.js, de sorte qu'il gère les requêtes faites à l'URL /api/users: + +```js +const usersRouter = require('./controllers/users') + +// ... + +app.use('/api/users', usersRouter) +``` + +Le contenu du fichier, controllers/users.js, qui définit le routeur est le suivant: + +```js +const bcrypt = require('bcrypt') +const usersRouter = require('express').Router() +const User = require('../models/user') + +usersRouter.post('/', async (request, response) => { + const { username, name, password } = request.body + + const saltRounds = 10 + const passwordHash = await bcrypt.hash(password, saltRounds) + + const user = new User({ + username, + name, + passwordHash, + }) + + const savedUser = await user.save() + + response.status(201).json(savedUser) +}) + +module.exports = usersRouter +``` + +Le mot de passe envoyé dans la requête n'est pas stocké dans la base de données. Nous stockons le hash du mot de passe qui est généré avec la fonction _bcrypt.hash_. + +Les principes fondamentaux de [stockage des mots de passe](https://codahale.com/how-to-safely-store-a-password/) sont hors du champ d'application de ce matériel de cours. Nous n'aborderons pas ce que signifie le nombre magique 10 assigné à la variable [saltRounds](https://github.com/kelektiv/node.bcrypt.js/#a-note-on-rounds), mais vous pouvez en lire plus à ce sujet dans le matériel lié. + +Notre code actuel ne contient aucune gestion des erreurs ou validation des entrées pour vérifier que le nom d'utilisateur et le mot de passe sont dans le format souhaité. + +La nouvelle fonctionnalité peut et doit initialement être testée manuellement avec un outil comme Postman. Cependant, tester les choses manuellement deviendra rapidement trop fastidieux, en particulier une fois que nous aurons mis en oeuvre une fonctionnalité qui impose l'unicité des noms d'utilisateur. + +Il demande beaucoup moins d'effort d'écrire des tests automatisés, et cela rendra le développement de notre application beaucoup plus facile. + +Nos tests initiaux pourraient ressembler à ceci: + +```js +const bcrypt = require('bcrypt') +const User = require('../models/user') + +//... + +describe('when there is initially one user in db', () => { + beforeEach(async () => { + await User.deleteMany({}) + + const passwordHash = await bcrypt.hash('sekret', 10) + const user = new User({ username: 'root', passwordHash }) + + await user.save() + }) + + test('creation succeeds with a fresh username', async () => { + const usersAtStart = await helper.usersInDb() + + const newUser = { + username: 'mluukkai', + name: 'Matti Luukkainen', + password: 'salainen', + } + + await api + .post('/api/users') + .send(newUser) + .expect(201) + .expect('Content-Type', /application\/json/) + + const usersAtEnd = await helper.usersInDb() + expect(usersAtEnd).toHaveLength(usersAtStart.length + 1) + + const usernames = usersAtEnd.map(u => u.username) + expect(usernames).toContain(newUser.username) + }) +}) +``` + +Les tests utilisent la fonction d'aide usersInDb() que nous avons implémentée dans le fichier tests/test_helper.js. Cette fonction est utilisée pour nous aider à vérifier l'état de la base de données après la création d'un utilisateur: + +```js +const User = require('../models/user') + +// ... + +const usersInDb = async () => { + const users = await User.find({}) + return users.map(u => u.toJSON()) +} + +module.exports = { + initialNotes, + nonExistingId, + notesInDb, + usersInDb, +} +``` + +Le bloc beforeEach ajoute un utilisateur avec le nom d'utilisateur root à la base de données. Nous pouvons écrire un nouveau test qui vérifie qu'un nouvel utilisateur avec le même nom d'utilisateur ne peut pas être créé: + +```js +describe('when there is initially one user in db', () => { + // ... + + test('creation fails with proper statuscode and message if username already taken', async () => { + const usersAtStart = await helper.usersInDb() + + const newUser = { + username: 'root', + name: 'Superuser', + password: 'salainen', + } + + const result = await api + .post('/api/users') + .send(newUser) + .expect(400) + .expect('Content-Type', /application\/json/) + + expect(result.body.error).toContain('expected `username` to be unique') + + const usersAtEnd = await helper.usersInDb() + expect(usersAtEnd).toEqual(usersAtStart) + }) +}) +``` + +Le cas de test ne passera évidemment pas à ce stade. Nous pratiquons essentiellement le [développement piloté par les tests (TDD)](https://en.wikipedia.org/wiki/Test-driven_development), où les tests pour une nouvelle fonctionnalité sont écrits avant que la fonctionnalité soit implémentée. + +Mongoose n'a pas de validateur intégré pour vérifier l'unicité d'un champ. Heureusement, il existe une solution toute faite pour cela, la bibliothèque [mongoose-unique-validator](https://www.npmjs.com/package/mongoose-unique-validator). Installons cette bibliothèque: + +```bash +npm install mongoose-unique-validator +``` + +et étendons le code en suivant la documentation de la bibliothèque dans models/user.js: + +```js +const mongoose = require('mongoose') +const uniqueValidator = require('mongoose-unique-validator') // highlight-line + +const userSchema = mongoose.Schema({ + // highlight-start + username: { + type: String, + required: true, + unique: true + }, + // highlight-end + name: String, + passwordHash: String, + notes: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: 'Note' + } + ], +}) + +userSchema.plugin(uniqueValidator) // highlight-line + +// ... +``` + + +Nous pourrions également implémenter d'autres validations dans la création de l'utilisateur. Nous pourrions vérifier que le nom d'utilisateur est assez long, que le nom d'utilisateur ne se compose que de caractères autorisés, ou que le mot de passe est suffisamment fort. L'implémentation de ces fonctionnalités est laissée comme un exercice optionnel. + +Avant de poursuivre, ajoutons une première implémentation d'un gestionnaire de route qui retourne tous les utilisateurs dans la base de données: + +```js +usersRouter.get('/', async (request, response) => { + const users = await User.find({}) + response.json(users) +}) +``` + +Pour créer de nouveaux utilisateurs dans un environnement de production ou de développement, vous pouvez envoyer une requête POST à ```/api/users/``` via Postman ou un client REST dans le format suivant: + +```js +{ + "username": "root", + "name": "Superuser", + "password": "salainen" +} + +``` + +La liste ressemble à ceci: + +![navigateur api/users montre des données JSON avec un tableau de notes](../../images/4/9.png) + +Vous pouvez trouver le code de notre application actuelle dans son intégralité dans la branche part4-7 de [ce dépôt GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-7). + +### Création d'une nouvelle note + +Le code pour créer une nouvelle note doit être mis à jour afin que la note soit attribuée à l'utilisateur qui l'a créée. + +Étendons notre implémentation actuelle dans controllers/notes.js pour que les informations concernant l'utilisateur qui a créé une note soient envoyées dans le champ userId du corps de la requête: + +```js +const User = require('../models/user') //highlight-line + +//... + +notesRouter.post('/', async (request, response) => { + const body = request.body + + const user = await User.findById(body.userId) //highlight-line + + const note = new Note({ + content: body.content, + important: body.important === undefined ? false : body.important, + user: user.id //highlight-line + }) + + const savedNote = await note.save() + user.notes = user.notes.concat(savedNote._id) //highlight-line + await user.save() //highlight-line + + response.status(201).json(savedNote) +}) +``` + +Le schéma de la note devra également changer comme suit dans notre fichier models/note.js: + +```js +const noteSchema = new mongoose.Schema({ + content: { + type: String, + required: true, + minlength: 5 + }, + important: Boolean, + // highlight-start + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + } + //highlight-end +}) +``` + +Il convient de noter que l'objet user change également. L'id de la note est stocké dans le champ notes de l'objet user: + +```js +const user = await User.findById(body.userId) + +// ... + +user.notes = user.notes.concat(savedNote._id) +await user.save() +``` + +Essayons de créer une nouvelle note: + +![Postman créant une nouvelle note](../../images/4/10e.png) + +L'opération semble fonctionner. Ajoutons encore une note puis visitons la route pour récupérer tous les utilisateurs: + +![api/users retourne du JSON avec les utilisateurs et leur tableau de notes](../../images/4/11e.png) + +Nous pouvons voir que l'utilisateur a deux notes. + +De même, les identifiants des utilisateurs qui ont créé les notes peuvent être vus lorsque nous visitons la route pour récupérer toutes les notes : + +![api/notes montre les identifiants des numéros en JSON](../../images/4/12e.png) + +### Populate + +Nous aimerions que notre API fonctionne de telle manière que lorsqu'une requête HTTP GET est faite à la route /api/users, les objets utilisateur contiennent également le contenu des notes de l'utilisateur et pas seulement leur id. Dans une base de données relationnelle, cette fonctionnalité serait mise en oeuvre avec une requête jointe. + +Comme mentionné précédemment, les bases de données de documents ne prennent pas correctement en charge les requêtes jointes entre collections, mais la bibliothèque Mongoose peut faire certaines de ces jointures pour nous. Mongoose réalise la jointure en effectuant plusieurs requêtes, ce qui est différent des requêtes jointes dans les bases de données relationnelles qui sont transactionnelles, ce qui signifie que l'état de la base de données ne change pas pendant le temps de la requête. Avec les requêtes jointes dans Mongoose, rien ne peut garantir que l'état entre les collections jointes est cohérent, ce qui signifie que si nous faisons une requête qui joint les collections utilisateur et notes, l'état des collections peut changer pendant la requête. + +La jointure Mongoose est réalisée avec la méthode [populate](http://mongoosejs.com/docs/populate.html). Mettons à jour en premier lieu la route qui retourne tous les utilisateurs dans le fichier controllers/users.js: + +```js +usersRouter.get('/', async (request, response) => { + const users = await User // highlight-line + .find({}).populate('notes') // highlight-line + + response.json(users) +}) +``` + +La méthode [populate](http://mongoosejs.com/docs/populate.html) est enchaînée après la méthode find qui effectue la requête initiale. Le paramètre donné à la méthode populate définit que les ids référençant les objets note dans le champ notes du document user seront remplacés par les documents note référencés. + +Le résultat est presque exactement ce que nous voulions: + +![Données JSON montrant des notes et des données d'utilisateurs remplies avec répétition](../../images/4/13new.png) + +Nous pouvons utiliser le paramètre populate pour choisir les champs que nous voulons inclure des documents. En plus du champ id, nous sommes maintenant intéressés uniquement par content et important. + +La sélection des champs se fait avec la [syntaxe de Mongo](https://docs.mongodb.com/manual/tutorial/project-fields-from-query-results/#return-the-specified-fields-and-the-id-field-only): + +```js +usersRouter.get('/', async (request, response) => { + const users = await User + .find({}).populate('notes', { content: 1, important: 1 }) + + response.json(users) +}) +``` + +Le résultat est maintenant exactement comme nous le voulons: + +![données combinées montrant aucune répétition](../../images/4/14new.png) + +Ajoutons également une population appropriée des informations utilisateur aux notes dans le fichier controllers/notes.js: + +```js +notesRouter.get('/', async (request, response) => { + const notes = await Note + .find({}).populate('user', { username: 1, name: 1 }) + + response.json(notes) +}) +``` + +Maintenant, les informations de l'utilisateur sont ajoutées au champ user des objets note. + +![le JSON des notes contient maintenant aussi les informations de l'utilisateur](../../images/4/15new.png) + +Il est important de comprendre que la base de données ne sait pas que les identifiants stockés dans le champ user de la collection de notes référencent des documents dans la collection utilisateur. + +La fonctionnalité de la méthode populate de Mongoose repose sur le fait que nous avons défini des "types" pour les références dans le schéma Mongoose avec l'option ref: + +```js +const noteSchema = new mongoose.Schema({ + content: { + type: String, + required: true, + minlength: 5 + }, + important: Boolean, + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + } +}) +``` + +Vous pouvez trouver le code de notre application actuelle dans son intégralité dans la branche part4-8 de [ce dépôt GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-8). + +REMARQUE : À ce stade, premièrement, certains tests échoueront. Nous laisserons la correction des tests à un exercice non obligatoire. Deuxièmement, dans l'application de notes déployée, la fonctionnalité de création d'une note cessera de fonctionner car l'utilisateur n'est pas encore lié à l'interface utilisateur. + +
    \ No newline at end of file diff --git a/src/content/4/fr/part4d.md b/src/content/4/fr/part4d.md new file mode 100644 index 00000000000..2eac917cc8f --- /dev/null +++ b/src/content/4/fr/part4d.md @@ -0,0 +1,536 @@ +--- +mainImage: ../../../images/part-4.svg +part: 4 +letter: d +lang: fr +--- + +
    + +Les utilisateurs doivent pouvoir se connecter à notre application, et lorsque l'utilisateur est connecté, ses informations doivent automatiquement être attachées à toutes les nouvelles notes qu'ils créent. + +Nous allons maintenant implémenter le support de [l'authentification basée sur des jetons](https://www.digitalocean.com/community/tutorials/the-ins-and-outs-of-token-based-authentication#how-token-based-works) dans le backend. + +Les principes de l'authentification basée sur des jetons sont représentés dans le diagramme de séquence suivant : + +![diagramme de séquence de l'authentification basée sur des jetons](../../images/4/16new.png) + +- L'utilisateur commence par se connecter à l'aide d'un formulaire de connexion implémenté avec React + - Nous ajouterons le formulaire de connexion à l'interface utilisateur dans la [partie 5](/fr/part5) +- Cela amène le code React à envoyer le nom d'utilisateur et le mot de passe à l'adresse du serveur /api/login sous forme de requête HTTP POST. +- Si le nom d'utilisateur et le mot de passe sont corrects, le serveur génère un jeton qui identifie d'une certaine manière l'utilisateur connecté. + - Le jeton est signé numériquement, le rendant impossible à falsifier (par des moyens cryptographiques) +- Le backend répond avec un code d'état indiquant que l'opération a réussi et retourne le jeton avec la réponse. +- Le navigateur enregistre le jeton, par exemple dans l'état d'une application React. +- Lorsque l'utilisateur crée une nouvelle note (ou effectue une autre opération nécessitant une identification), le code React envoie le jeton au serveur avec la requête. +- Le serveur utilise le jeton pour identifier l'utilisateur + +Implémentons d'abord la fonctionnalité de connexion. Installez la bibliothèque [jsonwebtoken](https://github.com/auth0/node-jsonwebtoken), qui nous permet de générer des [jetons web JSON](https://jwt.io/). + +```bash +npm install jsonwebtoken +``` + +Le code pour la fonctionnalité de connexion est placé dans le fichier controllers/login.js. + +```js +const jwt = require('jsonwebtoken') +const bcrypt = require('bcrypt') +const loginRouter = require('express').Router() +const User = require('../models/user') + +loginRouter.post('/', async (request, response) => { + const { username, password } = request.body + + const user = await User.findOne({ username }) + const passwordCorrect = user === null + ? false + : await bcrypt.compare(password, user.passwordHash) + + if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) + } + + const userForToken = { + username: user.username, + id: user._id, + } + + const token = jwt.sign(userForToken, process.env.SECRET) + + response + .status(200) + .send({ token, username: user.username, name: user.name }) +}) + +module.exports = loginRouter +``` + +Le code commence par rechercher l'utilisateur dans la base de données en utilisant le nom d'utilisateur joint à la requête. + +```js +const user = await User.findOne({ username }) +``` + +Ensuite, il vérifie le mot de passe, également joint à la requête. + +```js +const passwordCorrect = user === null + ? false + : await bcrypt.compare(password, user.passwordHash) +``` + +Comme les mots de passe eux-mêmes ne sont pas enregistrés dans la base de données, mais plutôt des hashes calculés à partir des mots de passe, la méthode _bcrypt.compare_ est utilisée pour vérifier si le mot de passe est correct: + +```js +await bcrypt.compare(password, user.passwordHash) +``` + +Si l'utilisateur n'est pas trouvé ou si le mot de passe est incorrect, la requête reçoit une réponse avec le code d'état [401 non autorisé](https://www.rfc-editor.org/rfc/rfc9110.html#name-401-unauthorized). La raison de l'échec est expliquée dans le corps de la réponse. + +```js +if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) +} +``` + +Si le mot de passe est correct, un jeton est créé avec la méthode _jwt.sign_. Le jeton contient le nom d'utilisateur et l'identifiant de l'utilisateur sous une forme numériquement signée. + +```js +const userForToken = { + username: user.username, + id: user._id, +} + +const token = jwt.sign(userForToken, process.env.SECRET) +``` + +Le jeton a été signé numériquement en utilisant une chaîne de la variable d'environnement SECRET comme secret. +La signature numérique garantit que seules les parties qui connaissent le secret peuvent générer un jeton valide. +La valeur de la variable d'environnement doit être définie dans le fichier .env. + +Une requête réussie reçoit une réponse avec le code d'état 200 OK. Le jeton généré et le nom d'utilisateur de l'utilisateur sont renvoyés dans le corps de la réponse. + +```js +response + .status(200) + .send({ token, username: user.username, name: user.name }) +``` + +Il ne reste plus qu'à ajouter le code pour la connexion à l'application en ajoutant le nouveau routeur à app.js. + +```js +const loginRouter = require('./controllers/login') + +//... + +app.use('/api/login', loginRouter) +``` + +Essayons de nous connecter en utilisant le client REST de VS Code: + +![post de vscode rest avec nom d'utilisateur/mot de passe](../../images/4/17e.png) + +Cela ne fonctionne pas. Le message suivant est imprimé dans la console: + +```bash +(node:32911) UnhandledPromiseRejectionWarning: Error: secretOrPrivateKey must have a value + at Object.module.exports [as sign] (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/sign.js:101:20) + at loginRouter.post (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/controllers/login.js:26:21) +(node:32911) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 2) +``` + +La commande _jwt.sign(userForToken, process.env.SECRET)_ échoue. Nous avons oublié de définir une valeur pour la variable d'environnement SECRET. Cela peut être n'importe quelle chaîne. Lorsque nous définissons la valeur dans le fichier .env (et redémarrons le serveur), la connexion fonctionne. + +Une connexion réussie renvoie les détails de l'utilisateur et le jeton: + +![réponse du client rest de VS Code montrant les détails et le jeton](../../images/4/18ea.png) + +Un nom d'utilisateur ou un mot de passe incorrect renvoie un message d'erreur et le code d'état approprié: + +![réponse du client rest de VS Code pour des détails de connexion incorrects](../../images/4/19ea.png) + +### Limiter la création de nouvelles notes aux utilisateurs connectés + +Changeons la création de nouvelles notes de manière à ce qu'elle ne soit possible que si la requête post a un jeton valide attaché. La note est ensuite enregistrée dans la liste des notes de l'utilisateur identifié par le jeton. + +Il existe plusieurs façons d'envoyer le jeton du navigateur au serveur. Nous utiliserons l'en-tête [Authorization](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization). L'en-tête indique également quel [schéma d'authentification](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#Authentication_schemes) est utilisé. Cela peut être nécessaire si le serveur offre plusieurs façons de s'authentifier. +L'identification du schéma indique au serveur comment les informations d'identification jointes doivent être interprétées. + +Le schéma Bearer convient à nos besoins. + +En pratique, cela signifie que si le jeton est, par exemple, la chaîne eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW, l'en-tête Authorization aura la valeur : + +``` +Bearer eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW +``` +La création de nouvelles notes changera ainsi (controllers/notes.js): + +```js +const jwt = require('jsonwebtoken') //highlight-line + +// ... + //highlight-start +const getTokenFrom = request => { + const authorization = request.get('authorization') + if (authorization && authorization.startsWith('Bearer ')) { + return authorization.replace('Bearer ', '') + } + return null +} + //highlight-end + +notesRouter.post('/', async (request, response) => { + const body = request.body +//highlight-start + const decodedToken = jwt.verify(getTokenFrom(request), process.env.SECRET) + if (!decodedToken.id) { + return response.status(401).json({ error: 'token invalid' }) + } + + const user = await User.findById(decodedToken.id) +//highlight-end + + const note = new Note({ + content: body.content, + important: body.important === undefined ? false : body.important, + user: user._id + }) + + const savedNote = await note.save() + user.notes = user.notes.concat(savedNote._id) + await user.save() + + response.json(savedNote) +}) +``` + +La fonction d'aide _getTokenFrom_ isole le jeton de l'en-tête authorization. La validité du jeton est vérifiée avec _jwt.verify_. La méthode décode également le jeton ou renvoie l'objet sur lequel le jeton était basé. + +```js +const decodedToken = jwt.verify(token, process.env.SECRET) +``` + +Si le jeton est manquant ou invalide, l'exception JsonWebTokenError est levée. Nous devons étendre le middleware de gestion des erreurs pour prendre en charge ce cas particulier: + +```js +const errorHandler = (error, request, response, next) => { + logger.error(error.message) + + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } else if (error.name === 'ValidationError') { + return response.status(400).json({ error: error.message }) + } else if (error.name === 'JsonWebTokenError') { // highlight-line + return response.status(401).json({ error: error.message }) // highlight-line + } + + next(error) +} +``` + +L'objet décodé du jeton contient les champs username et id, qui indiquent au serveur qui a effectué la requête. + +Si l'objet décodé du jeton ne contient pas l'identité de l'utilisateur (si decodedToken.id est indéfini), le code d'état d'erreur [401 non autorisé](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.2) est renvoyé et la raison de l'échec est expliquée dans le corps de la réponse. + +```js +if (!decodedToken.id) { + return response.status(401).json({ + error: 'token invalid' + }) +} +``` + +Lorsque l'identité de l'auteur de la requête est résolue, l'exécution continue comme auparavant. + +Une nouvelle note peut maintenant être créée en utilisant Postman si l'en-tête authorization se voit attribuer la valeur correcte, la chaîne Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ, où la seconde valeur est le jeton renvoyé par l'opération login. + +Avec Postman, cela ressemble à ceci: + +![postman ajoutant un jeton bearer](../../images/4/20e.png) + +et avec le client REST de Visual Studio Code + +![exemple vscode ajoutant un jeton bearer](../../images/4/21e.png) + +Le code actuel de l'application peut être trouvé sur [Github](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-9), branche part4-9. + +Si l'application a plusieurs interfaces nécessitant une identification, la validation du JWT devrait être séparée dans son propre middleware. Une bibliothèque existante comme [express-jwt](https://www.npmjs.com/package/express-jwt) pourrait également être utilisée. + +### Problèmes de l'authentification basée sur des jetons + +L'authentification par jeton est assez facile à mettre en oeuvre, mais elle contient un problème. Une fois que l'utilisateur de l'API, par exemple une application React, obtient un jeton, l'API fait entièrement confiance au détenteur du jeton. Que faire si les droits d'accès du détenteur du jeton doivent être révoqués? + +Il existe deux solutions à ce problème. La plus simple consiste à limiter la période de validité d'un jeton: + +```js +loginRouter.post('/', async (request, response) => { + const { username, password } = request.body + + const user = await User.findOne({ username }) + const passwordCorrect = user === null + ? false + : await bcrypt.compare(password, user.passwordHash) + + if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) + } + + const userForToken = { + username: user.username, + id: user._id, + } + + // token expires in 60*60 seconds, that is, in one hour + // highlight-start + const token = jwt.sign( + userForToken, + process.env.SECRET, + { expiresIn: 60*60 } + ) + // highlight-end + + response + .status(200) + .send({ token, username: user.username, name: user.name }) +}) +``` + +Une fois que le jeton expire, l'application cliente doit obtenir un nouveau jeton. Habituellement, cela se fait en obligeant l'utilisateur à se reconnecter à l'application. + +Le middleware de gestion des erreurs devrait être étendu pour fournir une erreur appropriée en cas de jeton expiré: + + +```js +const errorHandler = (error, request, response, next) => { + logger.error(error.message) + + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } else if (error.name === 'ValidationError') { + return response.status(400).json({ error: error.message }) + } else if (error.name === 'JsonWebTokenError') { + return response.status(401).json({ + error: 'invalid token' + }) + // highlight-start + } else if (error.name === 'TokenExpiredError') { + return response.status(401).json({ + error: 'token expired' + }) + } + // highlight-end + + next(error) +} +``` + +La durée de validité plus courte, la solution est plus sûre. Ainsi, si le jeton tombe entre de mauvaises mains ou si l'accès de l'utilisateur au système doit être révoqué, le jeton n'est utilisable que pendant une durée limitée. D'autre part, une durée de validité courte force l'utilisateur à se connecter plus fréquemment au système, ce qui peut être pénible. + +L'autre solution consiste à enregistrer des informations sur chaque jeton dans la base de données du backend et à vérifier pour chaque requête API si les droits d'accès correspondant aux jetons sont toujours valides. Avec ce schéma, les droits d'accès peuvent être révoqués à tout moment. Ce type de solution est souvent appelé une session côté serveur. + +L'aspect négatif des sessions côté serveur est la complexité accrue dans le backend et aussi l'effet sur les performances puisque la validité du jeton doit être vérifiée pour chaque requête API dans la base de données. L'accès à la base de données est considérablement plus lent par rapport à la vérification de la validité du jeton lui-même. C'est pourquoi il est assez courant d'enregistrer la session correspondant à un jeton dans une base de données clé-valeur comme [Redis](https://redis.io/), qui est limitée en fonctionnalités par rapport, par exemple, à MongoDB ou à une base de données relationnelle, mais extrêmement rapide dans certains scénarios d'utilisation. + +Lorsque les sessions côté serveur sont utilisées, le jeton est souvent juste une chaîne aléatoire, qui n'inclut pas d'informations sur l'utilisateur, comme c'est souvent le cas avec les jetons jwt. Pour chaque requête API, le serveur récupère les informations pertinentes sur l'identité de l'utilisateur depuis la base de données. Il est également assez courant que, au lieu d'utiliser l'en-tête d'autorisation, les cookies soient utilisés comme mécanisme pour transférer le jeton entre le client et le serveur. + +### Notes de fin + +Il y a eu de nombreux changements dans le code qui ont causé un problème typique pour un projet logiciel en rapide évolution: la plupart des tests ont échoué. Comme cette partie du cours est déjà saturée de nouvelles informations, nous laisserons la réparation des tests en tant qu'exercice facultatif. + +Les noms d'utilisateur, les mots de passe et les applications utilisant l'authentification par jeton doivent toujours être utilisés via [HTTPS](https://en.wikipedia.org/wiki/HTTPS). Nous pourrions utiliser un serveur [HTTPS](https://nodejs.org/api/https.html) de Node dans notre application au lieu du serveur [HTTP](https://nodejs.org/docs/latest-v8.x/api/http.html) (cela nécessite plus de configuration). D'autre part, la version de production de notre application est sur Fly.io, donc notre application reste sécurisée : Fly.io achemine tout le trafic entre un navigateur et le serveur Fly.io via HTTPS. + +Nous mettrons en oeuvre la connexion au frontend dans la [partie suivante](/fr/part5). + +REMARQUE: À ce stade, dans l'application de prise de notes déployée, il est prévu que la fonctionnalité de création d'une note cesse de fonctionner car la fonction de connexion du backend n'est pas encore liée au frontend. + +
    + +
    + +### Exercices 4.15.-4.23. + +Dans les exercices suivants, les bases de la gestion des utilisateurs seront implémentées pour l'application Bloglist. La manière la plus sûre est de suivre l'histoire de la partie 4 du chapitre [Administration des utilisateurs](/en/part4/user_administration) au chapitre [Authentification par jeton](/en/part4/token_authentication). Vous pouvez bien sûr aussi utiliser votre créativité. + +**Encore un avertissement:** Si vous remarquez que vous mélangez les appels async/await et _then_, il est à 99 % certain que vous faites quelque chose de mal. Utilisez l'un ou l'autre, mais jamais les deux. + +#### 4.15 : expansion de Blog List étape3 + +Mettez en oeuvre un moyen de créer de nouveaux utilisateurs en effectuant une requête HTTP POST à l'adresse api/users. Les utilisateurs ont un nom d'utilisateur, un mot de passe et un nom. + +Ne sauvegardez pas les mots de passe dans la base de données en texte clair, mais utilisez la bibliothèque bcrypt comme nous l'avons fait dans la partie 4 du chapitre [Créer de nouveaux utilisateurs](/en/part4/user_administration#creating-users). + +**NB** Certains utilisateurs de Windows ont eu des problèmes avec bcrypt. Si vous rencontrez des problèmes, supprimez la bibliothèque avec la commande + +```bash +npm uninstall bcrypt +``` + +et installez [bcryptjs](https://www.npmjs.com/package/bcryptjs) à la place. + +Mettez en oeuvre un moyen de voir les détails de tous les utilisateurs en effectuant une requête HTTP appropriée. + +La liste des utilisateurs peut, par exemple, ressembler à ceci: + +![api/users du navigateur affiche les données JSON de deux utilisateurs](../../images/4/22.png) + +#### 4.16* : expansion de Blog List étape4 + +Ajoutez une fonctionnalité qui impose les restrictions suivantes pour la création de nouveaux utilisateurs : Le nom d'utilisateur et le mot de passe doivent être fournis. Le nom d'utilisateur et le mot de passe doivent avoir au moins 3 caractères de long. Le nom d'utilisateur doit être unique. + +L'opération doit répondre avec un code de statut approprié et une sorte de message d'erreur si un utilisateur invalide est créé. + +**NB** Ne testez pas les restrictions de mot de passe avec les validations Mongoose. Ce n'est pas une bonne idée car le mot de passe reçu par le backend et le hash du mot de passe enregistré dans la base de données ne sont pas la même chose. La longueur du mot de passe doit être validée dans le contrôleur comme nous l'avons fait dans la [partie 3](/en/part3/node_js_and_express) avant d'utiliser la validation Mongoose. + +Mettez également en oeuvre des tests qui garantissent que les utilisateurs invalides ne sont pas créés et qu'une opération d'ajout d'utilisateur invalide retourne un code de statut et un message d'erreur appropriés. + +#### 4.17 : expansion de Blog List étape5 + +Étendez les blogs pour que chaque blog contienne des informations sur le créateur du blog. + +Modifiez l'ajout de nouveaux blogs pour que, lorsqu'un nouveau blog est créé, n'importe quel utilisateur de la base de données soit désigné comme son créateur (par exemple, le premier trouvé). Implémentez cela selon la partie 4 du chapitre [populate](/en/part4/user_administration#populate). +Peu importe quel utilisateur est désigné comme le créateur pour le moment. La fonctionnalité est terminée dans l'exercice 4.19. + +Modifiez la liste de tous les blogs pour que les informations de l'utilisateur créateur soient affichées avec le blog: + +![api/blogs intègre les informations de l'utilisateur créateur dans les données JSON](../../images/4/23e.png) + +et que la liste de tous les utilisateurs affiche également les blogs créés par chaque utilisateur: + +![api/users intègre les blogs dans les données JSON](../../images/4/24e.png) + +#### 4.18 : expansion de Blog List étape6 + +Mettez en oeuvre l'authentification basée sur les jetons selon le chapitre [Authentification par jeton](/en/part4/token_authentication) de la partie 4. + +#### 4.19 : expansion de Blog List étape7 + +Modifiez l'ajout de nouveaux blogs pour qu'il ne soit possible que si un jeton valide est envoyé avec la requête HTTP POST. L'utilisateur identifié par le jeton est désigné comme le créateur du blog. + +#### 4.20* : expansion de Blog List étape8 + +[Cet exemple](/en/part4/token_authentication) de la partie 4 montre comment extraire le jeton de l'en-tête avec la fonction d'aide _getTokenFrom_ dans controllers/blogs.js. + +Si vous avez utilisé la même solution, refactorisez l'extraction du jeton en un [middleware](/en/part3/node_js_and_express#middleware). Le middleware devrait prendre le jeton de l'en-tête Authorization et le placer dans le champ token de l'objet request. + +En d'autres termes, si vous enregistrez ce middleware dans le fichier app.js avant toutes les routes + +```js +app.use(middleware.tokenExtractor) +``` + +les routes peuvent accéder au jeton avec _request.token_: + +```js +blogsRouter.post('/', async (request, response) => { + // .. + const decodedToken = jwt.verify(request.token, process.env.SECRET) + // .. +}) +``` + +souvenez-vous qu'une [fonction middleware](/en/part3/node_js_and_express#middleware) normale est une fonction avec trois paramètres, qui, à la fin, appelle le dernier paramètre next pour transférer le contrôle au prochain middleware: + +```js +const tokenExtractor = (request, response, next) => { + // code that extracts the token + + next() +} +``` + +#### 4.21* : expansion de Blog List étape9 + +Modifiez l'opération de suppression de blog de manière à ce qu'un blog puisse être supprimé uniquement par l'utilisateur qui l'a ajouté. Par conséquent, la suppression d'un blog n'est possible que si le jeton envoyé avec la requête est le même que celui du créateur du blog. + +Si une tentative de suppression d'un blog est faite sans jeton ou par un utilisateur invalide, l'opération doit retourner un code de statut approprié. + +Notez que si vous récupérez un blog de la base de données, + +```js +const blog = await Blog.findById(...) +``` + +le champ blog.user ne contient pas une chaîne de caractères, mais un objet. Donc, si vous voulez comparer l'identifiant de l'objet récupéré de la base de données avec un identifiant sous forme de chaîne, une opération de comparaison normale ne fonctionnera pas. L'identifiant récupéré de la base de données doit d'abord être converti en chaîne de caractères. + +```js +if ( blog.user.toString() === userid.toString() ) ... +``` + +#### 4.22* : expansion de Blog List étape10 + +Les opérations de création d'un nouveau blog et de suppression d'un blog doivent toutes deux déterminer l'identité de l'utilisateur qui effectue l'opération. Le middleware _tokenExtractor_ que nous avons réalisé dans l'exercice 4.20 aide, mais les gestionnaires des opérations post et delete doivent toujours déterminer qui est l'utilisateur associé à un jeton spécifique. + +Créez maintenant un nouveau middleware _userExtractor_, qui identifie l'utilisateur et le définit dans l'objet de requête. Lorsque vous enregistrez le middleware dans app.js + +```js +app.use(middleware.userExtractor) +``` + +l'utilisateur sera défini dans le champ _request.user_: + +```js +blogsRouter.post('/', async (request, response) => { + // get user from request object + const user = request.user + // .. +}) + +blogsRouter.delete('/:id', async (request, response) => { + // get user from request object + const user = request.user + // .. +}) +``` + +Notez qu'il est possible d'enregistrer un middleware uniquement pour un ensemble spécifique de routes. Ainsi, au lieu d'utiliser _userExtractor_ avec toutes les routes, + +```js +const middleware = require('../utils/middleware'); +// ... + +// use the middleware in all routes +app.use(middleware.userExtractor) // highlight-line + +app.use('/api/blogs', blogsRouter) +app.use('/api/users', usersRouter) +app.use('/api/login', loginRouter) +``` + +nous pourrions l'enregistrer pour qu'il soit exécuté uniquement avec les routes du chemin /api/blogs: + +```js +const middleware = require('../utils/middleware'); +// ... + +// use the middleware only in /api/blogs routes +app.use('/api/blogs', middleware.userExtractor, blogsRouter) // highlight-line +app.use('/api/users', usersRouter) +app.use('/api/login', loginRouter) +``` + +Comme on peut le voir, cela se fait en chaînant plusieurs middlewares en tant que paramètre de la fonction use. Il serait également possible d'enregistrer un middleware uniquement pour une opération spécifique: + +```js +const middleware = require('../utils/middleware'); +// ... + +router.post('/', middleware.userExtractor, async (request, response) => { + // ... +} +``` + +#### 4.23* : expansion de Blog List étape11 + +Après l'ajout de l'authentification basée sur les jetons, les tests pour l'ajout d'un nouveau blog ont échoué. Corrigez les tests. Écrivez également un nouveau test pour vous assurer que l'ajout d'un blog échoue avec le code de statut approprié 401 Non autorisé si un jeton n'est pas fourni. + +[Ceci](https://github.com/visionmedia/supertest/issues/398) pourrait être très utile pour effectuer la correction. + +C'est le dernier exercice de cette partie du cours et il est temps de pousser votre code sur GitHub et de marquer tous vos exercices terminés dans le [système de soumission](https://studies.cs.helsinki.fi/stats/courses/fullstackopen) des exercices. + +
    \ No newline at end of file diff --git a/src/content/4/ptbr/part4.md b/src/content/4/ptbr/part4.md new file mode 100644 index 00000000000..c94f569a5f7 --- /dev/null +++ b/src/content/4/ptbr/part4.md @@ -0,0 +1,14 @@ +--- +mainImage: ../../../images/part-4.svg +part: 4 +lang: ptbr +--- + +
    + +Nesta parte, continuaremos nosso trabalho no backend. Nosso primeiro grande tema será escrever testes unitários e de integração para o backend. Depois de cobrir os testes, vamos dar uma olhada na implementação da autenticação e autorização do usuário. + +Parte atualizada em 22 de janeiro de 2023 +- Sem maiores atualizações + +
    diff --git a/src/content/4/ptbr/part4a.md b/src/content/4/ptbr/part4a.md new file mode 100644 index 00000000000..76e902107b7 --- /dev/null +++ b/src/content/4/ptbr/part4a.md @@ -0,0 +1,803 @@ +--- +mainImage: ../../../images/part-4.svg +part: 4 +letter: a +lang: ptbr +--- + +
    + +Vamos continuar nosso trabalho no backend da aplicação de notas que iniciamos na [parte 3](/ptbr/part3) + +### Estrutura do projeto + +Antes de adentrarmos no tópico de testes, nós iremos modificar a estrutura do projeto para aderir às melhores práticas do Node.js. + +Após fazer as mudanças na estrutura do diretório de nosso projeto, terminaremos com a seguinte estrutura: + +```bash +├── index.js +├── app.js +├── build +│ └── ... +├── controllers +│ └── notes.js +├── models +│ └── note.js +├── package-lock.json +├── package.json +├── utils +│ ├── config.js +│ ├── logger.js +│ └── middleware.js +``` + +Até o momento estamos utilizando o console.log e console.error para imprimir diferentes informações do código. +No entanto, esse não é o melhor jeito de fazer as coisas. +Vamos separar todas as impressões no console em seu próprio módulo utils/logger.js: + +```js +const info = (...params) => { + console.log(...params) +} + +const error = (...params) => { + console.error(...params) +} + +module.exports = { + info, error +} +``` + +O logger que criamos tem duas funções: __info__ para imprimir mensagens normais no console; e __error__ para as mensagens de erro. + +Extrair o código de log para seu próprio módulo é uma boa ideia, por diversos motivos. Se decidirmos escrever logs em um arquivo ou enviá-los para um serviço externo de logging, como [graylog](https://www.graylog.org/) ou [papertrail](https://papertrailapp.com) só precisaremos realizar mudanças em um determinado lugar. + +O conteúdo de index.js, arquivo usado para iniciar a aplicação, fica simplificado da seguinte forma: + +```js +const app = require('./app') // atual aplicação Express +const config = require('./utils/config') +const logger = require('./utils/logger') + +app.listen(config.PORT, () => { + logger.info(`Server running on port ${config.PORT}`) +}) +``` + +O arquivo index.js somente importa a aplicação do arquivo app.js e depois inicia a aplicação. A função _info_ do módulo logger é usada para imprimir no console, informando que a aplicação está sendo executada. + +Agora, o app Express e o código encarregado de cuidar do servidor web estão separados, seguindo assim [as melhores](https://dev.to/nermineslimane/always-separate-app-and-server-files--1nc7) práticas. Uma das vantagens desse método é que a aplicação poderá agora ser testada a nível de chamadas de API HTTP, sem realizar chamadas via HTTP sobre a rede, o que resultará em execuções de testes mais rápidas. + +O gerenciamento de variáveis de ambiente é extraído em um arquivo separado utils/config.js: + +```js +require('dotenv').config() + +const PORT = process.env.PORT +const MONGODB_URI = process.env.MONGODB_URI + +module.exports = { + MONGODB_URI, + PORT +} +``` +As outras partes da aplicação podem acessar as variáveis de ambiente importando o módulo de configuração (config.js) +```js +const config = require('./utils/config') + +logger.info(`Server running on port ${config.PORT}`) +``` + +Os gerenciadores de rota também foram movidos para um módulo dedicado. Os gerenciadores de evento das rotas são normalmente chamados de controllers (controladores), por esta razão nós criamos um novo diretório chamado controllers. Todas as rotas relacionadas às notas estão agora em notes.js, que é um módulo dentro do diretório controllers. + +O conteúdo do módulo notes.js é o seguinte: + +```js +const notesRouter = require('express').Router() +const Note = require('../models/note') + +notesRouter.get('/', (request, response) => { + Note.find({}).then(notes => { + response.json(notes) + }) +}) + +notesRouter.get('/:id', (request, response, next) => { + Note.findById(request.params.id) + .then(note => { + if (note) { + response.json(note) + } else { + response.status(404).end() + } + }) + .catch(error => next(error)) +}) + +notesRouter.post('/', (request, response, next) => { + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + + note.save() + .then(savedNote => { + response.json(savedNote) + }) + .catch(error => next(error)) +}) + +notesRouter.delete('/:id', (request, response, next) => { + Note.findByIdAndDelete(request.params.id) + .then(() => { + response.status(204).end() + }) + .catch(error => next(error)) +}) + +notesRouter.put('/:id', (request, response, next) => { + const body = request.body + + const note = { + content: body.content, + important: body.important, + } + + Note.findByIdAndUpdate(request.params.id, note, { new: true }) + .then(updatedNote => { + response.json(updatedNote) + }) + .catch(error => next(error)) +}) + +module.exports = notesRouter +``` + +Essa é quase uma cópia exata do conteúdo que anteriormente estava no arquivo index.js + +Mas há algumas mudanças significativas. No início do arquivo nós criamos um novo objeto [router](http://expressjs.com/en/api.html#router) (roteador): + +```js +const notesRouter = require('express').Router() + +//... + +module.exports = notesRouter +``` + +O módulo exporta o roteador para estar disponível para todos os consumidores. + +Todas as rotas estão agora definidas no objeto _router_, parecido com o que fizemos antes com o objeto que representava a aplicação inteira. + +Vale ressaltar que os caminhos nos manipuladores de rotas foram encurtados. Na versão anterior, tínhamos: + +```js +app.delete('/api/notes/:id', (request, response) => { +``` + +E agora temos: + +```js +notesRouter.delete('/:id', (request, response) => { +``` + +O que exatamente são esses objetos _router_? O manual do Express explica o seguinte: + +> Um objeto router é uma instância isolada de middleware e rotas. Você pode pensar nele como uma "mini-aplicação", capaz de somente executar funções de middleware e de roteamento. Toda aplicação Express possui um app router integrado. + +De fato, o _router_ é um middleware, que pode ser utilizado para definir "rotas relacionadas" a um determinado lugar, e que tipicamente é colocado em seu próprio módulo. + +O arquivo app.js que cria a aplicação recebe o _router_ e o utiliza da seguinte forma: + +```js +const notesRouter = require('./controllers/notes') +app.use('/api/notes', notesRouter) +``` + +Esse _router_ que definimos mais cedo é usado se a URL da requisição começar com /api/notes. Por este motivo, o objeto notesRouter somente deve definir rotas com caminhos relativos, por exemplo o caminho vazio / ou apenas o parâmetro /:id. + +Após estas mudanças, nosso arquivo app.js ficará desta forma: + +```js +const config = require('./utils/config') +const express = require('express') +const app = express() +const cors = require('cors') +const notesRouter = require('./controllers/notes') +const middleware = require('./utils/middleware') +const logger = require('./utils/logger') +const mongoose = require('mongoose') + +mongoose.set('strictQuery', false) + +logger.info('connecting to', config.MONGODB_URI) + +mongoose.connect(config.MONGODB_URI) + .then(() => { + logger.info('connected to MongoDB') + }) + .catch((error) => { + logger.error('error connecting to MongoDB:', error.message) + }) + +app.use(cors()) +app.use(express.static('build')) +app.use(express.json()) +app.use(middleware.requestLogger) + +app.use('/api/notes', notesRouter) + +app.use(middleware.unknownEndpoint) +app.use(middleware.errorHandler) + +module.exports = app +``` + +O código coloca diferentes middleware em uso, um deles é o notesRouter que está acoplado à rota /api/notes. + +Nosso middleware personalizado foi movido para o novo módulo utils/middleware.js: + +```js +const logger = require('./logger') + +const requestLogger = (request, response, next) => { + logger.info('Method:', request.method) + logger.info('Path: ', request.path) + logger.info('Body: ', request.body) + logger.info('---') + next() +} + +const unknownEndpoint = (request, response) => { + response.status(404).send({ error: 'unknown endpoint' }) +} + +const errorHandler = (error, request, response, next) => { + logger.error(error.message) + + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } else if (error.name === 'ValidationError') { + return response.status(400).json({ error: error.message }) + } + + next(error) +} + +module.exports = { + requestLogger, + unknownEndpoint, + errorHandler +} +``` + +A responsabilidade de estabelecer a conexão com o banco de dados foi dada ao módulo app.js. O arquivo note.js que está no diretório models somente define o _schema_ do Mongoose para as notas: + +```js +const mongoose = require('mongoose') + +const noteSchema = new mongoose.Schema({ + content: { + type: String, + required: true, + minlength: 5 + }, + important: Boolean, +}) + +noteSchema.set('toJSON', { + transform: (document, returnedObject) => { + returnedObject.id = returnedObject._id.toString() + delete returnedObject._id + delete returnedObject.__v + } +}) + +module.exports = mongoose.model('Note', noteSchema) +``` + +Para recapitular, após estas mudanças a estrutura de diretórios estará desta forma: + +```bash +├── index.js +├── app.js +├── build +│ └── ... +├── controllers +│ └── notes.js +├── models +│ └── note.js +├── package-lock.json +├── package.json +├── utils +│ ├── config.js +│ ├── logger.js +│ └── middleware.js +``` + +Para aplicações pequenas, a estrutura de diretórios não é muito relevante. Mas uma vez que a aplicação começa a crescer, você precisará estabelecer algum tipo de estrutura e separar diferentes responsabilidades da aplicação em módulos distintos. Isso facilitará muito o desenvolvimento da aplicação. + +As aplicações Express não requerem uma estrutura de diretórios pré-determinada ou convenção de nomes para arquivos. Em contrapartida, Ruby on Rails de fato requer uma estrutura específica. Nossa estrutura atual simplesmente segue algumas das melhores práticas que você poderá encontrar na internet. + +Você pode encontrar o código atual da nossa aplicação na branch part4-1 [neste repositório GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-1). + +Se você fizer o clone do projeto, execute o comando _npm install_ antes de iniciar a aplicação com o comando _npm run dev_. + +### Observação sobre os exports + +Nós fizemos dois diferentes tipos de exportações, isto é, _exports_, nesta parte. Em primeiro lugar, por exemplo, o arquivo utils/logger.js faz _export_ da seguinte forma: + +```js +const info = (...params) => { + console.log(...params) +} + +const error = (...params) => { + console.error(...params) +} + +// highlight-start +module.exports = { + info, error +} +// highlight-end +``` + +O código exporta um objeto que possui dois campos, ambos funções. As funções podem ser utilizadas de duas maneiras diferentes: a primeira maneira é requerer o objeto inteiro e referenciar cada função por meio do objeto, usando a notação de ponto: + +```js +const logger = require('./utils/logger') + +logger.info('message') + +logger.error('error message') +``` + +A segunda maneira é desestruturar as funções para variáveis no momento do require: + +```js +const { info, error } = require('./utils/logger') + +info('message') +error('error message') +``` + +A segunda maneira pode ser mais indicada caso somente uma pequena parte das funções exportadas forem utilizadas no código. + +No arquivo controller/notes.js a exportação funciona assim: + +```js +const notesRouter = require('express').Router() +const Note = require('../models/note') + +// ... + +module.exports = notesRouter // highlight-line +``` + +Neste caso, há somente uma "coisa" sendo exportada, logo a única maneira de usá-la é assim: + +```js +const notesRouter = require('./controllers/notes') + +// ... + +app.use('/api/notes', notesRouter) +``` + +Agora o "objeto" exportado (neste caso um objeto router) é atribuído a uma variável e usado como tal. + +
    + +
    + +### Exercícios 4.1.-4.2. + +Nos exercícios desta parte, iremos construir uma aplicação de listagem de blogs, que permitirá a usuários salvarem informações sobre blogs interessantes que encontrem pela internet. Para cada blog listado. iremos salvar o autor, o título, a URL e a quantidade de votos positivos de usuários da aplicação. + +#### 4.1 Blog list, passo 1 + +Vamos imaginar um cenário onde você receba um email contendo o seguinte código da aplicação: + +```js +const http = require('http') +const express = require('express') +const app = express() +const cors = require('cors') +const mongoose = require('mongoose') + +const blogSchema = new mongoose.Schema({ + title: String, + author: String, + url: String, + likes: Number +}) + +const Blog = mongoose.model('Blog', blogSchema) + +const mongoUrl = 'mongodb://localhost/bloglist' +mongoose.connect(mongoUrl) + +app.use(cors()) +app.use(express.json()) + +app.get('/api/blogs', (request, response) => { + Blog + .find({}) + .then(blogs => { + response.json(blogs) + }) +}) + +app.post('/api/blogs', (request, response) => { + const blog = new Blog(request.body) + + blog + .save() + .then(result => { + response.status(201).json(result) + }) +}) + +const PORT = 3003 +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) +``` + +Transforme a aplicação em um projeto npm funcional. Para manter seu desenvolvimento produtivo, configure a aplicação para ser executada com o nodemon. Você pode criar um novo banco de dados para sua aplicação utilizando o MongoDB Atlas ou usar algum banco de dados dos exercícios anteriores. + +Verifique se é possível adicionar blogs à lista usando o Postman ou o VS Code REST Client e se a aplicação retorna o blog adicionado no _endpoint_ correto. + +#### 4.2 Blog list, passo 2 + +Refatore a aplicação em módulos separados como demonstrado anteriormente nesta parte do curso. + +**Obs.:** refatore sua aplicação dando pequenos passos (baby steps) e verifique a aplicação após cada mudança. Se você tentar "pegar atalhos" refatorando muita coisa de uma vez, a [lei de Murphy](https://en.wikipedia.org/wiki/Murphy%27s_law) irá te acertar e alguma coisa provavelmente quebrará na sua aplicação. O "atalho" vai acabar causando mais lentidão do que avançar em passos pequenos e sistemáticos. + +Uma boa prática é realizar _commits_ do seu código periodicamente, sempre que alcançar um estado estável. Isso possibilita fazer retornos (_rollbacks_) para momentos em que aplicação ainda funcionava. + +
    + +
    + +### Testando aplicações Node + +Nós negligenciamos completamente uma área essencial no desenvolvimento de software chamada de testes. + +Vamos iniciar nossa jornada nos testes dando uma olhada nos testes unitários (_unit tests_). A lógica de nossa aplicação é tão simples, que não faz muito sentido os testes unitários. Vamos criar um novo arquivo utils/for_testing.js e escrever algumas funções simples para praticarmos a escrita de testes: + +```js +const reverse = (string) => { + return string + .split('') + .reverse() + .join('') +} + +const average = (array) => { + const reducer = (sum, item) => { + return sum + item + } + + return array.reduce(reducer, 0) / array.length +} + +module.exports = { + reverse, + average, +} +``` + +> A função _average_ utiliza o método de array [reduce](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce). Se você ainda não está familiarizado com este método, agora é uma boa oportunidade para assistir os três primeiros vídeos da série [Functional Javascript](https://www.youtube.com/watch?v=BMUiFMZr7vk&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84) no YouTube. (**Nota dos tradutores**: o vídeo está em inglês, mas você pode ativar as legendas ocultas e escolher o idioma português). + +Existem muitas bibliotecas para testes ou test runners disponíveis para JavaScript. Neste curso, nós vamos utilizar uma biblioteca de testes desenvolvida e usada internamente pelo Facebook, chamada [jest](https://jestjs.io/), que se assemelha à antiga principal biblioteca de testes JavaScript [Mocha](https://mochajs.org/). + +Jest é uma escolha natural para este curso, pois trabalha bem com tests de backend, e brilha quando chega o momento de testar aplicações React. + +> **Usuários de Windows:** Jest pode não funcionar se o diretório do projeto estiver em um caminho com espaço no nome. + +Já que os testes somente são executados durante a etapa de desenvolvimento da aplicação, iremos instalar o jest como uma dependência de desenvolvimento: + +```bash +npm install --save-dev jest +``` + +Vamos definir um script para os testes com o comando npm script _test_ que servirá para executar os testes com o Jest e relatar a execução em um estilo verboso: + +```bash +{ + //... + "scripts": { + "start": "node index.js", + "dev": "nodemon index.js", + "build:ui": "rm -rf build && cd ../frontend/ && npm run build && cp -r build ../backend", + "deploy": "fly deploy", + "deploy:full": "npm run build:ui && npm run deploy", + "logs:prod": "fly logs", + "lint": "eslint .", + "test": "jest --verbose" // highlight-line + }, + //... +} +``` + +O Jest requer a especificação de que o ambiente de execução é o Node. Isso pode ser feito adicionando as seguintes linhas ao final do arquivo package.json: + +```js +{ + //... + "jest": { + "testEnvironment": "node" + } +} +``` + +Vamos criar um diretório separado, chamado de tests, e nele vamos criar um novo arquivo chamado reverse.test.js contendo o seguinte: + +```js +const reverse = require('../utils/for_testing').reverse + +test('reverse of a', () => { + const result = reverse('a') + + expect(result).toBe('a') +}) + +test('reverse of react', () => { + const result = reverse('react') + + expect(result).toBe('tcaer') +}) + +test('reverse of releveler', () => { + const result = reverse('releveler') + + expect(result).toBe('releveler') +}) +``` + +A configuração do ESLint que adicionamos ao projeto na parte anterior está reclamando sobre os comandos _test_ e _expect_ em nosso arquivo de teste, já que a configuração não permite globals. Vamos nos livrar disso adicionando "jest": true na propriedade env do arquivo .eslintrc.js. + +```js +module.exports = { + 'env': { + 'commonjs': true, + 'es2021': true, + 'node': true, + 'jest': true, // highlight-line + }, + // ... +} +``` + +Na primeira linha, o código de teste importa a função que será testada e a atribui a uma variável chamada _reverse_: + +```js +const reverse = require('../utils/for_testing').reverse +``` + +Testes individuais são definidos com a função _test_. O primeiro parâmetro desta função é a descrição do teste como uma string. O segundo parâmetro, é a função que define a funcionalidade para o caso de teste. A funcionalidade para o segundo caso de teste é a seguinte: + +```js +() => { + const result = reverse('react') + + expect(result).toBe('tcaer') +} +``` + +Primeiro, nós executamos o código que será testado, o que significa que geraremos a string reversa de react. Após, verificamos o resultado com a função [expect](https://jestjs.io/docs/expect#expectvalue). Expect envolve o resultado em um objeto que suporta uma coleção de funções de comparadores (matcher), que podem ser usadas durante a checagem de resultados. Já que neste caso de teste nós estamos comparando duas strings, podemos usar o matcher [toBe](https://jestjs.io/docs/expect#tobevalue). (**Nota dos tradutores**: No contexto de testes em programação, um "matcher" seria um método que realiza uma comparação entre valores esperados e valores obtidos durante a execução dos testes, com o objetivo de verificar se o comportamento do código está correto). + +Como esperado, todos os testes passaram: + +![terminal output from npm test](../../images/4/1x.png) + +Por padrão, o Jest espera que os nomes dos arquivos de teste contenham .test. Neste curso, seguiremos a convenção de nomes em nossos arquivos de teste com a extensão .test.js. + +Jest possui excelentes mensagens de erro. Vamos quebrar o teste para demonstrar isso: + +```js +test('palindrome of react', () => { + const result = reverse('react') + + expect(result).toBe('tkaer') +}) +``` + +Executando o teste acima, resultará na seguinte mensagem de erro: + +![terminal output shows failure from npm test](../../images/4/2x.png) + +Vamos adicionar alguns poucos testes para a função _average_, em um novo arquivo tests/average.test.js. + +```js +const average = require('../utils/for_testing').average + +describe('average', () => { + test('of one value is the value itself', () => { + expect(average([1])).toBe(1) + }) + + test('of many is calculated right', () => { + expect(average([1, 2, 3, 4, 5, 6])).toBe(3.5) + }) + + test('of empty array is zero', () => { + expect(average([])).toBe(0) + }) +}) +``` + +O teste revela que a função não funciona corretamente com um array vazio (isso se deve ao fato da divisão por zero no JavaScript resultar em NaN): + +![terminal output showing empty array fails with jest](../../images/4/3.png) + +Ajustar a função é fácil: + +```js +const average = array => { + const reducer = (sum, item) => { + return sum + item + } + + return array.length === 0 + ? 0 + : array.reduce(reducer, 0) / array.length +} +``` + +Se o length (largura) do array é 0, então retorna 0, em todos os outros casos, usamos o método _reduce_ para calcular a média. + +Existem algumas pequenas coisas para observar sobre os testes que escrevemos. Definimos um bloco describe em volta dos testes que recebe o nome _average_: + +```js +describe('average', () => { + // tests +}) +``` + +Os blocos de descrição (describe blocks) são utilizados para agrupar testes em uma coleção lógica. A saída do teste no Jest também usa o nome do bloco de descrição: + +![screenshot of npm test showing describe blocks](../../images/4/4x.png) + +Como veremos mais tarde, os blocos describe são necessários quando queremos executar alguma configuração compartilhada ou operações de encerramento (teardown) para um grupo de testes. + +Outra coisa a se observar é que escrevemos testes de maneira compacta, sem atribuir a saída da função testada a uma variável: + +```js +test('of empty array is zero', () => { + expect(average([])).toBe(0) +}) +``` + +
    + +
    + +### Exercícios 4.3.-4.7. + +Vamos criar uma coleção de funções auxiliares que ajudarão a lidar com a lista de blogs. Crie as funções no arquivo chamado utils/list_helper.js. Escreva seus testes em arquivos nomeados de forma apropriada dentro do diretório tests. + +#### 4.3: funções auxiliares e testes unitários, passo 1 + +Primeiro, defina uma função _dummy_ que recebe um array de posts de blog como parâmetro e sempre retorna o valor 1. O conteúdo do arquivo list_helper.js neste momento deve ser o seguinte: + +```js +const dummy = (blogs) => { + // ... +} + +module.exports = { + dummy +} +``` + +Verifique se sua configuração de teste funciona com o seguinte teste: + +```js +const listHelper = require('../utils/list_helper') + +test('dummy returns one', () => { + const blogs = [] + + const result = listHelper.dummy(blogs) + expect(result).toBe(1) +}) +``` + +#### 4.4: funções auxiliares e testes unitários, passo 2 + +Defina uma nova função _totalLikes_ que recebe uma lista de posts de blog como parâmetro. A função retorna o total da soma de likes em todos os posts. + +Escreva os testes apropriados para a função. É recomendado colocar os testes dentro de um bloco describe para que a saída do relatório de testes seja agrupada de forma eficiente: + +![npm test passing for list_helper_test](../../images/4/5.png) + +Definir inputs para testar as funções pode ser feito desta forma: + +```js +describe('total likes', () => { + const listWithOneBlog = [ + { + _id: '5a422aa71b54a676234d17f8', + title: 'Go To Statement Considered Harmful', + author: 'Edsger W. Dijkstra', + url: 'http://www.u.arizona.edu/~rubinson/copyright_violations/Go_To_Considered_Harmful.html', + likes: 5, + __v: 0 + } + ] + + test('when list has only one blog, equals the likes of that', () => { + const result = listHelper.totalLikes(listWithOneBlog) + expect(result).toBe(5) + }) +}) +``` + +Se definir seus próprios inputs para testes for muito trabalhoso, você pode usar uma lista pronta [aqui](https://raw.githubusercontent.com/fullstack-hy2020/misc/master/blogs_for_test.md). + +Você vai enfrentar problemas ao escrever testes. Lembre-se das coisas que aprendemos sobre [depuração](/ptbr/part3/salvando_dados_no_mongo_db#depurando-aplicacoes-node) na parte 3. Você pode imprimir coisas no console com _console.log_ mesmo durante a execução de testes. É possível até mesmo utilizar o depurador (debugger) enquanto estiver rodando os testes, veja como fazer isso [aqui](https://jestjs.io/docs/en/troubleshooting). + +**Obs.::** se algum teste estiver falhando, então é recomendado executar somente este teste enquanto você estiver resolvendo o problema. Você pode executar um único teste com o método [only](https://jestjs.io/docs/api#testonlyname-fn-timeout). + +Uma outra forma de executar um único teste (ou um bloco describe) é especificar o nome do teste que será executado com a flag [-t](https://jestjs.io/docs/en/cli.html): + +```js +npm test -- -t 'when list has only one blog, equals the likes of that' +``` + +#### 4.5*: funções auxiliares e testes unitários, passo 3 + +Crie uma nova função _favoriteBlog_ que recebe uma lista de blogs como parâmetro. A função descobre o blog com mais likes. Se acaso houver empate, é suficiente retornar apenas um deles. + +O retorno da função pode estar no seguinte formato: + +```js +{ + title: "Canonical string reduction", + author: "Edsger W. Dijkstra", + likes: 12 +} +``` + +**Obs.:** quando estiver comparando objetos, o método [toEqual](https://jestjs.io/docs/en/expect#toequalvalue) provavelmente será o que você deverá usar, já que o método [toBe](https://jestjs.io/docs/en/expect#tobevalue) tenta verificar se os dois valores são o mesmo valor, e não apenas se eles possuem as mesmas propriedades. + +Escreva os testes para este exercício dentro de um novo bloco describe. Faça o mesmo para os demais exercícios. + +#### 4.6*: funções auxiliares e testes unitários, passo 4 + +Esse e o próximo exercício são um pouco mais desafiadores. Terminar esses dois exercício não é um requisito para avançar pelo material do curso, então pode ser uma boa ideia retornar a estes exercícios quando você passar pelo material desta parte completamente. + +A conclusão deste exercício pode se dá sem o uso de bibliotecas adicionais. No entanto, esse exercício é uma grande oportunidade para aprender a utilizar a biblioteca [Lodash](https://lodash.com/). + +Crie uma função chamada _mostBlogs_ que recebe um array de blogs como parâmetro. A função retorna o author (autor) com o maior número de blogs. O retorno também deverá conter a quantidade de blogs que este autor possui: + +```js +{ + author: "Robert C. Martin", + blogs: 3 +} +``` + +Se houver empate, é suficiente retornar apenas um dos autores. + +#### 4.7*: funções auxiliares e testes unitários, passo 5 + +Crie uma função _mostLikes_ que recebe um array de blogs como parâmetro. A função retorna o autor cujos posts têm a maior quantidade de likes. O valor retornado também deve conter o número total de likes que o autor recebeu: + +```js +{ + author: "Edsger W. Dijkstra", + likes: 17 +} +``` + +Se houver empate, é suficiente mostrar qualquer um deles. + +
    diff --git a/src/content/4/ptbr/part4b.md b/src/content/4/ptbr/part4b.md new file mode 100644 index 00000000000..2bbce7bb494 --- /dev/null +++ b/src/content/4/ptbr/part4b.md @@ -0,0 +1,1255 @@ +--- +mainImage: ../../../images/part-4.svg +part: 4 +letter: b +lang: ptbr +--- + +
    + +Começaremos agora a escrever testes para o backend. Já que o backend não contém nenhuma lógica complicada, não faz sentido escrever [testes de unidade (unit tests)](https://pt.wikipedia.org/wiki/Teste_de_unidade) para ele. A única coisa que provavelmente poderíamos testar com unit test seria o método _toJSON_ que é utilizado para formatar as notas. + +Em algumas situações, pode ser vantajoso implementar alguns dos testes de backend fazendo um mock do banco de dados ao invés de utilizar um banco de dados real. Uma biblioteca que poderia ser utilizada para isso é a [mongodb-memory-server](https://github.com/nodkz/mongodb-memory-server). + +Já que o backend de nossa aplicação ainda é relativamente simples, nós iremos testar a aplicação inteira por meio de sua API REST, o que inclui o banco de dados. Esse tipo de teste no qual múltiplos componentes do sistema estão sendo testados em grupo é chamado de [teste de integração](https://pt.wikipedia.org/wiki/Teste_de_integra%C3%A7%C3%A3o). + +### Ambiente de teste + +Em um dos capítulos anteriores do curso, nós mencionamos que quando o seu backend está rodando no Fly.io ou no Render, ele está em modo de produção. + +A convenção no Node é definir o modo de execução da aplicação com a variável de ambiente NODE\_ENV. Em nossa aplicação atual, nós apenas carregamos as variáveis de ambiente que estão definidas no arquivo .env se a aplicação não está no modo de produção. + +Uma prática comum é definir modos separados para desenvolvimento e testes. + +A seguir, vamos alterar os scripts no nosso package.json de forma que ao executar os testes, a variável de ambiente NODE\_ENV receberá o valor test: + +```json +{ + // ... + "scripts": { + "start": "NODE_ENV=production node index.js",// highlight-line + "dev": "NODE_ENV=development nodemon index.js",// highlight-line + "build:ui": "rm -rf build && cd ../frontend/ && npm run build && cp -r build ../backend", + "deploy": "fly deploy", + "deploy:full": "npm run build:ui && npm run deploy", + "logs:prod": "fly logs", + "lint": "eslint .", + "test": "NODE_ENV=test jest --verbose --runInBand"// highlight-line + }, + // ... +} +``` + +Nós também acrescentamos a opção [runInBand](https://jestjs.io/docs/cli#--runinband) no script npm que executa os testes. Essa opção previne que o Jest execute testes em paralelo; nós iremos discutir a importância disso quando nossos testes começarem a utilizar o banco de dados. + +Especificamos que o modo da aplicação será desenvolvimento (development) no script _npm run dev_ que utiliza o nodemon. Nós também especificamos que o comando padrão _npm start_ irá definir o modo como sendo produção (production). + +Ainda há um pequeno problema na forma como especificamos o modo da aplicação em nossos scripts: não funcionará no Windows. Podemos corrigir isso instalando o pacote [cross-env](https://www.npmjs.com/package/cross-env) como uma dependência de desenvolvimento, por meio do comando: + +```bash +npm install --save-dev cross-env +``` + +Feito, conseguiremos obter compatibilidade entre plataformas (cross-platform), utilizando a biblioteca cross-env em nossos scripts npm definidos no package.json: + +```json +{ + // ... + "scripts": { + "start": "cross-env NODE_ENV=production node index.js", + "dev": "cross-env NODE_ENV=development nodemon index.js", + // ... + "test": "cross-env NODE_ENV=test jest --verbose --runInBand", + }, + // ... +} +``` + +**Obs.:**: Se você fez o deploy de sua aplicação no Fly.io/Render, tenha em mente que se cross-env estiver salvo como dependência de desenvolvimento, isso poderá causar um erro de aplicação no seu servidor web. Para corrigir isso, altere cross-env para dependência de produção, executando o seguinte comando: + +```bash +npm install cross-env +``` + +Agora nós podemos modificar a forma como nossa aplicação roda em diferentes modos. Como um exemplo, nós configuraremos a aplicação para usar um banco de dados separado quando estiver executando testes. + +Podemos criar um banco de dados separado para testes no MongoDB Atlas. Essa não é a melhor solução quando muitas pessoas estão desenvolvimento a mesma aplicação. Execução de testes normalmente requer uma única instância de banco de dados que não é usada por testes em execução simultânea. + +Seria melhor rodar nossos testes usando um banco de dados que estivesse instalado e sendo executado localmente na máquina do desenvolvedor. A solução ideal seria que cada execução de teste usasse um banco de dados separado. Isso é "relativamente simples" de alcançar [executando Mongo in-memory](https://docs.mongodb.com/manual/core/inmemory/) ou containers do [Docker](https://www.docker.com). Não iremos complicar as coisas, mas ao invés continuaremos a usar o banco de dados do MongoDB Atlas. + +Vamos fazer algumas mudanças no módulo que define a configuração da aplicação: + +```js +require('dotenv').config() + +const PORT = process.env.PORT + +// highlight-start +const MONGODB_URI = process.env.NODE_ENV === 'test' + ? process.env.TEST_MONGODB_URI + : process.env.MONGODB_URI +// highlight-end + +module.exports = { + MONGODB_URI, + PORT +} +``` + +O arquivo .env tem variáveis separadas para os endereços do banco de dados dos ambientes de desenvolvimento e testes: + +```bash +MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority +PORT=3001 + +// highlight-start +TEST_MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/testNoteApp?retryWrites=true&w=majority +// highlight-end +``` + +O módulo _config_ que implementamos se assemelha ligeiramente ao pacote [node-config](https://github.com/lorenwest/node-config). Escrever nossa própria implementação é justificável, uma vez que nossa aplicação é simples e também porque isso nos ensina lições valiosas. + +Essas são as únicas mudanças que precisamos fazer no código de nossa aplicação. + +Você pode encontrar o código para nossa aplicação atual na íntegra no branch part4-2 [deste repositório do GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-2). + +### supertest + +Vamos utilizar o pacote [supertest](https://github.com/visionmedia/supertest) para nos ajudar a escrever os testes para nossa API. + +Vamos instalar o pacote como uma dependência de desenvolvimento: + +```bash +npm install --save-dev supertest +``` + +Vamos escrever nosso primeiro teste no arquivo tests/note_api.test.js: + +```js +const mongoose = require('mongoose') +const supertest = require('supertest') +const app = require('../app') + +const api = supertest(app) + +test('notes are returned as json', async () => { + await api + .get('/api/notes') + .expect(200) + .expect('Content-Type', /application\/json/) +}) + +afterAll(async () => { + await mongoose.connection.close() +}) +``` + +O teste importa a aplicação Express do módulo app.js e o envolve com a função supertest em um objeto chamado [superagent](https://github.com/visionmedia/superagent). Esse objeto é atribuído à variável api, usada nos testes para fazer requisições HTTP para o backend. + +Nosso teste faz uma requisição HTTP GET na url api/notes e verifica se a requisição é respondida com o código de status 200. O teste também verifica se o cabeçalho Content-Type está configurado como application/json, o que indica que os dados estão no formato desejado. + +A checagem do valor do cabeçalho possui uma sintaxe estranha: + +```js +.expect('Content-Type', /application\/json/) +``` + +O valor desejado foi definido por meio de uma [expressão regular](https://developer.mozilla.org/pt-BR/docs/Web/JavaScript/Guide/Regular_Expressions), ou simplesmente regex. Uma regex inicia e termina com uma barra /; já que a string que desejamos também possui em seu conteúdo a mesma barra (application/json) precisamos inserir antes dela uma contra-barra \ para que ela não seja entendida como um caractere de encerramento de nossa regex. + +Em princípio, poderíamos definir o parâmetro para o teste como uma string: + +```js +.expect('Content-Type', 'application/json') +``` + +O problema aqui, no entanto, é que ao utilizar uma string, o valor do cabeçalho deve ser exatamente o mesmo. Para a regex que criamos, é aceitável que o cabeçalho contenha a string em questão. O valor atual do cabeçalho é application/json; charset=utf-8, pois ele também contém informação sobre a codificação dos caracteres. No entanto, nosso teste não está interessado nisso, motivo pelo qual é melhor usar neste caso uma regex ao invés de uma string. + +O teste contém alguns detalhes que vamos explorar [um pouco mais tarde](/ptbr/part4/testando_o_backend#async-await). A arrow function que define o teste é precedida pela palavra-chave async e a chamada dos métodos no objeto api está precedida da palavra-chave await. Nós iremos escrever alguns testes e depois daremos uma olhada nessa mágica async/await. Não se preocupe com isso agora, apenas assegure que os testes do exemplo funcionem corretamente. A sintaxe async/await está relacionada ao fato de fazer requisições para a API em uma operação assíncrona. A [sintaxe async/await](https://jestjs.io/pt-BR/docs/asynchronous) pode ser utilizada para escrever código assíncrono com aparência de código síncrono. + +Assim que todos os testes (atualmente existe apenas um) terminarem a execução, temos que encerrar a conexão usada pelo Mongoose. Isso pode ser facilmente feito com o método [afterAll](https://jestjs.io/pt-BR/docs/api#afterallfn-timeout): + +```js +afterAll(async () => { + await mongoose.connection.close() +}) +``` + +Ao executar os testes você pode se deparar com o seguinte alerta no console: + +![jest console warning about not exiting](../../images/4/8.png) + +O problema é causado pela versão 6.x do Mongoose; esse problema não acontece quando a versão 5.x é utilizada [A documentação do Mongoose](https://mongoosejs.com/docs/jest.html) não recomenda testar aplicações Mongoose com Jest. + +[Uma forma](https://stackoverflow.com/questions/50687592/jest-and-mongoose-jest-has-detected-opened-handles) de contornar esse problema é adicionar ao diretório tests um arquivo teardown.js contendo o seguinte: + +```js +module.exports = () => { + process.exit(0) +} +``` + +e acrescentar nas definições do Jest no package.json o seguinte: + +```js +{ + //... + "jest": { + "testEnvironment": "node", + "globalTeardown": "./tests/teardown.js" // highlight-line + } +} +``` + +Outro erro que pode aparecer para você nos seus testes é se a execução deles demorar mais do que 5 segundos (5000ms), que é o tempo padrão do Jest para timeout. Isso pode ser resolvido adicionando um terceiro parâmetro na função test: + +```js +test('notes are returned as json', async () => { + await api + .get('/api/notes') + .expect(200) + .expect('Content-Type', /application\/json/) +}, 100000) +``` + +Esse terceiro parâmetro configura o timeout para 100 segundos (100000ms). Um tempo longo garantirá que seu teste não falhe em razão do tempo que ele leva para executar. (Um timeout muito longo talvez não seja o que você queira para testes baseados em performance ou velocidade, mas para este nosso exemplo é ok). + +Um detalhe importante é o seguinte: no [começo](/ptbr/part4/estrutura_de_uma_aplicacao_back_end_introducao_a_testes#estrutura-do-projeto) dessa parte, nós extraímos a aplicação Express no arquivo app.js, e o papel do arquivo index.js foi alterado para iniciar a aplicação na porta especificada com o objeto http integrado do Node: + +```js +const app = require('./app') // the actual Express app +const config = require('./utils/config') +const logger = require('./utils/logger') + +app.listen(config.PORT, () => { + logger.info(`Server running on port ${config.PORT}`) +}) +``` + +Os testes somente utilizam a aplicação express definida no arquivo app.js: + +```js +const mongoose = require('mongoose') +const supertest = require('supertest') +const app = require('../app') // highlight-line + +const api = supertest(app) // highlight-line + +// ... +``` + +A documentação do supertest informa o seguinte: + +> se o servidor ainda não estiver ouvindo as conexões, então ele estará ligado em uma porta efêmera, logo não há necessidade de acompanhar as portas. + +Em outras palavras, o supertest se responsabiliza de iniciar a aplicação que está sendo testada na porta que está em uso internamente. + +Vamos adicionar duas notas no banco de dados de teste, usando o _mongo.js_ (lembre-se de mudar para a url correta do banco de dados). + +Vamos escrever alguns testes a mais: + +```js +test('there are two notes', async () => { + const response = await api.get('/api/notes') + + expect(response.body).toHaveLength(2) +}) + +test('the first note is about HTTP methods', async () => { + const response = await api.get('/api/notes') + + expect(response.body[0].content).toBe('HTML is easy') +}) +``` + +Ambos os testes armazenam a resposta da requisição na variáveis _response_, e diferente da versão anterior do teste que utilizava o método provido pelo _supertest_ para verificar o código de status e o cabeçalho, desta vez nós estamos inspecionando os dados de resposta armazenados na propriedade response.body. Nossos testes verificam o formato e o conteúdo dos dados da resposta com o método do Jest [expect](https://jestjs.io/pt-BR/docs/expect). + +O benefício de usar a sintaxe async/await começa a ficar evidente. Normalmente, teríamos que usar funções de retorno de chamada para acessar os dados retornados pelas promessas, mas, com a nova sintaxe, as coisas estão muito mais simples: + +```js +const response = await api.get('/api/notes') + +// a execução chega aqui somente após a requisição HTTP estar completa +// o resultado da requisição HTTP é salva na variável response +expect(response.body).toHaveLength(2) +``` + +O middleware que exibe informações sobre as solicitações HTTP está obstruindo a saída da execução do teste. Vamos modificar o logger para que ele não imprima no console no modo de teste: + +```js +const info = (...params) => { + // highlight-start + if (process.env.NODE_ENV !== 'test') { + console.log(...params) + } + // highlight-end +} + +const error = (...params) => { + // highlight-start + if (process.env.NODE_ENV !== 'test') { + console.error(...params) + } + // highlight-end +} + +module.exports = { + info, error +} +``` + +### Inicializando o banco de dados antes dos testes + +Testar parece ser fácil e atualmente nossos testes estão passando. No entanto, nossos testes são ruins, uma vez que dependem do estado do banco de dados, que agora tem duas notas. Para tornar nossos testes mais robustos, devemos redefinir o banco de dados e gerar os dados de teste necessários de maneira controlada antes de executarmos os testes. + +Nosso código já está utilizando a função do Jest [afterAll](https://jestjs.io/pt-BR/docs/api#afterallfn-timeout) para encerrar a conexão com o banco de dados após a conclusão da execução dos testes. O Jest oferece muitas outras [funções](https://jestjs.io/pt-BR/docs/setup-teardown) que podem ser utilizadas para executar operações uma vez antes de executar qualquer teste ou antes da execução de cada teste. + +Vamos inicializar o banco de dados antes de cada teste com a função [beforeEach](https://jestjs.io/docs/en/api.html#beforeeachfn-timeout): + +```js +const mongoose = require('mongoose') +const supertest = require('supertest') +const app = require('../app') +const api = supertest(app) +// highlight-start +const Note = require('../models/note') +// highlight-end + +// highlight-start +const initialNotes = [ + { + content: 'HTML is easy', + important: false, + }, + { + content: 'Browser can execute only JavaScript', + important: true, + }, +] +// highlight-end + +// highlight-start +beforeEach(async () => { + await Note.deleteMany({}) + + let noteObject = new Note(initialNotes[0]) + await noteObject.save() + + noteObject = new Note(initialNotes[1]) + await noteObject.save() +}) +// highlight-end +// ... +``` + +O banco de dados é apagado logo no início, após isso salvamos no banco as duas notas armazenadas no array initialNotes. Fazendo isso, garantimos que o banco de dados esteja no mesmo estado antes da execução de cada teste. + +Vamos fazer também algumas alterações nos dois últimos testes: + +```js +test('all notes are returned', async () => { + const response = await api.get('/api/notes') + + expect(response.body).toHaveLength(initialNotes.length) // highlight-line +}) + +test('a specific note is within the returned notes', async () => { + const response = await api.get('/api/notes') + + // highlight-start + const contents = response.body.map(r => r.content) + + expect(contents).toContain( + 'Browser can execute only JavaScript' + ) + // highlight-end +}) +``` + +Dê uma atenção especial no expect do último teste. O comando response.body.map(r => r.content) é utilizado para criar um array contendo o que está no content de cada nota retornada pela API. O método [toContain](https://jestjs.io/docs/expect#tocontainitem) é utilizado para checar se a nota passada como parâmetro está na lista de notas retornada pela API. + +### Executando os testes um por um + +O comando _npm test_ executa todos os testes da aplicação. Quando estamos escrevendo testes, normalmente é sábio executar um ou dois testes. O Jest oferece algumas formas diferentes de fazer isso, uma delas é o método [only](https://jestjs.io/docs/en/api#testonlyname-fn-timeout). Se os testes estiverem escritos em vários arquivos, esse método não é muito bom. + +Uma opção melhor é especificar os testes que precisam ser executados como parâmetros do comando npm test. + +O comando a seguir somente executa os testes encontrados no arquivo tests/note_api.test.js: + +```js +npm test -- tests/note_api.test.js +``` + +A opção -t pode ser utilizada para executar testes com um nome específico: + + +```js +npm test -- -t "a specific note is within the returned notes" +``` + +O parâmetros informado pode referenciar o nome do teste ou o bloco describe. O parâmetro também pode conter somente parte do nome. O comando a seguir vai executar todos os testes que contenha notes em seu nome + +```js +npm test -- -t 'notes' +``` + +**Obs.:**: Ao executar um único teste, a conexão mongoose pode permanecer aberta se nenhum teste usando a conexão for executado. O problema pode ser porque o supertest prepara a conexão, mas o Jest não executa a parte afterAll do código. + +### async/await + +Antes de escrevermos mais testes, vamos dar uma olhada nas palavras-chave _async_ e _await_. + +A sintaxe async/await que foi introduzida no ES7, torna possível o uso de funções assíncronas que retornam uma promessa de um jeito que parece com código síncrono. + +Como um exemplo, a busca das notas do banco de dados utilizando promessas é assim: + +```js +Note.find({}).then(notes => { + console.log('operation returned the following notes', notes) +}) +``` + +O método _Note.find()_ retorna uma promessa e podemos acessar o resultado da operação registrando a função callback com o método _then()_. + +Todo o código que queremos executar quando a operação termina está escrito na função callback. Se quisermos fazer diversas chamadas de funções assíncronas em sequência, a situação logo ficará complicada. As chamadas assíncronas seriam feitas na função callback. Isso resultaria em um código complicado e provavelmente surgiria o chamado [callback hell](http://callbackhell.com/). + +[Encadeando promessas](https://javascript.info/promise-chaining), nós conseguiríamos manter a situação sob controle e evitar o callback hell ao criar uma cadeia bem limpa de chamadas de métodos _then()_. Vimos alguns desses durante o curso. Para ilustrar, você pode ver um exemplo de uma função que busca todas as notas e, em seguida, exclui a primeira delas: + +```js +Note.find({}) + .then(notes => { + return notes[0].remove() + }) + .then(response => { + console.log('the first note is removed') + // mais código aqui + }) +``` + +A cadeia de métodos _then()_ é ok, mas podemos fazer melhor do que isso. As [generator functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator) introduzidas no ES6 trazem um [jeito inteligente](https://github.com/getify/You-Dont-Know-JS/blob/1st-ed/async%20%26%20performance/ch4.md#iterating-generators-asynchronously) de escrever código assíncrono de uma forma que "parece síncrono". A sintaxe é um pouco estranha e não é muito utilizada. + +As palavras-chave _async_ e _await_ introduzidas no ES7 trazem a mesma funcionalidade dos generators, mas de uma maneira compreensível e sintaticamente mais limpa para todos os cidadãos do mundo JavaScript. + +Poderíamos buscar todas as notas no banco de dados utilizando o operador [await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await) dessa forma: + +```js +const notes = await Note.find({}) + +console.log('operation returned the following notes', notes) +``` + +O código parece idêntico a um código síncrono. A execução do código pausa em const notes = await Note.find({}) e aguarda até que a promessa seja completada (fulfilled), então continua a sua execução na próxima linha. Quando a execução continua, o resultado da operação que retornou a promessa é atribuído à variável _notes_. + +O exemplo um pouco complicado apresentado acima poderia ser implementado usando await assim: + +```js +const notes = await Note.find({}) +const response = await notes[0].remove() + +console.log('the first note is removed') +``` + +Graças à nova sintaxe, o código é muito mais simples do que a cadeia de _then()_ anterior. + +Existem alguns detalhes importantes para prestar atenção ao usar a sintaxe async/await. Para usar o operador await com operações assíncronas, elas precisam retornar uma promessa. Isso não é um problema em si, já que as funções assíncronas regulares que usam callbacks são fáceis de envolver em promessas. + +A palavra-chave await não pode ser utilizada em qualquer lugar do código JavaScript. Somente é possível usar o await dentro de uma função [assíncrona (async)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function). + +Isso significa que para os exemplos anteriores funcionarem, é preciso que estejam em funções assíncronas. Note a primeira linha da definição da arrow function: + +```js +const main = async () => { // highlight-line + const notes = await Note.find({}) + console.log('operation returned the following notes', notes) + + const response = await notes[0].remove() + console.log('the first note is removed') +} + +main() // highlight-line +``` + +O código declara que a função atribuída a _main_ é assíncrona. Após isso, o código chama a função com main(). + +### async/await in the backend + +Vamos começar a alterar o backend para usar async/await. Como todas as operações assíncronas atualmente são feitas dentro de uma função, basta mudar as funções que gerenciam a rota. + +O código da rota para buscar todas as notas deve se alterado como segue: + +```js +notesRouter.get('/', async (request, response) => { + const notes = await Note.find({}) + response.json(notes) +}) +``` + +Podemos verificar que nosso refatoramento foi bem sucedido testando o endpoint pelo navegador e executando os testes que escrevemos mais cedo. + +Você pode encontrar o código para da aplicação atual na branch da part4-3 [nesse repositório GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-3). + +### Mais testes e refatoração do backend + +Quando o código é refatorado, sempre existe o risco de[regressão](https://pt.wikipedia.org/wiki/Teste_de_regress%C3%A3o), o que significa que funcionalidades existentes podem quebrar. Vamos refatorar as operações primeiramente restantes escrevendo um teste para cada rota da API. + +Vamos começar com a operação que adiciona uma nova nota. Vamos escrever um teste que adiciona uma nova nota e verifica que se o número de notas retornadas pela API aumenta e se a nova nota adicionada está na lista. + +```js +test('a valid note can be added', async () => { + const newNote = { + content: 'async/await simplifies making async calls', + important: true, + } + + await api + .post('/api/notes') + .send(newNote) + .expect(201) + .expect('Content-Type', /application\/json/) + + const response = await api.get('/api/notes') + + const contents = response.body.map(r => r.content) + + expect(response.body).toHaveLength(initialNotes.length + 1) + expect(contents).toContain( + 'async/await simplifies making async calls' + ) +}) +``` + +O teste falha já que nós estamos acidentalmente retornando o códio de status 200 OK quando uma nova nota é criada. Vamos mudar para 201 CRIADO(CREATED): + +```js +notesRouter.post('/', (request, response, next) => { + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + + note.save() + .then(savedNote => { + response.status(201).json(savedNote) // highlight-line + }) + .catch(error => next(error)) +}) +``` + +Vamos também escrever um teste que verifica que uma nota sem conteúdo não será salva no banco de dados. + +```js +test('note without content is not added', async () => { + const newNote = { + important: true + } + + await api + .post('/api/notes') + .send(newNote) + .expect(400) + + const response = await api.get('/api/notes') + + expect(response.body).toHaveLength(initialNotes.length) +}) +``` + +Ambos os testes checam o estado armazenado no banco de dados após a operação salvar, buscando todas as notas da aplicação. + +```js +const response = await api.get('/api/notes') +``` + +Os mesmos passos de verificação serão repetidos posteriormente em outros testes, então é uma boa ideia extrair esses passos em uma função auxiliadora. Vamos adicionar a função em um novo arquivo chamado tests/test_helper.js que está no mesmo diretório do arquivo de teste. + +```js +const Note = require('../models/note') + +const initialNotes = [ + { + content: 'HTML is easy', + important: false + }, + { + content: 'Browser can execute only JavaScript', + important: true + } +] + +const nonExistingId = async () => { + const note = new Note({ content: 'willremovethissoon' }) + await note.save() + await note.deleteOne() + + return note._id.toString() +} + +const notesInDb = async () => { + const notes = await Note.find({}) + return notes.map(note => note.toJSON()) +} + +module.exports = { + initialNotes, nonExistingId, notesInDb +} +``` + +O módulo define a função _notesInDb_ que pode ser utilizada para checar as notas armazenadas no banco de dados. O array _inicialNotes_ contendo o estado inicial do banco de dados também está no módulo. Além disso, definimos a futura função _noExistingId_. que pode ser utilizada para criar um objeto ID de banco de dados que não pertence a nenhum objeto nota no banco de dados. + +Nossos testes agora podem usar o módulo auxiliador (helper): + +```js +const supertest = require('supertest') +const mongoose = require('mongoose') +const helper = require('./test_helper') // highlight-line +const app = require('../app') +const api = supertest(app) + +const Note = require('../models/note') + +beforeEach(async () => { + await Note.deleteMany({}) + + let noteObject = new Note(helper.initialNotes[0]) // highlight-line + await noteObject.save() + + noteObject = new Note(helper.initialNotes[1]) // highlight-line + await noteObject.save() +}) + +test('notes are returned as json', async () => { + await api + .get('/api/notes') + .expect(200) + .expect('Content-Type', /application\/json/) +}) + +test('all notes are returned', async () => { + const response = await api.get('/api/notes') + + expect(response.body).toHaveLength(helper.initialNotes.length) // highlight-line +}) + +test('a specific note is within the returned notes', async () => { + const response = await api.get('/api/notes') + + const contents = response.body.map(r => r.content) + + expect(contents).toContain( + 'Browser can execute only JavaScript' + ) +}) + +test('a valid note can be added ', async () => { + const newNote = { + content: 'async/await simplifies making async calls', + important: true, + } + + await api + .post('/api/notes') + .send(newNote) + .expect(201) + .expect('Content-Type', /application\/json/) + + const notesAtEnd = await helper.notesInDb() // highlight-line + expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1) // highlight-line + + const contents = notesAtEnd.map(n => n.content) // highlight-line + expect(contents).toContain( + 'async/await simplifies making async calls' + ) +}) + +test('note without content is not added', async () => { + const newNote = { + important: true + } + + await api + .post('/api/notes') + .send(newNote) + .expect(400) + + const notesAtEnd = await helper.notesInDb() // highlight-line + + expect(notesAtEnd).toHaveLength(helper.initialNotes.length) // highlight-line +}) + +afterAll(async () => { + await mongoose.connection.close() +}) +``` + +O código usando promessas funciona e os testes passam. Estamos prontos para refatorar nosso código para usar a sintaxe async/await + +Nós fizemos as seguintes alterações no código que cuida de adicionar uma nova nota (note que a definição do gerenciador de rota está precedida pela palavra-chave _async_): + +```js +notesRouter.post('/', async (request, response, next) => { + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + + const savedNote = await note.save() + response.status(201).json(savedNote) +}) +``` + +Existe um pequeno problema no código: nós não estamos tratando erros. Como deveríamos lidar com eles? + +### Tratamento de erro e async/await + +Se ocorrer uma exceção durante a requisição POST, enfrentaremos uma situação familiar: + +![terminal mostrando alertas de rejeições não tratadas de promessas](../../images/4/6.png) + +Em outras palavras, acabamos com uma rejeição de promessa que não foi tratada e a solicitação nunca recebe uma resposta. + +Com o async/await, o recomendado no tratamento de exceções é o mecanismo familiar _try/catch_: + +```js +notesRouter.post('/', async (request, response, next) => { + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + // highlight-start + try { + const savedNote = await note.save() + response.status(201).json(savedNote) + } catch(exception) { + next(exception) + } + // highlight-end +}) +``` + +O bloco catch simplesmente chama a função next, que passa o tratamento da requisição para o middleware de tratamento de erro. + +Depois de fazer essa alteração, todos os nossos testes passarão novamente. + +Em seguida, vamos escrever testes para buscar e apagar uma nota individual: + +```js +test('a specific note can be viewed', async () => { + const notesAtStart = await helper.notesInDb() + + const noteToView = notesAtStart[0] + +// highlight-start + const resultNote = await api + .get(`/api/notes/${noteToView.id}`) + .expect(200) + .expect('Content-Type', /application\/json/) +// highlight-end + + expect(resultNote.body).toEqual(noteToView) +}) + +test('a note can be deleted', async () => { + const notesAtStart = await helper.notesInDb() + const noteToDelete = notesAtStart[0] + +// highlight-start + await api + .delete(`/api/notes/${noteToDelete.id}`) + .expect(204) +// highlight-end + + const notesAtEnd = await helper.notesInDb() + + expect(notesAtEnd).toHaveLength( + helper.initialNotes.length - 1 + ) + + const contents = notesAtEnd.map(r => r.content) + + expect(contents).not.toContain(noteToDelete.content) +}) +``` + +Ambos os testes compartilham uma estrutura semelhante. Na fase de inicialização, eles buscam uma nota no banco de dados. Depois disso, os testes chamam a operação que está sendo testada, que é destacada no bloco de código. Por último, os testes verificam se o resultado da operação está de acordo com o esperado. + +Os testes passam e podemos seguramente refatorar as rotas testadas para usar async/await: + +```js +notesRouter.get('/:id', async (request, response, next) => { + try { + const note = await Note.findById(request.params.id) + if (note) { + response.json(note) + } else { + response.status(404).end() + } + } catch(exception) { + next(exception) + } +}) + +notesRouter.delete('/:id', async (request, response, next) => { + try { + await Note.findByIdAndDelete(request.params.id) + response.status(204).end() + } catch(exception) { + next(exception) + } +}) +``` + +Você encontrará o código de nossa aplicação atual na branch part4-4[nesse repositório GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-4). + +### Eliminando o try-catch + +Async/await simplifica um pouco o código, mas o 'preço' a se pagar é a estrutura try/catch necessária ao tratamento de exceções. +Todos os gerenciadores de rotas seguem a mesma estrutura: + +```js +try { + // faça as operações assíncronas aqui +} catch(exception) { + next(exception) +} +``` + +Talvez você esteja se perguntando se é possível refatorar o código para eliminar o try/catch dos métodos. + +A biblioteca [express-async-errors](https://github.com/davidbanham/express-async-errors) traz uma solução para isso. + +Vamos instalar a biblioteca: + +```bash +npm install express-async-errors +``` + +Utilizar essa biblioteca é muito fácil. +Você deve usar a biblioteca no arquivo app.js: + +```js +const config = require('./utils/config') +const express = require('express') +require('express-async-errors') // highlight-line +const app = express() +const cors = require('cors') +const notesRouter = require('./controllers/notes') +const middleware = require('./utils/middleware') +const logger = require('./utils/logger') +const mongoose = require('mongoose') + +// ... + +module.exports = app +``` + +A 'mágica' aqui é eliminar completamente os blocos try-catch. Por exemplo, a rota para deletar notas: + +```js +notesRouter.delete('/:id', async (request, response, next) => { + try { + await Note.findByIdAndDelete(request.params.id) + response.status(204).end() + } catch (exception) { + next(exception) + } +}) +``` + +fica assim: + +```js +notesRouter.delete('/:id', async (request, response) => { + await Note.findByIdAndDelete(request.params.id) + response.status(204).end() +}) +``` + +Por causa da biblioteca, não precisamos mais chamar _next(exception)_. +A biblioteca cuida de gerenciar tudo por baixo dos panos. Se uma exceção ocorre em uma rota async, ela é automaticamente passada para o middleware de tratamento de exceção. + +As outras rotas ficam assim: + +```js +notesRouter.post('/', async (request, response) => { + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + + const savedNote = await note.save() + response.status(201).json(savedNote) +}) + +notesRouter.get('/:id', async (request, response) => { + const note = await Note.findById(request.params.id) + if (note) { + response.json(note) + } else { + response.status(404).end() + } +}) +``` + +### Otimizando a função beforeEach + +Vamos retornar a escrita de nossos testes a dar uma olhada mais de perto na função _beforeEach_ que configura os testes: + +```js +beforeEach(async () => { + await Note.deleteMany({}) + + let noteObject = new Note(helper.initialNotes[0]) + await noteObject.save() + + noteObject = new Note(helper.initialNotes[1]) + await noteObject.save() +}) +``` + +A função armazena no banco de dados as primeiras duas notas do array _helper.initialNotes_ em duas operações separadas. A solução está correta, mas há uma forma melhor de salvar múltiplos objetos no banco de dados: + +```js +beforeEach(async () => { + await Note.deleteMany({}) + console.log('cleared') + + helper.initialNotes.forEach(async (note) => { + let noteObject = new Note(note) + await noteObject.save() + console.log('saved') + }) + console.log('done') +}) + +test('notes are returned as json', async () => { + console.log('entered test') + // ... +} +``` + +Nós salvamos no banco dedados as notas armazenadas no array por meio de um loop _forEach_. No entanto, os testes não parecem funcionar, então precisamos adicionar alguns logs no console para nos ajudar a encontrar o problema. + +O console mostra a seguinte saída: + +``` +cleared +done +entered test +saved +saved +``` + +Apesar de usarmos a sintaxe async/await, nossa solução não funciona como esperávamos. A execução do teste começa antes que o banco de dados seja inicializado! + +O problema é que cada iteração do loop forEach gera uma operação assíncrona e o _beforeEach_ não aguardará até que elas terminem de ser executadas. Em outras palavras, os comandos _await_ definidos dentro do loop _forEach_ não estão na função _beforeEach_, mas em funções separadas cujas execuções _beforeEach_ não aguardará chegar ao fim. + +Como a execução dos testes começa imediatamente após o _beforeEach_ ter sido concluído, a execução dos testes começa antes que o estado do banco de dados seja inicializado. + +Uma maneira de corrigir isso é aguardar que todas as operações assíncronas terminem de ser executadas com o método [Promise.all](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all): + +```js +beforeEach(async () => { + await Note.deleteMany({}) + + const noteObjects = helper.initialNotes + .map(note => new Note(note)) + const promiseArray = noteObjects.map(note => note.save()) + await Promise.all(promiseArray) +}) +``` + +A solução é bastante avançada, apesar de sua aparência compacta. A variável _noteObjects_ é atribuída a um array de objetos Mongoose que são criados com o construtor _Note_ para cada uma das notas no array _helper.initialNotes_. A próxima linha de código cria um novo array de promessas, que são criadas chamando o método _save_ de cada item no array _noteObjects_. Em outras palavras, é um array de promessas para salvar cada um dos itens no banco de dados. + +O método [Promise.all](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) pode ser usado para transformar um array de promessas em uma única promessa, que será cumprida (fulfilled) assim que todas as promessas no array passado como parâmetro forem resolvidas. A última linha de código await Promise.all(promiseArray) espera até que todas as promessas para salvar uma nota sejam concluídas, o que significa que o banco de dados foi inicializado. + +> Os valores retornados por cada promessa do array ainda podem ser acessados ao usar o método Promise.all. Se esperarmos pelas promessas serem resolvidas com a sintaxe _await_ const results = await Promise.all(promiseArray), a operação retornará um array que contém os valores resultantes de cada promessa em _promiseArray_, e elas aparecem na mesma ordem das promessas no array. + +O método Promise.all executa as promessas que recebe em paralelo. Se as promessas precisam ser executadas em uma ordem específica, isso será um problema. Nestas situações, as operações podem ser executadas dentro de um bloco [for...of](https://developer.mozilla.org/pt-BR/docs/Web/JavaScript/Reference/Statements/for...of) que garante uma ordem específica de execução: + +```js +beforeEach(async () => { + await Note.deleteMany({}) + + for (let note of helper.initialNotes) { + let noteObject = new Note(note) + await noteObject.save() + } +}) +``` + +A natureza assíncrona do JavaScript pode conduzir em comportamentos surpreendentes, por esta razão é importante prestar atenção na utilização da sintaxe async/await. Mesmo que a sintaxe facilite lidar com promessas, ainda é necessário entender como as promessas funcionam! + +O código de nossa aplicação pode ser encontrado no [GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-5), na branch part4-5. + +### O juramento de um verdadeiro desenvolvedor full stack + +Incluir testes traz mais um desafio à programação. Precisamos atualizar nosso juramento de desenvolvedor full stack para lembrar que a sistemática também é fundamental ao desenvolver testes. + +Então devemos estender nosso juramento novamente: + +O desenvolvimento full stack é extremamente difícil, por isso usarei todos os meios possíveis para torná-lo mais fácil. + +- Vou manter sempre aberto o console do desenvolvedor do meu navegador +- Usarei a guia de rede das ferramentas de desenvolvimento do navegador para garantir que o frontend e o backend estejam se comunicando como esperado +- Vou constantemente monitorar o estado do servidor para ter certeza que o dado enviado pelo frontend foi salvo conforme esperado +- Vou monitorar o banco de dados: o backend está salvando os dados no formato adequado? +- Eu progredirei em pequenos passos +- Vou escrever muitos comandos console.log para entender o comportamento do código e dos testes e auxiliar na identificação de problemas +- Se meu código não funcionar, não escreverei mais código. Em vez disso, voltarei a apagar o código até que funcione ou simplesmente retorne a um estado anterior em que tudo ainda estava funcionando. +- Se um teste falhar, eu vou me certificar de que a funcionalidade testada realmente funcione na aplicação +- Quando pedir ajuda no canal Discord do curso ou em outro lugar, formularei corretamente minhas perguntas. Veja [aqui](https://fullstackopen.com/ptbr/part0/informacoes_gerais#canal-do-curso-no-discord) como pedir ajuda. + +
    + +
    + +### Exercícios 4.8.-4.12. + +**Obs.:** o material utiliza o comparador [toContain](https://jestjs.io/pt-BR/docs/expect#tocontainitem) em vários lugares para verificar se um array contém um elemento específico. Vale ressaltar que o método utiliza o operador === para comparar a correspondência de elementos, o que significa que nem sempre é apropriado para comparar objetos. Em muitos casos, o método mais adequado para verificação de objetos em um array é o comparador [toContainEqual](https://jestjs.io/pt-BR/docs/expect#tocontainequalitem). No entanto, as soluções-modelo não checam os objetos do array com comparadores, logo utilizar o método não é necessário para resolver os exercícios. + +**Atenção:** Se você estiver utilizando async/await e métodos then no mesmo código, é quase certo de que você esteja fazendo algo errado. Use um ou outro, mas não misture ambos. + +#### 4.8: Testes para lista de Blog, passo1 + +Utilize o pacote supertest para escrever um teste que faça uma requisição HTTP GET para a URL /api/blogs. Verifica se a aplicação de lista de blog retorna a quantidade correta de posts de blog em formato JSON. + +Uma vez que os testes estejam concluídos, refatore o gerenciador de rotas para utilizar a sintaxe async/await ao invés de promises. + +Note que você terá que fazer mudanças no código parecidas com as que fizemos [no material](/ptbr/part4/testando_o_back_end#ambiente-de-teste), como definir o ambiente de teste de forma que você possa escrever os testes em banco de dados separados. + +**Obs.:** Quando estiver executando os testes, você pode se deparar com os seguintes alertas: + +![Alerta sobre a documentação enquanto conectando ao mongoose pelo jest](../../images/4/8a.png) + +[Uma forma](https://stackoverflow.com/questions/50687592/jest-and-mongoose-jest-has-detected-opened-handles) de se livrar disso é adicionar no diretório de tests um arquivo de desmontagem chamado teardown.js com o seguinte conteúdo: + +```js +module.exports = () => { + process.exit(0) +} +``` + +e incluir nas definições do Jest no package.json o seguinte: + +```js +{ + //... + "jest": { + "testEnvironment": "node" + "globalTeardown": ".test/teardown.js" // highlight-line + } +} +``` + +**Obs.:** quando estiver escrevendo seus testes **é melhor não executar todos os testes de uma vez**, somente execute aqueles em que estiver trabalhando no momento. Leia mais sobre isso [aqui](/ptbr/part4/testando_o_back_end#executando-os-testes-um-por-um). + +#### 4.9: Testes para lista de Blog, passo2 + +Escreva um teste que verifique se a propriedade para o identificador único dos posts de blog tem o nome de id, por padrão o banco de dados nomeia a propriedade como _id. Verificar a existência de uma propriedade é fácil com o comparador [toBeDefined](https://jestjs.io/pt-BR/docs/expect#tobedefined) do Jest. + +Faça as mudanças necessárias no código a fim de que os testes passem. O método [toJSON](/ptbr/part3/salvando_dados_no_mongo_db#conectando-o-backend-a-um-banco-de-dados) discutido na parte 3 é o mais apropriado para definir o parâmetro id. + +#### 4.10: Testes para lista de Blog, passo3 + +Escreva um teste que verifica se ao fazer uma requisição HTTP POST para a URL /api/blogs um novo post de blog é criado. Ao fim, verifica se o número total de blogs no sistema aumentou para mais um. Você também pode verificar se o conteúdo do post de blog está salvo no banco de dados. + +Uma vez que o teste estiver finalizado, refatore-o utilizando a sintaxe async/await ao invés de promises. + +#### 4.11*: Testes para lista de Blog, passo4 + +Escreva um teste que verifica se a propriedade likes está faltando na requisição e se estiver, defina como padrão o valor 0 para esta propriedade. + +Faça as mudanças necessárias no código para que o teste passe. + +#### 4.12*: Testes para lista de Blog, passo5 + +Escreva testes relacionados a criação de um novo blog pelo endpoint /api/blogs, que verifique se as propriedades title ou url estão faltando nos dados da requisição, o backend deve responder a requisição com código de status 400 Bad Request. + +Faça as mudanças necessárias no código para que o teste passe. + +
    + +
    + +### Refatorando os testes + +Atualmente, nossa cobertura de testes é insuficiente. Algumas requisições como GET /api/notes/:id e DELETE /api/notes/:id não foram testadas quando uma requisição é feita com um id inválido. O agrupamento e a organização dos testes também podem melhorar, já que todos os testes estão "no mesmo nível" no arquivo de testes. A legibilidade dos testes aumentaria se agrupássemos os testes relacionados com blocos describe. + +Abaixo está um exemplo do arquivo de teste após pequenas melhorias: + +```js +const supertest = require('supertest') +const mongoose = require('mongoose') +const helper = require('./test_helper') +const app = require('../app') +const api = supertest(app) + +const Note = require('../models/note') + +beforeEach(async () => { + await Note.deleteMany({}) + await Note.insertMany(helper.initialNotes) +}) + +describe('when there is initially some notes saved', () => { + test('notes are returned as json', async () => { + await api + .get('/api/notes') + .expect(200) + .expect('Content-Type', /application\/json/) + }) + + test('all notes are returned', async () => { + const response = await api.get('/api/notes') + + expect(response.body).toHaveLength(helper.initialNotes.length) + }) + + test('a specific note is within the returned notes', async () => { + const response = await api.get('/api/notes') + + const contents = response.body.map(r => r.content) + + expect(contents).toContain( + 'Browser can execute only JavaScript' + ) + }) +}) + +describe('viewing a specific note', () => { + test('succeeds with a valid id', async () => { + const notesAtStart = await helper.notesInDb() + + const noteToView = notesAtStart[0] + + const resultNote = await api + .get(`/api/notes/${noteToView.id}`) + .expect(200) + .expect('Content-Type', /application\/json/) + + expect(resultNote.body).toEqual(noteToView) + }) + + test('fails with statuscode 404 if note does not exist', async () => { + const validNonexistingId = await helper.nonExistingId() + + await api + .get(`/api/notes/${validNonexistingId}`) + .expect(404) + }) + + test('fails with statuscode 400 if id is invalid', async () => { + const invalidId = '5a3d5da59070081a82a3445' + + await api + .get(`/api/notes/${invalidId}`) + .expect(400) + }) +}) + +describe('addition of a new note', () => { + test('succeeds with valid data', async () => { + const newNote = { + content: 'async/await simplifies making async calls', + important: true, + } + + await api + .post('/api/notes') + .send(newNote) + .expect(201) + .expect('Content-Type', /application\/json/) + + const notesAtEnd = await helper.notesInDb() + expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1) + + const contents = notesAtEnd.map(n => n.content) + expect(contents).toContain( + 'async/await simplifies making async calls' + ) + }) + + test('fails with status code 400 if data invalid', async () => { + const newNote = { + important: true + } + + await api + .post('/api/notes') + .send(newNote) + .expect(400) + + const notesAtEnd = await helper.notesInDb() + + expect(notesAtEnd).toHaveLength(helper.initialNotes.length) + }) +}) + +describe('deletion of a note', () => { + test('succeeds with status code 204 if id is valid', async () => { + const notesAtStart = await helper.notesInDb() + const noteToDelete = notesAtStart[0] + + await api + .delete(`/api/notes/${noteToDelete.id}`) + .expect(204) + + const notesAtEnd = await helper.notesInDb() + + expect(notesAtEnd).toHaveLength( + helper.initialNotes.length - 1 + ) + + const contents = notesAtEnd.map(r => r.content) + + expect(contents).not.toContain(noteToDelete.content) + }) +}) + +afterAll(async () => { + await mongoose.connection.close() +}) +``` + +A saída é agrupada de acordo com os blocos describe: + +![saídas do jest mostrando blocos describe agrupados](../../images/4/7.png) + +Ainda há espaço para melhorias, mas precisamos seguir adiante. + +Essa forma de testar a API, fazendo solicitações HTTP e inspecionando o banco de dados com o Mongoose, não é de forma alguma a única nem a melhor maneira de conduzir testes de integração em nível de API para aplicações de servidor. Não há uma melhor maneira universal de escrever testes, pois tudo depende da aplicação sendo testada e dos recursos disponíveis. + +Você encontrará o código atual de nossa aplicação na branch part4-6 do [repositório GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-6). + +
    + +
    + +### Exercises 4.13.-4.14. + +#### 4.13 Expansões na lista de Blog, passo1 + +Implemente a funcionalidade de deletar um post de blog. + +Utilize a sintaxe async/await. Siga a convenção [RESTful](ptbr/part3/node_js_e_express#rest) quando estiver definindo a API HTTP. + +Implemente os testes para a funcionalidade. + +#### 4.14 Expansões na lista de Blog, passo2 + +Implemente a funcionalidade de atualizar informações individuais de um post de blog. + +Use async/await. + +A aplicação necessita atualizar o número de likes de um post de blog. Você pode implementar esta funcionalidade da mesma forma que fizemos para atualizar as notas na [parte 3](/ptbr/part3/salvando_dados_no_mongo_db#outras-operacoes). + +Implemente os testes para a funcionalidade. + +
    diff --git a/src/content/4/ptbr/part4c.md b/src/content/4/ptbr/part4c.md new file mode 100644 index 00000000000..75d45997650 --- /dev/null +++ b/src/content/4/ptbr/part4c.md @@ -0,0 +1,541 @@ +--- +mainImage: ../../../images/part-4.svg +part: 4 +letter: c +lang: ptbr +--- + +
    + +Queremos adicionar autenticação e autorização do usuário ao nosso aplicativo. Os usuários devem ser armazenados no banco de dados e cada nota deve ser vinculada ao usuário que a criou. Excluir e editar uma nota só deve ser permitido para o usuário que a criou. + +Vamos começar adicionando informações sobre usuários ao banco de dados. Existe uma relação um-para-muitos entre o usuário (Usuário) e as notas (Nota): + +![diagrama ligando usuário e notas](https://yuml.me/a187045b.png) + +Se estivéssemos trabalhando com um banco de dados relacional, a implementação seria direta. Ambos os recursos teriam suas tabelas de banco de dados separadas e o id do usuário que criou uma nota seria armazenado na tabela notas como uma chave estrangeira. + +Ao trabalhar com bancos de dados de documentos, a situação é um pouco diferente, pois há muitas maneiras diferentes de modelar a situação. + +A solução existente salva cada nota na coleção de notas no banco de dados. Se não quisermos alterar essa coleção existente, a escolha natural é salvar os usuários em sua própria coleção, usuários por exemplo. + +Como em todos os bancos de dados de documentos, podemos usar IDs de objeto no Mongo para fazer referência a documentos em outras coleções. Isso é semelhante ao uso de chaves estrangeiras em bancos de dados relacionais. + +Tradicionalmente, os bancos de dados de documentos, como o Mongo, não oferecem suporte a join queries que estão disponíveis em bancos de dados relacionais, usados ​​para agregar dados de várias tabelas. No entanto, a partir da versão 3.2, o Mongo oferece suporte a [consultas de agregação de pesquisa](https://docs.mongodb.com/manual/reference/operator/aggregation/lookup/). Não veremos essa funcionalidade neste curso. + +Se precisarmos de funcionalidade semelhante a consultas de junção, iremos implementá-la em nosso aplicativo fazendo várias consultas. Em certas situações, o Mongoose pode cuidar da junção e agregação de dados, o que dá a aparência de uma consulta de junção. No entanto, mesmo nessas situações, o Mongoose faz várias consultas ao banco de dados em segundo plano. + +### Referências entre coleções + +Se estivéssemos usando um banco de dados relacional, a nota conteria uma chave estrangeira para o usuário que a criou. Em bancos de dados de documentos, podemos fazer a mesma coisa. + +Vamos supor que a coleção users contém dois usuários: + +```js +[ + { + username: 'mluukkai', + _id: 123456, + }, + { + username: 'hellas', + _id: 141414, + }, +] +``` + +A coleção notes contém três notas, todas elas com um campo user que faz referência a um usuário na coleção users: + +```js +[ + { + content: 'HTML is easy', + important: false, + _id: 221212, + user: 123456, + }, + { + content: 'The most important operations of HTTP protocol are GET and POST', + important: true, + _id: 221255, + user: 123456, + }, + { + content: 'A proper dinosaur codes with Java', + important: false, + _id: 221244, + user: 141414, + }, +] +``` + +Os bancos de dados de documentos não exigem que a chave estrangeira seja armazenada nos recursos de nota, ela também pode ser armazenada na coleção users ou até mesmo em ambos: + +```js +[ + { + username: 'mluukkai', + _id: 123456, + notes: [221212, 221255], + }, + { + username: 'hellas', + _id: 141414, + notes: [221244], + }, +] +``` + +Como os usuários podem ter muitas notas, os IDs relacionados são armazenados em uma matriz no campo de notas. + +Os bancos de dados de documentos também oferecem uma maneira radicalmente diferente de organizar os dados: em algumas situações, pode ser benéfico aninhar todo o array de notas como parte dos documentos na coleção users: + +```js +[ + { + username: 'mluukkai', + _id: 123456, + notes: [ + { + content: 'HTML is easy', + important: false, + }, + { + content: 'The most important operations of HTTP protocol are GET and POST', + important: true, + }, + ], + }, + { + username: 'hellas', + _id: 141414, + notes: [ + { + content: + 'A proper dinosaur codes with Java', + important: false, + }, + ], + }, +] +``` + +Nesse esquema, as notas seriam fortemente aninhadas nos usuários e o banco de dados não geraria ids para eles. + +A estrutura e o esquema do banco de dados não são tão evidentes como eram nos bancos de dados relacionais. O esquema escolhido deve oferecer o melhor suporte aos casos de uso do aplicativo. Esta não é uma decisão de design simples de ser tomada, pois todos os casos de uso dos aplicativos não são conhecidos quando a decisão de design é tomada. + +Paradoxalmente, bancos de dados sem esquema como o Mongo exigem que os desenvolvedores tomem decisões de design muito mais radicais sobre a organização de dados no início do projeto do que bancos de dados relacionais com esquemas. Em média, os bancos de dados relacionais oferecem uma maneira mais ou menos adequada de organizar dados para muitos aplicativos. + +### Esquema Mongoose para usuários + +Neste caso, decidimos armazenar os ids das notas criadas pelo usuário no documento user. Vamos definir o modelo para representar um usuário no arquivo models/user.js: + +```js +const mongoose = require('mongoose') + +const userSchema = new mongoose.Schema({ + username: String, + name: String, + passwordHash: String, + notes: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: 'Note' + } + ], +}) + +userSchema.set('toJSON', { + transform: (document, returnedObject) => { + returnedObject.id = returnedObject._id.toString() + delete returnedObject._id + delete returnedObject.__v + // the passwordHash should not be revealed + delete returnedObject.passwordHash + } +}) + +const User = mongoose.model('User', userSchema) + +module.exports = User +``` + +Os ids das notas são armazenados no documento user como uma matriz de ids do Mongo. A definição é a seguinte: + +```js +{ + type: mongoose.Schema.Types.ObjectId, + ref: 'Note' +} +``` + +O tipo do campo é ObjectId que faz referência a documentos de estilo Note. O Mongo não sabe inerentemente que este é um campo que faz referência a notas, a sintaxe é puramente relacionada e definida pelo Mongoose. + +Vamos expandir o esquema da nota definida no arquivo models/note.js de modo que que a nota contenha informações sobre o usuário que a criou: + +```js +const noteSchema = new mongoose.Schema({ + content: { + type: String, + required: true, + minlength: 5 + }, + important: Boolean, + // highlight-start + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + } + // highlight-end +}) +``` + +Em total contraste com as convenções dos bancos de dados relacionais, as referências agora são armazenadas em ambos os documentos: a nota faz referência ao usuário que a criou e o usuário tem um array de referências a todas as notas criadas por ele. + +### Criando usuários + +Vamos implementar uma rota para criar novos usuários. Os usuários têm um username exclusivo, um name e algo chamado passwordHash. O hash da senha é a saída de uma [função hash unidirecional](https://en.wikipedia.org/wiki/Cryptographic_hash_function) aplicada à senha do usuário. Nunca é aconselhável armazenar senhas de texto simples não criptografadas no banco de dados! + +Vamos instalar o pacote [bcrypt](https://github.com/kelektiv/node.bcrypt.js) para gerar os hashes de senha: + +```bash +npm install bcrypt +``` + +A criação de novos usuários ocorre em conformidade com as convenções RESTful discutidas na [part 3](/ptbr/part3/node_js_and_express#rest), fazendo uma solicitação HTTP POST para o caminho users. + +Vamos definir um roteador separado para lidar com usuários em um novo arquivo controllers/users.js. Vamos colocar o roteador em uso em nossa aplicação no arquivo app.js, para que ele trate as solicitações feitas à url /api/users: + +```js +const usersRouter = require('./controllers/users') + +// ... + +app.use('/api/users', usersRouter) +``` + +O conteúdo do arquivo que define o roteador é o seguinte: + +```js +const bcrypt = require('bcrypt') +const usersRouter = require('express').Router() +const User = require('../models/user') + +usersRouter.post('/', async (request, response) => { + const { username, name, password } = request.body + + const saltRounds = 10 + const passwordHash = await bcrypt.hash(password, saltRounds) + + const user = new User({ + username, + name, + passwordHash, + }) + + const savedUser = await user.save() + + response.status(201).json(savedUser) +}) + +module.exports = usersRouter +``` + +A senha enviada na solicitação não é armazenada no banco de dados. Armazenamos o hash da senha que é gerada com a função _bcrypt.hash_. + +Os fundamentos do [armazenamento de senhas](https://codahale.com/how-to-safely-store-a-password/) estão fora do escopo deste material do curso. Não discutiremos o que significa o número mágico 10 atribuído à variável [saltRounds](https://github.com/kelektiv/node.bcrypt.js/#a-note-on-rounds), mas você pode ler mais sobre isso no material vinculado. + +Nosso código atual não contém nenhum tratamento de erro ou validação de entrada para verificar se o nome de usuário e a senha estão no formato desejado. + +O novo recurso pode e deve inicialmente ser testado manualmente com uma ferramenta como o Postman. No entanto, testar as coisas manualmente rapidamente se tornará muito complicado, especialmente depois que implementarmos a funcionalidade que impõe que os nomes de usuário sejam exclusivos. + +É preciso muito menos esforço para escrever testes automatizados e facilitará muito o desenvolvimento da nossa aplicação. + +Nossos testes iniciais podem ficar assim: + +```js +const bcrypt = require('bcrypt') +const User = require('../models/user') + +//... + +describe('when there is initially one user in db', () => { + beforeEach(async () => { + await User.deleteMany({}) + + const passwordHash = await bcrypt.hash('sekret', 10) + const user = new User({ username: 'root', passwordHash }) + + await user.save() + }) + + test('creation succeeds with a fresh username', async () => { + const usersAtStart = await helper.usersInDb() + + const newUser = { + username: 'mluukkai', + name: 'Matti Luukkainen', + password: 'salainen', + } + + await api + .post('/api/users') + .send(newUser) + .expect(201) + .expect('Content-Type', /application\/json/) + + const usersAtEnd = await helper.usersInDb() + expect(usersAtEnd).toHaveLength(usersAtStart.length + 1) + + const usernames = usersAtEnd.map(u => u.username) + expect(usernames).toContain(newUser.username) + }) +}) +``` + +Os testes usam a função auxiliar usersInDb() que implementamos no arquivo tests/test_helper.js. A função é usada para nos ajudar a verificar o estado do banco de dados após um usuário for criado: + +```js +const User = require('../models/user') + +// ... + +const usersInDb = async () => { + const users = await User.find({}) + return users.map(u => u.toJSON()) +} + +module.exports = { + initialNotes, + nonExistingId, + notesInDb, + usersInDb, +} +``` + +O bloco beforeEach adiciona um usuário com o nome de usuário root ao banco de dados. Podemos escrever um novo teste que verifique se um novo usuário com o mesmo nome de usuário não pode ser criado: + +```js +describe('when there is initially one user in db', () => { + // ... + + test('creation fails with proper statuscode and message if username already taken', async () => { + const usersAtStart = await helper.usersInDb() + + const newUser = { + username: 'root', + name: 'Superuser', + password: 'salainen', + } + + const result = await api + .post('/api/users') + .send(newUser) + .expect(400) + .expect('Content-Type', /application\/json/) + + expect(result.body.error).toContain('expected `username` to be unique') + + const usersAtEnd = await helper.usersInDb() + expect(usersAtEnd).toEqual(usersAtStart) + }) +}) +``` + +O caso de teste, obviamente, não passará neste ponto. Estamos essencialmente praticando o [desenvolvimento orientado a testes (TDD)](https://en.wikipedia.org/wiki/Test-driven_development), em que os testes para novas funcionalidades são escritos antes que a funcionalidade seja implementada. + +O Mongoose não possui um validador integrado para verificar a exclusividade de um campo. Felizmente existe uma solução pronta para isso, a biblioteca [mongoose-unique-validator](https://www.npmjs.com/package/mongoose-unique-validator). Vamos instalar a biblioteca: + +```bash +npm install mongoose-unique-validator +``` + +e amplie o código seguindo a documentação da biblioteca: + +```js +const mongoose = require('mongoose') +const uniqueValidator = require('mongoose-unique-validator') // highlight-line + +const userSchema = mongoose.Schema({ + // highlight-start + username: { + type: String, + required: true, + unique: true + }, + // highlight-end + name: String, + passwordHash: String, + notes: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: 'Note' + } + ], +}) + +userSchema.plugin(uniqueValidator) // highlight-line + +// ... +``` + +Também poderíamos implementar outras validações na criação do usuário. Poderíamos verificar se o nome de usuário é longo o suficiente, se o nome de usuário consiste apenas em caracteres permitidos ou se a senha é forte o suficiente. A implementação dessas funcionalidades é deixada como um exercício opcional. + +Antes de prosseguirmos, vamos adicionar uma implementação inicial de um manipulador de rota que retorna todos os usuários do banco de dados: + +```js +usersRouter.get('/', async (request, response) => { + const users = await User.find({}) + response.json(users) +}) +``` +Para criar novos usuários em um ambiente de produção ou desenvolvimento, você pode enviar uma solicitação POST para ```/api/users/``` via Postman ou REST Client no seguinte formato: + +```js +{ + "username": "root", + "name": "Superuser", + "password": "salainen" +} + +``` + +A lista fica assim: + +![api do navegador/usuários mostra dados JSON com array de notas](../../images/4/9.png) + +Você pode encontrar o código da nossa aplicação atual na íntegra na branch part4-7 [deste repositório GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-7). + +### Criando uma nova nota + +O código para criar uma nova nota deve ser atualizado para que a nota seja atribuída ao usuário que a criou. + +Vamos expandir nossa implementação atual para que as informações sobre o usuário que criou uma nota sejam enviadas no campo userId do corpo da solicitação: + +```js +const User = require('../models/user') //highlight-line + +//... + +notesRouter.post('/', async (request, response) => { + const body = request.body + + const user = await User.findById(body.userId) //highlight-line + + const note = new Note({ + content: body.content, + important: body.important === undefined ? false : body.important, + user: user.id //highlight-line + }) + + const savedNote = await note.save() + user.notes = user.notes.concat(savedNote._id) //highlight-line + await user.save() //highlight-line + + response.json(savedNote) +}) +``` + +Vale a pena notar que o objeto do usuário também muda. O id da nota é armazenado no campo notas: + +```js +const user = await User.findById(body.userId) + +// ... + +user.notes = user.notes.concat(savedNote._id) +await user.save() +``` + +Vamos tentar criar uma nova nota + +![Postman criando uma nova nota](../../images/4/10e.png) + +A operação parece funcionar. Vamos adicionar mais uma nota e depois visitar a rota para buscar todos os usuários: + +![api/users retorna JSON com usuários e sua matriz de notas](../../images/4/11e.png) + +Podemos ver que o usuário tem duas notas. + +Da mesma forma, os ids dos usuários que criaram as notas podem ser vistos quando visitamos a rota para buscar todas as notas: + +![api/notes mostra ids de números em JSON](../../images/4/12e.png) + +### Popular + +Gostaríamos que nossa API funcionasse de forma que, quando uma solicitação HTTP GET fosse feita para a rota /api/users, os objetos de usuário também contivessem o conteúdo das notas do usuário e não apenas seu id. Em um banco de dados relacional, essa funcionalidade seria implementada com uma consulta de junção. + +Conforme mencionado anteriormente, os bancos de dados de documentos não oferecem suporte adequado a consultas de junção entre coleções, mas a biblioteca Mongoose pode fazer algumas dessas junções para nós. O Mongoose realiza a junção fazendo várias consultas, o que é diferente das consultas de junção em bancos de dados relacionais que são transacionais, o que significa que o estado do banco de dados não muda durante o tempo em que a consulta é feita. Com as consultas de junção no Mongoose, nada pode garantir que o estado entre as coleções que estão sendo unidas seja consistente, o que significa que se fizermos uma consulta que junte as coleções do usuário e das notas, o estado das coleções pode mudar durante a consulta. + +A junção do Mongoose é feita com o método [populate](http://mongoosejs.com/docs/populate.html). Vamos atualizar a rota que retorna todos os usuários primeiro: + +```js +usersRouter.get('/', async (request, response) => { + const users = await User // highlight-line + .find({}).populate('notes') // highlight-line + + response.json(users) +}) +``` + +O método [populate](http://mongoosejs.com/docs/populate.html) é encadeado após o método find fazer a consulta inicial. O parâmetro fornecido ao método populate define que os ids que fazem referência aos objetos de nota no campo de notas do documento do usuário serão substituídos pelos documentos de nota referenciados. + +O resultado é quase exatamente o que queríamos: + +![Dados JSON mostrando notas preenchidas e dados de usuários com repetição](../../images/4/13new.png) + +Podemos usar o parâmetro populate para escolher os campos que queremos incluir nos documentos. Além do campo id:n, agora estamos interessados ​​apenas em conteúdo e importante. + +A seleção dos campos é feita com a [syntax](https://docs.mongodb.com/manual/tutorial/project-fields-from-query-results/#return-the-specified-fields-and-the-id-field-only) do Mongo: + + +```js +usersRouter.get('/', async (request, response) => { + const users = await User + .find({}).populate('notes', { content: 1, important: 1 }) + + response.json(users) +}) +``` + +O resultado agora é exatamente como queremos que seja: + +![dados combinados mostrando nenhuma repetição](../../images/4/14new.png) + +Vamos também adicionar uma população adequada de informações do usuário às notas: + +```js +notesRouter.get('/', async (request, response) => { + const notes = await Note + .find({}).populate('user', { username: 1, name: 1 }) + + response.json(notes) +}) +``` + +Agora as informações do usuário são adicionadas ao campo de usuário dos objetos de nota. + +![notes JSON agora tem informações do usuário incorporadas também](../../images/4/15new.png) + +É importante entender que o banco de dados não sabe que os ids armazenados no campo do usuário das notas fazem referência a documentos na coleção do usuário. + +A funcionalidade do método populate do Mongoose é baseada no fato de termos definido "tipos" para as referências no esquema do Mongoose com a opção ref: + +```js +const noteSchema = new mongoose.Schema({ + content: { + type: String, + required: true, + minlength: 5 + }, + important: Boolean, + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + } +}) +``` + +Você pode encontrar o código da nossa aplicação atual na íntegra na ramificação part4-8 [deste repositório GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-8). + +
    diff --git a/src/content/4/ptbr/part4d.md b/src/content/4/ptbr/part4d.md new file mode 100644 index 00000000000..0b6781e9147 --- /dev/null +++ b/src/content/4/ptbr/part4d.md @@ -0,0 +1,498 @@ +--- +mainImage: ../../../images/part-4.svg +part: 4 +letter: d +lang: ptbr +--- + +
    + +Os usuários devem estar aptos a logarem na aplicação, e quando um usuário é logado, suas informações devem ser adicionadas automaticamente a qualquer nova nota que você criar. + +Agora nós vamos implementar no backend uma [autenticação baseada em token](https://scotch.io/tutorials/the-ins-and-outs-of-token-based-authentication#toc-how-token-based-works). + +Os princípios da autenticação baseada em token são apresentados no diagrama abaixo: + +![diagrama de sequência da autenticação baseada em token](../../images/4/16new.png) + +- Usuário começa a logar usando uma formulário de login implementado com React + - Nós adicionamos o formulário no front-end ns [part 5](/ptbr/part5) +- O React code envia o nome de usuário e a senha para o endereço /api/login do servidor como uma requisição HTTP POST +- Se o usuário e a senha estiverem corretos, o servidor gera um token que de alguma forma identifica o usuário logado. + - O token é assinado digitalmente, tornando-o impossível de falsificar (no sentido criptográfico) +- O backend responde com um código de status de operação bem sucedida e retorna o token com a resposta. +- O browser salva o token, como por exemplo em um estado (state) de uma aplicação React. +- Quando o usuário cria uma nova nota (ou outra operação que requer identificação), o código React envia o token para o servidor com a requisição. +- O servidor usa o token para identificar o usuário. + +Vamos implementar a funcionalidade de login. Instale o [jsonwebtoken](https://github.com/auth0/node-jsonwebtoken) biblioteca, que permite gerar [JSON web tokens](https://jwt.io/). + +```bash +npm install jsonwebtoken +``` + +O código para a funcionalidade de login está no arquivo controllers/login.js. + +```js +const jwt = require('jsonwebtoken') +const bcrypt = require('bcrypt') +const loginRouter = require('express').Router() +const User = require('../models/user') + +loginRouter.post('/', async (request, response) => { + const { username, password } = request.body + + const user = await User.findOne({ username }) + const passwordCorrect = user === null + ? false + : await bcrypt.compare(password, user.passwordHash) + + if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) + } + + const userForToken = { + username: user.username, + id: user._id, + } + + const token = jwt.sign(userForToken, process.env.SECRET) + + response + .status(200) + .send({ token, username: user.username, name: user.name }) +}) + +module.exports = loginRouter +``` + +O código inicia buscando o usuário no banco de dados pelo username anexado à requisição. +Em seguida, verifica a senha, também anexada à requisição. +Como as senhas não são armazenadas no banco de dados, mas sim hashes calculados a partir das senhas, o método _bcrypt.compare_ é usado para comparar se a senha está correta: + +```js +await bcrypt.compare(body.password, user.passwordHash) +``` + +Se o usuário não for encontrado ou se a senha estiver incorreta, a requisição é respondida com o código de status [401 Unauthorized](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.2). O motivo para a falha é explicado no corpo da resposta. + +Se a senha estiver correta, o token é gerado com o método _jwt.sign_. O token contém o nome de usuário e o ID do usuário assinado digitalmente. + +```js +const userForToken = { + username: user.username, + id: user._id, +} + +const token = jwt.sign(userForToken, process.env.SECRET) +``` + +O token é assinado digitalmente utilizando-se uma string passada pela variável de ambiente SECRET como o segredo. A assinatura digital garante que somente as partes que conhecem o segredo podem gerar o token válido. +O valor da variável de ambiente deve ser definido no arquivo env. + +Uma requisição bem sucedida é respondida com o código de status 200 ok. O token e o username do usuários são enviados de volta no corpo da resposta. + +Agora basta adicionar o código do login na aplicação, por meio de uma nova rota no app.js + +```js +const loginRouter = require('./controllers/login') + +//... + +app.use('/api/login', loginRouter) +``` + +Vamos tentar logar usando o VS Code REST-client: + +![vscode rest post com username/password](../../images/4/17e.png) + +Se não funcionar, a seguinte mensagem vai aparecer no console: + +```bash +(node:32911) UnhandledPromiseRejectionWarning: Error: secretOrPrivateKey must have a value + at Object.module.exports [as sign] (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/sign.js:101:20) + at loginRouter.post (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/controllers/login.js:26:21) +(node:32911) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 2) +``` + +O comando _jwt.sign(userForToken, process.env.SECRET)_ falhou. Nós esquecemos de definir um valor para a variável de ambiente SECRET. Pode ser qualquer string. Quando definimos o valor no arquivo env, (e reiniciamos o servidor), o login funcionará. + +Um login bem-sucedido retorna os detalhes do usuário e o token: + +![resposta do vs code rest mostrando os detalhes e o token](../../images/4/18ea.png) + +Um nome de usuário ou senha incorretos retorna uma messagem de erro e o código de status apropriado + +![resposta do vs code rest com detalhes do login incorreto](../../images/4/19ea.png) + +### Limitando a criação de novas notas para somente usuários logados + +Vamos modificar a criação de novas notas de forma que somente seja possível se a requisição post contiver um token válido. A nota será salva na lista de notas do usuário identificado pelo token. + +Existem diversas formas de enviar o token do navegador para o servidor. Nós vamos utilizar o cabeçalho (header) [Authorization](https://developer.mozilla.org/pt-BR/docs/Web/HTTP/Headers/Authorization). O header também informa qual é o [esquema de autenticação](https://developer.mozilla.org/pt-BR/docs/Web/HTTP/Authentication#esquema_basic_de_autentica%C3%A7%C3%A3o) utilizado. Isso pode ser necessário se o servidor oferece múltiplas formas de autenticação. +Identificar o esquema revela ao servidor como as credenciais anexadas devem ser interpretadas. + +O esquema Bearer é adequado às nossas necessidades. + +Na prática, significa que se o token é a string eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW, por exemplo, o header de autorização (Authorization) terá o seguinte valor: + +``` +Bearer eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW +``` + +O código para criação de novas notas deve ser alterado assim: + +```js +const jwt = require('jsonwebtoken') //highlight-line + +// ... + //highlight-start +const getTokenFrom = request => { + const authorization = request.get('authorization') + if (authorization && authorization.startsWith('Bearer ')) { + return authorization.replace('Bearer ', '') + } + return null +} + //highlight-end + +notesRouter.post('/', async (request, response) => { + const body = request.body +//highlight-start + const decodedToken = jwt.verify(getTokenFrom(request), process.env.SECRET) + if (!decodedToken.id) { + return response.status(401).json({ error: 'token invalid' }) + } + + const user = await User.findById(decodedToken.id) +//highlight-end + + const note = new Note({ + content: body.content, + important: body.important === undefined ? false : body.important, + user: user._id + }) + + const savedNote = await note.save() + user.notes = user.notes.concat(savedNote._id) + await user.save() + + response.json(savedNote) +}) +``` + +A função auxiliar _getTokenFrom_ isola o token do header authorization. A validade do token é checada com _jwt.verify_. O método também decodifica o token, ou retorna o objeto no qual o token foi baseado. + +```js +const decodedToken = jwt.verify(token, process.env.SECRET) +``` + +Se o token estiver faltando ou for inválido, a exceção JsonWebTokenError será lançada. Precisamos expandir o tratamento de erros do middleware para que também cuide desse caso em particular: + +```js +const errorHandler = (error, request, response, next) => { + logger.error(error.message) + + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } else if (error.name === 'ValidationError') { + return response.status(400).json({ error: error.message }) + } else if (error.name === 'JsonWebTokenError') { // highlight-line + return response.status(400).json({ error: error.message }) // highlight-line + } + + next(error) +} +``` + +O objeto decodificado do token contém os campos username e id, os quais informar ao servidor quem fez a requisição. + +Se o objeto decodificado do token não contiver a identidade do usuário (decodedToken.id é undefined), o código de status [401 unauthorized](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.2) será retornado e o motivo da falha será explicado no corpo da resposta. + +```js +if (!decodedToken.id) { + return response.status(401).json({ + error: 'token invalid' + }) +} +``` + +Se a identidade de quem fez a requisição for resolvida, a execução continuará como antes. + +Uma nova nota pode agora ser criada utilizando o Postman se o header authorization fornecer o valor correto com a string Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ, onde o segundo valor é o token retornado pela operação login. + +Utilizando o Postman, seria assim: + +![postman adding bearer token](../../images/4/20e.png) + +já com o Visual Studio Code REST client: + +![vscode adding bearer token example](../../images/4/21e.png) + +A aplicação atual pode ser encontrada no [Github](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-9), branch part4-9. + +Se a aplicação possuir múltiplas interfaces requerendo identificação, a validação pelo JWT deve estar separada em seus próprio middleware. Uma biblioteca já existente também pode ser utilizada, como a [express-jwt](https://www.npmjs.com/package/express-jwt). + +### O problema da autenticação baseada em token + +Autenticação por token é muito fácil de implementar, mas possui um problema. Uma vez que o usuário da API, por exemplo um app React, obtém um token, a API confiará cegamente nele. E se o direito de acesso do token precisar ser revogado? + +Há duas soluções para o problema. A mais fácil delas é limitar o período de validade do token. + +```js +loginRouter.post('/', async (request, response) => { + const { username, password } = request.body + + const user = await User.findOne({ username }) + const passwordCorrect = user === null + ? false + : await bcrypt.compare(password, user.passwordHash) + + if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) + } + + const userForToken = { + username: user.username, + id: user._id, + } + + // o token expira em uma hora (60*60 segundos) + // highlight-start + const token = jwt.sign( + userForToken, + process.env.SECRET, + { expiresIn: 60*60 } + ) + // highlight-end + + response + .status(200) + .send({ token, username: user.username, name: user.name }) +}) +``` + +Uma vez expirado, o app cliente necessitará de um novo token. Normalmente, isso acontece forçando o usuário a logar novamente no app. + +O tratamento de erro do middleware deve ser expandido para fornecer o erro apropriado no caso de um token expirado: + +```js +const errorHandler = (error, request, response, next) => { + logger.error(error.message) + + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } else if (error.name === 'ValidationError') { + return response.status(400).json({ error: error.message }) + } else if (error.name === 'JsonWebTokenError') { + return response.status(401).json({ + error: 'invalid token' + }) + // highlight-start + } else if (error.name === 'TokenExpiredError') { + return response.status(401).json({ + error: 'token expired' + }) + } + // highlight-end + + next(error) +} +``` + +Quanto mais curto o tempo de expiração, mais segura é a solução. Portanto, se o token cair em mãos erradas ou se o acesso do usuário ao sistema precisar ser revogado, o token só poderá ser usado por um período limitado de tempo. Por outro lado, um tempo de expiração curto força o usuário a fazer login no sistema com mais frequência. + +A outra solução é salvar informações sobre cada token no banco de dados do backend e verificar se o direito de acesso correspondente ao token ainda é válido para cada solicitação da API. Com esse esquema, os direitos de acesso podem ser revogados a qualquer momento. Esse tipo de solução é frequentemente chamado de sessão do lado do servidor (server-side session). + +O aspecto negativo das sessões do lado do servidor é o aumento da complexidade no backend e também o efeito na performance, uma vez que a validade do token precisa ser verificada para cada solicitação da API ao banco de dados. O acesso ao banco de dados é consideravelmente mais lento em comparação com a verificação da validade do próprio token. É por isso que é bastante comum salvar a sessão correspondente a um token em um banco de dados chave-valor como [Redis] (https://redis.io/), que é limitado em funcionalidade em comparação, por exemplo, ao MongoDB ou um banco de dados relacional, mas extremamente rápido em alguns cenários de uso. + +Quando as sessões do lado do servidor são usadas, o token geralmente é apenas uma string aleatória que não inclui nenhuma informação sobre o usuário, como geralmente é o caso quando os tokens jwt são usados. Para cada solicitação da API, o servidor busca as informações relevantes sobre a identidade do usuário no banco de dados. Também é bastante comum que, em vez de usar o cabeçalho (header) Authorization, sejam usados cookies como mecanismo para transferir o token entre o cliente e o servidor. + +### Notas finais + +Foram realizadas muitas alterações no código que causaram um problema típico em projetos de software de ritmo acelerado: a maioria dos testes quebrou. Porque esta parte do curso já está cheia de informações novas, deixaremos a correção dos testes como um exercício não obrigatório. + +Nomes de usuário, senhas e aplicações que usam autenticação por token devem sempre ser usados ​​por meio de [HTTPS] (https://en.wikipedia.org/wiki/HTTPS). Poderíamos usar um servidor Node [HTTPS] (https://pt.wikipedia.org/wiki/Hyper_Text_Transfer_Protocol_Secure) em nossa aplicação em vez do servidor [HTTP] (https://nodejs.org/docs/latest-v8.x/api/http.html) (ele requer mais configuração). Por outro lado, a versão de produção de nossa aplicação está no Heroku, portanto, nossa aplicação permanece segura: o Heroku direciona todo o tráfego entre um navegador e o servidor Heroku através de HTTPS. + +Vamos implementar o login no frontend na próxima parte. + +
    + +
    + +### Exercício 4.15.-4.23. + +No próximo exercício, o básico do gerenciamento de usuário será implementado na aplicação Bloglist. O jeito mais seguro é seguir os ensinamentos do capítulo 4 [Administração de usuário](ptbr/part4/administracao_de_usuarios) ao capítulo [Autenticação por token](/ptbr/part4/autenticacao_por_token). Você também pode usar sua criatividade. + +**Mai um aviso:** Se você perceber que está misturando async/await com _then_, é 99% de certeza de que você está fazendo alguma coisa errada. Use um ou outro. + +#### 4.15: Expansão na lista de Blog, passo3 + +Implemente uma maneira de criar novos usuários por meio de requisições HTTP POST para o endereço /api/users. Usuários devem ter um username, password e name + +Não salva a senha no banco de dados como texto puro, mas sim utilize a biblioteca bcrypt como fizemos na parte 4, no capítulo [Criando usuários](ptbr/part4/administracao_de_usuarios#criando-usuarios). + +**Obs.:** Alguns usuários de Windows podem ter problemas com o bcrypt. Se for o seu caso, removo a biblioteca com o comando + +```bash +npm uninstall bcrypt +``` + +e instale o [bcryptjs](https://www.npmjs.com/package/bcryptjs). + +Implemente uma forma de ver os detalhes de todos os usuários por meio da requisição HTTP adequada. + +A lista de usuários pode, por exemplo, parecer com o seguinte: + +![rota api/users no navegador mostrando dados JSON de dois usuários](../../images/4/22.png) + +#### 4.16*: Expansão na lista de Blog, passo4 + +Implemente uma funcionalidade que adicione as seguintes restrições na criação de novos usuários: O nome de usuário e a senha devem ser fornecidos. O nome de usuário e a senha devem ter pelo menos 3 caracteres. O nome de usuário dever único. + +A operação deve retornar um código de status adequado e algum tipo de mensagem de erro caso um usuário inválido seja fornecido. + +**Obs.:** Não teste as restrições da senha com as validações do Mongoose. Não é uma boa ideia, pois a senha recebida pelo backend e o hash da senha salvo no banco de dados não são a mesma coisa. O cumprimento da senha deve ser validado pelo controller, como fizemos na [parte 3](/ptbr/part3/node_js_e_express) antes de utilizarmos a validação do Mongoose. + +Implemente também testes que assegurem que usuários inválidos não serão criados e que uma operação inválida para adicionar esse usuário retorne o código de status adequado e uma mensagem de erro. + +#### 4.17: Expansão da lista de Blog, passo5 + +Expanda a aplicação de forma que cada blog contenha informação sobre o usuário que criou o blog. + +Altere a adição de novos blogs de forma que quando um novo blog é criado, qualquer usuário do banco de dados é designado como o criador (por exemplo, o primeiro usuário que for encontrado). Implemente isso conforme parte 4, capítulo [popular](ptbr/part4/administracao_de_usuarios#popular). +Qual usuário é designado como o criador não importa muito agora. A funcionalidade será finalizada no exercício 4.19. + +Modifique a listagem de todos os blogs de forma que a informação sobre o usuário criador seja mostrada no blog: + +![api/blogs incorpora informação sobre o usuário criador em dados JSON](../../images/4/23e.png) + +e a listagem de todos os usuários também mostre os blogs criados por cada usuário: + +![api/users incorpora blogs em dados JSON](../../images/4/24e.png) + +#### 4.18: Expansão da lista de Blog, passo6 + +Implemente uma autenticação baseada em token conforme o capítulo [Autenticação por Token](/ptbr/part4/autenticacao_por_token). + +#### 4.19: Expansão da lista de Blog, passo7 + +Altere o código para adicionar novos blogs de forma que somente seja possível adicionar se um token válido por enviado por uma requisição HTTP POST. O usuário identificado no tolen deverá ser designado como o criador do blog. + +#### 4.20*: Expansão da lista de Blog, passo7 + +[Nesse exemplo](/ptbr/part4/autenticacao_por_token) da parte 4 é demonstrado como obter o token do cabeçalho (header) com a função auxiliar _getTokenFrom_. + +Se você utilizou a mesma solução, refatore o código para obter o token por meio de um [middleware](/ptbr/part3/node_js_e_express#middleware). O middleware deve obter o token do cabeçalho Autorization e atribuí-lo no campo token do objeto request. + +Em outras palavras, se você registrar este middleware no arquivo app.js antes de todas as rotas + +```js +app.use(middleware.tokenExtractor) +``` + +Routes can access the token with _request.token_: + +```js +blogsRouter.post('/', async (request, response) => { + // .. + const decodedToken = jwt.verify(request.token, process.env.SECRET) + // .. +}) +``` + +Lembre-se que uma [função middleware](/en/part3/node_js_and_express#middleware) normal é uma função com três parâmetros que ao final chamará o último parâmetro next para mover o controle para o próximo middleware: + +```js +const tokenExtractor = (request, response, next) => { + // código que extrai o token + + next() +} +``` + +#### 4.21*: Expansão da lista de Blog, passo9 + +Altere o código que deleta um blog de forma que possa ser apagado somente pelo usuário que o adicionou. Além disso, deletar somente será possível se o token enviado com a requisição for o mesmo do criador do blog. + +Se for feita uma tentativa de deletar um blog sem um token ou por um usuário inválido, a operação deve retornar um código de status adequado. + +Note que se você buscar um blog no banco de dados, + +```js +const blog = await Blog.findById(...) +``` + +o campo blog.user não conterá uma string, mas um objeto (Object). Logo, se você comparar o id do objeto buscado no banco de dados e a string id, uma comparação normal não funcionará. O id buscado no banco de dados deve ser convertido em string primeiro. + +```js +if ( blog.user.toString() === userid.toString() ) ... +``` + +#### 4.22*: Expansão da lista de Blog, passo10 + +Tanto a criação de um blog quanto a exclusão precisam descobrir a identidade do usuário que está realizando a operação. O middleware _tokenExtractor_ que fizemos no exercício 4.20 ajuda, mas ainda assim ambas operações post e delete precisam descobrir que usuário possui um determinado token. + +Crie um middleware _userExtractor_, que descobre quem é o usuário e o atribui ao objeto da requisição. Quando for registrar o middlware no app.js + +```js +app.use(middleware.userExtractor) +``` + +o usuário será atribuído ao campo _request.user_: + +```js +blogsRouter.post('/', async (request, response) => { + // get user do objeto requisição + const user = request.user + // .. +}) + +blogsRouter.delete('/:id', async (request, response) => { + // get user do objeto requisição + const user = request.user + // .. +}) +``` + +Observe que é possível registar o middlware somente para um rota determinada. Assim, ao invés de usar _userExtractor_ com todas as rotas + +```js +// utiliza o middleware em todas as rotas +app.use(userExtractor) // highlight-line + +app.use('/api/blogs', blogsRouter) +app.use('/api/users', usersRouter) +app.use('/api/login', loginRouter) +``` + +Poderíamos registrar para que seja usado somente na rota /api/blogs: + +```js +// utiliza o middleware somente na rota /api/blogs +app.use('/api/blogs', userExtractor, blogsRouter) // highlight-line +app.use('/api/users', usersRouter) +app.use('/api/login', loginRouter) +``` + +Como pode ser visto, fazemos isso encadeando múltiplos middleware como parâmetro da função use. Também seria possível registrar um middlware para apenas uma operação específica. + +```js +router.post('/', userExtractor, async (request, response) => { + // ... +} +``` + +#### 4.23*: Expansão da lista de Blog, passo11 + +Após adicionar a autenticação baseada em token, os testes para adicionar novo blog quebraram. Corrija os testes. Além disso, escreva um novo teste que certifique que a adição de um novo blog falhará com o código de status apropriado 401 Unauthorized se um token não for provido. + +[Isso](https://github.com/visionmedia/supertest/issues/398) será muito útil para fazer a correção. + +Esse é o último exercício dessa parte do curso e agora é hora de fazer o push do código para o GitHub e marcar todos os exercícios concluídos [no sistema de submissão de exercícios](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    diff --git a/src/content/4/zh/part4.md b/src/content/4/zh/part4.md index 7ca4d04a1b0..ad3ca549d19 100644 --- a/src/content/4/zh/part4.md +++ b/src/content/4/zh/part4.md @@ -6,9 +6,10 @@ lang: zh
    + + 在这一部分,我们将继续我们在后端的工作。我们的第一大主题将是为后端编写单元和集成测试。在我们完成测试后,我们将看看如何实现用户认证和授权。 - -在这一章节,我们将继续改造我们的后端程序。我们的第一个议题是为后端编写单元测试和集成测试。在我们完成了测试部分,我们会转向实现用户认证和用户鉴权的内容。 +该部分更新时间:2024年2月13日(翻译:3月6日)/i> +- Jest已替换为Node内置的测试模块
    - diff --git a/src/content/4/zh/part4a.md b/src/content/4/zh/part4a.md index 5d5dc50ddaf..fbc09a02134 100644 --- a/src/content/4/zh/part4a.md +++ b/src/content/4/zh/part4a.md @@ -8,23 +8,26 @@ lang: zh
    - -继续我们在[第3章](/zh/part3)中关于便笺应用的后端编码。 + + 让我们继续我们在[第三章节](/zh/part3)中开始的笔记应用的后端工作。 ### Project structure -【项目结构】 - -在我们进入“测试”这个议题之前,我们将修改我们项目的结构,来遵循 Node.js 的最佳实践。 - -在对我们项目的目录结构进行了优化之后,我们得到了如下结构: + +请注意,本课程材料是使用 Node.js v20.11.0 版本编写的。请确保您的 Node 版本至少与材料中使用的版本一样新(您可以通过在命令行中运行 node -v 来检查版本)。 + + + 在我们进入测试主题之前,我们将修改我们项目的结构以遵守Node.js的最佳实践。 + + + 在对我们项目的目录结构进行修改后,我们最终得到以下结构。 ```bash ├── index.js ├── app.js ├── build -│ ├── ... +│ └── ... ├── controllers │ └── notes.js ├── models @@ -34,17 +37,15 @@ lang: zh ├── utils │ ├── config.js │ ├── logger.js -│ └── middleware.js +│ └── middleware.js ``` - - - -到目前为止,我们一直在使用console.logconsole.error 来打印代码中的变化信息。 - -然而,这并不是一个很好的实践方式。 - -让我们将所有到控制台的打印分离到它自己的模块 utils/logger.js + + 到目前为止,我们一直使用console.logconsole.error来打印代码中的不同信息。 + + 然而,这并不是一个很好的方法。 + + 让我们把所有打印到控制台的工作分离到自己的模块utils/logger.js。 ```js const info = (...params) => { @@ -60,19 +61,17 @@ module.exports = { } ``` + + 记录器有两个函数,__info__用于打印正常的日志信息,__error__用于所有的错误信息。 + + 将日志提取到自己的模块中是一个好主意,而且不止一个方面。如果我们想开始将日志写入文件或将它们发送到外部日志服务,如 [graylog](https://www.graylog.org/) 或 [papertrail](https://papertrailapp.com) 我们只需要在一个地方进行修改。 - -日志记录器有两个功能,__info__ 用于打印正常的日志消息,__error__ 用于所有错误消息。 - - -将日志记录功能提取到一个单独的模块是个不错的实践。 如果我们后来想将日志写入一个文件,或者将它们发送到一个外部日志服务中,比如 [graylog](https://www.graylog.org/) 或者 [papertrail](https://papertrailapp.com) ,我们只需要在一个地方进行修改就可以了。 - - -这样用于启动应用的index.js 文件的内容简化如下: + + 用于启动应用的index.js文件的内容被简化如下。 ```js -const app = require('./app') // highlight-line +const app = require('./app') // the actual Express application const http = require('http') const config = require('./utils/config') const logger = require('./utils/logger') @@ -84,17 +83,17 @@ server.listen(config.PORT, () => { }) ``` - - index.js 文件只从 app.js 文件导入实际的应用,然后启动应用。 logger- 模块的功能用于控制台的打印输出,告诉应用的运行状态。 + + index.js文件只从app.js文件中导入实际应用,然后启动应用。logger-module的函数_info_用于控制台打印输出,告诉人们应用正在运行。 - -环境变量的处理被提取到一个单独的utils/config.js 文件中: + + 对环境变量的处理被提取到一个单独的utils/config.js文件中。 ```js require('dotenv').config() -let PORT = process.env.PORT -let MONGODB_URI = process.env.MONGODB_URI +const PORT = process.env.PORT +const MONGODB_URI = process.env.MONGODB_URI module.exports = { MONGODB_URI, @@ -102,20 +101,20 @@ module.exports = { } ``` - -应用的其他部分可以通过导入配置模块来访问环境变量: + + 应用的其他部分可以通过导入配置模块访问环境变量。 ```js const config = require('./utils/config') -console.log(`Server running on port ${config.PORT}`) +logger.info(`Server running on port ${config.PORT}`) ``` - -路由处理程序也被移动到一个专用的模块中。 路由的事件处理程序通常称为controllers,出于这个原因,我们创建了一个新的controllers 目录。 所有与便笺相关的路由现在都在controllers 目录下的notes.js 模块中定义。 + + 路由处理程序也被移到一个专门的模块中。路由的事件处理程序通常被称为controllers,为此我们创建了一个新的controllers目录。所有与notes相关的路由现在都在controllers目录下的notes.js模块中。 - -模块的内容如下: + + notes.js模块的内容如下。 ```js const notesRouter = require('express').Router() @@ -123,7 +122,7 @@ const Note = require('../models/note') notesRouter.get('/', (request, response) => { Note.find({}).then(notes => { - response.json(notes.map(note => note.toJSON())) + response.json(notes) }) }) @@ -131,7 +130,7 @@ notesRouter.get('/:id', (request, response, next) => { Note.findById(request.params.id) .then(note => { if (note) { - response.json(note.toJSON()) + response.json(note) } else { response.status(404).end() } @@ -145,18 +144,17 @@ notesRouter.post('/', (request, response, next) => { const note = new Note({ content: body.content, important: body.important || false, - date: new Date() }) note.save() .then(savedNote => { - response.json(savedNote.toJSON()) + response.json(savedNote) }) .catch(error => next(error)) }) notesRouter.delete('/:id', (request, response, next) => { - Note.findByIdAndRemove(request.params.id) + Note.findByIdAndDelete(request.params.id) .then(() => { response.status(204).end() }) @@ -173,7 +171,7 @@ notesRouter.put('/:id', (request, response, next) => { Note.findByIdAndUpdate(request.params.id, note, { new: true }) .then(updatedNote => { - response.json(updatedNote.toJSON()) + response.json(updatedNote) }) .catch(error => next(error)) }) @@ -181,11 +179,11 @@ notesRouter.put('/:id', (request, response, next) => { module.exports = notesRouter ``` - -这几乎是我们之前的index.js 文件的完整复制粘贴。 + +这几乎是我们之前的index.js文件的完全复制粘贴。 - -然而,有一些重要的变化,在文件的开始我们创建了一个新的[router](http://expressjs.com/en/api.html#router) 对象: + + 然而,有几个重要的变化。在文件的最开始,我们创建了一个新的[router](http://expressjs.com/en/api.html#router)对象。 ```js const notesRouter = require('express').Router() @@ -195,49 +193,51 @@ const notesRouter = require('express').Router() module.exports = notesRouter ``` - -该模块将路由导出,该模块的所有消费者可用。 + + 该模块导出了路由器,以便对该模块的所有消费者可用。 + - -现在已经为路由对象定义了所有路由,这与我们之前对展示整个应用的对象所做的工作类似。 + +现在所有的路由都是为路由器对象定义的,与我们之前对代表整个应用的对象所做的类似。 - -值得注意的是,路由处理程序中的路径已经缩短: + + + 值得注意的是,路由处理程序中的路径已经缩短。在以前的版本中,我们有。 ```js app.delete('/api/notes/:id', (request, response) => { ``` - -在目前的版本中,代码为: + + 而在当前版本中,我们有。 ```js notesRouter.delete('/:id', (request, response) => { ``` - -那么这些路由对象到底是什么呢? Express手册提供了如下解释: + + 那么这些路由器对象到底是什么?Express手册提供了以下解释。 -> A router object is an isolated instance of middleware and routes. You can think of it as a “mini-application,” capable only of performing middleware and routing functions. Every Express application has a built-in app router.
    -路由对象是中间件和路由的单例。 您可以把它看作是一个“迷你应用” ,只能执行中间件和路由功能。 每个 Express 应用都有一个内置的应用路由。 + + > 一个路由器对象是一个孤立的中间件和路由实例。你可以把它看作是一个 "小型应用",只能够执行中间件和路由功能。每个Express应用都有一个内置的应用路由器。 - -路由实际上是一个中间件,可用于在某个位置定义“相关路由” ,通常放置在单独的模块中。 + + 路由器实际上是一个中间件,它可以用来在一个地方定义 "相关的路由",它通常被放在自己的模块中。 - -下面的app.js 是一个创建实际应用的文件,对路由对象使用use方法,按如下方式使用: + + 创建实际应用的app.js文件使用了路由器,如下所示。 ```js const notesRouter = require('./controllers/notes') app.use('/api/notes', notesRouter) ``` - -如果请求的 URL 以 /api/notes开头,则会使用之前定义的路由。 由于这个原因,notesRouter 对象必须只定义路由的相对部分,即空路径/或仅仅定义参数/:id。【TODO】 + + 我们之前定义的路由器被使用,如果请求的URL以/api/notes开头。由于这个原因,notesRouter对象必须只定义路由的相对部分,即空的路径/或只定义参数/:id。 - -在进行了这些更改之后,我们的app.js 文件如下所示: + + 做了这些改动后,我们的app.js文件看起来是这样的。 ```js const config = require('./utils/config') @@ -249,18 +249,20 @@ const middleware = require('./utils/middleware') const logger = require('./utils/logger') const mongoose = require('mongoose') +mongoose.set('strictQuery', false) + logger.info('connecting to', config.MONGODB_URI) -mongoose.connect(config.MONGODB_URI, { useNewUrlParser: true, useUnifiedTopology: true }) +mongoose.connect(config.MONGODB_URI) .then(() => { logger.info('connected to MongoDB') }) .catch((error) => { - logger.error('error connection to MongoDB:', error.message) + logger.error('error connecting to MongoDB:', error.message) }) app.use(cors()) -app.use(express.static('build')) +app.use(express.static('dist')) app.use(express.json()) app.use(middleware.requestLogger) @@ -272,11 +274,11 @@ app.use(middleware.errorHandler) module.exports = app ``` - -该文件将不同的中间件放到use中,其中之一是附加到 /api/notes 路由的notesRouter。 + +该文件使用了不同的中间件,其中一个是连接到/api/notes路线的notesRouter。 - -我们的自定义中间件已经移动到一个新的 utils/middleware.js 模块: + +我们的自定义中间件已经被转移到一个新的utils/middleware.js模块。 ```js const logger = require('./logger') @@ -312,24 +314,18 @@ module.exports = { } ``` - -建立到数据库的连接的责任已经交给了app.js 模块。models 目录下的note.js 文件只为 notes 定义了 Mongoose schema。 + +与数据库建立连接的责任已经交给了app.js模块。在models目录下的note.js文件只定义了Mongoose模式的笔记。 ```js const mongoose = require('mongoose') -mongoose.set('useFindAndModify', false) - const noteSchema = new mongoose.Schema({ content: { type: String, required: true, minlength: 5 }, - date: { - type: Date, - required: true, - }, important: Boolean, }) @@ -344,14 +340,14 @@ noteSchema.set('toJSON', { module.exports = mongoose.model('Note', noteSchema) ``` - -总结一下,修改后的目录结构如下所示: + + 概括地说,目录结构在做了修改后看起来是这样的。 ```bash ├── index.js ├── app.js ├── build -│ ├── ... +│ └── ... ├── controllers │ └── notes.js ├── models @@ -361,34 +357,104 @@ module.exports = mongoose.model('Note', noteSchema) ├── utils │ ├── config.js │ ├── logger.js -│ └── middleware.js +│ └── middleware.js ``` - -对于较小的应用,结构并不重要。 一旦应用开始增大,您就必须建立某种结构,并将应用的不同职责分离到单独的模块中。 这将使开发应用更加容易。 + + 对于较小的应用,这个结构并不重要。一旦应用的规模开始扩大,你就必须建立某种结构,并将应用的不同职责分成独立的模块。这将使应用的开发更加容易。 - -对于 Express 应用,没有严格的目录结构或文件命名原则。 与此相对的,Ruby on Rails 就需要一个特定的结构。 我们目前的结构只是遵循一些你可以在互联网上遇到的最佳实践。 + + Express应用没有严格的目录结构或文件命名惯例要求。与此相反,Ruby on Rails确实需要一个特定的结构。我们目前的结构只是简单地遵循了一些你可以在网上看到的最佳实践。 - -您可以在 [this Github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-1)的part4-1 分支中找到我们当前应用的全部代码。 + + 你可以在[这个Github仓库](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-1)的part4-1分支中找到我们当前应用的全部代码。 - -如果您自己克隆项目,请在启动应用之前运行 npm install 命令。 + + 如果你为自己克隆了这个项目,在用_npm run dev_启动应用之前,先运行_npm install_命令。 -
    +### Note on exports + + 我们在这部分使用了两种不同的导出方式。首先,例如,文件utils/logger.js做了如下的导出。 -
    +```js +const info = (...params) => { + console.log(...params) +} + +const error = (...params) => { + console.error(...params) +} + +// highlight-start +module.exports = { + info, error +} +// highlight-end +``` + + +该文件导出了一个对象,该对象有两个字段,都是函数。这些函数可以用两种不同的方式来使用。第一个选项是要求整个对象,并通过对象使用点符号来引用函数。 + +```js +const logger = require('./utils/logger') + +logger.info('message') + +logger.error('error message') +``` + + 另一种方法是在require语句中把函数分解成它自己的变量。 + +```js +const { info, error } = require('./utils/logger') + +info('message') +error('error message') +``` + + + 如果一个文件中只使用了一小部分导出的函数,后者可能是更好的方式。 + + + 例如,在文件controller/notes.js中,导出的情况如下。 + +```js +const notesRouter = require('express').Router() +const Note = require('../models/note') + +// ... + +module.exports = notesRouter // highlight-line +``` + + + 在这种情况下,只有一个 "东西 "被导出,所以使用它的唯一方法是如下。 + +```js +const notesRouter = require('./controllers/notes') + +// ... + +app.use('/api/notes', notesRouter) +``` + + 现在,导出的 "东西"(在本例中是一个路由器对象)被分配到一个变量中,并按此使用。 + +
    + +
    ### Exercises 4.1.-4.2. - -在这一章节的练习中,我们将构建一个【博客列表的应用】blog list application,它允许用户保存他们在互联网上偶然发现的有趣博客的信息。 对于每个列表中的博客,我们将保存作者、标题、 url 和点赞数。 -#### 4.1 Blog list, 步骤1 - -假设您收到一封包含如下应用代码的电子邮件: + + 在这部分的练习中,我们将建立一个博客列表应用,它允许用户保存他们在互联网上偶然发现的有趣博客的信息。对于每一个列出的博客,我们将保存作者、标题、网址和应用的用户加注的数量。 + +#### 4.1 Blog list, step1 + + + 让我们想象一下,你收到一封电子邮件,其中包含以下应用的主体。 ```js const http = require('http') @@ -397,7 +463,7 @@ const app = express() const cors = require('cors') const mongoose = require('mongoose') -const blogSchema = mongoose.Schema({ +const blogSchema = new mongoose.Schema({ title: String, author: String, url: String, @@ -407,7 +473,7 @@ const blogSchema = mongoose.Schema({ const Blog = mongoose.model('Blog', blogSchema) const mongoUrl = 'mongodb://localhost/bloglist' -mongoose.connect(mongoUrl, { useNewUrlParser: true, useUnifiedTopology: true }) +mongoose.connect(mongoUrl) app.use(cors()) app.use(express.json()) @@ -436,39 +502,40 @@ app.listen(PORT, () => { }) ``` - -将应用转换为一个正常运行的npm 项目。 为了保持您的开发效率,将应用配置为使用nodemon 执行。 您可以使用 MongoDB Atlas 为您的应用创建一个新的数据库,或者和前面章节练习中的数据库共用。 + + 把应用变成一个有效的npm项目。为了保持你的开发成果,将应用配置为用nodemon执行。你可以用MongoDB Atlas为你的应用创建一个新的数据库,或者使用前一部分练习中的同一个数据库。 + + + 验证是否可以用Postman或VS Code REST客户端将博客添加到列表中,并且应用在正确的端点返回添加的博客。 - -验证是否可以将 blog 添加到list ,使用 Postman 或 VS Code REST 客户端进行验证,并验证应用是否在正确的端侧返回已添加的 blog。 +#### 4.2 Blog list, step2 -#### 4.2 Blog list, 步骤2 - -将应用重构为单独的模块,如本课程教材前面所示。 + + 如本章节教材前面所示,将应用重构为独立的模块。 - -**注意** 重构您的应用时,步子不要迈得太大,并在每次更改后验证该应用是否正常工作。 如果你试图同时重构许多东西来走“捷径” ,那么[墨菲定律](https://en.wikipedia.org/wiki/Murphy%27s_law)就会生效,而且几乎可以肯定,某些东西会使你的应用中中断 。**如果不是缓慢而系统地向前推进,“捷径”最终将花费更多的时间** + +**注意** 逐步重构您的应用程序,并在每次进行更改后验证它是否有效。如果您尝试通过一次重构许多内容来走“捷径”,那么 [墨菲定律](https://zh.wikipedia.org/wiki/%E5%A7%86%E5%B8%83%E5%AE%B6%E6%B3%95) 将发挥作用,并且几乎可以肯定您的应用程序中会发生一些故障。“捷径”最终将花费比缓慢而系统地前进更多的时间。 -一个最佳实践就是每次代码处于稳定状态时再提交它。 这样可以很容易地回滚到应用仍然可以工作的情况。 +最佳实践之一是在每次代码处于稳定状态时提交代码。这使得回滚到应用程序仍然可以工作的状态变得容易。 + +如果您无缘无故地遇到 content.bodyundefined 的问题,请确保您没有忘记在文件顶部附近添加 app.use(express.json())
    -
    +### Testing Node applications -### Testing Node applications -【测试 Node 应用】 - -我们一直忽略了软件开发的一个重要环节,那就是自动化测试。 + + 我们完全忽视了软件开发的一个重要领域,那就是自动化测试。 -让我们从单元测试开始我们的测试之旅。 我们应用的逻辑非常简单,使用单元测试来进行测试没有多大意义。 让我们创建一个新的文件utils/for_testing.js ,并编写几个简单的函数,可以用于实践测试: +让我们从单元测试开始我们的测试之旅。我们应用程序的逻辑非常简单,因此没有太多内容可以进行单元测试。让我们创建一个新文件 *utils/for_testing.js* 并编写一些简单的函数,以便我们可以在测试编写练习中使用: ```js -const palindrome = (string) => { +const reverse = (string) => { return string .split('') .reverse() @@ -484,33 +551,24 @@ const average = (array) => { } module.exports = { - palindrome, + reverse, average, } ``` - -> _average_ 函数使用 array的 [reduce](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/array/reduce)方法。 如果你对这个方法还不熟悉,是时候在 Youtube 上观看3个视频了,这3个视频来自[Functional Javascript](https://www.Youtube.com/watch?v=bmuifmzr7vk&list=pl0zvegevsaeed9hlmcxrk5yuyquag-n84)系列。 - - -有许多不同的测试库或者test runner 可用于 JavaScript。 在本课程中,我们将使用一个由 Facebook 内部开发和使用的测试库,这个测试库名为[jest](https://jestjs.io/) ,类似于之前 JavaScript 测试库之王[Mocha](https://mochajs.org/)。 其他替代品也确实存在,比如在某些圈子里受欢迎的[ava](https://github.com/avajs/ava)。 + +> _average_函数使用数组[reduce](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce)方法。如果你对这个方法还不熟悉,那么现在是观看Youtube上[Functional Javascript](https://www.youtube.com/watch?v=BMUiFMZr7vk&list=PL0zVEGEvSaeEd9hlmCXrk5yUyqUag-n84)系列的前三个视频的好时机 - -对于本课程来说,Jest 是一个自然的选择,因为它可以很好地测试后端,并且在测试 React 应用时表现出色。 + +JavaScript 有大量的测试库或“测试运行器”可用。 + +测试库的旧王者是 [Mocha](https://mochajs.org/),它在几年前被 [Jest](https://jestjs.io/) 取代。这些库的新人是 [Vitest](https://vitest.dev/),它自称为新一代测试库。 + +如今,Node 还有一个内置的测试库 [node:test](https://nodejs.org/docs/latest/api/test.html),它非常适合本课程的需求。 - -> **Windows 用户: 如果项目目录的路径所包含的目录名称含有空格,** Jest 可能无法工作。 - - -由于测试只在应用开发过程中执行,我们将使用下面的命令安装jest作为开发依赖项: - -```bash -npm install --save-dev jest -``` - - -让我们定义npm script _test_,用 Jest 执行测试,用verbose 样式报告测试执行情况: + +让我们为测试执行定义 npm script _test_: ```bash { @@ -518,160 +576,126 @@ npm install --save-dev jest "scripts": { "start": "node index.js", "dev": "nodemon index.js", - "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend", - "deploy": "git push heroku master", - "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy", - "logs:prod": "heroku logs --tail", + "build:ui": "rm -rf build && cd ../frontend/ && npm run build && cp -r build ../backend", + "deploy": "fly deploy", + "deploy:full": "npm run build:ui && npm run deploy", + "logs:prod": "fly logs", "lint": "eslint .", - "test": "jest --verbose" // highlight-line + "test": "node --test" // highlight-line }, //... } ``` - -Jest 需要指定执行环境为 Node。 可以通过在package.json 的末尾添加如下内容来实现: - -```js -{ - //... - "jest": { - "testEnvironment": "node" - } -} -``` - -或者,Jest 会查找默认名为 jest.config.js的配置文件,在这里我们可以这样定义执行环境: + +让我们为我们的测试创建一个名为 *tests* 的单独目录,并创建一个名为 *reverse.test.js* 的新文件,内容如下: ```js -module.exports = { - testEnvironment: 'node', -}; -``` - - -让我们为我们的测试创建一个名为tests 的单独目录,并创建一个名为palindrome.test.js 的新文件,其内容如下: +const { test } = require('node:test') +const assert = require('node:assert') -```js -const palindrome = require('../utils/for_testing').palindrome +const reverse = require('../utils/for_testing').reverse -test('palindrome of a', () => { - const result = palindrome('a') +test('reverse of a', () => { + const result = reverse('a') - expect(result).toBe('a') + assert.strictEqual(result, 'a') }) -test('palindrome of react', () => { - const result = palindrome('react') +test('reverse of react', () => { + const result = reverse('react') - expect(result).toBe('tcaer') + assert.strictEqual(result, 'tcaer') }) -test('palindrome of releveler', () => { - const result = palindrome('releveler') +test('reverse of saippuakauppias', () => { + const result = reverse('saippuakauppias') - expect(result).toBe('releveler') + assert.strictEqual(result, 'saippuakauppias') }) ``` - -我们在上一章节中添加到项目中的 ESLint 配置会在我们的测试文件中提示 _test_ 和 _expect_ 命令,因为配置不允许globals。 让我们通过在.eslintrc.js 文件的env 属性中添加"jest": true 来消除这些提示。 + +该测试定义了关键字 _test_ 和库 [assert](https://nodejs.org/docs/latest/api/assert.html),该库由测试用于检查被测函数的结果。 -```js -module.exports = { - "env": { - "commonjs": true - "es6": true, - "node": true, - "jest": true, // highlight-line - }, - "extends": "eslint:recommended", - "rules": { - // ... - }, -}; -``` - - -在第一行,测试文件导入要测试的函数,并将其赋值给一个名为_palindrome_的变量: + +在下一行中,测试文件导入要测试的函数,并将其分配给名为 _reverse_ 的变量: ```js -const palindrome = require('../utils/for_testing').palindrome +const reverse = require('../utils/for_testing').reverse ``` -单个测试用例是用测试函数定义的。 该函数的第一个参数是作为字符串的测试描述。 第二个参数是function,它定义了测试用例的功能。 第二个测试用例的功能如下: +使用 _test_ 函数定义各个测试用例。该函数的第一个参数是作为字符串的测试描述。第二个参数是 _function_,用于定义测试用例的功能。第二个测试用例的功能如下所示: ```js () => { - const result = palindrome('react') + const result = reverse('react') - expect(result).toBe('tcaer') + assert.strictEqual(result, 'tcaer') } ``` - -首先执行要测试的代码,这意味着为字符串react 生成一个回文。 接下来,我们用[expect](https://facebook.github.io/jest/docs/en/expect.html#content)函数验证结果。 Expect 将结果值封装到一个对象中,该对象提供一组matcher 函数,可用于验证结果的正确性。 因为在这个测试用例中,我们要比较两个字符串,所以我们可以使用[toBe](https://facebook.github.io/jest/docs/en/expect.html#tobevalue)匹配器。 + +首先,我们执行要测试的代码,这意味着我们为字符串 *react* 生成一个反转。接下来,我们使用 [assert](https://nodejs.org/docs/latest/api/assert.html) 库的 [strictEqual](https://nodejs.org/docs/latest/api/assert.html#assertstrictequalactual-expected-message) 方法验证结果。 -正如所料,所有的测试都通过了: +不出所料,所有测试都通过: -![](../../images/4/1.png) +![npm test 的终端输出,所有测试都通过](../../images/4/1new.png) + +库 node:test 默认情况下期望测试文件的文件名包含 *test*。在本课程中,我们将遵循使用扩展名 *test.js* 命名测试文件约定的约定。 - - -Jest 默认情况下希望测试文件的名称包含 .test. 在本课程中,我们将遵循将测试文件命名为扩展名 .test.js的约定。 - - -Jest有很友好的错误消息,让我们破坏这个测试来演示一下: + +让我们破坏测试: ```js -test('palindrom of react', () => { - const result = palindrome('react') +test('reverse of react', () => { + const result = reverse('react') - expect(result).toBe('tkaer') + assert.strictEqual(result, 'tkaer') }) ``` - -运行上面的测试会产生如下错误消息: + +运行此测试会导致以下错误消息: -![](../../images/4/2e.png) +![npm test 的终端输出显示失败](../../images/4/2new.png) + +让我们将 npm test 的输出与 _average_ 函数放入一个新文件 *tests/average.test.js* 中。 +```js +const { test, describe } = require('node:test') - -让我们在一个新文件 tests/average.test.js.中添加一些对 average 函数的测试。 +// ... -```js const average = require('../utils/for_testing').average describe('average', () => { test('of one value is the value itself', () => { - expect(average([1])).toBe(1) + assert.strictEqual(average([1]), 1) }) test('of many is calculated right', () => { - expect(average([1, 2, 3, 4, 5, 6])).toBe(3.5) + assert.strictEqual(average([1, 2, 3, 4, 5, 6]), 3.5) }) test('of empty array is zero', () => { - expect(average([])).toBe(0) + assert.strictEqual(average([]), 0) }) }) ``` -测试显示,该函数在空数组中不能正常工作(这是因为在 JavaScript 中, 除以零的结果为NaN ) : - -![](../../images/4/3.png) - +测试显示该函数不能正确处理空数组(这是因为在 JavaScript 中除以零会导致 *NaN*): +![终端输出显示空数组失败](../../images/4/3new.png) -修复这个函数非常简单: +修复该函数非常容易: ```js const average = array => { @@ -685,11 +709,11 @@ const average = array => { } ``` - -如果数组的长度是0,那么我们返回0,在所有其他情况下,我们使用 _reduce_ 方法来计算平均值。 + +如果数组的长度为 0,我们返回 0,在所有其他情况下,我们使用 _reduce_ 方法计算平均值。 - -关于我们刚才写的测试,有一些事情需要注意。 我们在测试周围定义了一个describe 块,它的名字是 average: + +关于我们刚刚编写的测试需要注意几件事。我们在给定名称为 _average_ 的测试周围定义了一个 *describe* 块: ```js describe('average', () => { @@ -697,41 +721,36 @@ describe('average', () => { }) ``` - -描述块可用于将测试分组为逻辑集合。 测试输出也使用了描述块的名称: - -![](../../images/4/4.png) - + +描述块可用于将测试分组到逻辑集合中。测试输出也使用描述块的名称: +![npm test 的屏幕截图,显示 describe 块](../../images/4/4new.png) -正如我们将在后面看到的,当我们要为一组测试运行一些共享的安装或拆卸操作时,describe块是必要的。 +正如我们稍后将看到的,当我们想要为一组测试运行一些共享的设置或拆卸操作时,*describe* 块是必需的。 -另一件需要注意的事情是,我们用非常简洁的方式编写了测试,而没有将被测试函数的输出分配给一个变量: +需要注意的另一件事是,我们以非常简洁的方式编写了测试,没有将被测函数的输出分配给变量: ```js test('of empty array is zero', () => { - expect(average([])).toBe(0) + assert.strictEqual(average([]), 0) }) ```
    -
    - - ### Exercises 4.3.-4.7. - -让我们创建一个辅助函数的集合,这些函数用于帮助处理博客列表。 将函数创建到一个名为utils/list_helper.js 的文件中。 将您的测试写入tests 目录下,并给一个合适的测试文件名。 + + 让我们创建一个辅助函数的集合,旨在帮助处理博客列表。将这些函数创建为一个名为utils/list_helper.js的文件。把你的测试写进tests目录下一个适当命名的测试文件。 +#### 4.3: helper functions and unit tests, step1 -#### 4.3: helper functions and unit tests, 步骤1 - -首先定义一个虚拟函数,它接收一个 blog posts 数组作为参数,并总是返回值1。 list_helper.js 文件应该是这样的: + +首先,定义一个 _dummy_ 函数,它接收一个博客文章数组作为参数,并始终返回 1。此时 *list_helper.js* 文件的内容应如下: ```js const dummy = (blogs) => { @@ -744,33 +763,33 @@ module.exports = { ``` -验证您的测试配置是否能用如下测试工作良好: +使用以下测试验证你的测试配置是否有效: ```js +const { test, describe } = require('node:test') +const assert = require('node:assert') const listHelper = require('../utils/list_helper') test('dummy returns one', () => { const blogs = [] const result = listHelper.dummy(blogs) - expect(result).toBe(1) + assert.strictEqual(result, 1) }) ``` +#### 4.4: Helper Functions and Unit Tests, step 2 -#### 4.4: helper functions and unit tests, 步骤2 -定义一个新的 _totalLikes_ 函数,该函数接收博客文章列表作为参数。 该函数返回所有博客文章中likes 的总和。 - - -为函数编写适当的测试。 建议将测试放在describe 块中,这样测试报告输出就可以很好地分组: - -![](../../images/4/5.png) +定义一个新的 _totalLikes_ 函数,它接收一个博客文章列表作为参数。该函数返回所有博客文章中 *likes* 的总和。 + +为该函数编写适当的测试。建议将测试放在 *describe* 块中,以便测试报告输出得到很好地分组: +![list_helper_test 的 npm 测试通过](../../images/4/5.png) -为函数定义测试用例可以这样做: +可以像这样为函数定义测试输入: ```js describe('total likes', () => { @@ -779,41 +798,32 @@ describe('total likes', () => { _id: '5a422aa71b54a676234d17f8', title: 'Go To Statement Considered Harmful', author: 'Edsger W. Dijkstra', - url: 'http://www.u.arizona.edu/~rubinson/copyright_violations/Go_To_Considered_Harmful.html', + url: 'https://homepages.cwi.nl/~storm/teaching/reader/Dijkstra68.pdf', likes: 5, __v: 0 } ] - test('when list has only one blog equals the likes of that', () => { + test('when list has only one blog, equals the likes of that', () => { const result = listHelper.totalLikes(listWithOneBlog) - expect(result).toBe(5) + assert.strictEqual(result, 5) }) }) ``` -如果觉得定义自己的博客测试列表用例工作量太大,可以使用现成的列表,[在这里](https://github.com/fullstack-hy2020/misc/blob/master/blogs_for_test.md)。 +如果定义你自己的博客测试输入列表工作量太大,你可以使用现成的列表 [此处](https://github.com/fullstack-hy2020/misc/blob/master/blogs_for_test.md)。 - -在编写测试时,您肯定会遇到问题。 还记得我们在第3章节中学到的关于[debugging](/zh/part3/将数据存入_mongo_db#debugging-node-applications)的知识吗。 即使在测试执行期间,也可以使用 console.log 将内容打印到控制台。 你甚至可以在运行测试的时候使用调试器,你可以在这里找到相关的指示 [here](https://jestjs.io/docs/en/troubleshooting)。 + +在编写测试时,你一定会遇到问题。记住我们在第 3 部分中了解的有关 [调试](/zh/part3/saving_data_to_mongo_db#debugging-node-applications) 的内容。你可以在测试执行期间使用 _console.log_ 将内容打印到控制台。 - -注意: 如果某个测试失败,那么建议在修复问题时只运行该测试。 您可以使用[only](https://facebook.github.io/jest/docs/en/api.html#testonlyname-fn-timeout)方法运行单个测试。 +#### 4.5*: Helper Functions and Unit Tests, step 3 - -运行单个测试(或描述块)的另一种方法是指定使用[-t](https://jestjs.io/docs/en/cli.html)标志运行,后面跟上测试的名称: - -```js -npm test -- -t 'when list has only one blog, equals the likes of that' -``` - -#### 4.5*: helper functions and unit tests, 步骤3 - -定义一个新的 _favoriteBlog_ 函数,该函数接收一个博客列表作为参数。 这个功能可以找出哪个博客有最多的likes。 如果有多个并列第一,返回其中一个就足够了。 + +定义一个新的 _favoriteBlog_ 函数,它接收一个博客列表作为参数。该函数找出哪个博客的点赞数最多。如果有很多热门博客,返回其中一个就足够了。 -函数返回的值可以是如下格式: +函数返回的值可以采用以下格式: ```js { @@ -823,21 +833,22 @@ npm test -- -t 'when list has only one blog, equals the likes of that' } ``` - -**注意** 当您比较对象时,[toEqual](https://jestjs.io/docs/en/expect#toequalvalue)方法可能是您所需要的,因为[toBe](https://jestjs.io/docs/en/expect#tobevalue)试图验证这两个值是否是相同的值,而不仅仅是它们是否包含相同值的属性。 + +**注意** 当你比较对象时,[deepStrictEqual](https://nodejs.org/api/assert.html#assertdeepstrictequalactual-expected-message) 方法可能是你想要使用的,因为 [strictEqual](https://nodejs.org/api/assert.html#assertstrictequalactual-expected-message) 尝试验证这两个值是相同的值,而不仅仅是包含相同的属性。对于各种断言模块函数之间的差异,你可以参考 [此 Stack Overflow 答案](https://stackoverflow.com/a/73937068/15291501)。 -在一个新的describe 块中编写这个练习的测试。 对剩下的练习也做同样的操作。 +将此练习的测试写在新的 *describe* 块中。对剩余的练习也做同样的事情。 + +#### 4.6*: Helper Functions and Unit Tests, step 4 -#### 4.6*: helper functions and unit tests, 步骤4 - -这个练习和下一个练习更有挑战性。 完成这两个练习并不是为了提前学习课程材料,所以一旦你完成了这一章节的全部材料,回到这些练习可能是一个好主意。 + +本练习和下一练习有点挑战性。完成这两项练习不是学习本课程材料的先决条件,因此最好在完成本部分的全部材料后再来学习这两项练习。 -可以在不使用其他库的情况下完成这个练习。 然而,这个练习是一个很好的机会来学习如何使用[Lodash](https://lodash.com/)。 +完成此练习时无需使用其他库。但是,此练习是学习如何使用 [Lodash](https://lodash.com/) 库的好机会。 -定义一个名为 mostBlogs 的函数,它接收一个 blog 数组作为参数。 该函数返回拥有最多博客的author。 返回值还包含了这个作者的博客数量: +定义一个名为 _mostBlogs_ 的函数,它接收一个博客数组作为参数。该函数返回拥有最多博客的 *作者*。返回值还包含顶级作者拥有的博客数量: ```js { @@ -847,14 +858,12 @@ npm test -- -t 'when list has only one blog, equals the likes of that' ``` -如果有多个并列第一,那么只要返回其中的一个就足够了。 - +如果有很多顶级博主,那么返回其中任何一个就足够了。 -#### 4.7*: helper functions and unit tests, 步骤5 -4.7 * : helper function and unit tests,步骤5 +#### 4.7*: Helper Functions and Unit Tests, step 5 -定义一个名为 mostlike 的函数,该函数接收一个 blog 数组作为参数。 该函数返回其所有博客点赞最多的作者。 返回值还包含作者收到的赞总数: +定义一个名为 _mostLikes_ 的函数,它接收一个博客数组作为其参数。该函数返回作者,其博客文章获得的点赞数最多。返回值还包含作者收到的点赞总数: ```js { @@ -863,9 +872,7 @@ npm test -- -t 'when list has only one blog, equals the likes of that' } ``` - -如果有多个并列第一,那么展示其中的一个就足够了。 - -
    +如果有很多顶级博主,那么展示其中任何一个就足够了。 + \ No newline at end of file diff --git a/src/content/4/zh/part4b.md b/src/content/4/zh/part4b.md index fe22b674fdf..a014f11d600 100644 --- a/src/content/4/zh/part4b.md +++ b/src/content/4/zh/part4b.md @@ -7,62 +7,63 @@ lang: zh
    + + 我们现在将开始为后端编写测试。因为后端不包含任何复杂的逻辑,所以为它写[单元测试](https://en.wikipedia.org/wiki/Unit_testing)没有意义。我们唯一可能进行单元测试的是用于格式化笔记的_toJSON_方法。 - -我们现在要开始为后端写测试了。由于我们的后端并没有包含任何复杂逻辑,为它编写[单元测试](https://en.wikipedia.org/wiki/Unit_testing)并没有什么意义。唯一一个我们有必要写写单元测试的就是为了格式化 note 的 _toJSON_ 方法了。 + + 在某些情况下,通过模拟数据库而不是使用真正的数据库来实现一些后端测试是有益的。一个可以用于此的库是[mongodb-memory-server](https://github.com/nodkz/mongodb-memory-server)。 - -在某些情况下,我们通过模拟数据库数据而非使用真正的数据库来进行后端测试。有一个可以模拟 mongo 的库[mongo-mock](https://github.com/williamkapke/mongo-mock)可以帮助我们达到这个目的。 + + 由于我们的应用的后端仍然相对简单,我们将决定通过其REST API测试整个应用,这样数据库也包括在内。这种将系统的多个组件作为一个整体进行测试的测试,被称为[集成测试](https://en.wikipedia.org/wiki/Integration_testing)。 - -由于我们的后端应用相对简单,我们将会通过它的 REST API 来测试整个应用,当然数据库也包含在内。这种将系统的多个组件联合进行测试的方法称为集成测试。 +### Test environment -### Test environment -【测试环境】 - -在课程教材的前几章中,我们提到当你的后端服务器在 Heroku 运行时,它处于 production 模式。 + +在课程材料的前几章中,我们提到当后端服务器在 Fly.io 或 Render 中运行时,它处于production(生产)模式。 - -Node 中的约定是用 NODE\_ENV 环境变量定义应用的执行模式。 在我们当前的应用中,如果应用不是在生产模式下,我们只加载 .env 中定义的环境变量。 + +Node中的惯例是用NODE\_ENV环境变量来定义应用的执行模式。在我们当前的应用中,如果应用不是在生产模式下,我们只加载.env文件中定义的环境变量。 - + 通常的做法是为开发和测试定义不同的模式。 - -接下来,让我们修改package.json 中的脚本,以便在运行测试时,NODE\_ENV 获得值test: + + 接下来,让我们改变package.json中的脚本,以便当测试运行时,NODE\_ENV获得test值。 ```json { // ... "scripts": { - "start": "NODE_ENV=production node index.js",// highlight-line - "dev": "NODE_ENV=development nodemon index.js",// highlight-line - "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend", - "deploy": "git push heroku master", - "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy", - "logs:prod": "heroku logs --tail", + "start": "NODE_ENV=production node index.js", // highlight-line + "dev": "NODE_ENV=development nodemon index.js", // highlight-line + "test": "NODE_ENV=test node --test", // highlight-line + "build:ui": "rm -rf build && cd ../frontend/ && npm run build && cp -r build ../backend", + "deploy": "fly deploy", + "deploy:full": "npm run build:ui && npm run deploy", + "logs:prod": "fly logs", "lint": "eslint .", - "test": "NODE_ENV=test jest --verbose --runInBand"// highlight-line }, // ... } ``` - -我们还在执行测试的 npm 脚本中添加了[runInBand](https://jestjs.io/docs/en/cli.html#--runinband)选项。 这个选项将防止 Jest 并行运行测试; 一旦我们的测试开始使用数据库,我们将讨论它的重要性。 + + 我们还在执行测试的npm脚本中加入了[runInBand](https://jestjs.io/zh-Hans/docs/cli#--runinband)选项。这个选项将阻止Jest并行运行测试;一旦我们的测试开始使用数据库,我们将讨论其意义。 - -我们在使用 nodemon 的 _npm run dev_ 脚本中指定了应用的模式为 development 。 我们还指定了默认的 npm start 命令将模式定义为production。 - -我们在脚本中指定应用模式的方式有一个小问题: 它不能在 Windows 上工作。 我们可以通过如下命令安装[cross-env](https://www.npmjs.com/package/cross-env)包来纠正这个问题: + + 我们在使用nodemon的_npm run dev_脚本中指定应用的模式为development。我们还指定默认的_npm start_命令将定义模式为production。 + + + + 我们在脚本中指定应用模式的方式有一个小问题:它在Windows上将无法工作。我们可以通过安装[cross-env](https://www.npmjs.com/package/cross-env)包作为开发依赖的命令来纠正这个问题。 ```bash -npm install cross-env +npm install --save-dev cross-env ``` - -然后,我们可以通过在package.json 中定义的 npm 脚本中使用跨平台兼容性的 cross-env 库来实现: + + 然后我们可以通过在package.json中定义的npm脚本中使用cross-env库来实现跨平台兼容。 ```json { @@ -76,29 +77,37 @@ npm install cross-env // ... } ``` + + **nb*:如果你要把这个应用部署到heroku,请记住,如果cross-env被保存为开发依赖项,它将在你的Web服务器上引起应用错误。为了解决这个问题,通过在命令行中运行这个命令,将cross-env改为生产依赖关系。 + +```bash +npm i cross-env -P +``` + + + 现在我们可以修改我们的应用在不同模式下的运行方式。作为一个例子,我们可以定义应用在运行测试时使用一个单独的测试数据库。 + - -现在我们可以修改应用在不同模式下运行的方式。 作为示例,我们可以定义应用在运行测试时使用单独的测试数据库。 + + 我们可以在Mongo DB Atlas中创建我们单独的测试数据库。在有很多人开发同一个应用的情况下,这不是一个最佳解决方案。特别是测试执行,通常需要一个数据库实例不被同时运行的测试所使用。 - -我们可以在 Mongo DB Atlas 中创建单独的测试数据库。 在多人开发同一个应用的情况下,这不是一个最佳解决方案。 特别是测试执行时,通常要求并发运行的测试,因此不能使用单个数据库实例。 - -最好使用安装并跑在开发人员本地机器上的数据库来运行我们的测试。 最佳的解决方案是让每个测试用例执行时使用自己独立的数据库。 通过[运行内存中的 Mongo](https://docs.mongodb.com/manual/core/inmemory/)或使用[Docker](https://www.Docker.com)容器来实现这个“相对简单”。 我们不会把事情复杂化,而是继续使用 MongoDB Atlas 数据库。 + + 最好是使用安装在开发者本地机器上运行的数据库来运行我们的测试。最佳的解决方案是让每个测试执行都使用它自己的独立数据库。通过[在内存中运行Mongo](https://docs.mongodb.com/manual/core/inmemory/)或使用[Docker](https://www.docker.com)容器,这是 "相对简单 "的实现。我们不会将事情复杂化,而是继续使用MongoDB Atlas数据库。 - -让我们对定义应用配置的模块进行一些修改: + + + 让我们对定义应用配置的模块做一些修改。 ```js require('dotenv').config() -let PORT = process.env.PORT -let MONGODB_URI = process.env.MONGODB_URI +const PORT = process.env.PORT // highlight-start -if (process.env.NODE_ENV === 'test') { - MONGODB_URI = process.env.TEST_MONGODB_URI -} +const MONGODB_URI = process.env.NODE_ENV === 'test' + ? process.env.TEST_MONGODB_URI + : process.env.MONGODB_URI // highlight-end module.exports = { @@ -107,43 +116,45 @@ module.exports = { } ``` - -在 .env 文件中,为开发和测试数据库的数据库地址分别设置变量: + + .env 文件中有独立的变量,用于开发和测试数据库的数据库地址。 ```bash -MONGODB_URI=mongodb+srv://fullstack:secred@cluster0-ostce.mongodb.net/note-app?retryWrites=true +MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority PORT=3001 // highlight-start -TEST_MONGODB_URI=mongodb+srv://fullstack:secret@cluster0-ostce.mongodb.net/note-app-test?retryWrites=true +TEST_MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/testNoteApp?retryWrites=true&w=majority // highlight-end ``` - -我们实现的配置模块有点类似于[node-config](https://github.com/lorenwest/node-config)包。 但编写我们自己的实现是合理的,因为我们的应用很简单,并且因为它能教会我们宝贵的经验教训。 + + 我们实现的_config_模块与[node-config](https://github.com/lorenwest/node-config)包略有相似。编写我们自己的实现是合理的,因为我们的应用很简单,同时也因为它给我们带来了宝贵的经验。 - -这些是我们需要对应用代码进行的惟一更改。 + + 这些是我们需要对我们的应用的代码进行的唯一修改。 - -您可以在[this github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-2)的part4-2 分支中找到我们当前应用的全部代码。 + + 你可以在[这个github仓库](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-2)的part4-2分支中找到我们当前应用的全部代码。 ### supertest - -让我们使用[supertest](https://github.com/visionmedia/supertest)包来帮助我们编写 API 的测试。 - -我们将这个软件包作为一个开发依赖项安装: + + 让我们使用[supertest](https://github.com/visionmedia/supertest)包来帮助我们编写测试API的测试。 + + + 我们将把这个包作为开发依赖项来安装。 ```bash npm install --save-dev supertest ``` - -让我们在tests/note_api.test.js文件中编写第一个测试: + + 让我们在tests/note_api.test.js文件中编写第一个测试。 ```js +const { test, after } = require('node:test') const mongoose = require('mongoose') const supertest = require('supertest') const app = require('../app') @@ -157,63 +168,64 @@ test('notes are returned as json', async () => { .expect('Content-Type', /application\/json/) }) -afterAll(() => { - mongoose.connection.close() +after(async () => { + await mongoose.connection.close() }) ``` + -测试从app.js 模块导入 Express 应用,并用supertest 函数将其包装成一个所谓的[superagent](https://github.com/visionmedia/superagent)对象。 这个对象被分配给api 变量,测试可以使用它向后端发出 HTTP 请求。 +该测试从 app.js 模块中导入 Express 应用程序,并用 supertest 函数将其封装到一个所谓的 [superagent](https://github.com/visionmedia/superagent) 对象中。该对象分配给 api 变量,测试可以使用它向后端发出 HTTP 请求。 -我们的测试向api/notes url 发出 HTTP GET 请求,并验证请求是否用状态码200响应。 测试还验证Content-Type 头是否设置为 application/json,表明数据是所需的格式。 - - -该测试包含一些细节,我们将在[稍后讨论](/zh/part4/测试后端应用#async-await)。 定义测试的箭头函数的前面是async 关键字,对api 对象的方法调用的前面是await 关键字。 我们将编写一些测试,然后仔细研究这个 async/await 黑魔法。 现在不要关心它们,只要确保示例测试正确工作就可以了。 async/await 语法与向 API 发出的请求是异步 操作这一事实相关。 [Async/await 语法](https://facebook.github.io/jest/docs/en/asynchronous.html)可以用于编写具有同步代码外观的异步代码 。 - - -一旦所有的测试(目前只有一个)已经完成运行,我们必须使用Mongoose关闭数据库连接的。这可以很容易地通过[afterAll](https://facebook.github.io/jest/docs/en/api.html#afterallfn-timeout)方法来实现: +我们的测试向 api/notes url 发出 HTTP GET 请求,并验证请求是否已使用状态代码 200 作出响应。测试还验证 Content-Type 标头是否设置为 application/json,表示数据采用所需格式。 + +检查标头值使用一个看起来有点奇怪的语法: ```js -afterAll(() => { - mongoose.connection.close() -}) +.expect('Content-Type', /application\/json/) ``` - -在运行测试时,您可能会遇到如下控制台警告: + +期望值现在定义为 [正则表达式](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions),简称 regex。regex 以斜杠 / 开始和结束,因为期望字符串 application/json 也包含相同的斜杠,因此在其之前添加一个 \,这样就不会将其解释为 regex 终止字符。 + + +原则上,测试也可以定义为一个字符串: -![](../../images/4/8.png) +```js +.expect('Content-Type', 'application/json') +``` + +但是,此处的问题在于,在使用字符串时,标头的值必须完全相同。对于我们定义的正则表达式,标头 包含 相关字符串是可以接受的。标头的实际值为 application/json; charset=utf-8,即它还包含有关字符编码的信息。但是,我们的测试对此不感兴趣,因此最好将测试定义为正则表达式,而不是确切的字符串。 + +该测试包含一些我们将在 [稍后](/zh/part4/测试后端应用#async-await) 探讨的详细信息。定义测试的箭头函数前有async关键字,而api对象的函数调用前有await关键字。我们将编写一些测试,然后仔细了解此async/await的魔力。现在不必担心它们,只要确保示例测试正确工作即可。async/await语法与向API发出请求是异步操作的事实有关。async/await语法可用于编写异步代码,使其看起来像同步代码。 - -如果发生这种情况,让我们按照[指示](https://mongoosejs.com/docs/jest.html) ,在项目的根目录添加一个jest.config.js 文件,内容如下: + +一旦所有测试(当前只有一个)运行完毕,我们必须关闭Mongoose使用的数据库连接。可以使用[after](https://nodejs.org/api/test.html#afterfn-options)方法轻松实现此目的: ```js -module.exports = { - testEnvironment: 'node' -} +after(async () => { + await mongoose.connection.close() +}) ``` - -一个很小但很重要的细节是: 在这一章节的 [开始](/zh/part4/从后端结构到测试入门#project-structure) ,我们将 Express 应用提取到app.js 文件中,并且改变了index.js 文件的角色,使用 Node 的内置http 对象在指定端口启动应用: + +一个微小但重要的细节:在本部分的 [开头](/zh/part4/从后端结构到测试入门#project-structure),我们将Express应用程序提取到app.js文件中,而index.js文件的作用已更改为通过_app.listen_在指定端口启动应用程序: ```js const app = require('./app') // the actual Express app -const http = require('http') const config = require('./utils/config') const logger = require('./utils/logger') -const server = http.createServer(app) - -server.listen(config.PORT, () => { +app.listen(config.PORT, () => { logger.info(`Server running on port ${config.PORT}`) }) ``` - -测试只使用app.js 文件中定义的express应用: + + 测试只使用app.js文件中定义的Express应用。 ```js const mongoose = require('mongoose') @@ -225,62 +237,81 @@ const api = supertest(app) // highlight-line // ... ``` - -supertest的文档说明如下: -> if the server is not already listening for connections then it is bound to an ephemeral port for you so there is no need to keep track of ports.
    -如果服务器还没有监听连接,那么它就会绑定到一个临时端口,因此没有必要跟踪端口。 + + supertest的文档说如下: + + + > 如果服务器还没有监听连接,那么它就会为你绑定一个短暂的端口,所以不需要跟踪端口。 - -换句话说,supertest 负责在内部使用端口启动被测试的应用。 - -让我们再写一些测试: + + 换句话说,supertest注意到被测试的应用是在它内部使用的端口启动的。 + + + + 让我们再写几个测试: ```js test('there are two notes', async () => { const response = await api.get('/api/notes') - expect(response.body).toHaveLength(2) + assert.strictEqual(response.body.length, 2) }) test('the first note is about HTTP methods', async () => { const response = await api.get('/api/notes') - expect(response.body[0].content).toBe('HTML is easy') + const contents = response.body.map(e => e.content) + assert.strictEqual(contents.includes('HTML is easy'), true) }) ``` - -这两个测试都存储了请求对响应变量的响应,并且与前面的测试不同,前面的测试使用 supertest 提供的方法来验证状态代码和报头,这次我们检查存储在 response.body 属性中的响应数据。 我们的测试使用 Jest 的[expect](https://facebook.github.io/Jest/docs/en/expect.html#content)方法验证响应数据的格式和内容。 + +这两个测试都将请求的响应存储在 _response_ 变量中,并且与使用 _supertest_ 提供的方法来验证状态代码和标头的前一个测试不同,这次我们正在检查存储在 response.body 属性中的响应数据。我们的测试使用 assert-library 的 [strictEqual](https://nodejs.org/docs/latest/api/assert.html#assertstrictequalactual-expected-message) 方法验证响应数据的格式和内容。 - -使用async/await 语法的好处开始变得明显。 通常情况下,我们必须使用回调函数来访问由 promises 返回的数据,但是使用新的语法会更加方便: + +我们可以稍微简化第二个测试,并使用 [assert](https://nodejs.org/docs/latest/api/assert.html#assertokvalue-message) 本身来验证该笔记属于返回的笔记之一: ```js -const res = await api.get('/api/notes') +test('the first note is about HTTP methods', async () => { + const response = await api.get('/api/notes') -// execution gets here only after the HTTP request is complete -// the result of HTTP request is saved in variable res -expect(res.body).toHaveLength(2) + const contents = response.body.map(e => e.content) + // is the parameter truthy + assert(contents.includes('HTML is easy')) +}) ``` + + 使用async/await语法的好处开始变得明显了。通常情况下,我们必须使用回调函数来访问由 promise 返回的数据,但有了新的语法,事情就好办多了。 + +```js +const response = await api.get('/api/notes') +// execution gets here only after the HTTP request is complete +// the result of HTTP request is saved in variable response +assert.strictEqual(response.body.length, 2) +``` - -输出 HTTP 请求信息的中间件阻碍了测试执行输出。 让我们修改日志记录器,使其不会在测试模式下打印到控制台: + + 输出HTTP请求信息的中间件阻碍了测试执行的输出。让我们修改记录器,使其在测试模式下不打印到控制台。 ```js const info = (...params) => { // highlight-start - if (process.env.NODE_ENV !== 'test') { + if (process.env.NODE_ENV !== 'test') { console.log(...params) } // highlight-end } const error = (...params) => { - console.error(...params) + // highlight-start + if (process.env.NODE_ENV !== 'test') { + console.error(...params) + } + // highlight-end } module.exports = { @@ -289,34 +320,38 @@ module.exports = { ``` ### Initializing the database before tests -【在测试前初始化数据库】 - -测试看起来很简单,我们的测试目前正在通过。 但是,我们的测试很糟糕,因为它们依赖于数据库的状态(这在我的测试数据库中恰好是正确的)。 为了使我们的测试更加健壮,在运行测试之前,我们必须重置数据库并以可控的方式生成所需的测试数据。 - -我们的测试已经使用 Jest 的[afterAll](https://facebook.github.io/Jest/docs/en/api.html#afterallfn-timeout)函数在测试执行完成后关闭到数据库的连接。 Jest 提供了许多其他的[函数](https://facebook.github.io/Jest/docs/en/setup-teardown.html#content) ,可以在运行任何测试之前或每次运行测试之前执行一次操作。 + +测试看起来很简单,我们的测试目前通过。但是,我们的测试很糟糕,因为它们依赖于数据库的状态,而现在恰好有两个笔记。为了使我们的测试更加稳健,我们必须在运行测试之前以一种可控的方式重置数据库并生成所需的测试数据。 + + +我们的测试已经使用了 [after](https://nodejs.org/zh-Hans/api/test.html#afterfn-options) 函数在测试执行完成后关闭与数据库的连接。Node:test 库提供了许多其他函数,可用于在任何测试运行之前或每次在测试运行之前执行操作。 - -让我们在每个 test 之前使用[beforeEach](https://jestjs.io/docs/en/api.html#aftereachfn-timeout)函数初始化数据库 i: + + 让我们使用 [beforeEach](https://nodejs.org/api/test.html#beforeeachfn-options) 函数在每次测试之前初始化数据库: ```js -const mongoose = require('mongoose') -const supertest = require('supertest') -const app = require('../app') -const api = supertest(app) +// highlight-start +const { test, after, beforeEach } = require('node:test') const Note = require('../models/note') +// highlight-end +// highlight-start const initialNotes = [ { content: 'HTML is easy', important: false, }, { - content: 'Browser can execute only Javascript', + content: 'Browser can execute only JavaScript', important: true, }, ] +// highlight-end + +// ... +// highlight-start beforeEach(async () => { await Note.deleteMany({}) @@ -326,81 +361,99 @@ beforeEach(async () => { noteObject = new Note(initialNotes[1]) await noteObject.save() }) +// highlight-end +// ... ``` - -在开始时清除数据库,然后将存储在 initialNotes 数组中的两个便笺保存到数据库中。 这样做,我们可以确保在运行每个测试之前,数据库处于相同的状态。 + + 数据库在开始时被清空,之后我们将存储在 _initialNotes_ 数组中的两个笔记保存到数据库中。这样做,我们确保数据库在每次测试运行前处于相同的状态。 - -让我们对最后两个测试进行如下修改: + + 让我们也对最后两个测试做如下修改。 ```js -test('all notes are returned', async () => { +test('there are two notes', async () => { const response = await api.get('/api/notes') - expect(response.body).toHaveLength(initialNotes.length) // highlight-line + assert.strictEqual(response.body.length, initialNotes.length) }) -test('a specific note is within the returned notes', async () => { +test('the first note is about HTTP methods', async () => { const response = await api.get('/api/notes') - const contents = response.body.map(r => r.content) // highlight-line + const contents = response.body.map(e => e.content) + assert(contents.includes('HTML is easy')) +}) +``` - expect(contents).toContain( - 'Browser can execute only Javascript' // highlight-line - ) +### Running tests one by one + +_npm test_ 命令将执行应用程序的所有测试。当我们编写测试时,通常明智的做法是只执行一两个测试。 + + +有几种不同的方法可以实现此目的,其中之一是 [only](https://nodejs.org/api/test.html#testonlyname-options-fn) 方法。使用该方法,我们可以在代码中定义应执行哪些测试: + +```js +test.only('notes are returned as json', async () => { + await api + .get('/api/notes') + .expect(200) + .expect('Content-Type', /application\/json/) +}) + +test.only('there are two notes', async () => { + const response = await api.get('/api/notes') + + assert.strictEqual(response.body.length, 2) }) ``` + +当使用选项 _--test-only_ 运行测试时,即使用命令 - -在后一个测试中要特别注意expect。 代码response.body.map(r => r.content) 命令用于创建一个数组,该数组包含 API 返回的每个便笺的内容。 方法用于检查作为参数传给它的便笺是否在 API 返回的便笺列表中。 +``` +npm test -- --test-only +``` -### Running tests one by one -【一个接一个的测试】 - -_npm test_ 命令执行应用的所有测试。 在编写测试时,通常明智的做法是一次只执行一个或两个测试。 Jest 提供了几种不同的方法来实现这一点,其中一种就是 [only](https://jestjs.io/docs/en/api#testonlyname-fn-timeout) 方法。 如果测试是跨多个文件编写的,那么这种方法不是很好。 + +只有标记为 _only_ 的测试才会被执行。 - -一个更好的选择是指定需要运行的测试作为npm test 命令的参数。 + +_only_ 的危险在于人们忘记从代码中删除它们。 + + +另一种选择是将需要运行的测试指定为 npm test 命令的参数。 -下面的命令只运行 tests/note_api.test.js文件中的测试: +以下命令只运行 tests/note_api.test.js 文件中找到的测试: ```js npm test -- tests/note_api.test.js ``` - - -t 选项可用于运行具有特定名称的测试: + +[--tests-by-name-pattern](https://nodejs.org/api/test.html#filtering-tests-by-name) 选项可用于运行具有特定名称的测试: ```js -npm test -- -t 'a specific note is within the returned notes' +npm test -- --test-name-pattern="the first note is about HTTP methods" ``` -提供的参数可以引用测试或描述块的名称。 参数也可以只包含名称的一部分。 下面的命令将运行名称中包含notes 的所有测试: +提供的参数可以引用测试的名称或 describe 块。该参数也可以只包含名称的一部分。以下命令将运行所有名称中包含 notes 的测试: ```js -npm test -- -t 'notes' +npm run test -- --test-name-pattern="notes" ``` - - - -**注意**: 当运行单个测试时,如果运行的测试没有使用该连接,则 mongoose 连接可能保持打开状态。 - -这个问题可能是因为 supertest 为连接优先,但是 jest 并不运行代码的 afterAll 部分。 - ### async/await - -在我们写更多测试前,让我们来认真看一下 _async_ 和 _await_ 关键字。 + + 在我们写更多的测试之前,让我们看一下_async_和_await_关键字。 - -async/await 语法是在 ES7 引入 JS 的,其目的是使用异步调用函数来返回一个 promise,但使代码看起来像是同步调用。 + + ES7中引入的async/await语法使得使用返回 promise 的异步函数的方式可以使代码看起来是同步的。 - -举个例子,我们从数据库中利用 promise 返回 note 的代码如下所示: + + 作为一个例子,用 promise 从数据库中获取笔记的过程看起来是这样的。 ```js Note.find({}).then(notes => { @@ -408,14 +461,14 @@ Note.find({}).then(notes => { }) ``` - -_Note.find()_ 方法会返回一个 promise, 我们可以通过使用 _then_ 来注册一个回调函数,来访问上一个操作的结果。 + + _Note.find()_方法返回一个 promise ,我们可以通过用_then_方法注册一个回调函数来访问操作的结果。 - -原来,我们所有想在操作完成后处理的逻辑都可以写到回调函数中。但如果我们想要创建多个异步函数,但想要顺序调用,这个过程就会比较痛苦。因为我们必须要在回调函数中写异步调用。这不但会导致代码变得更加复杂,而且可能产生所谓的[回调地狱](http://callbackhell.com/) + + 一旦操作完成,我们想要执行的所有代码都写在回调函数中。如果我们想依次进行几个异步函数的调用,情况很快就会变得很痛苦。异步调用将不得不在回调中进行。这将可能导致复杂的代码,并有可能诞生所谓的[回调地狱](http://callbackhell.com/)。 - -通过[链式 Promise](https://javascript.info/promise-chaining) 可以一定程度上让这种熵增变得可控。并且通过 _then_ 方法的链式调用来避免回调地狱。我们在这门课中已经见到了一些这种场景。为了说明这一点,你可以看如下例子,利用函数来获取所有的 note 并删除第一个: + + 通过[链式 promise ](https://javascript.info/promise-chaining),我们可以在一定程度上控制局面,并通过创建一个相当干净的_then_方法调用链来避免回调地狱。我们在课程中已经看到了一些这样的情况。为了说明这一点,你可以查看一个人为的例子,这个函数获取了所有的笔记,然后删除了第一条。 ```js Note.find({}) @@ -428,14 +481,14 @@ Note.find({}) }) ``` - -这种链式的 then 的确不错,但我们可以做得更好。ES6 引入的[生成器函数](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator) 提供了一种更[聪明的方式](https://github.com/getify/You-Dont-Know-JS/blob/1st-ed/async%20%26%20performance/ch4.md#iterating-generators-asynchronously) 来写异步代码,使这种代码看起来像同步的。这种语法有点笨拙,因此并没有被广泛使用。 + + 然后链是好的,但我们可以做得更好。ES6中引入的[生成器函数](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator)提供了一种[聪明的方法](https://github.com/getify/You-Dont-Know-JS/blob/1st-ed/async%20%26%20performance/ch4.md#iterating-generators-asynchronously),将异步代码写得 "看起来是同步的"。该语法有点笨重,没有得到广泛使用。 - -ES7 引入的 _async_ 和 _await_ 关键字带来了和生成器相同的功能,但是以一种更容易理解以及看起来更像同步的方式来展现的,使得所有的 Javascript 使用者都更容易理解。 + + ES7中引入的_async_和_await_关键字带来了与生成器相同的功能,但以一种可理解的、语法上更简洁的方式送到了JavaScript世界所有公民的手中。 - -我们使用[await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await)操作符来获取所有的 note,代码如下: + + 我们可以通过利用[await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await)操作符来获取数据库中的所有笔记,像这样。 ```js const notes = await Note.find({}) @@ -443,11 +496,11 @@ const notes = await Note.find({}) console.log('operation returned the following notes', notes) ``` - -代码看起来十分像同步函数。代码的执行会在 const notes = await Note.find({}) 处暂停等待,直到相关的 promise 都满足了,然后代码会继续执行到下一行。当继续执行,所有 promise 返回的结果都指向了 _notes_ 这个变量。 + + 这段代码看起来和同步代码完全一样。代码的执行在const notes = await Note.find({})处暂停,等待相关的 promise 被满足,然后继续执行到下一行。当继续执行时,返回 promise 的操作结果被分配给_notes_变量。 - -上面提到的稍微复杂一点的例子,用 await 实现的代码如下所示。 + + 上面介绍的稍微复杂的例子可以通过使用await这样来实现。 ```js const notes = await Note.find({}) @@ -456,17 +509,17 @@ const response = await notes[0].remove() console.log('the first note is removed') ``` - -受益于这种新语法,代码比之前的 then 链式调用看起来简单多了。 + + 由于新的语法,代码比以前的then-chain简单多了。 - -在使用 async/await 语法时,有一些重要的细节值得我们注意。为了使用 await 操作符来执行异步操作,它的返回必须是一个 promise。这本身并不是一个问题,因为以前我们使用的回调函数也是打包了一个返回 promise 的异步函数。 + + 使用async/await语法时,有几个重要的细节需要注意。为了在异步操作中使用await操作符,它们必须返回一个 promise 。这并不是一个问题,因为使用回调的常规异步函数很容易被 promise 所包裹。 - -在 Javascript 中,await 关键字不能随意使用。而只能在[async](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function)函数中使用。 + + await关键字不能在JavaScript代码中随便使用。只有在[async](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function)函数中才能使用await。 - -这也就是说为了保证之前那个例子能正常运行,就必须使用 async 来声明整个函数。注意第一行箭头函数的定义: + +这意味着,为了使前面的例子能够工作,它们必须使用异步函数。注意箭头函数定义中的第一行。 ```js const main = async () => { // highlight-line @@ -480,41 +533,40 @@ const main = async () => { // highlight-line main() // highlight-line ``` - -代码声明了 _main_ 是一个异步函数,然后才进行 main()的调用。 + + 该代码声明分配给_main_的函数是异步的。在这之后,代码用main()调用该函数。 ### async/await in the backend -【后端中的 async/await】 - -下面我们来将后端代码改为 async 和 await 的方式。 由于当前所有的异步操作都是在函数那完成的,因此只需要将 route handler 函数更改声明为异步的即可。 + + 让我们开始把后端改成异步和await。由于目前所有的异步操作都是在一个函数内完成的,所以只需将路由处理函数改为异步函数即可。 - -获取所有 note 的路由函数更改如下: + + 获取所有笔记的路由被改成如下: ```js -notesRouter.get('/', async (request, response) => { +notesRouter.get('/', async (request, response) => { const notes = await Note.find({}) - response.json(notes.map(note => note.toJSON())) + response.json(notes) }) ``` - -我们可以通过在浏览器中执行端测试和运行我们之前写的测试代码来验证我们的重构是否成功。 + + 我们可以通过浏览器测试端点和运行我们之前写的测试来验证我们的重构是否成功。 + + + 你可以在[这个Github仓库](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-3)的part4-3分支中找到我们当前应用的全部代码。 - -您可以在[this Github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-3)的part4-3 分支中找到我们当前应用的全部代码 +### More tests and refactoring the backend -### More tests and refactoring the backend -【更多的测试和后端重构】 - -当代码被重构时,总是有[regression](https://en.wikipedia.org/wiki/Regression_testing)的风险,这意味着现有的功能可能会中断。 让我们通过为 API 的每个路由编写测试来重构剩余的操作。 + +当代码被重构时,总是有[(regression)回归](https://en.wikipedia.org/wiki/Regression_testing)的风险,这意味着现有的功能可能被破坏。让我们先为API的每条路线写一个测试,来重构剩下的操作。 - -让我们从添加新便笺的操作开始。 让我们编写一个测试,添加一个新的便笺,并验证 API 返回的便笺数量是否增加,以及新添加的便笺是否在列表中。 + + 让我们从添加一个新笔记的操作开始。让我们写一个测试,添加一个新的笔记,并验证API返回的笔记数量是否增加,以及新添加的笔记是否在列表中。 ```js -test('a valid note can be added', async () => { +test('a valid note can be added ', async () => { const newNote = { content: 'async/await simplifies making async calls', important: true, @@ -523,25 +575,41 @@ test('a valid note can be added', async () => { await api .post('/api/notes') .send(newNote) - .expect(200) + .expect(201) .expect('Content-Type', /application\/json/) const response = await api.get('/api/notes') const contents = response.body.map(r => r.content) - expect(response.body).toHaveLength(initialNotes.length + 1) - expect(contents).toContain( - 'async/await simplifies making async calls' - ) + assert.strictEqual(response.body.length, initialNotes.length + 1) + + assert(contents.includes('async/await simplifies making async calls')) }) ``` - -这个测试正如我们所希望和期望的那样通过了。 + + 测试实际上是失败的,因为当一个新的笔记被创建时,我们意外地返回状态代码200 OK。让我们把它改为201 CREATED。 + +```js +notesRouter.post('/', (request, response, next) => { + const body = request.body + + const note = new Note({ + content: body.content, + important: body.important || false, + }) + + note.save() + .then(savedNote => { + response.status(201).json(savedNote) // highlight-line + }) + .catch(error => next(error)) +}) +``` - -我们还要编写一个测试,验证没有内容的便笺不会保存到数据库中。 + + 我们也写一个测试,验证一个没有内容的笔记不会被保存到数据库。 ```js test('note without content is not added', async () => { @@ -556,19 +624,19 @@ test('note without content is not added', async () => { const response = await api.get('/api/notes') - expect(response.body).toHaveLength(initialNotes.length) + assert.strictEqual(response.body.length, initialNotes.length) }) ``` - -这两个测试都通过获取应用的所有便笺来检查保存操作之后存储在数据库中的状态。 + + 这两个测试都是通过获取应用的所有笔记,来检查保存操作后存储在数据库的状态。 ```js const response = await api.get('/api/notes') ``` - -相同的测试步骤将在稍后的其他测试中重复,最好将这些步骤提取到 辅助函数中。 让我们将该函数添加到一个名为 tests/test_helper.js 的新文件中,该文件与测试文件位于同一目录中。 + + 同样的验证步骤将在以后的其他测试中重复出现,将这些步骤提取到辅助函数中是个好主意。让我们把这个函数添加到一个新的文件中,叫做tests/test_helper.js,与测试文件在同一目录下。 ```js const Note = require('../models/note') @@ -576,12 +644,10 @@ const Note = require('../models/note') const initialNotes = [ { content: 'HTML is easy', - date: new Date(), important: false }, { - content: 'Browser can execute only Javascript', - date: new Date(), + content: 'Browser can execute only JavaScript', important: true } ] @@ -589,7 +655,7 @@ const initialNotes = [ const nonExistingId = async () => { const note = new Note({ content: 'willremovethissoon' }) await note.save() - await note.remove() + await note.deleteOne() return note._id.toString() } @@ -604,13 +670,15 @@ module.exports = { } ``` - -该模块定义了_notesInDb_函数,该函数可用于检查数据库中存储的便笺。 包含初始数据库状态的 initialNotes 数组也在模块中。 我们还提前定义了 nonExistingId 函数,该函数可用于创建不属于数据库中任何便笺对象的数据库对象 ID。 + + 该模块定义了 _notesInDb_ 函数,可用于检查存储在数据库中的笔记。包含初始数据库状态的 _initialNotes_ 数组也在该模块中。我们还提前定义了 _nonExistingId_ 函数,它可以用来创建一个不属于数据库中任何笔记对象的数据库对象ID。 - -我们的测试现在可以使用 helper 模块,并且可以像下面这样修改: + + 我们的测试现在可以使用helper模块,并进行如下更改: ```js +const { test, after, beforeEach } = require('node:test') +const assert = require('node:assert') const supertest = require('supertest') const mongoose = require('mongoose') const helper = require('./test_helper') // highlight-line @@ -639,16 +707,15 @@ test('notes are returned as json', async () => { test('all notes are returned', async () => { const response = await api.get('/api/notes') - expect(response.body).toHaveLength(helper.initialNotes.length) // highlight-line + assert.strictEqual(response.body.length, helper.initialNotes.length) // highlight-line }) test('a specific note is within the returned notes', async () => { const response = await api.get('/api/notes') const contents = response.body.map(r => r.content) - expect(contents).toContain( - 'Browser can execute only Javascript' - ) + + assert(contents.includes('Browser can execute only JavaScript')) }) test('a valid note can be added ', async () => { @@ -660,16 +727,14 @@ test('a valid note can be added ', async () => { await api .post('/api/notes') .send(newNote) - .expect(200) + .expect(201) .expect('Content-Type', /application\/json/) const notesAtEnd = await helper.notesInDb() // highlight-line - expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1) // highlight-line + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length + 1) // highlight-line const contents = notesAtEnd.map(n => n.content) // highlight-line - expect(contents).toContain( - 'async/await simplifies making async calls' - ) + assert(contents.includes('async/await simplifies making async calls')) }) test('note without content is not added', async () => { @@ -684,19 +749,19 @@ test('note without content is not added', async () => { const notesAtEnd = await helper.notesInDb() // highlight-line - expect(notesAtEnd).toHaveLength(helper.initialNotes.length) // highlight-line + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length) // highlight-line }) -afterAll(() => { - mongoose.connection.close() -}) +after(async () => { + await mongoose.connection.close() +}) ``` - -使用 promises 的代码可以工作,并且测试通过。 我们已经准备好以使用 async/await 语法重构代码了。 + + 使用 promise 的代码有效并测试通过。我们已准备好重构我们的代码以使用 async/await 语法。 - -我们对负责添加新便笺的代码进行如下更改(注意,路由处理程序的定义前面有 async 关键字) : + + 我们对负责添加新note的代码进行了以下更改(注意路由处理程序的定义前面有 _async_ 关键字)。 ```js notesRouter.post('/', async (request, response, next) => { @@ -704,30 +769,29 @@ notesRouter.post('/', async (request, response, next) => { const note = new Note({ content: body.content, - important: body.important === undefined ? false : body.important, - date: new Date(), + important: body.important || false, }) const savedNote = await note.save() - response.json(savedNote.toJSON()) + response.status(201).json(savedNote) }) ``` - -我们的代码有一个小问题: 我们没有处理错误情况。我们应该如何处理它们呢? + + 我们的代码有一个小问题:我们没有处理错误情况。我们应该如何处理它们呢? -### Error handling and async/await -【错误处理与async/await】 - -如果在处理 POST 请求时出现了异常,我们就会陷入熟悉的情况: +### Error handling and async/await + + + 如果在处理POST请求时出现了异常,我们就会陷入一个熟悉的情况。 ![](../../images/4/6.png) - -换句话说,我们最终得到的是一个未处理的承诺拒绝,而且请求从未收到响应。 + + 换句话说,我们最终会得到一个未经处理的 promise 拒绝,并且请求永远不会收到响应。 - -使用 async/await 处理异常的推荐方法是老套的、熟悉的 try/catch 机制: + + 使用 async/await 时,处理异常的推荐方式是古老而熟悉的 _try/catch_ 机制。 ```js notesRouter.post('/', async (request, response, next) => { @@ -735,13 +799,12 @@ notesRouter.post('/', async (request, response, next) => { const note = new Note({ content: body.content, - important: body.important === undefined ? false : body.important, - date: new Date(), + important: body.important || false, }) // highlight-start - try { + try { const savedNote = await note.save() - response.json(savedNote.toJSON()) + response.status(201).json(savedNote) } catch(exception) { next(exception) } @@ -749,14 +812,14 @@ notesRouter.post('/', async (request, response, next) => { }) ``` - -Catch 块只是简单调用_next_函数,该函数将请求处理传递给错误处理中间件。 + + catch 块简单地调用 _next_ 函数,它将请求处理传递给错误处理中间件。 - -做出改变之后,我们所有的测试都将再次通过。 + + 在做了这个改变之后,我们所有的测试将再次通过。 - -接下来,让我们编写获取和删除单个便笺的测试: + + 接下来,让我们编写获取和删除单个笔记的测试。 ```js test('a specific note can be viewed', async () => { @@ -771,7 +834,7 @@ test('a specific note can be viewed', async () => { .expect('Content-Type', /application\/json/) // highlight-end - expect(resultNote.body).toEqual(noteToView) + assert.deepStrictEqual(resultNote.body, noteToView) }) test('a note can be deleted', async () => { @@ -786,28 +849,36 @@ test('a note can be deleted', async () => { const notesAtEnd = await helper.notesInDb() - expect(notesAtEnd).toHaveLength( - helper.initialNotes.length - 1 - ) - const contents = notesAtEnd.map(r => r.content) + assert(!contents.includes(noteToDelete.content)) - expect(contents).not.toContain(noteToDelete.content) + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length - 1) }) ``` - -这两个测试有着相似的结构。 在初始化阶段,它们从数据库中获取一个便笺。 在此之后,测试调用被测试的实际操作,该操作在代码块中突出显示。 最后,测试验证了操作的结果是符合预期的。 + + 两个测试都有一个类似的结构。在初始化阶段,它们从数据库中获取一个笔记。之后,测试调用被测试的实际操作,这在代码块中被强调。最后,测试验证操作的结果是否符合预期。 + + +在第一个测试中有一点值得注意。它使用了方法 [deepStrictEqual](https://nodejs.org/api/assert.html#assertdeepstrictequalactual-expected-message),而不是之前使用的方法 [strictEqual](https://nodejs.org/api/assert.html#assertstrictequalactual-expected-message): + + +```js +assert.deepStrictEqual(resultNote.body, noteToView) +``` + + +这是因为 _strictEqual_ 使用方法 [Object.is](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/is) 来比较相似性,即它比较对象是否相同。在我们的例子中,检查对象的內容(即其字段的值)是否相同就足够了。为此,_deepStrictEqual_ 是合适的。 - -测试通过了,我们可以安全地重构测试的路由,同样使用 async/await: + + 测试通过,我们可以安全地重构已测试的路由以使用 async/await。 ```js notesRouter.get('/:id', async (request, response, next) => { - try{ + try { const note = await Note.findById(request.params.id) if (note) { - response.json(note.toJSON()) + response.json(note) } else { response.status(404).end() } @@ -818,24 +889,23 @@ notesRouter.get('/:id', async (request, response, next) => { notesRouter.delete('/:id', async (request, response, next) => { try { - await Note.findByIdAndRemove(request.params.id) + await Note.findByIdAndDelete(request.params.id) response.status(204).end() - } catch (exception) { + } catch(exception) { next(exception) } }) ``` - -您可以在[this Github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-4)的part4-4 分支中找到我们当前应用的全部代码。 + + 你可以在[这个Github仓库](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-4)的part4-4分支中找到我们当前应用的全部代码。 ### Eliminating the try-catch -【消除try-catch】 - -Async/await 稍微整理了一下代码,但是‘ 代价’是捕获异常所需的try/catch 结构。 - -所有的路由处理程序遵循相同的结构 + + Async/await使代码更加简洁,但其代价是捕捉异常所需的try/catch结构。 + +所有的路由处理程序都遵循相同的结构 ```js try { @@ -845,30 +915,23 @@ try { } ``` + +人们开始怀疑,是否有可能重构代码以消除方法中的catch? + + [express-async-errors](https://github.com/davidbanham/express-async-errors)库对此有一个解决方案。 - -人们开始怀疑,是否有可能重构代码以从方法中消除catch? - - - - -[express-async-errors](https://github.com/davidbanham/express-async-errors)库为此提供了一个解决方案。 - - - - -我们来安装这个库吧 + + 让我们安装这个库 ```bash -npm install express-async-errors --save +npm install express-async-errors ``` - - -使用这个库很容易。 - -在src/app.js 中引入库: + +使用这个库 非常 容易。 + +在app.js中引入该库。 ```js const config = require('./utils/config') @@ -885,48 +948,39 @@ const mongoose = require('mongoose') module.exports = app ``` - - - - -这个库的“魔法”是允许我们完全消除 try-catch 块。 - -例如,删除便笺的路由 + + 该库的"magic"使我们可以完全消除try-catch块。 + + 例如,删除一个笔记的路由 ```js notesRouter.delete('/:id', async (request, response, next) => { try { - await Note.findByIdAndRemove(request.params.id) + await Note.findByIdAndDelete(request.params.id) response.status(204).end() } catch (exception) { next(exception) } }) ``` - - - - -可以变成 + +变成 ```js notesRouter.delete('/:id', async (request, response) => { - await Note.findByIdAndRemove(request.params.id) + await Note.findByIdAndDelete(request.params.id) response.status(204).end() }) ``` + + 因为有了这个库,我们不再需要_next(exception)_的调用。 + + 库处理了引擎盖下的一切。如果在async路由中发生异常,执行会自动传递给错误处理中间件。 - - -由于库的存在,我们不再需要_next(exception)_ 这种调用方式了。 - -库会处理一切事务。 如果在async 路由中发生异常,执行将自动传递到错误处理中间件。 - - - - -其他的路由修改为: + + +其他路由成为: ```js notesRouter.post('/', async (request, response) => { @@ -934,33 +988,27 @@ notesRouter.post('/', async (request, response) => { const note = new Note({ content: body.content, - important: body.important === undefined ? false : body.important, - date: new Date(), + important: body.important || false, }) const savedNote = await note.save() - response.json(savedNote.toJSON()) + response.status(201).json(savedNote) }) notesRouter.get('/:id', async (request, response) => { const note = await Note.findById(request.params.id) if (note) { - response.json(note.toJSON()) + response.json(note) } else { response.status(404).end() } }) ``` - - - -我们应用的代码可以在[github](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-5) ,part4-5 中找到。 - ### Optimizing the beforeEach function -【优化 beforeEach 函数】 - -让我们回到编写测试的问题上来,仔细研究一下设置测试的 beforeEach 函数: + + + 让我们回到编写我们的测试,仔细看看设置测试的 _beforeEach_ 函数: ```js beforeEach(async () => { @@ -974,8 +1022,8 @@ beforeEach(async () => { }) ``` - -函数使用两个单独的操作将前两个便笺从 _helper.initialNotes_ 数组保存到数据库中。 解决方案是不错的,但是有一个更好的方法可以将多个对象保存到数据库中: + + 该函数通过两个独立的操作将 _helper.initialNotes_ 数组中的前两个笔记保存到数据库中。这个方案还不错,但有一个更好的方法来保存多个对象到数据库: ```js beforeEach(async () => { @@ -996,32 +1044,31 @@ test('notes are returned as json', async () => { } ``` - -我们将存储在数组中的便笺保存到 forEach 循环中的数据库中。 然而,这些测试似乎并不能正常工作,因此我们添加了一些控制台日志来帮助我们找到问题所在。 + + 我们在一个_forEach_循环中把存储在数组中的笔记保存到数据库中。然而,这些测试似乎并不奏效,所以我们添加了一些控制台日志来帮助我们找到问题所在。 - -控制台显示如下输出: + + 控制台显示以下输出。 -
    +```
     cleared
     done
     entered test
     saved
     saved
    -
    - +``` - -尽管我们使用了 async/await 语法,但是我们的解决方案并不像我们期望的那样工作。 测试在数据库初始化之前就开始了! + + 尽管我们使用了async/await语法,我们的解决方案并没有像我们预期的那样工作。测试执行在数据库初始化之前就开始了! - -问题在于 forEach 循环的每次迭代都会生成自己的异步操作,而 beforeEach 不会等待它们完成执行。 换句话说,在 forEach 循环中定义的 await 命令不在 beforeEach 函数中,而是在 beforeEach 不会等待的独立函数中。 + + 问题是forEach循环的每个迭代都会产生自己的异步操作,而 _beforeEach_ 不会等待它们执行完毕。换句话说,在 _forEach_ 循环内部定义的 _await_ 命令不在 _beforeEach_ 函数中,而是在 _beforeEach_ 不会等待的独立函数中。 - -由于测试的执行在 beforeEach 完成执行之后立即开始,因此测试的执行在初始化数据库状态之前开始。 + +由于测试的执行是在 _beforeEach_ 完成执行之后立即开始的,因此测试的执行在初始化数据库状态之前就开始了。 - -解决这个问题的一个方法是等待所有的异步操作,使用 [Promise.all](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) 方法完成: + + 解决这个问题的一个方法是用[Promise.all](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all)方法来等待所有的异步操作执行完毕。 ```js beforeEach(async () => { @@ -1034,18 +1081,17 @@ beforeEach(async () => { }) ``` - -解决方案是相当先进的,尽管看起来有点紧凑。_noteObjects_ 变量分配给一个 Mongoose 对象数组,这些对象是用 _helper.initialNotes_ 数组中的每个便笺的 Note 构造函数创建的。 下一行代码创建一个由 consists of promises组成的新数组,这个数组是通过调用 noteObjects 数组中每个项的 save 方法创建的。 换句话说,它是将每个项保存到数据库的Promise数组。 - - - [Promise.all](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) 方法可以用于将一个promises 数组转换为一个单一的promise,一旦数组中的每个promise作为参数被解析传递给它,它就会被实现。 最后一行代码 await Promise.all(promiseArray) 会等待着每个保存便笺的承诺都完成,这意味着数据库已经初始化。 + + 尽管外观紧凑,但该解决方案非常先进。 _noteObjects_变量被分配给一个Mongoose对象数组,这些对象是用_Note_构造函数为_helper.initialNotes_数组中的每个笔记创建的。下一行代码创建了一个新的数组,由 promise 组成,这些 promise 是通过调用_noteObjects_数组中每个项目的_save_方法创建的。换句话说,它是一个 promise 数组,用于将每个项目保存到数据库中。 + + [Promise.all](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all)方法可用于将 promise 数组转换为单个 promise,一旦解析作为参数传递给它的数组中的每个 promise,该 promise 就会被 fulfilled。最后一行代码await Promise.all(promiseArray)等待每个保存笔记的 promise 完成,这意味着数据库已经初始化。 - -> 当使用 Promise.all 方法时,仍然可以访问数组中每个promise的返回值。 如果我们使用 _await_ 语法 const results = await Promise.all(promiseArray) 等待Promises被解析,操作将返回一个数组,该数组包含在 promiseArray 中的每个promise的解析值,并且它们与数组中的promise以相同的顺序出现。 + + > 使用 Promise.all 方法时,数组中每个 promise 的返回值仍然可以被访问。如果我们用 _await_ 语法 const results = await Promise.all(promiseArray) 来等待 promise 的解析,该操作将返回一个数组,其中包含 _promiseArray_ 中每个 promise 的解析值,并且它们以与数组中 promise 相同的顺序显示。 - -Promise.all 并行执行它所收到的promises。 如果promise需要按照特定顺序执行,这将是个问题。 在这样的情况下,操作可以在一个[for... of](https://developer.mozilla.org/en-us/docs/web/javascript/reference/statements/for...of)块中执行,这样保证一个特定的执行顺序。 + + Promise.all以并行方式执行它收到的promise。如果这些promise需要以特定的顺序执行,这将是有问题的。在这样的情况下,可以在[for...of](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...of)块内执行操作,这样可以保证一个特定的执行顺序。 ```js beforeEach(async () => { @@ -1058,125 +1104,98 @@ beforeEach(async () => { }) ``` - -Javascript 的异步特性可能会导致令人惊讶的行为,因此,在使用 async/await 语法时需要特别注意。 尽管语法使得处理承诺更加容易,但是仍然有必要理解Promise是如何工作的! + + JavaScript的异步性可能会导致令人惊讶的行为,为此,在使用 async/await 语法时,一定要仔细注意。即使该语法使处理 promise 变得更容易,但仍然有必要了解 promise 是如何工作的! -
    + + 我们应用的代码可以从[github](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-5),分支part4-5找到。 +
    - - ### Exercises 4.8.-4.12. - -**注意:** 教材在几个地方使用[toContain](https://facebook.github.io/jest/docs/en/expect.html#tocontainitem)匹配器来验证数组是否包含特定元素。 值得注意的是,该方法使用 === 运算符来比较和匹配元素,这意味着它通常不适合匹配对象。 在大多数情况下,验证数组中对象的合适方法是[toContainEqual](https://facebook.github.io/jest/docs/en/expect.html#tocontainequalitem) 匹配器。 然而,模型解决方案不检查数组中与匹配器有关的对象,因此不需要使用该方法来解决练习。 - -**警告:**如果您发现自己在同一代码中使用 async/await 和then 方法,那么几乎可以肯定您正在做一些错误的事情。 使用其中之一,不要混淆两者。 + + **警告:**如果你发现自己在同一段代码中使用async/await和then方法,几乎可以肯定你做错了什么。请使用其中之一,不要将两者混用。 +### 4.8: Blog List Tests, step 1 -#### 4.8: Blog list tests, 步骤1 - -使用 supertest 包编写一个测试,该测试向/api/blogs url 发出 HTTP GET 请求。 验证 blog list 应用以 JSON 格式返回的 blog 文章数量是否正确。 + +使用 SuperTest 库编写一个测试,向 /api/blogs URL 发出 HTTP GET 请求。验证博客列表应用程序以 JSON 格式返回正确数量的博客文章。 -测试完成后,重构路由处理,使用 async/await 语法而不是 promises。 - - -请注意,您必须按照[材料中](/zh/part4/测试后端应用#test-environment)所进行的编码进行类似的更改,比如定义测试环境,这样您就可以编写使用自己独立数据库的测试。 - - -注意: 当运行测试时,你可能会遇到如下警告: +测试完成后,重构路由处理程序以使用 async/await 语法而不是 promises。 -![](../../images/4/8a.png) + +请注意,您必须对代码进行类似的更改,这些更改在 [材料](/zh/part4/测试后端应用#test-environment) 中进行了,例如定义测试环境以便您可以编写使用单独数据库的测试。 + +**注意:**在编写测试时**最好不要全部执行**,只执行您正在处理的测试。[此处](/zh/part4/测试后端应用#test-environment) 了解更多相关信息。 +#### 4.9: Blog List Tests, step 2 - -如果发生这种情况,请按照[说明](https://mongoosejs.com/docs/jest.html)文档 ,在项目的根目录下创建一个新的jest.config.js 文件,内容如下: - -```js -module.exports = { - testEnvironment: 'node' -} -``` - + +编写一个测试来验证博客文章的唯一标识符属性名为 id,默认情况下,数据库将该属性命名为 _id。 + +对代码进行必要的更改,使其通过测试。第 3 部分中讨论的 [toJSON](/zh-cn/part3/saving_data_to_mongo_db#connecting-the-backend-to-a-database) 方法是定义 id 参数的合适位置。 - -注意: 在编写测试时,最好不要执行所有的测试 ,只执行正在工作的测试。 [在这里](/zh/part4/测试后端应用#running-tests-one-by-one)阅读更多相关内容。 +#### 4.10: Blog List Tests, step 3 - -#### 4.9*: Blog list tests, 步骤2 - - -编写一个测试,验证博客文章的唯一标识符属性是否命名为id,默认情况下,数据库命名为属性_id。 用 Jest 的[toBeDefined](https://jestjs.io/docs/en/expect#toBeDefined) 匹配器可以很容易地验证一个属性的存在性。 - - -对代码进行必要的更改,以便它通过测试。 第3章节中讨论的[toJSON](/zh/part3/将数据存入_mongo_db#backend-connected-to-a-database)方法是定义id 参数的合适位置。 - - -#### 4.10: Blog list tests, 步骤3 - -编写一个测试,验证对/api/blogs url 发出 HTTP POST 请求是否成功地创建了一个新的 blog POST。 至少,验证系统中的博客总数是否增加了一个。 您还可以验证博客文章的内容是否正确地保存到数据库中。 + +编写一个测试来验证向 /api/blogs URL 发出 HTTP POST 请求可以成功创建新的博客文章。至少验证系统中的博客总数增加了 1。您还可以验证博客文章的内容是否正确保存到数据库中。 -一旦测试完成,重构操作,使用 async/await 而不是 promises。 - +测试完成后,重构操作以使用 async/await 而不是 promises。 -#### 4.11*: Blog list tests, 步骤4 +#### 4.11*: Blog List Tests, step 4 -编写一个测试,验证如果请求中缺少like 属性,它将默认为值0。 不要测试已创建博客的其他属性。 +编写一个测试来验证,如果请求中缺少 likes 属性,它将默认为值 0。不要测试已创建博客的其他属性。 -对代码进行必要的更改,以便它通过测试。 +对代码进行必要的更改,使其通过测试。 +#### 4.12*: Blog List tests, step 5 -#### 4.12*: Blog list tests, 步骤5 - - -编写一个与通过 /api/blogs 端创建新博客相关的测试,该测试验证如果请求数据中缺少titleurl 属性,则后端用状态代码400 Bad Request 响应该请求。 - + +编写与通过 /api/blogs 端点创建新博客相关的测试,验证如果请求数据中缺少 titleurl 属性,后端将以状态代码 400 Bad Request 响应请求。 -对代码进行必要的更改,以便它通过测试。 +对代码进行必要的更改,使其通过测试。
    -
    +### Refactoring tests + +我们的测试覆盖率目前还很欠缺。一些请求,如GET /api/notes/:idDELETE /api/notes/:id在请求被发送时,没有测试无效的id。测试的分组和组织也可以使用一些改进,因为所有的测试都存在于测试文件的同一个 "顶层"。如果我们用describe块来分组相关的测试,测试的可读性会得到改善。 -### Refactoring tests -【重构测试】 - -我们的测试覆盖率目前还不够。 有些请求,比如GET /api/notes/:idDELETE /api/notes/:id,在使用无效 id 发送请求时没有进行测试。 测试的分组和组织也可以使用一些改进,因为所有测试都存在于测试文件的同一“顶层”上。 如果我们将相关的测试与describe 块分组,测试的可读性将得到提高。 - -下面是一个在做了一些小改进后的测试文件的例子: + + 下面是做了一些小改进后的测试文件的例子。 ```js -const supertest = require('supertest') +const { test, after, beforeEach, describe } = require('node:test') +const assert = require('node:assert') const mongoose = require('mongoose') -const helper = require('./test_helper') +const supertest = require('supertest') const app = require('../app') const api = supertest(app) -const Note = require('../models/note') - -beforeEach(async () => { - await Note.deleteMany({}) +const helper = require('./test_helper') - const noteObjects = helper.initialNotes - .map(note => new Note(note)) - const promiseArray = noteObjects.map(note => note.save()) - await Promise.all(promiseArray) -}) +const Note = require('../models/note') describe('when there is initially some notes saved', () => { + beforeEach(async () => { + await Note.deleteMany({}) + await Note.insertMany(helper.initialNotes) + }) + test('notes are returned as json', async () => { await api .get('/api/notes') @@ -1187,169 +1206,153 @@ describe('when there is initially some notes saved', () => { test('all notes are returned', async () => { const response = await api.get('/api/notes') - expect(response.body).toHaveLength(helper.initialNotes.length) + assert.strictEqual(response.body.length, helper.initialNotes.length) }) test('a specific note is within the returned notes', async () => { const response = await api.get('/api/notes') const contents = response.body.map(r => r.content) - expect(contents).toContain( - 'Browser can execute only Javascript' - ) + assert(contents.includes('Browser can execute only JavaScript')) }) -}) -describe('viewing a specific note', () => { - test('succeeds with a valid id', async () => { - const notesAtStart = await helper.notesInDb() + describe('viewing a specific note', () => { - const noteToView = notesAtStart[0] + test('succeeds with a valid id', async () => { + const notesAtStart = await helper.notesInDb() - const resultNote = await api - .get(`/api/notes/${noteToView.id}`) - .expect(200) - .expect('Content-Type', /application\/json/) + const noteToView = notesAtStart[0] - expect(resultNote.body).toEqual(noteToView) - }) + const resultNote = await api + .get(`/api/notes/${noteToView.id}`) + .expect(200) + .expect('Content-Type', /application\/json/) - test('fails with statuscode 404 if note does not exist', async () => { - const validNonexistingId = await helper.nonExistingId() + assert.deepStrictEqual(resultNote.body, noteToView) + }) - console.log(validNonexistingId) + test('fails with statuscode 404 if note does not exist', async () => { + const validNonexistingId = await helper.nonExistingId() - await api - .get(`/api/notes/${validNonexistingId}`) - .expect(404) - }) + await api + .get(`/api/notes/${validNonexistingId}`) + .expect(404) + }) - test('fails with statuscode 400 id is invalid', async () => { - const invalidId = '5a3d5da59070081a82a3445' + test('fails with statuscode 400 id is invalid', async () => { + const invalidId = '5a3d5da59070081a82a3445' - await api - .get(`/api/notes/${invalidId}`) - .expect(400) + await api + .get(`/api/notes/${invalidId}`) + .expect(400) + }) }) -}) - -describe('addition of a new note', () => { - test('succeeds with valid data', async () => { - const newNote = { - content: 'async/await simplifies making async calls', - important: true, - } - await api - .post('/api/notes') - .send(newNote) - .expect(200) - .expect('Content-Type', /application\/json/) + describe('addition of a new note', () => { + test('succeeds with valid data', async () => { + const newNote = { + content: 'async/await simplifies making async calls', + important: true, + } + await api + .post('/api/notes') + .send(newNote) + .expect(201) + .expect('Content-Type', /application\/json/) - const notesAtEnd = await helper.notesInDb() - expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1) + const notesAtEnd = await helper.notesInDb() + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length + 1) - const contents = notesAtEnd.map(n => n.content) - expect(contents).toContain( - 'async/await simplifies making async calls' - ) - }) + const contents = notesAtEnd.map(n => n.content) + assert(contents.includes('async/await simplifies making async calls')) + }) - test('fails with status code 400 if data invaild', async () => { - const newNote = { - important: true - } + test('fails with status code 400 if data invalid', async () => { + const newNote = { + important: true + } - await api - .post('/api/notes') - .send(newNote) - .expect(400) + await api + .post('/api/notes') + .send(newNote) + .expect(400) - const notesAtEnd = await helper.notesInDb() + const notesAtEnd = await helper.notesInDb() - expect(notesAtEnd).toHaveLength(helper.initialNotes.length) + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length) + }) }) -}) - -describe('deletion of a note', () => { - test('succeeds with status code 204 if id is valid', async () => { - const notesAtStart = await helper.notesInDb() - const noteToDelete = notesAtStart[0] - await api - .delete(`/api/notes/${noteToDelete.id}`) - .expect(204) + describe('deletion of a note', () => { + test('succeeds with status code 204 if id is valid', async () => { + const notesAtStart = await helper.notesInDb() + const noteToDelete = notesAtStart[0] - const notesAtEnd = await helper.notesInDb() + await api + .delete(`/api/notes/${noteToDelete.id}`) + .expect(204) - expect(notesAtEnd).toHaveLength( - helper.initialNotes.length - 1 - ) + const notesAtEnd = await helper.notesInDb() - const contents = notesAtEnd.map(r => r.content) + assert.strictEqual(notesAtEnd.length, helper.initialNotes.length - 1) - expect(contents).not.toContain(noteToDelete.content) + const contents = notesAtEnd.map(r => r.content) + assert(!contents.includes(noteToDelete.content)) + }) }) }) -afterAll(() => { - mongoose.connection.close() +after(async () => { + await mongoose.connection.close() }) ``` - -测试输出根据describe 块进行分组: -![](../../images/4/7.png) + + 测试输出根据describe块进行分组。 +![](../../images/4/7.png) + + 仍有改进的余地,但现在是向前推进的时候了。 - -仍有改进的余地,但现在是向前迈进的时候了。 + + 这种测试API的方式,即通过HTTP请求和用Mongoose检查数据库,决不是对服务器应用进行API级集成测试的唯一或最佳方式。编写测试没有通用的最佳方式,因为它完全取决于被测试的应用和可用的资源。 - -这种通过发出 HTTP 请求和用 Mongoose 检查数据库来测试 API 的方法,绝不是对服务器应用进行 API 集成测试的唯一或最佳方法。 没有通用的编写测试的最佳方法,因为这完全取决于被测试的应用和可用资源。 - -您可以在[this Github repository](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-6)的part4-6 分支中找到我们当前应用的全部代码。 + + 你可以在[这个Github仓库](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-6)的part4-6分支中找到我们当前应用的全部代码。
    -
    - - ### Exercises 4.13.-4.14. +#### 4.13 Blog list expansions, step1 + + 实现删除单个博客文章资源的功能。 -#### 4.13 Blog list expansions, 步骤1 - -实现删除单个博客文章资源的功能。 - - -使用async/await 语法。在定义 HTTP API 时遵循[RESTful](/zh/part3/node_js_与_express#rest)约定。 + + 使用async/await语法。在定义HTTP API时遵循[RESTful](/en/part3/node_js_and_express#rest)惯例。 - -可以按自己的意愿自由地实现该功能的测试。 否则,请验证该功能是否与 Postman 或其他工具一起工作。 + + 实现功能的测试。 +#### 4.14 Blog list expansions, step2 -#### 4.14 Blog list expansions, 步骤2 + + 实现更新单个博客文章信息的功能。 - -实现更新个人博客文章信息的功能。 + + 使用async/await。 - -使用 async/await。 + + 应用主要需要更新一篇博客文章的喜欢的数量。你可以用我们在[第三章节](/en/part3/saving_data_to_mongo_db#other-operations)中实现更新笔记的方法来实现这一功能。 - -应用大多数情况下需要更新博客文章的like 数量。 您可以像在[第3章](/zh/part3/将数据存入_mongo_db#other-operations)中实现更新说明那样实现这个功能。 - - - -可以按自己的意愿自由地实现该功能的测试。 否则,请验证该功能是否与 Postman 或其他工具一起工作。 + + 实现该功能的测试。
    - diff --git a/src/content/4/zh/part4c.md b/src/content/4/zh/part4c.md index d92fb76cfd3..8ecb89239d2 100644 --- a/src/content/4/zh/part4c.md +++ b/src/content/4/zh/part4c.md @@ -7,48 +7,50 @@ lang: zh
    - -我们想要为我们的应用增加用户认证和鉴权的功能。用户 应当存储在数据库中,并且每一个 便笺 应当被关联到创建它的 用户。只有 便笺 的创建者才拥有删除和编辑它的权利。 + + 我们想在我们的应用中加入用户认证和授权。用户应该存储在数据库中,每个笔记应该与创建它的用户相联系。删除和编辑一个笔记应该只允许创建它的用户使用。 - -让我们从向数据库添加用户信息开始。UserNote 是典型的一对多关系 + + 让我们先把用户的信息添加到数据库中。在用户(User)和笔记(Note)之间有一个一对多的关系。 + ![](https://yuml.me/a187045b.png) - -如果我们用关系型数据库来实现会显得比较直白。每个资源都会有独立的数据库表,而创建 便笺 的 用户 ID 会作为 便笺 的外键进行存储。 + + 如果我们使用的是关系型数据库,实现起来就很简单了。这两种资源都有各自独立的数据库表,而创建笔记的用户的ID将作为外键存储在笔记表中。 + - + +当使用文档数据库时,情况就有点不同了,因为有许多不同的建模方式。 -但如果我们使用文档数据库,就会有一些不同,体现在实现这种模型会有多种不同的方式。 - + + 现有的解决方案将每个笔记保存在数据库的笔记集合中。如果我们不想改变这个现有的集合,那么自然的选择是将用户保存在他们自己的集合中,例如users。 -目前我们是将所有的 便笺 存储在了数据库的 notes collection 中。如果我们不想改变现有的 collection, 最自然的选择是将 用户 存储在另一个 collection 中, 比如users 这个 collection。 - + + 与所有文档数据库一样,我们可以使用Mongo中的对象ID来引用其他集合中的文档。这类似于在关系数据库中使用外键。 -与所有的文档数据库一样,我们可以使用 Mongo 的对象 id 来引用存储其他 collection 中的文档。这有点像关系型数据库的外键。 - + + 传统上,像Mongo这样的文档数据库不支持关系型数据库中的join查询,这些查询用于聚合多个表的数据。但是从3.2版本开始。Mongo已经支持[查找聚合查询](https://docs.mongodb.com/manual/reference/operator/aggregation/lookup/)。我们将不会在本课程中查看此功能。 -传统的文档数据库,例如 Mongo 是不支持join queries的,但这在关系型数据库却很常见,用来聚合不同表中的数据。但从 Mongo 的 3.2 版本开始,它开始支持[lookup aggregation queries](https://docs.mongodb.com/manual/reference/operator/aggregation/lookup/)。但我们的课程并不会讲这个功能。 - + + 如果我们需要类似于连接查询的功能,我们将在我们的应用代码中通过进行多次查询来实现它。在某些情况下,Mongoose可以负责连接和聚合数据,这给人以连接查询的感觉。然而,即使在这些情况下,Mongoose也会在后端对数据库进行多次查询。 -如果我们需要一个类似 join queries 的功能,我们会在应用中利用 multiple queries 来实现这个需求。在特定的场景下,Mongoose 可以处理 join 和聚合数据,使它看起来像 join 查询 一样。但是 Mongoose 其实也是在后台数据库使用了 multiple query。 ### References across collections -【跨 collection 引用】 - -如果我们使用关系型数据库,Note 会包含一个外键来指向创建它的 User。在文档数据库中,我们也可以这么做。 + + 如果我们使用的是关系型数据库,笔记会包含一个参考键,指向创建它的用户。在文档数据库中,我们可以做同样的事情。 - -我们假定users collection 包含两个 User + + + 我们假设users集合包含两个用户。 ```js [ @@ -63,9 +65,9 @@ lang: zh ]; ``` - -notes collection 包含三个 Note, 每个 Note 都有一个user 字段 来指向users collection 中的一个 user。 + + notes集合包含三个笔记,它们都有一个user字段,引用users集合中的一个用户。 ```js [ @@ -90,9 +92,9 @@ lang: zh ] ``` - -文档型数据库并不要求外键存储在 Note 资源中,它同样可以存储在 User Collection 中,甚至可以在 Note 和 User 中都存一份。 + +文档数据库不要求外键存储在注释资源中,它可以存储在用户集合中,甚至两者兼而有之: ```js [ @@ -109,13 +111,13 @@ lang: zh ] ``` - -既然 User 可以包含许多个 Note, 那么存储 Note id 的 字段 就应该是一个数组。 + + 由于用户可以有很多笔记,相关的ID被存储在notes字段的数组中。 - -文档型数据库还提供了一个完全不同的方式组织数据:在某些情况下,这可能收益更大,那就是将所有的 note 以数组的形式作为每个文档的一部分嵌套在 user collection 中。 + + 文档数据库也提供了一种完全不同的组织数据的方式。在某些情况下,将整个笔记数组嵌套为用户集合中的文档的一部分可能是有益的。 ```js [ @@ -147,23 +149,21 @@ lang: zh ] ``` - - -这种 Shema 下 Note 会紧密地嵌套于 User 之中,数据库也不会为它们(指 Note)生成 ID - + + 在这种模式下,笔记将被紧密地嵌套在用户之下,数据库不会为它们生成ID。 -这种数据库结构和 schema 不像关系型数据库那样自我解释。所选择的 schema 必须最大化地支撑应用的用例。这并不是简单的设计决策,因为在设计决策时并不能对每一种用户用例都考虑周全。 - + + 数据库的结构和模式并不像关系型数据库那样不言自明。所选架构必须最好地支持应用程序的用例。这不是一个简单的设计决策,因为在做出设计决策时,应用程序的所有用例都是未知的。 -矛盾的是,与关系型数据库相比,像 Mongo 这种弱 Schema 类型的数据库要求开发者做更多的这种关于数据组织的设计决定,而且是在项目的开始阶段。一般来说,关系型数据库为应用提供的是一种或多或少合适可用的组织数据的方式。 + + 矛盾的是,像Mongo这样的无模式数据库要求开发人员在项目开始时对数据组织做出比有模式的关系数据库更激进的设计决策。平均而言,关系数据库为许多应用程序提供了一种或多或少合适的数据组织方式。 ### Mongoose schema for users - - -在这个例子中,我们将 note 的 id 以数组的形式存储到 user 当中。让我们定义一个 model 来表示 User 吧, models/user.js 代码如下: + + 在这种情况下,我们决定将用户创建的笔记的ID存储在用户文档中。让我们在models/user.js文件中定义代表一个用户的模型。 ```js const mongoose = require('mongoose') @@ -195,8 +195,8 @@ const User = mongoose.model('User', userSchema) module.exports = User ``` - -Note 的 ID 以数组的形式存储在了 User 当中,定义如下: + + 笔记的id以Mongo id数组的形式存储在用户文档中。其定义如下。 ```js { @@ -205,13 +205,11 @@ Note 的 ID 以数组的形式存储在了 User 当中,定义如下: } ``` - -type 字段 是ObjectId,引用了 note 的文档类型。Mongo -本质上并不知道这是一个引用 Note 的字段,这种语法完全是与 Mongoose 的定义有关。 + +该字段的类型是ObjectId,引用note式文档。Mongo 本身并不知道这是一个引用笔记的字段,这个语法纯粹是与 Mongoose 有关,并由 Mongoose 定义。 - - -让我们展开 model/note.js 文件中 note 的 schema,让 note 包含其创建者的信息。 + + 让我们扩展models/note.js文件中定义的笔记模式,使笔记包含创建它的用户的信息。 ```js const noteSchema = new mongoose.Schema({ @@ -220,7 +218,6 @@ const noteSchema = new mongoose.Schema({ required: true, minlength: 5 }, - date: Date, important: Boolean, // highlight-start user: { @@ -231,31 +228,26 @@ const noteSchema = new mongoose.Schema({ }) ``` - - -与关系型数据库形成鲜明对比,引用被同时存储在了两个 document 中。 Note 引用了创建它的 User, User 引用了它所创建的 Note 的数组。 + + 与关系型数据库的惯例形成鲜明对比的是,引用现在被存储在两个文件中:笔记引用了创建它的用户,而用户有一个数组,引用了他们创建的所有笔记。 ### Creating users -【创建 User】 - + + 让我们实现一个创建新用户的路由。用户有一个唯一的用户名名字和一个叫做密码哈希的东西。密码散列是应用于用户密码的[单向散列函数](https://en.wikipedia.org/wiki/Cryptographic_hash_function)的输出。在数据库中存储未加密的纯文本密码是不明智的! -让我们来实现一个创建 User 的路由。User 拥有一个唯一的username, 一个name 以及一个passwordHash。 password 的 hash 是一个 [单向 Hash 函数](https://en.wikipedia.org/wiki/Cryptographic_hash_function)的输出,用来存储 User 的密码。永远不要以明文的方式将密码存储在数据库中。 - - -让我们来安装[bcrypt](https://github.com/kelektiv/node.bcrypt.js) 用来生成密码的哈希值。 + + 让我们安装[bcrypt](https://github.com/kelektiv/node.bcrypt.js)软件包来生成密码散列。 ```bash -npm install bcrypt --save +npm install bcrypt ``` - - -通过 HTTP 向users发送 POST 请求,按照[第3章](/zh/part3/node_js_与_express#rest)讨论的 RESTful 约定创建用户。 + + 创建新的用户是按照[第三章节](/en/part3/node_js_and_express#rest)中讨论的RESTful惯例进行的,通过向users路径发出HTTP POST请求。 - - -我们来定义一个独立的router 来处理controllers/users.js 中的 User。并在 app.js 中使用这个路由,这样就可以处理对 /api/users 发出的请求了。 + + 让我们在一个新的controllers/users.js文件中定义一个单独的router来处理用户。让我们在app.js文件中的应用中使用这个路由器,这样它就可以处理向/api/users网址发出的请求。 ```js const usersRouter = require('./controllers/users') @@ -265,8 +257,8 @@ const usersRouter = require('./controllers/users') app.use('/api/users', usersRouter) ``` - -定义路由的代码如下: + + 定义路由器的文件的内容如下。 ```js const bcrypt = require('bcrypt') @@ -274,44 +266,42 @@ const usersRouter = require('express').Router() const User = require('../models/user') usersRouter.post('/', async (request, response) => { - const body = request.body + const { username, name, password } = request.body const saltRounds = 10 - const passwordHash = await bcrypt.hash(body.password, saltRounds) + const passwordHash = await bcrypt.hash(password, saltRounds) const user = new User({ - username: body.username, - name: body.name, + username, + name, passwordHash, }) const savedUser = await user.save() - response.json(savedUser) + response.status(201).json(savedUser) }) module.exports = usersRouter ``` - - -request 当中的密码并没有存储在数据库中。我们存储的是 _bcrypt.hash_ 函数生成的 hash 值 + + 请求中发送的密码存储在数据库中。我们存储的是用_bcrypt.hash_函数生成的密码的hash。 - + + [存储密码](https://codahale.com/how-to-safely-store-a-password/)的基本原理不在本课程材料的范围之内。我们不会讨论分配给[saltRounds](https://github.com/kelektiv/node.bcrypt.js/#a-note-on-rounds)变量的神奇数字10是什么意思,但你可以在链接材料中读到更多关于它的信息。 -[存储密码](https://codahale.com/how-to-safely-store-a-password/) 的基本原理超出了本课程的范围。我们也不会讨论赋值给[saltRounds](https://github.com/kelektiv/node.bcrypt.js/#a-note-on-rounds) 的魔法值 10 代表什么,但你可以在相关文章中找到它。 + + 我们目前的代码不包含任何错误处理或输入验证,以验证用户名和密码是否符合所需的格式。 - -我们的当前代码不包含任何用于验证用户名和密码的功能,如用户名和密码是否为所需格式等错误处理或输入校验。 + + 这个新功能最初可以而且应该用像Postman这样的工具来手动测试。然而,手动测试很快就会变得非常麻烦,尤其是当我们实现了强制要求用户名是唯一的功能。 - -新特性可以并且应该首先使用 Postman 这样的工具进行手动测试。 然而,手动测试将很快变得过于繁琐,特别是一旦我们实现了强制用户名保持唯一等功能。 + + 编写自动化测试需要更少的努力,它将使我们的应用的开发更加容易。 - -编写自动化测试所需的工作量要少得多,而且它将使应用的开发更加容易。 - - -我们最初的测试可能是这样的: + + 我们最初的测试可以是这样的。 ```js const bcrypt = require('bcrypt') @@ -341,21 +331,20 @@ describe('when there is initially one user in db', () => { await api .post('/api/users') .send(newUser) - .expect(200) + .expect(201) .expect('Content-Type', /application\/json/) const usersAtEnd = await helper.usersInDb() - expect(usersAtEnd).toHaveLength(usersAtStart.length + 1) + assert.strictEqual(usersAtEnd.length, usersAtStart.length + 1) const usernames = usersAtEnd.map(u => u.username) - expect(usernames).toContain(newUser.username) + assert(usernames.includes(newUser.username)) }) }) ``` - - -测试使用了我们实现于tests/test_helper.js 文件中的 usersInDb() 这个辅助函数。这个函数用来帮助我们验证创建完一个用户后的数据库的状态。 + + 测试使用我们在tests/test_helper.js文件中实现的usersInDb()辅助函数。该函数用于帮助我们在创建用户后验证数据库的状态。 ```js const User = require('../models/user') @@ -375,8 +364,9 @@ module.exports = { } ``` - -beforeEach 代码块向数据库增加了一个用户名为root 的 User。我们可以写一个新的测试用来验证拥有相同用户名的用户不能被创建出来。 + + + beforeEach块将一个用户名为root的用户添加到数据库中。我们可以写一个新的测试,验证是否可以创建一个相同用户名的新用户。 ```js describe('when there is initially one user in db', () => { @@ -397,94 +387,117 @@ describe('when there is initially one user in db', () => { .expect(400) .expect('Content-Type', /application\/json/) - expect(result.body.error).toContain('`username` to be unique') - const usersAtEnd = await helper.usersInDb() - expect(usersAtEnd).toHaveLength(usersAtStart.length) + assert(result.body.error.includes('expected `username` to be unique')) + + assert.strictEqual(usersAtEnd.length, usersAtStart.length) }) }) ``` - - -测试用例显然不会在这一点上通过。我们实际上是在实践[测试驱动开发 TDD](https://en.wikipedia.org/wiki/Test-driven_development),也就是在函数实现之前先写测试用例。 - - - -让我们在 Mongoose validator 的帮助下验证用户名的唯一性。正如我们在练习 [3.19](/zh/part3/es_lint与代码检查#exercises)中提到的,Mongoose 并没有内置的 validator 来检查某个字段的唯一性。我们可以使用一个现成的解决方案[mongoose-unique-validator](https://www.npmjs.com/package/mongoose-unique-validator) 这个 npm 包,先安装一下: - -```bash -npm install --save mongoose-unique-validator -``` - - + + 在这一点上,测试案例显然不会通过。我们基本上是在实践[测试驱动开发(TDD)](https://en.wikipedia.org/wiki/Test-driven_development),即在功能实现之前编写新功能的测试。 -我们必须对models/user.js 定义的 schema 做如下修改: + +Mongoose 验证没有提供直接的方法来检查字段值的唯一性。但是,可以通过为字段定义 [唯一性索引](https://mongoosejs.com/docs/schematypes.html) 来实现唯一性。定义如下: ```js const mongoose = require('mongoose') -const uniqueValidator = require('mongoose-unique-validator') // highlight-line -const userSchema = new mongoose.Schema({ +const userSchema = mongoose.Schema({ + // highlight-start username: { type: String, - unique: true // highlight-line + required: true, + unique: true // this ensures the uniqueness of username }, + // highlight-end name: String, passwordHash: String, - // highlight-start notes: [ { type: mongoose.Schema.Types.ObjectId, ref: 'Note' } ], - // highlight-end }) -userSchema.plugin(uniqueValidator) // highlight-line - // ... ``` - + +但是,必须小心使用唯一性索引。如果数据库中已经存在违反唯一性条件的文档,[将不会创建索引](https://dev.to/akshatsinghania/mongoose-unique-not-working-16bf)。因此,在添加唯一性索引时,请确保数据库处于健康状态!上述测试将用户名为 _root_ 的用户两次添加到数据库中,必须删除这些用户才能形成索引并使代码工作。 + + +Mongoose 验证不会检测到索引违规,并且会返回类型为 _MongoServerError_ 的错误,而不是 _ValidationError_。因此,我们需要为此情况扩展错误处理程序: + +```js +const errorHandler = (error, request, response, next) => { + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } else if (error.name === 'ValidationError') { + return response.status(400).json({ error: error.message }) +// highlight-start + } else if (error.name === 'MongoServerError' && error.message.includes('E11000 duplicate key error')) { + return response.status(400).json({ error: 'expected `username` to be unique' }) + } + // highlight-end + + next(error) +} +``` + + +进行这些更改后,测试将通过。 -我们同样可以在创建 User 的时候实现其他验证。比如我们可以检查用户名的长度,检查仅可以使用合法字符,或者密码的强度是否足够。实现这些 validator 是一个可选的练习。 + + 我们还可以在创建用户时实现其他验证。我们可以检查用户名是否足够长,用户名是否只由允许的字符组成,或者密码是否足够强大。实现这些功能是一个可选的练习。 - -在我们继续之前,让我们增加一个路由的初始实现,即返回数据库中的所有用户: + + 在继续之前,让我们添加一个路由处理程序的初始实现,以返回数据库中所有的用户。 ```js usersRouter.get('/', async (request, response) => { const users = await User.find({}) - response.json(users.map(u => u.toJSON())) + response.json(users) }) ``` - -这个列表看起来像这样: + + 在生产或开发环境中创建新用户,你可以通过Postman或REST客户端向```/api/users/```发送一个POST请求,格式如下。 +```js +{ + "username": "root", + "name": "Superuser", + "password": "salainen" +} + +``` + + + 列表如下所示: + ![](../../images/4/9.png) - -你也可以在[Github](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-7)的 part4-7 分支中找到当前应用的代码。 -### Creating a new note -【创建一个新 Note】 + + 你可以在[这个github仓库](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-7)的part4-7分支中找到我们当前应用的全部代码。 - +### Creating a new note -创建新 Note 的代码同样也需要更新了,以便 Note 指向创建它的 User。 + + 创建新笔记的代码需要更新,以便将笔记分配给创建它的用户。 - -让我们展开当前实现,以便在 request body 的userId 发送关于创建 Note 的信息。 + + 让我们扩展我们目前的实现,以便在请求体的userId字段中发送关于创建笔记的用户的信息。 ```js -const User = require('../models/user') +const User = require('../models/user') //highlight-line //... -notesRouter.post('/', async (request, response, next) => { +notesRouter.post('/', async (request, response) => { const body = request.body const user = await User.findById(body.userId) //highlight-line @@ -492,23 +505,22 @@ notesRouter.post('/', async (request, response, next) => { const note = new Note({ content: body.content, important: body.important === undefined ? false : body.important, - date: new Date(), - user: user._id //highlight-line + user: user.id //highlight-line }) const savedNote = await note.save() user.notes = user.notes.concat(savedNote._id) //highlight-line await user.save() //highlight-line - response.json(savedNote.toJSON()) + response.status(201).json(savedNote) }) ``` - -值得注意的是user同样变化了。Note 的 id 存储在了 notes field 中。 + + 值得注意的是,user对象也会改变。笔记的id被保存在notes字段中。 ```js -const user = User.findById(userId) +const user = await User.findById(body.userId) // ... @@ -516,94 +528,95 @@ user.notes = user.notes.concat(savedNote._id) await user.save() ``` - -让我们尝试创建一个新的 Note + + 让我们试着创建一个新的笔记 ![](../../images/4/10e.png) - -操作看起来起作用了。让我们增加另一个 Note ,并访问获取所有 User 的路由。 + + 该操作似乎是有效的。让我们再添加一个笔记,然后访问获取所有用户的路径。 ![](../../images/4/11e.png) - -我们可以看到 User 拥有两个 Note 了。 + + 我们可以看到,该用户有两个笔记。 - -同样,User 的 id 同样创建在了 Note 中,我们通过访问获取所有 Note 的路由可以看到。 + + 同样地,当我们访问获取所有笔记的路线时,可以看到创建笔记的用户的ID。 ![](../../images/4/12e.png) ### Populate - - -我们希望我们的 API 以这样的方式工作,即当一个 HTTP GET 请求到/api/users 路由时,User 对象同样包含其创建 Note 的内容,而不仅仅是 Note 的 id。 在关系型数据库中,这个功能需求可以通过 join query 实现。 + + 我们希望我们的API能够以这样的方式工作,即当HTTP GET请求被发送到/api/users路由时,用户对象也将包含用户的笔记内容,而不仅仅是他们的ID。在一个关系型数据库中,这个功能将通过一个连接查询来实现。 - + + 如前所述,文档数据库并不正确支持集合之间的连接查询,但 Mongoose 库可以为我们做一些这样的连接。Mongoose 通过做多个查询来完成连接,这与关系数据库中的连接查询不同,关系数据库是事务性的,意味着数据库的状态在查询期间不会改变。在 Mongoose 的连接查询中,没有任何东西可以保证被连接的集合之间的状态是一致的,这意味着如果我们做一个连接用户和笔记集合的查询,集合的状态可能在查询过程中发生变化。 -如前所述,文档型数据库不能很好地支持 collection 之间的 join queries,但是 Mongoose 库可以做一些类似 join 的工作。Mongoose 是通过 multiple queries 来实现这种类 join 查询的,这与关系型数据库中的事务性 join 查询不同,也就是数据库的状态在查询执行期间并不改变。而使用 Mongoose 的 join 查询,并不能保证 collection 在 join 时的状态是一致的,也就是如果我们在进行 Note 和 User 的 join 查询后,在查询期间 collection 的状态可能发生变化。 - - -Mongoose 的 join 是通过[populate](http://mongoosejs.com/docs/populate.html) 方法完成的。让我们更新返回所有 User 的路由: + + Mongoose的连接是通过[populate](http://mongoosejs.com/docs/populate.html)方法完成的。让我们先更新返回所有用户的路线。 ```js usersRouter.get('/', async (request, response) => { const users = await User // highlight-line .find({}).populate('notes') // highlight-line - response.json(users.map(u => u.toJSON())) + response.json(users) }) ``` + -[populate](http://mongoosejs.com/docs/populate.html) 方法是 find 方法这一初始查询方法后的一个链式调用。populate 方法的入参,定义了存储在 User 中的 Note id, 这些 id 指向了 Note Collection 的 Note,而这些 id 也会被真实的 Note 所替代。 + [populate](http://mongoosejs.com/docs/populate.html) 方法在初始查询后链接到 find 方法。提供给 populate 方法的参数定义了在user文档的notes字段中引用note对象的ids(id的复数)将被替换为被引用的note文档。 - -结果正如我们所预期的那样: + +结果几乎正是我们想要的。 -![](../../images/4/13e.png) +![](../../images/4/13ea.png) - -我们可以使用 populate 的参数来选择我们想要包含的文档 field。field 的选择遵循 Mongo 的[语法](https://docs.mongodb.com/manual/tutorial/project-fields-from-query-results/#return-the-specified-fields-and-the-id-field-only): + + 我们可以使用populate参数来选择我们想从文档中包含的字段。字段的选择是通过Mongo的[语法](https://docs.mongodb.com/manual/tutorial/project-fields-from-query-results/#return-the-specified-fields-and-the-id-field-only)完成的。 ```js usersRouter.get('/', async (request, response) => { const users = await User .find({}).populate('notes', { content: 1, date: 1 }) - response.json(users.map(u => u.toJSON())) + response.json(users) }); ``` - -结果也如我们的预期: -![](../../images/4/14e.png) + + 现在的结果就像我们希望的那样。 - -让我们增加一组合适的 User 信息到 Note 中: +![](../../images/4/14ea.png) + + + 让我们在 controllers/notes.js 文件中为笔记添加一个合适的用户详细信息填充: ```js notesRouter.get('/', async (request, response) => { const notes = await Note .find({}).populate('user', { username: 1, name: 1 }) - response.json(notes.map(note => note.toJSON())) + response.json(notes) }); ``` - -现在用户的信息已经被添加到 Note 对象的user field 中了。 -![](../../images/4/15e.png) + + 现在用户的信息被添加到笔记对象的user字段中。 + +![](../../images/4/15ea.png) - -数据库实际上并不知道 Note 中user field 中的 id 实际指向了 User collection 中的 User, 了解这一点十分重要。 + + 重要的是要明白,数据库并不知道存储在笔记集合的 user 字段中的 id 引用用户集合中的文档。 - -Mongoose 中populate 方法的功能是基于这样一个事实,即我们已经用 ref 选项为 Mongoose Schema 中的引用定义了类型: + + Mongoose 的 populate 方法的功能基于这样一个事实:我们已经使用 ref 选项为 Mongoose 模式中的引用定义了“类型”: ```js const noteSchema = new mongoose.Schema({ @@ -612,7 +625,6 @@ const noteSchema = new mongoose.Schema({ required: true, minlength: 5 }, - date: Date, important: Boolean, user: { type: mongoose.Schema.Types.ObjectId, @@ -621,7 +633,9 @@ const noteSchema = new mongoose.Schema({ }) ``` - -你可以在这个[分支](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-8).中找到本节课的代码。 + + 你可以在[这个github仓库](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-8)的part4-8分支中找到我们当前应用的全部代码。 + +注意:在此阶段,首先,一些测试将失败。我们将把修复测试留作非强制性练习。其次,在已部署的笔记应用程序中,创建笔记功能将停止工作,因为用户尚未链接到前端。 -
    \ No newline at end of file + diff --git a/src/content/4/zh/part4d.md b/src/content/4/zh/part4d.md index 06d3f869c4b..c07bf3e7676 100644 --- a/src/content/4/zh/part4d.md +++ b/src/content/4/zh/part4d.md @@ -7,65 +7,45 @@ lang: zh
    - - -用户必须能够登录我们的应用,而当用户一旦登录,他们的用户信息必须能够自动地加到他们所创建的任何便笺中 - - - -我们现在将让后端支持[基于令牌的认证](https://scotch.io/tutorials/the-ins-and-outs-of-token-based-authentication#toc-how-token-based-works) - - - -基于令牌认证的原理在下面的时序图中进行了描述: - -![](../../images/4/16e.png) - - - -- 用户首先在 React 中通过登录表单实现登录 - - - - 我们将在[第5章](/zh/part5) 在前台增加登录表单 - - - -- 这会使得 React 代码将用户名和密码通过/api/login 作为一个 HTTP POST 请求发送给服务器。 - - -- 如果用户名和密码是正确的,服务器会生成一个 token,用来标识登录的用户。 - - - - - 这个 Token 是数字化签名的,也就是它不可能被伪造(使用加密手段)。 - - - -- 后台通过状态码返回一个 response, 表示操作成功,同时返回的还有这个 token。 - - - -- 浏览器将这个 token 保存到 React 应用的状态中 - - - -- 当用户请求创建一个新的 Note(或者做一些需要认证的操作), React 会通过 requset 发送这个 token 给 server - - - -- server 便可以通过这个 token 来验证用户 - - - -让我们先来实现登录的功能。安装[jsonwebtoken](https://github.com/auth0/node-jsonwebtoken) 库, 它会允许我们生成 [Json Web Token](https://jwt.io/)。 + + 用户必须能够登录我们的应用,当用户登录后,他们的用户信息必须自动附加到他们创建的任何新笔记上。 + + +我们现在将在后端实现对[基于令牌的认证](https://scotch.io/tutorials/the-ins-and-outs-of-token-based-authentication#toc-how-token-based-works)的支持。 + + + 基于令牌的认证的原则在下面的顺序图中得到描述。 + +![sequence diagram of token-based authentication](../../images/4/16new.png) + + + - 用户通过使用React实现的登录表单开始登录 + + - 我们将在[第五章节](/en/part5)中把登录表单添加到前端。 + + - 这使得React代码将用户名和密码作为HTTP POST请求发送到服务器地址/api/login。 + + - 如果用户名和密码正确,服务器会生成一个token,以某种方式识别登录的用户。 + + - 令牌经过数字签名,使其不可能被伪造(用密码学手段)。 + + - 后端以一个状态代码响应,表明操作成功,并将令牌与响应一起返回。 + + - 浏览器保存令牌,例如保存到React应用的状态中。 + + - 当用户创建一个新的笔记(或做一些其他需要识别的操作),React代码将令牌与请求一起发送到服务器。 + + - 服务器使用该令牌来识别用户 + + + 让我们首先实现登录的功能。安装[jsonwebtoken](https://github.com/auth0/node-jsonwebtoken)库,它允许我们生成[JSON web tokens](https://jwt.io/)。 ```bash -npm install jsonwebtoken --save +npm install jsonwebtoken ``` - -登录功能的代码放到 controllers/login.js 中 - + + 登录功能的代码在controllers/login.js文件中。 ```js const jwt = require('jsonwebtoken') @@ -74,12 +54,12 @@ const loginRouter = require('express').Router() const User = require('../models/user') loginRouter.post('/', async (request, response) => { - const body = request.body + const { username, password } = request.body - const user = await User.findOne({ username: body.username }) + const user = await User.findOne({ username }) const passwordCorrect = user === null ? false - : await bcrypt.compare(body.password, user.passwordHash) + : await bcrypt.compare(password, user.passwordHash) if (!(user && passwordCorrect)) { return response.status(401).json({ @@ -102,25 +82,52 @@ loginRouter.post('/', async (request, response) => { module.exports = loginRouter ``` +- 用户开始通过使用React实现的登录表单进行登录 + - 我们将在[第5部分](/en/part5)中将登录表单添加到前端 +- 这会导致React代码将用户名和密码作为HTTP POST请求发送到服务器地址/api/login。 +- 如果用户名和密码正确,服务器会生成一个以某种方式识别已登录用户的token。 + - 该token被数字签名,使其无法伪造(通过密码学手段) +- 后端以状态码响应,表示操作成功,并在响应中返回token。 +- 浏览器保存token,例如保存到React应用的状态中。 +- 当用户创建新的笔记(或进行其他需要身份验证的操作)时,React代码将请求一起将token发送到服务器。 +- 服务器使用token来识别用户 + +代码首先通过请求中附带的username从数据库中搜索用户。 -代码首先从数据库中根据 request 提供的 username 搜索用户。 +```js +const user = await User.findOne({ username }) +``` - +接下来,它检查附加到请求的password。 -然后通过检查 request 中的password, 由于 password 在数据库中并不是明文存储的,而是存储的通过 password 计算的 Hash 值, _bcrypt.compare_ 方法用来检查 password 是否正确。 +```js +const passwordCorrect = user === null + ? false + : await bcrypt.compare(password, user.passwordHash) +``` + + +因为密码本身并未保存到数据库,而是从密码计算出的hashes,所以使用_bcrypt.compare_方法来检查密码是否正确: ```js -await bcrypt.compare(body.password, user.passwordHash) +await bcrypt.compare(password, user.passwordHash) ``` - + +如果找不到用户,或者密码不正确,请求将以状态码[401 unauthorized](https://www.rfc-editor.org/rfc/rfc9110.html#name-401-unauthorized)进行响应。失败的原因在响应体中解释。 -如果用户没有找到, 或者是密码错误,request 会被 response 成[401 unauthorized](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.2), 失败的原因会被放到 response 的 body 体中。 +```js +if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) +} +``` -如果密码正确,通过 _jwt.sign_ 方法创建一个 token, 这个 token 包含了数字签名表单中的用户名以及 user id。 +如果密码正确,将使用方法 _jwt.sign_ 创建一个token。该 token 以数字签名的形式包含用户名和用户id。 ```js const userForToken = { @@ -131,25 +138,24 @@ const userForToken = { const token = jwt.sign(userForToken, process.env.SECRET) ``` - - -这个 token 通过环境变量中的SECRET 作为密码 来生成数字化签名。 - - - -这个数字化签名确保只有知道了这个 secret 的组织才能够生成合法的 token - - - -这个环境变量的值必须放到 .env文件中。 + +该token已使用环境变量SECRET中的字符串作为secret进行数字签名。 +数字签名确保只有知道秘密的方可以生成有效的token。 +环境变量的值必须在.env文件中设置。 +成功的请求将以状态码200 OK进行响应。生成的token和用户的用户名在响应体中返回。 -一个成功的请求会返回 200 OK的状态码。这个生成的 token 以及用户名放到了返回体中被返回。 +```js +response + .status(200) + .send({ token, username: user.username, name: user.name }) +``` - -现在登录代码通过新的路由加到了 app.js中。 +现在只需要将登录代码添加到应用中,通过将新的路由器添加到app.js即可。 ```js const loginRouter = require('./controllers/login') @@ -160,12 +166,12 @@ app.use('/api/login', loginRouter) ``` -让我们尝试使用 VS Code REST-client 登录: +让我们试试用 VS Code REST-client 登录。 -![](../../images/4/17e.png) +![vscode rest post with username/password](../../images/4/17e.png) - -并没法正常工作,以下是控制台信息: + + 它不工作。下面的内容被打印到控制台。 ```bash (node:32911) UnhandledPromiseRejectionWarning: Error: secretOrPrivateKey must have a value @@ -174,43 +180,41 @@ app.use('/api/login', loginRouter) (node:32911) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 2) ``` - -_jwt.sign(userForToken, process.env.SECRET)_ 方法失败了。因为我们忘记了给环境变量一个 SECRET。它可以是任何 string, 只要我们放到 .env中,登录就正常了。 + + 命令 _jwt.sign(userForToken, process.env.secret)_ 失败。我们忘记给环境变量SECRET设置一个值。它可以是任何字符串。当我们在文件.env中设置了这个值,登录就成功了。 - -一次成功的登录将返回用户详细信息和 token: + + 一个成功的登录会返回用户的详细资料和令牌。 -![](../../images/4/18e.png) +![](../../images/4/18ea.png) - -错误的用户名或密码会返回错误信息和相应的状态码 + + 一个错误的用户名或密码会返回一个错误信息和正确的状态代码。 -![](../../images/4/19e.png) +![](../../images/4/19ea.png) ### Limiting creating new notes to logged in users -【为登录用户限制创建 Note】 - - - - -让我们更改以下创建新 Note 的逻辑,即只有合法 token 的 request 才能被通过。 + +让我们更改创建新的笔记,使其只有在post请求附带有效token时才可能。然后,将笔记保存到由token识别的用户的笔记列表中。 - - -有几种方法可以将令牌从浏览器发送到服务器中。我们将使用[Authorization](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) 头信息。头信息还包含了使用哪一种[authentication schema](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#Authentication_schemes) 。如果服务器提供多种认证方式,那么认证 Schema 就十分必要。这种 Schema 用来告诉服务器应当如何解析发来的认证信息。 + +将token从浏览器发送到服务器有几种方法。我们将使用[Authorization](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Authorization)头。该头还告诉我们使用了哪种[认证方案](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Authentication#Authentication_schemes)。如果服务器提供了多种认证方式,这可能是必要的。 +识别方案告诉服务器应如何解释附加的凭据。 - -Bearer schema 正是我们需要的。 + +Bearer 方案适合我们的需求。 - -实际上,这意味着假设我们有一个 token 字符串eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW, 认证头信息的值则为: + +实际上,这意味着如果token是例如字符串 eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW ,那么Authorization头将具有以下值: -
    +```
     Bearer eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW
    -
    - -将新建 Note 的代码修改如下: +``` + + +创建新的笔记将如此改变(controllers/notes.js): ```js const jwt = require('jsonwebtoken') //highlight-line @@ -219,8 +223,8 @@ const jwt = require('jsonwebtoken') //highlight-line //highlight-start const getTokenFrom = request => { const authorization = request.get('authorization') - if (authorization && authorization.toLowerCase().startsWith('bearer ')) { - return authorization.substring(7) + if (authorization && authorization.startsWith('Bearer ')) { + return authorization.replace('Bearer ', '') } return null } @@ -228,13 +232,10 @@ const getTokenFrom = request => { notesRouter.post('/', async (request, response) => { const body = request.body - //highlight-start - const token = getTokenFrom(request) - - - const decodedToken = jwt.verify(token, process.env.SECRET) - if (!token || !decodedToken.id) { - return response.status(401).json({ error: 'token missing or invalid' }) +//highlight-start + const decodedToken = jwt.verify(getTokenFrom(request), process.env.SECRET) + if (!decodedToken.id) { + return response.status(401).json({ error: 'token invalid' }) } const user = await User.findById(decodedToken.id) @@ -243,7 +244,6 @@ notesRouter.post('/', async (request, response) => { const note = new Note({ content: body.content, important: body.important === undefined ? false : body.important, - date: new Date(), user: user._id }) @@ -251,212 +251,270 @@ notesRouter.post('/', async (request, response) => { user.notes = user.notes.concat(savedNote._id) await user.save() - response.json(savedNote.toJSON()) + response.json(savedNote) }) ``` - - -_getTokenFrom_ 这个 辅助函数将 token 与认证头信息相分离。token 的有效性通过 _jwt.verify_ 进行检查。这个方法同样解码了 token, 或者返回了一个 token 所基于的对象 + +辅助函数_getTokenFrom_将token从authorization头部分离出来。使用_jwt.verify_检查token的有效性。该方法还解码token,或返回token基于的对象。 ```js const decodedToken = jwt.verify(token, process.env.SECRET) ``` - -这个对象通过 token 解码后得到usernameid ,用来告诉 server 谁创建了这次 request。 + +如果token丢失或无效,将引发异常JsonWebTokenError。我们需要扩展错误处理中间件以处理这种特殊情况: + +```js +const errorHandler = (error, request, response, next) => { + if (error.name === 'CastError') { + return response.status(400).send({ error: 'malformatted id' }) + } else if (error.name === 'ValidationError') { + return response.status(400).json({ error: error.message }) + } else if (error.name === 'MongoServerError' && error.message.includes('E11000 duplicate key error')) { + return response.status(400).json({ error: 'expected `username` to be unique' }) + } else if (error.name === 'JsonWebTokenError') { // highlight-line + return response.status(400).json({ error: 'token missing or invalid' }) // highlight-line + } + + next(error) +} +``` - + +从token解码的对象包含usernameid字段,这些字段告诉服务器是谁发出的请求。 -如果没有 token, 或者对象解析后没有获得用户认证 (_decodedToken.id_ is undefined), 错误码[401 unauthorized](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.2) 就会返回,并在 response 的 body 体中包含了失败的原因 + +如果从token解码的对象不包含用户的身份(_decodedToken.id_未定义),则返回错误状态码[401 unauthorized](https://www.rfc-editor.org/rfc/rfc9110.html#name-401-unauthorized),并在响应体中解释失败的原因。 ```js -if (!token || !decodedToken.id) { +if (!decodedToken.id) { return response.status(401).json({ - error: 'token missing or invalid' + error: 'token invalid' }) } ``` -当请求的创建者被成功解析,就会继续执行。 +当请求者的身份被解析后,执行将像以前一样继续。 - -使用 Postman 赋值正确的 authorization 头信息,即bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ, 第二个值是登录操作返回的令牌,新的 Note 就能创建了。 + +现在,如果authorization头给出了正确的值,即字符串Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ,其中第二个值是login操作返回的token,那么就可以使用Postman创建新的笔记了。 -使用 Postman 看起来如下: +在Postman中,这看起来如下: -![](../../images/4/20e.png) +![postman添加bearer token](../../images/4/20new.png) -或者使用 Visual Studio Code REST client: +和在Visual Studio Code REST客户端中 -![](../../images/4/21e.png) +![vscode添加bearer token示例](../../images/4/21new.png) -### Error handling -【错误处理】 + +当前应用程序代码可以在[GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-9)上找到,分支是part4-9。 + +如果应用程序有多个接口需要身份验证,JWT的验证应该分离到自己的中间件中。也可以使用现有的库,如[express-jwt](https://www.npmjs.com/package/express-jwt)。 +### Problems of Token-based authentication - -Token 认证也可能引起JsonWebTokenError, 例如如果我们从 token 中删除了几个字符并提交创建 Note, 就会有如下报错: + + Token认证是很容易实现的,但它包含一个问题。一旦API用户,例如React应用得到一个令牌,API就会对令牌持有者产生盲目信任。如果令牌持有者的访问权被撤销了怎么办? -```bash -JsonWebTokenError: invalid signature - at /Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:126:19 - at getSecret (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:80:14) - at Object.module.exports [as verify] (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:84:10) - at notesRouter.post (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/controllers/notes.js:40:30) + +这个问题有两种解决方案。比较简单的是限制令牌的有效期。 + +```js +loginRouter.post('/', async (request, response) => { + const { username, password } = request.body + + const user = await User.findOne({ username }) + const passwordCorrect = user === null + ? false + : await bcrypt.compare(password, user.passwordHash) + + if (!(user && passwordCorrect)) { + return response.status(401).json({ + error: 'invalid username or password' + }) + } + + const userForToken = { + username: user.username, + id: user._id, + } + + // token expires in 60*60 seconds, that is, in one hour + // highlight-start + const token = jwt.sign( + userForToken, + process.env.SECRET, + { expiresIn: 60*60 } + ) + // highlight-end + + response + .status(200) + .send({ token, username: user.username, name: user.name }) +}) ``` - -有许多原因会产生解码错误。token 可能是错误的(本例)、或者是伪造的或过期的。让我们来展开 errorHandler 中间件,来考虑不同的解码错误。 + + 令牌过期后,客户端应用需要获取新令牌。通常,这是通过强制用户重新登录应用程序来实现的。 -```js -const unknownEndpoint = (request, response) => { - response.status(404).send({ error: 'unknown endpoint' }) -} + + 应扩展错误处理中间件,以便在令牌过期的情况下给出适当的错误: +```js const errorHandler = (error, request, response, next) => { + logger.error(error.message) + if (error.name === 'CastError') { - return response.status(400).send({ - error: 'malformatted id' - }) + return response.status(400).send({ error: 'malformatted id' }) } else if (error.name === 'ValidationError') { - return response.status(400).json({ - error: error.message + return response.status(400).json({ error: error.message }) + } else if (error.name === 'JsonWebTokenError') { + return response.status(401).json({ + error: 'invalid token' + }) + // highlight-start + } else if (error.name === 'TokenExpiredError') { + return response.status(401).json({ + error: 'token expired' }) - } else if (error.name === 'JsonWebTokenError') { // highlight-line - return response.status(401).json({ // highlight-line - error: 'invalid token' // highlight-line - }) // highlight-line } - - logger.error(error.message) + // highlight-end next(error) } ``` - -当前的应用可以在part4-9,这个[Github](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part4-9)中找到 + +过期时间越短,解决方案就越安全。所以如果令牌落入坏人之手,或者用户对系统的访问需要被撤销,令牌只能在有限的时间内使用。另一方面,过期时间短会给用户带来潜在的痛苦,用户必须更频繁地登录系统。 - + + 另一个解决方案是将每个令牌的信息保存在后端数据库中,并为每个API请求检查该令牌对应的访问权是否仍然有效。通过这种方案,访问权可以在任何时候被撤销。这种方案通常被称为服务器端会话。 -如果应用有很多接口都需要认证,JWT 认证应当被分拆到它们自己的中间件中。一些现成的类库,如[express-jwt](https://www.npmjs.com/package/express-jwt)可以使用。 + + 服务器端会话的消极方面是增加了后端的复杂性,也影响了性能,因为需要对每个API请求到数据库的token有效性进行检查数据库访问相比检查token本身的有效性要慢得多。这就是为什么将一个token对应的会话保存到一个键-值数据库(如[Redis](https://redis.io/))是很常见的,与MongoDB或关系型数据库相比,其功能有限,但在某些使用场景下速度极快。 -### End notes -【结束吧】 + + 当使用服务器端会话时,令牌通常只是一个随机字符串,不包括关于用户的任何信息,因为在使用jwt令牌时通常是这样的。对于每个API请求,服务器从数据库中获取有关用户身份的相关信息。另外,通常不使用授权头,而是使用cookies作为客户端和服务器之间传输令牌的机制。 +### End notes + +代码有很多变化,这对一个快节奏的软件项目来说,造成了一个典型的问题:大多数测试都坏了。由于课程的这一部分已经充斥着新信息,我们将把修复测试留作一个非强制性的练习。 - -对于一个快节奏的项目来说,代码有很多变化,这就导致了一个典型的问题:大多数的测试都失败了,由于这部分的课程包含了许多新的内容,我们把改造测试的任务放到非强制性的练习中。 + + 使用token认证的用户名、密码和应用程序必须始终通过[HTTPS](https://en.wikipedia.org/wiki/HTTPS)使用。我们可以在我们的应用程序中使用Node [HTTPS](https://nodejs.org/docs/latest-v18.x/api/https.html)服务器(它需要更多的配置),而不是[HTTP](https://nodejs.org/docs/latest-v18.x/api/http.html)服务器。另一方面,我们应用程序的生产版本在Fly.io上,所以我们的应用程序保持安全:Fly.io将浏览器和Fly.io服务器之间的所有流量通过HTTPS路由。 - -使用 token 认证的用户名、密码以及应用应当始终在 [HTTPS](https://en.wikipedia.org/wiki/HTTPS)上使用。我们可以使用 Node [HTTPS](https://nodejs.org/api/https.html) 服务器来替换我们的 [HTTP](https://nodejs.org/docs/latest-v8.x/api/http.html)服务器,(HTTPS 需要更多配置)。从另一方面来说,我们应用的生产版本在 Heroku 中,所以我们的应用才能十分安全:Heroku 通过 HTTPS 在浏览器和 Heroku 服务器之间路由了所有的流量 + + 我们将在[下一部分](/en/part5)中实现对前端的登录。 - -我们将在下一章节实现对前端的登录。 + +**注意:**在这个阶段,在部署的笔记应用中,预计创建笔记的功能将停止工作,因为后端登录功能尚未与前端链接。
    +### Exercises 4.15.-4.23. -### Exercises 4.15.-4.22. - -在接下来的练习中,我们将为 Bloglist 应用实现基本的用户管理。 最安全的方法是遵循第4章 [User administration](/zh/part4/用户管理)到[Token-based authentication](/zh/part4/密钥认证)这一章的内容。 当然,你也可以运用你的创造力。 + + 在接下来的练习中,将为Bloglist应用实现用户管理的基础知识。最安全的方法是从第四章节的[用户管理](/en/part4/user_administration)到[基于令牌的认证](/en/part4/token_authentication)这一章的故事。当然,你也可以发挥你的创造力。 - -**再提醒一下:** 如果你注意到你混用了 async/await 和 _then_ 调用,你99% 做错了什么。 使用其中一种,不要两者都使用。 + + **还有一个警告:**如果你注意到你在混合使用async/await和_then_调用,那么99%肯定是你做错了什么。使用其中之一,而不是同时使用。 -#### 4.15: bloglist expansion, 步骤3 - - -通过执行 HTTP POST-请求来访问 api/users,实现创建新用户的方法,需要包含用户名、密码及名字。 +#### 4.15: Blog List Expansion, step3 - -不要将数据库的密码保存为明文,而是使用bcrypt 库,就像我们在第4章[Creating new users](/zh/part4/用户管理#creating-users)中所做的那样。 + + 实现一种创建新用户的方法,通过HTTP POST-request来解决api/users。用户有用户名、密码和姓名。 - -注意:有些 Windows 用户在bcrypt 方面有问题。如果遇到问题,请使用命令删除该库 + + 不要将密码以明文形式保存到数据库中,而是使用bcrypt库,就像我们在第四章节[创建新用户](/en/part4/user_administration#creating-users)中所做的那样。 + + + **NB** 一些Windows用户在使用bcrypt时遇到问题。如果你遇到问题,请用命令删除该库 ```bash -npm uninstall bcrypt --save +npm uninstall bcrypt ``` - -并安装[bcryptjs](https://www.npmjs.com/package/bcryptjs)来作为替代。 + + 并安装 [bcryptjs](https://www.npmjs.com/package/bcryptjs) 来代替。 - -通过执行合适的 HTTP 请求,实现查看所有用户详细信息的方法。 + + 实现一种方法,通过做一个合适的HTTP请求来查看所有用户的详细信息。 - -例如,用户列表可以如下所示: + + 例如,用户列表可以如下所示。 ![](../../images/4/22.png) +#### 4.16*: Blog List Expansion, step4 + + + 添加一个功能,在创建新用户时增加以下限制。必须给出用户名和密码。用户名和密码都必须至少有3个字符长。用户名必须是唯一的。 -#### 4.16*: bloglist expansion, 步骤4 - -添加一个创建新用户的特性,并添加如下限制: 必须同时给出用户名和密码。 用户名和密码必须至少3个字符长。 用户名必须是唯一的。 + + 如果创建了无效的用户,该操作必须以一个合适的状态代码和某种错误信息来回应。 - -如果创建了无效用户,操作必须使用适当的状态代码和某种错误消息进行响应。 + + **NB** 不要用Mongoose验证来测试密码限制。这不是一个好主意,因为后端收到的密码和保存在数据库中的密码哈希值不是一回事。在使用Mongoose验证之前,应该像我们在[第3章节](/en/part3/node_js_and_express)中那样在控制器中验证密码长度。 - -**注意**不要用 Mongoose 验证测试密码限制。 这不是一个好主意,因为后端接收到的密码和保存到数据库的密码散列不是一回事。 在使用 Mongoose 验证之前,应该像在 [第3章](/zh/part3/es_lint与代码检查)中那样在控制器中验证密码长度。 + + 同时,实施测试,检查无效的用户是否被创建,无效的添加用户操作是否返回合适的状态代码和错误信息。 - -此外,实现一些测试,测试可以检查未被创建的无效用户,以及无效的添加用户操作,并返回合适的状态码和错误消息。 +#### 4.17: Blog List Expansion, step5 -#### 4.17: bloglist expansion, 步骤5 - -扩展博客,使每个博客包含关于博客创建者的信息。 + + 扩展博客,使每个博客包含博客创建者的信息。 - -修改添加新博客,以便在创建新博客时,将数据库中的任何 用户指定为其创建者(例如首先找到的那个)。 根据第4章 [populate](/zh/part4/用户管理#populate)实现这一点。 - -哪个用户被指定为创建者还不重要。这个功能在 + + 修改添加新的博客,以便当一个新的博客被创建时,数据库中的任何用户都被指定为它的创建者(例如,首先找到的那个)。根据第4章节章节[populate](/en/part4/user_administration#populate)来实现。 + +哪个用户被指定为创建者还不重要。该功能在练习4.19中完成。 - -修改所有博客列表,以便创建者的用户信息与博客一起显示: + + 修改列出所有博客,使创建者的用户信息与博客一起显示。 ![](../../images/4/23e.png) - -并列出所有用户,同时显示每个用户创建的博客: + + 列出所有用户,同时显示每个用户创建的博客。 ![](../../images/4/24e.png) +#### 4.18: Blog List Expansion, step6 -#### 4.18: bloglist expansion, 步骤6 - -根据第4章节[Token authentication](/zh/part4/密钥认证)实现基于令牌的认证。 + + 根据第四章节[令牌认证](/en/part4/token_authentication)实施基于令牌的认证。 -#### 4.19: bloglist expansion, 步骤7 - -修改添加新博客的内容,以便只有在使用 HTTP POST 请求发送有效令牌的情况下才可以添加新博客。 该令牌标识的用户被指定为博客的创建者。 +#### 4.19: Blog List Expansion, step7 -#### 4.20*: bloglist expansion, 步骤8 - -第4章节的[示例](/zh/part4/密钥认证)显示了使用 getTokenFrom 辅助函数从头部获取令牌。 + + 修改添加新的博客,以便只有在HTTP POST请求中发送了有效的令牌才有可能。由令牌识别的用户被指定为博客的创建者。 - -如果您使用相同的解决方案,重构一下,将令牌移到[中间件](/zh/part3/node_js_与_express#middleware)。 中间件应该从Authorization 标头获取令牌,并将其放置到request 对象的token 字段。 +#### 4.20*: Blog List Expansion, step8 - -换句话说,如果在所有路由之前在app.js 文件中注册这个中间件 + + [这个例子](/en/part4/token_authentication)来自第四章节,显示了用_getTokenFrom_辅助函数从头文件中获取令牌。 + + + 如果你使用了相同的解决方案,请将获取令牌重构为一个[中间件](/en/part3/node_js_and_express#middleware)。中间件应该从Authorization头中获取令牌,并将其放到request对象的token字段中。 + + +换句话说,如果你在所有路由之前的app.js文件中注册了这个中间件 ```js app.use(middleware.tokenExtractor) ``` - -路由可以使用 request.token 访问令牌: - + +路由可以用_request.token_来访问令牌。 ```js blogsRouter.post('/', async (request, response) => { // .. @@ -465,8 +523,8 @@ blogsRouter.post('/', async (request, response) => { }) ``` - -请记住,普通的[中间件](/zh/part3/node_js_与_express#middleware)是一个带有三个参数的函数,它在最后调用最后一个参数next,以便将控制移动到下一个中间件: + + 记住,一个正常的[中间件](/en/part3/node_js_and_express#middleware)是一个有三个参数的函数,在最后调用最后一个参数next,以便将控制权转移到下一个中间件。 ```js const tokenExtractor = (request, response, next) => { @@ -476,64 +534,97 @@ const tokenExtractor = (request, response, next) => { } ``` -#### 4.21*: bloglist expansion, 步骤9 - -更改删除博客操作,以便只有添加博客的用户才能删除博客。 因此,只有在请求中发送的令牌与博客创建者的令牌相同时,才可以删除博客。 +#### 4.21*: Blog List Expansion, step9 + + + 改变删除博客的操作,使一个博客只能由添加该博客的用户删除。因此,只有当与请求一起发送的令牌与博客创建者的令牌相同时,删除博客才有可能。 - -如果在没有标记的情况下尝试删除博客,或者由错误的用户删除,则该操作应该返回一个合适的状态代码。 + + 如果尝试删除一个博客,但没有令牌或由错误的用户删除,该操作应返回一个合适的状态代码。 - -请注意,如果您从数据库中获取博客, + + 注意,如果你从数据库中获取一个博客。 ```js const blog = await Blog.findById(...) ``` - -字段blog.user 不包含字符串,而是包含一个 Object。 因此,如果要比较从数据库获取的对象的 id 和字符串 id,通常的比较操作是不起作用的。 从数据库获取的 id 必须首先解析为字符串。 + +字段blog.user并不包含一个字符串,而是一个对象。因此,如果你想比较从数据库中获取的对象的id和一个字符串id,正常的比较操作是不起作用的。从数据库中获取的id必须先被解析成一个字符串。 ```js if ( blog.user.toString() === userid.toString() ) ... ``` -#### 4.22*: bloglist expansion, 步骤10 - -在添加了基于令牌的身份验证之后,添加新博客的测试中断了。 现在修复测试。 还要编写一个新的测试,以确保添加一个博客失败与适当的状态代码401 Unauthorized it 令牌没有提供。 - - -在进行修复时,[这个](https://github.com/visionmedia/supertest/issues/398)很可能是最有用的。 - - -这是本课程这一章节的最后一个练习,是时候将你的代码推送到 GitHub,并将所有已完成的练习标记到[练习提交系统](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)。 - - - - - - - - - - - - - - - - - - - - - - - - - - - +#### 4.22*: Blog List Expansion, step10 -
    + + 新博客的创建和博客的删除都需要找出进行操作的用户的身份。我们在练习4.20中所做的中间件_tokenExtractor_有帮助,但postdelete操作的处理程序仍然需要找出谁是持有特定令牌的用户。 + + + 现在创建一个新的中间件_userExtractor_,它可以找出用户并将其设置为请求对象。当你在app.js中注册这个中间件时 + +```js +app.use(middleware.userExtractor) +``` + + + 用户将被设置在_request.user_字段中。 + +```js +blogsRouter.post('/', async (request, response) => { + // get user from request object + const user = request.user + // .. +}) + +blogsRouter.delete('/:id', async (request, response) => { + // get user from request object + const user = request.user + // .. +}) +``` + + + 注意,有可能只为一组特定的路由注册一个中间件。因此,与其在所有的路由中使用_userExtractor_,不如在所有的路由中使用_userExtractor_。 + +```js +// use the middleware in all routes +app.use(userExtractor) // highlight-line +app.use('/api/blogs', blogsRouter) +app.use('/api/users', usersRouter) +app.use('/api/login', loginRouter) +``` + + + 我们可以把它注册为只在/api/blogs路径下执行。 + +```js +// use the middleware only in /api/blogs routes +app.use('/api/blogs', userExtractor, blogsRouter) // highlight-line +app.use('/api/users', usersRouter) +app.use('/api/login', loginRouter) +``` + + + 正如我们所看到的,这可以通过连锁多个中间件作为函数use的参数来实现。也可以只为一个特定的操作注册一个中间件。 + +```js +router.post('/', userExtractor, async (request, response) => { + // ... +} +``` + +#### 4.23*: Blog List Expansion, step11 + + + 在添加了基于令牌的认证后,添加一个新博客的测试出现了问题。修复测试。同时写一个新的测试,以确保在没有提供令牌的情况下,添加一个博客会以适当的状态代码401 Unauthorized失败。 + + + [这个](https://github.com/visionmedia/supertest/issues/398)在做修复时很可能有用。 + + + 这是这部分课程的最后一个练习,是时候把你的代码推送到GitHub,并把你所有完成的练习标记到[练习提交系统](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)。 + + diff --git a/src/content/5/en/part5.md b/src/content/5/en/part5.md index 805c9904b3b..b031c73690a 100644 --- a/src/content/5/en/part5.md +++ b/src/content/5/en/part5.md @@ -8,4 +8,11 @@ lang: en In this part we return to the frontend, first looking at different possibilities for testing the React code. We will also implement token based authentication which will enable users to log in to our application. - \ No newline at end of file +Section updated 21st August 2025 + +- React version updated from v18 to v19. PropTypes and forwardRef has deprecated +- A label element has been added to the login form fields and used later in tests to identify the fields +- .eslintrc.cjs replaced with eslint.config.js file +- .eslintignore replaced with configuration in eslint.config.js + + diff --git a/src/content/5/en/part5a.md b/src/content/5/en/part5a.md index f06e53bdcf6..a77689b057f 100644 --- a/src/content/5/en/part5a.md +++ b/src/content/5/en/part5a.md @@ -7,22 +7,19 @@ lang: en
    +In the last two parts, we have mainly concentrated on the backend. The frontend that we developed in [part 2](/en/part2) does not yet support the user management we implemented to the backend in part 4. -In the last two parts, we have mainly concentrated on the backend, and the frontend does not yet support the user management we implemented to the backend in part 4. +At the moment the frontend shows existing notes and lets users change the state of a note from important to not important and vice versa. New notes cannot be added anymore because of the changes made to the backend in part 4: the backend now expects that a token verifying a user's identity is sent with the new note. +We'll now implement a part of the required user management functionality in the frontend. Let's begin with the user login. Throughout this part, we will assume that new users will not be added from the frontend. -At the moment the frontend shows existing notes, and lets users change the state of a note from important to not important and vice versa. New notes cannot be added anymore because of the changes made to the backend in part 4: the backend now expects that a token verifying a user's identity is sent with the new note. +### Adding a Login Form +A login form has now been added to the top of the page: -We'll now implement a part of the required user management functionality to the frontend. Let's begin with user login. Throughout this part we will assume that new users will not be added from the frontend. +![browser showing user login for notes](../../images/5/1new.png) - -A login form has now been added to the top of the page. The form for adding new notes has also been moved to the top of the list of notes. - -![](../../images/5/1e.png) - - -The code of the App component now looks as follows: +The code of the App component now looks as follows: ```js const App = () => { @@ -54,30 +51,30 @@ const App = () => { return (

    Notes

    - - -

    Login

    - + // highlight-start +

    Login

    - username +
    - password +
    @@ -91,25 +88,26 @@ const App = () => { export default App ``` +The current application code can be found on [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-1), in the branch part5-1. If you clone the repo, don't forget to run _npm install_ before attempting to run the frontend. -Current application code can be found on [Github](https://github.com/fullstack-hy2020/part2-notes/tree/part5-1), branch part5-1. +The frontend will not display any notes if it's not connected to the backend. You can start the backend with _npm run dev_ in its folder from Part 4. This will run the backend on port 3001. While that is active, in a separate terminal window you can start the frontend with _npm run dev_, and now you can see the notes that are saved in your MongoDB database from Part 4. +Keep this in mind from now on. -The login form is handled the same way we handled forms in -[part 2](/en/part2/forms). The app state has fields for username and password to store the data from the form. The form fields have event handlers, which synchronize changes in the field to the state of the App component. The event handlers are simple: An object is given to them as a parameter, and they destructure the field target from the object and save its value to the state. +The login form is handled the same way we handled forms in +[part 2](/en/part2/forms). The app state has fields for username and password to store the data from the form. The form fields have event handlers, which synchronize changes in the field to the state of the App component. The event handlers are simple: An object is given to them as a parameter, and they destructure the field target from the object and save its value to the state. ```js ({ target }) => setUsername(target.value) ``` +The method _handleLogin_, which is responsible for handling the data in the form, is yet to be implemented. -The method _handleLogin_, which is responsible for sending the form, does not yet do anything. - +### Adding Logic to the Login Form -Logging in is done by sending an HTTP POST request to server address api/login. Let's separate the code responsible for this request to its own module, to file services/login.js. +Logging in is done by sending an HTTP POST request to the server address api/login. Let's separate the code responsible for this request into its own module, to file services/login.js. - -We'll use async/await syntax instead of promises for the HTTP request: +We'll use async/await syntax instead of promises for the HTTP request: ```js import axios from 'axios' @@ -123,11 +121,10 @@ const login = async credentials => { export default { login } ``` - -The method for handling the login can be implemented as follows: +The method for handling the login can be implemented as follows: ```js -import loginService from './services/login' +import loginService from './services/login' // highlight-line const App = () => { // ... @@ -137,39 +134,39 @@ const App = () => { const [user, setUser] = useState(null) // highlight-end - const handleLogin = async (event) => { + // ... + + const handleLogin = async event => { // highlight-line event.preventDefault() + + // highlight-start try { - const user = await loginService.login({ - username, password, - }) - + const user = await loginService.login({ username, password }) setUser(user) setUsername('') setPassword('') - } catch (exception) { - setErrorMessage('Wrong credentials') + } catch { + setErrorMessage('wrong credentials') setTimeout(() => { setErrorMessage(null) }, 5000) } + // highlight-end } // ... } ``` - If the login is successful, the form fields are emptied and the server response (including a token and the user details) is saved to the user field of the application's state. +If the login fails or running the function _loginService.login_ results in an error, the user is notified. -If the login fails, or running the function _loginService.login_ results in an error, the user is notified. +### Conditional Rendering of the Login Form +The user is not notified about a successful login in any way. Let's modify the application to show the login form only if the user is not logged-in, so when _user === null_. The form for adding new notes is shown only if the user is logged-in, so when user state contains the user's details. -User is not notified about a successful login in any way. Let's modify the application to show the login form only if the user is not logged in so _user === null_. The form for adding new notes is shown only if user is logged in, so user contains the user details. - - -Let's add two helper functions to the App component for generating the forms: +Let's add two helper functions to the App component for generating the forms: ```js const App = () => { @@ -178,35 +175,34 @@ const App = () => { const loginForm = () => (
    - username +
    - password +
    -
    + ) const noteForm = () => (
    - + -
    + ) return ( @@ -215,7 +211,6 @@ const App = () => { } ``` - and conditionally render them: ```js @@ -233,11 +228,10 @@ const App = () => { return (

    Notes

    - - {user === null && loginForm()} // highlight-line - {user !== null && noteForm()} // highlight-line + {!user && loginForm()} // highlight-line + {user && noteForm()} // highlight-line
      - {notesToShow.map((note, i) => + {notesToShow.map(note => ( toggleImportanceOf(note.id)} /> - )} + ))}
    @@ -260,80 +254,84 @@ const App = () => { } ``` -A slightly odd looking, but commonly used [React trick](https://reactjs.org/docs/conditional-rendering.html#inline-if-with-logical--operator) is used to render the forms conditionally: +A slightly odd looking, but commonly used [React trick](https://react.dev/learn/conditional-rendering#logical-and-operator-) is used to render the forms conditionally: ```js -{ - user === null && loginForm() -} +{!user && loginForm()} ``` +If the first statement evaluates to false or is [falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy), the second statement (generating the form) is not executed at all. -If the first statement evaluates false, or is [falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy), the second statement ( generating the form ) is not executed at all. - -We can make this even more straightforward by using the [conditional operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator): +Let's do one more modification. If the user is logged in, their name is shown on the screen: ```js return (

    Notes

    + - - - {user === null ? - loginForm() : - noteForm() - } - -

    Notes

    + {!user && loginForm()} + // highlight-start + {user && ( +
    +

    {user.name} logged in

    + {noteForm()} +
    + )} + // highlight-end +
    +
    -) ``` +The solution isn't perfect, but we'll leave it like this for now. -If _user === null_ is [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy), _loginForm()_ is executed. If not, _noteForm()_. +Our main component App is at the moment way too large. The changes we did now are a clear sign that the forms should be refactored into their own components. However, we will leave that for an optional exercise. +The current application code can be found on [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-2), in the branch part5-2. -Let's do one more modification. If user is logged in, their name is shown on the screen: +### Note on Using the Label Element -```js -return ( -
    -

    Notes

    - - +We used the [label](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/label) element for the input fields in the login form. The input field for the username is placed inside the corresponding label element: - {user === null ? - loginForm() : -
    -

    {user.name} logged in

    - {noteForm()} -
    - } - -

    Notes

    - - // ... - -
    -) +```js +
    + +
    +// ... ``` +Why did we implement the form this way? Visually, the same result could be achieved with simpler code, without a separate label element: -The solution looks a bit ugly, but we'll leave it for now. - +```js +
    + username + setUsername(target.value)} + /> +
    +// ... +``` -Our main component App is at the moment way too large. The changes we did now are a clear sign that the forms should be refactored into their own components. However, we will leave that for an optional excercise. +The label element is used in forms to describe and name input fields. It provides a description for the input field, helping the user understand what information should be entered into each field. This description is programmatically linked to the corresponding input field, improving the form's accessibility. +This way, screen readers can read the field's name to the user when the input field is selected, and clicking on the label's text automatically focuses on the correct input field. Using the label element with input fields is always recommended, even if the same visual result could be achieved without it. -Current application code can be found on [Github](https://github.com/fullstack-hy2020/part2-notes/tree/part5-2), branch part5-2. +There are [several ways](https://react.dev/reference/react-dom/components/input#providing-a-label-for-an-input) to link a specific label to an input element. The easiest method is to place the input element inside the corresponding label element, as demonstrated in this material. This automatically associates the label with the correct input field, requiring no additional configuration. ### Creating new notes -The token returned with a successful login is saved to the application state user field token: +The token returned with a successful login is saved to the application's state - the user's field token: ```js const handleLogin = async (event) => { @@ -352,10 +350,9 @@ const handleLogin = async (event) => { } ``` -Let's fix creating new notes to work with the backend. This means adding the token of the logged in user to the Authorization header of the HTTP request. - +Let's fix creating new notes so it works with the backend. This means adding the token of the logged-in user to the Authorization header of the HTTP request. -The noteService module changes like so: +The noteService module changes like so: ```js import axios from 'axios' @@ -365,7 +362,7 @@ let token = null // highlight-line // highlight-start const setToken = newToken => { - token = `bearer ${newToken}` + token = `Bearer ${newToken}` } // highlight-end @@ -377,7 +374,7 @@ const getAll = () => { const create = async newObject => { // highlight-start const config = { - headers: { Authorization: token }, + headers: { Authorization: token } } // highlight-end @@ -386,87 +383,70 @@ const create = async newObject => { } const update = (id, newObject) => { - const request = axios.put(`${ baseUrl } /${id}`, newObject) + const request = axios.put(`${ baseUrl }/${id}`, newObject) return request.then(response => response.data) } export default { getAll, create, update, setToken } // highlight-line ``` +The noteService module contains a private variable called _token_. Its value can be changed with the _setToken_ function, which is exported by the module. _create_, now with async/await syntax, sets the token to the Authorization header. The header is given to axios as the third parameter of the post method. -The noteService module contains a private variable _token_. Its value can be changed with a function _setToken_, which is exported by the module. _create_, now with async/await syntax, sets the token to the Authorization header. The header is given to axios as the third parameter of the post method. - - -The event handler responsible for log in must be changed to call the method noteService.setToken(user.token) with a successful log in: +The event handler responsible for login must be changed to call the method noteService.setToken(user.token) with a successful login: ```js const handleLogin = async (event) => { event.preventDefault() - try { - const user = await loginService.login({ - username, password, - }) + try { + const user = await loginService.login({ username, password }) noteService.setToken(user.token) // highlight-line setUser(user) setUsername('') setPassword('') - } catch (exception) { + } catch { // ... } } ``` - And now adding new notes works again! -### Saving the token to browsers local storage - - -Our application has a flaw: When the page is rerendered, information of the user's login dissappears. This also slows down development. For example when we test creating new notes, we have to login again every single time. +### Saving the token to the browser's local storage +Our application has a small flaw: if the browser is refreshed (eg. pressing F5), the user's login information disappears. -This problem is easily solved by saving the login details to [local storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage). Local Storage is a [key-value](https://en.wikipedia.org/wiki/Key-value_database) database in the browser. +This problem is easily solved by saving the login details to [local storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage). Local Storage is a [key-value](https://en.wikipedia.org/wiki/Key-value_database) database in the browser. - -It is very easy to use. A value corresponding to a certain key is saved to the database with method [setItem](https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem). For example: +It is very easy to use. A value corresponding to a certain key is saved to the database with the method [setItem](https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem). For example: ```js window.localStorage.setItem('name', 'juha tauriainen') ``` +saves the string given as the second parameter as the value of the key name. -saves the string given as the second parameter as the value of key name. - - -The value of a key can be found with method [getItem](https://developer.mozilla.org/en-US/docs/Web/API/Storage/getItem): +The value of a key can be found with the method [getItem](https://developer.mozilla.org/en-US/docs/Web/API/Storage/getItem): ```js window.localStorage.getItem('name') ``` +while [removeItem](https://developer.mozilla.org/en-US/docs/Web/API/Storage/removeItem) removes a key. -and [removeItem](https://developer.mozilla.org/en-US/docs/Web/API/Storage/removeItem) removes a key. - +Values in the local storage are persisted even when the page is re-rendered. The storage is [origin](https://developer.mozilla.org/en-US/docs/Glossary/Origin)-specific so each web application has its own storage. -Values in the storage stay even when the page is rerendered. The storage is [origin](https://developer.mozilla.org/en-US/docs/Glossary/Origin)-specific so each web application has its own storage. +Let's extend our application so that it saves the details of a logged-in user to the local storage. +Values saved to the storage are [DOMstrings](https://docs.w3cub.com/dom/domstring), so we cannot save a JavaScript object as it is. The object has to be parsed to JSON first, with the method _JSON.stringify_. Correspondingly, when a JSON object is read from the local storage, it has to be parsed back to JavaScript with _JSON.parse_. -Let's extend our application so that it saves the details of a logged in user to the local storage. - - -Values saved to the storage are [DOMstrings](https://developer.mozilla.org/en-US/docs/Web/API/DOMString), so we cannot save a JavaScript object as is. The object has to be first parsed to JSON with the method _JSON.stringify_. Correspondigly, when a JSON object is read from the local storage, it has to be parsed back to JavaScript with _JSON.parse_. - - -Changes to the login method are as follows: +Changes to the login method are as follows: ```js const handleLogin = async (event) => { event.preventDefault() try { - const user = await loginService.login({ - username, password, - }) + const user = await loginService.login({ username, password }) // highlight-start window.localStorage.setItem( @@ -483,37 +463,34 @@ Changes to the login method are as follows: } ``` +The details of a logged-in user are now saved to the local storage, and they can be viewed on the console (by typing _window.localStorage_ in it): -The details of a logged in user are now saved to the local storage, and they can be viewed on the console: - -![](../../images/5/3e.png) - - -We still have to modify our application so that when we enter the page, the application checks if user details of a logged in user can already be found from the local storage. If they can, the details are saved to the state of the application and to noteService. +![browser showing user data in console saved in local storage](../../images/5/3e.png) +You can also inspect the local storage using the developer tools. On Chrome, go to the Application tab and select Local Storage (more details [here](https://developer.chrome.com/docs/devtools/storage/localstorage)). On Firefox go to the Storage tab and select Local Storage (details [here](https://firefox-source-docs.mozilla.org/devtools-user/storage_inspector/index.html)). -The right way to do this is with an [effect hook](https://reactjs.org/docs/hooks-effect.html): A mechanism we first encountered in [part 2](/en/part2/getting_data_from_server#effect-hooks), and used to fetch notes from the server to the frontend. +We still have to modify our application so that when we enter the page, the application checks if user details of a logged-in user can already be found on the local storage. If they are there, the details are saved to the state of the application and to noteService. +The right way to do this is with an [effect hook](https://react.dev/reference/react/useEffect): a mechanism we first encountered in [part 2](/en/part2/getting_data_from_server#effect-hooks), and used to fetch notes from the server. We can have multiple effect hooks, so let's create a second one to handle the first loading of the page: ```js const App = () => { - const [notes, setNotes] = useState([]) + const [notes, setNotes] = useState([]) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) const [errorMessage, setErrorMessage] = useState(null) - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [user, setUser] = useState(null) + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [user, setUser] = useState(null) useEffect(() => { - noteService - .getAll().then(initialNotes => { - setNotes(initialNotes) - }) + noteService.getAll().then(initialNotes => { + setNotes(initialNotes) + }) }, []) - + // highlight-start useEffect(() => { const loggedUserJSON = window.localStorage.getItem('loggedNoteappUser') @@ -529,27 +506,24 @@ const App = () => { } ``` +The empty array as the parameter of the effect ensures that the effect is executed only when the component is rendered [for the first time](https://react.dev/reference/react/useEffect#parameters). -The empty array as the parameter of the effect ensures that the effect is executed only when the component is rendered [for the first time](https://reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect). - - -Now a user stays logged in to the application forever. We should probably add a logout functionality which removes the login details from the local storage. We will however leave it for an exercise. - +Now a user stays logged in to the application forever. We should probably add a logout functionality, which removes the login details from the local storage. We will however leave it as an exercise. -It's possible to log out a user using the console, and that is enough for now. +It's possible to log out a user using the console, and that is enough for now. You can log out with the command: ```js window.localStorage.removeItem('loggedNoteappUser') ``` -or with the command which empties localstorage completely: + +or with the command which empties localstorage completely: ```js window.localStorage.clear() ``` - -Current application code can be found on [Github](https://github.com/fullstack-hy2020/part2-notes/tree/part5-3), branch part5-3. +The current application code can be found on [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-3), in the branch part5-3.
    @@ -557,63 +531,52 @@ Current application code can be found on [Github](https://github.com/fullstack-h ### Exercises 5.1.-5.4. +We will now create a frontend for the blog list backend we created in the last part. You can use [this application](https://github.com/fullstack-hy2020/bloglist-frontend) from GitHub as the base of your solution. You need to connect your backend with a proxy as shown in [part 3](/en/part3/deploying_app_to_internet#proxy). -We will now create a frontend for the bloglist backend we created in the last part. You can use [this application](https://github.com/fullstack-hy2020/bloglist-frontend) from GitHub as the base of your solution. The application expects your backend to be running on port 3001. - -It is enough to submit your finished solution. You can do a commit after each exercise, but that is not necessary. - -The first few exercises revise everything we have learned about React so far. They can be challenging, especially if your backend is incomplete. -It might be best to use the backend from model answers of part 4. +It is enough to submit your finished solution. You can commit after each exercise, but that is not necessary. +The first few exercises revise everything we have learned about React so far. They can be challenging, especially if your backend is incomplete. +It might be best to use the backend that we marked as the answer for part 4. -While doing the exercises, remember all of the debugging methods we have talked about, especially keeping an eye on the console. +While doing the exercises, remember all of the debugging methods we have talked about, especially keeping an eye on the console. +**Warning:** If you notice you are mixing in the _async/await_ and _then_ commands, it's 99.9% certain you are doing something wrong. Use either or, never both. -**Warning:** If you notice you are mixing async/await and _then_ commands, its 99.9% certain you are doing something wrong. Use either or, never both. +#### 5.1: Blog List Frontend, step 1 -#### 5.1: bloglist frontend, step1 - - -Clone the application from [Github](https://github.com/fullstack-hy2020/bloglist-frontend) with the command: +Clone the application from [GitHub](https://github.com/fullstack-hy2020/bloglist-frontend) with the command: ```bash git clone https://github.com/fullstack-hy2020/bloglist-frontend ``` - -remove the git configuration of the cloned application +Remove the git configuration of the cloned application ```bash cd bloglist-frontend // go to cloned repository rm -rf .git ``` - -The application is started the usual way, but you have to install its dependencies first: +The application is started the usual way, but you have to install its dependencies first: ```bash npm install -npm start +npm run dev ``` - Implement login functionality to the frontend. The token returned with a successful login is saved to the application's state user. +If a user is not logged in, only the login form is visible. -If a user is not logged in, only the login form is visible. - -![](../../images/5/4e.png) - - -If user is logged in, the name of the user and a list of blogs is shown. +![browser showing visible login form only](../../images/5/4e.png) -![](../../images/5/5e.png) +If the user is logged-in, the name of the user and a list of blogs is shown. +![browser showing blogs and who is logged in](../../images/5/5e.png) -User details of the logged in user do not have to be saved to the local storage yet. +User details of the logged-in user do not have to be saved to the local storage yet. - -**NB** You can implement the conditional rendering of the login form like this for example: +**NB** You can implement the conditional rendering of the login form like this for example: ```js if (user === null) { @@ -638,35 +601,50 @@ User details of the logged in user do not have to be saved to the local storage } ``` -#### 5.2: bloglist frontend, step2 +#### 5.2: Blog List Frontend, step 2 + +Make the login 'permanent' by using the local storage. Also, implement a way to log out. + +![browser showing logout button after logging in](../../images/5/6e.png) + +Ensure the browser does not remember the details of the user after logging out. +#### 5.3: Blog List Frontend, step 3 -Make the login 'permanent' by using the local storage. Also implement a way to log out. +Expand your application to allow a logged-in user to add new blogs: -![](../../images/5/6e.png) +![browser showing new blog form](../../images/5/7e.png) -Ensure the browser does not remember the details of the user after logging out. +#### 5.4: Blog List Frontend, step 4 -#### 5.3: bloglist frontend, step3 +Implement notifications that inform the user about successful and unsuccessful operations at the top of the page. For example, when a new blog is added, the following notification can be shown: +![browser showing successful operation notification](../../images/5/8e.png) -Expand your application to allow a logged in user to add new blogs: +Failed login can show the following notification: -![](../../images/5/7e.png) +![browser showing failed login attempt notification](../../images/5/9e.png) +The notifications must be visible for a few seconds. It is not compulsory to add colors. + +
    + +
    -#### 5.4*: bloglist frontend, step4 +### A note on using local storage -Implement notifications which inform the user about successful and unsuccessful operations at the top of the page. For example, when a new blog is added, the following notification can be shown: +At the [end](/en/part4/token_authentication#problems-of-token-based-authentication) of the last part, we mentioned that the challenge of token-based authentication is how to cope with the situation when the API access of the token holder to the API needs to be revoked. -![](../../images/5/8e.png) +There are two solutions to the problem. The first one is to limit the validity period of a token. This forces the user to re-login to the app once the token has expired. The other approach is to save the validity information of each token to the backend database. This solution is often called a server-side session. +No matter how the validity of tokens is checked and ensured, saving a token in the local storage might contain a security risk if the application has a security vulnerability that allows [Cross Site Scripting (XSS)](https://owasp.org/www-community/attacks/xss/) attacks. An XSS attack is possible if the application would allow a user to inject arbitrary JavaScript code (e.g. using a form) that the app would then execute. When using React sensibly it should not be possible since [React sanitizes](https://legacy.reactjs.org/docs/introducing-jsx.html#jsx-prevents-injection-attacks) all text that it renders, meaning that it is not executing the rendered content as JavaScript. -Failed login can show the following notification: +If one wants to play safe, the best option is to not store a token in local storage. This might be an option in situations where leaking a token might have tragic consequences. -![](../../images/5/9e.png) +It has been suggested that the identity of a signed-in user should be saved as [httpOnly cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies), so that JavaScript code could not have any access to the token. The drawback of this solution is that it would make implementing SPA applications a bit more complex. One would need at least to implement a separate page for logging in. +However, it is good to notice that even the use of httpOnly cookies does not guarantee anything. It has even been suggested that httpOnly cookies are [not any safer than](https://academind.com/tutorials/localstorage-vs-cookies-xss/) the use of local storage. -The notifications must be visible for a few seconds. It is not compulsory to add colors. +So no matter the used solution the most important thing is to [minimize the risk](https://cheatsheetseries.owasp.org/cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.html) of XSS attacks altogether.
    diff --git a/src/content/5/en/part5b.md b/src/content/5/en/part5b.md index ea7a5c37a2b..abc37d6d685 100644 --- a/src/content/5/en/part5b.md +++ b/src/content/5/en/part5b.md @@ -7,28 +7,21 @@ lang: en
    - ### Displaying the login form only when appropriate - Let's modify the application so that the login form is not displayed by default: -![](../../images/5/10e.png) - +![browser showing log in button by default](../../images/5/10e.png) The login form appears when the user presses the login button: -![](../../images/5/11e.png) - +![user at login screen about to press cancel](../../images/5/11e.png) The user can close the login form by clicking the cancel button. - Let's start by extracting the login form into its own component: ```js -import React from 'react' - const LoginForm = ({ handleSubmit, handleUsernameChange, @@ -65,10 +58,8 @@ const LoginForm = ({ export default LoginForm ``` - The state and all the functions related to it are defined outside of the component and are passed to the component as props. - Notice that the props are assigned to variables through destructuring, which means that instead of writing: ```js @@ -93,10 +84,8 @@ const LoginForm = (props) => { } ``` - where the properties of the _props_ object are accessed through e.g. _props.handleSubmit_, the properties are assigned directly to their own variables. - One fast way of implementing the functionality is to change the _loginForm_ function of the App component like so: ```js @@ -132,11 +121,9 @@ const App = () => { } ``` +The App component state now contains the boolean loginVisible, which defines if the login form should be shown to the user or not. -The App components state now contains the boolean loginVisible, that defines if the login form should be shown to the user or not. - - -The value of loginVisible is toggled with two buttons. Both buttons have their event handlers defined directly in the component: +The value of _loginVisible_ is toggled with two buttons. Both buttons have their event handlers defined directly in the component: ```js @@ -144,7 +131,6 @@ The value of loginVisible is toggled with two buttons. Both buttons have their e ``` - The visibility of the component is defined by giving the component an [inline](/en/part2/adding_styles_to_react_app#inline-styles) style rule, where the value of the [display](https://developer.mozilla.org/en-US/docs/Web/CSS/display) property is none if we do not want the component to be displayed: ```js @@ -160,22 +146,17 @@ const showWhenVisible = { display: loginVisible ? '' : 'none' }
    ``` - We are once again using the "question mark" ternary operator. If _loginVisible_ is true, then the CSS rule of the component will be: ```css display: 'none'; ``` - -If _loginVisible_ is false, then display will not receive any value related to the visibility of the component. - +If _loginVisible_ is false, then display will not receive any value related to the visibility of the component. ### The components children, aka. props.children - -The code related to managing the visibility of the login form could be considered to be its own logical entity, and for this reason it would be good to extract it from the App component into its own separate component. - +The code related to managing the visibility of the login form could be considered to be its own logical entity, and for this reason, it would be good to extract it from the App component into a separate component. Our goal is to implement a new Togglable component that can be used in the following way: @@ -191,9 +172,7 @@ Our goal is to implement a new Togglable component that can be used in th ``` - -The way that the component is used is slightly different from our previous components. The component has both an opening and a closing tags which surround a LoginForm component. In React terminology LoginForm is a child component of Togglable. - +The way that the component is used is slightly different from our previous components. The component has both opening and closing tags that surround a LoginForm component. In React terminology LoginForm is a child component of Togglable. We can add any React elements we want between the opening and closing tags of Togglable, like this for example: @@ -204,11 +183,10 @@ We can add any React elements we want between the opening and closing tags of ``` - The code for the Togglable component is shown below: ```js -import React, { useState } from 'react' +import { useState } from 'react' const Togglable = (props) => { const [visible, setVisible] = useState(false) @@ -236,9 +214,7 @@ const Togglable = (props) => { export default Togglable ``` - -The new and interesting part of the code is [props.children](https://reactjs.org/docs/glossary.html#propschildren), that is used for referencing the child components of the component. The child components are the React elements that we define between the opening and closing tags of a component. - +The new and interesting part of the code is [props.children](https://react.dev/learn/passing-props-to-a-component#passing-jsx-as-children), which is used for referencing the child components of the component. The child components are the React elements that we define between the opening and closing tags of a component. This time the children are rendered in the code that is used for rendering the component itself: @@ -249,7 +225,6 @@ This time the children are rendered in the code that is used for rendering the c
    ``` - Unlike the "normal" props we've seen before, children is automatically added by React and always exists. If a component is defined with an automatically closing _/>_ tag, like this: ```js @@ -260,14 +235,11 @@ Unlike the "normal" props we've seen before, children is automatically ad /> ``` - Then props.children is an empty array. - The Togglable component is reusable and we can use it to add similar visibility toggling functionality to the form that is used for creating new notes. - -Before we do that, let's extract the form for creating notes into its own component: +Before we do that, let's extract the form for creating notes into a component: ```js const NoteForm = ({ onSubmit, handleChange, value}) => { @@ -286,6 +258,7 @@ const NoteForm = ({ onSubmit, handleChange, value}) => { ) } ``` + Next let's define the form component inside of a Togglable component: ```js @@ -298,42 +271,32 @@ Next let's define the form component inside of a Togglable component: ``` - -You can find the code for our current application in its entirety in the part5-4 branch of [this github repository](https://github.com/fullstack-hy2020/part2-notes/tree/part5-4). - +You can find the code for our current application in its entirety in the part5-4 branch of [this GitHub repository](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-4). ### State of the forms - The state of the application currently is in the _App_ component. - -React documentation says the [following](https://reactjs.org/docs/lifting-state-up.html) about where to place the state: +React documentation says the [following](https://react.dev/learn/sharing-state-between-components) about where to place the state: -> Often, several components need to reflect the same changing data. We recommend lifting the shared state up to their closest common ancestor. +Sometimes, you want the state of two components to always change together. To do it, remove state from both of them, move it to their closest common parent, and then pass it down to them via props. This is known as lifting state up, and it’s one of the most common things you will do writing React code. - -If we think about the state of the forms, so for example the contents of a new note before it has been created, the _App_ component does not actually need it for anything. -We could just as well move the state of the forms to the corresponding components. +If we think about the state of the forms, so for example the contents of a new note before it has been created, the _App_ component does not need it for anything. +We could just as well move the state of the forms to the corresponding components. - -The component for a note changes like so: +The component for creating a new note changes like so: ```js -import React, {useState} from 'react' +import { useState } from 'react' const NoteForm = ({ createNote }) => { - const [newNote, setNewNote] = useState('') - - const handleChange = (event) => { - setNewNote(event.target.value) - } + const [newNote, setNewNote] = useState('') const addNote = (event) => { event.preventDefault() createNote({ content: newNote, - important: Math.random() > 0.5, + important: true }) setNewNote('') @@ -346,29 +309,30 @@ const NoteForm = ({ createNote }) => {
    setNewNote(event.target.value)} />
    ) } + +export default NoteForm ``` - -The newNote state attribute and the event handler responsible for changing it have been moved from the _App_ component to the component responsible for the note form. +**NOTE** At the same time, we changed the behavior of the application so that new notes are important by default, i.e. the field important gets the value true. + +The newNote state variable and the event handler responsible for changing it have been moved from the _App_ component to the component responsible for the note form. - -There is only one prop left, the _createNote_ function, which the form calls when a new note is created. +There is only one prop left, the _createNote_ function, which the form calls when a new note is created. - -The _App_ component becomes simpler now that we have got rid of the newNote state and its event handler. -The _addNote_ function for creating new notes receives a new note as a parameter, and the function is the only prop we send to the form: +The _App_ component becomes simpler now that we have got rid of the newNote state and its event handler. +The _addNote_ function for creating new notes receives a new note as a parameter, and the function is the only prop we send to the form: ```js const App = () => { // ... - const addNote = (noteObject) => { + const addNote = (noteObject) => { // highlight-line noteService .create(noteObject) .then(returnedNote => { @@ -386,27 +350,28 @@ const App = () => { } ``` - -We could do the same for the log in form, but we'll leave that for an optional exercise. +We could do the same for the log in form, but we'll leave that for an optional exercise. - -The application code can be found from [github](https://github.com/fullstack-hy2020/part2-notes/tree/part5-5), -branch part5-5. +The application code can be found on [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-5), branch part5-5. ### References to components with ref -Our current implementation is quite good, it has one aspect that could be improved. +Our current implementation is quite good; it has one aspect that could be improved. + +After a new note is created, it would make sense to hide the new note form. Currently, the form stays visible. There is a slight problem with hiding it, the visibility is controlled with the visible state variable inside of the Togglable component. -After a new note is created, it would make sense to hide the new note form. Currently the form stays visible. There is a slight problem with hiding the form. The visibility is controlled with the visible variable inside of the Togglable component. How can we access it outside of the component? +One solution to this would be to move control of the Togglable component's state outside the component. However, we won't do that now, because we want the component to be responsible for its own state. So we have to find another solution, and find a mechanism to change the state of the component externally. -There are many ways to implement closing the form from the parent component, but let's use the [ref](https://reactjs.org/docs/refs-and-the-dom.html) mechanism of React, which offers a reference to the component. +There are several different ways to implement access to a component's functions from outside the component, but let's use the [ref](https://react.dev/learn/referencing-values-with-refs) mechanism of React, which offers a reference to the component. Let's make the following changes to the App component: ```js +import { useState, useEffect, useRef } from 'react' // highlight-line + const App = () => { // ... - const noteFormRef = React.createRef() // highlight-line + const noteFormRef = useRef() // highlight-line const noteForm = () => ( // highlight-line @@ -418,16 +383,14 @@ const App = () => { } ``` - -The [createRef](https://reactjs.org/docs/react-api.html#reactcreateref) method is used to create a noteFormRef ref, that is assigned to the Togglable component containing the creation note form. The noteFormRef variable acts as a reference to the component. - +The [useRef](https://react.dev/reference/react/useRef) hook is used to create a noteFormRef reference, that is assigned to the Togglable component containing the creation note form. The noteFormRef variable acts as a reference to the component. This hook ensures the same reference (ref) that is kept throughout re-renders of the component. We also make the following changes to the Togglable component: ```js -import React, { useState, useImperativeHandle } from 'react' // highlight-line +import { useState, useImperativeHandle } from 'react' // highlight-line -const Togglable = React.forwardRef((props, ref) => { // highlight-line +const Togglable = (props) => { // highlight-line const [visible, setVisible] = useState(false) const hideWhenVisible = { display: visible ? 'none' : '' } @@ -438,10 +401,8 @@ const Togglable = React.forwardRef((props, ref) => { // highlight-line } // highlight-start - useImperativeHandle(ref, () => { - return { - toggleVisibility - } + useImperativeHandle(props.ref, () => { + return { toggleVisibility } }) // highlight-end @@ -456,15 +417,12 @@ const Togglable = React.forwardRef((props, ref) => { // highlight-line ) -}) // highlight-line +} export default Togglable ``` - -The function that creates the component is wrapped inside of a [forwardRef](https://reactjs.org/docs/react-api.html#reactforwardref) function call. This way the component can access the ref that is assigned to it. - -The component uses the [useImperativeHandle](https://reactjs.org/docs/hooks-reference.html#useimperativehandle) hook to make its toggleVisibility function available outside of the component. +The component uses the [useImperativeHandle](https://react.dev/reference/react/useImperativeHandle) hook to make its toggleVisibility function available outside of the component. We can now hide the form by calling noteFormRef.current.toggleVisibility() after a new note has been created: @@ -483,17 +441,16 @@ const App = () => { } ``` -To recap, the [useImperativeHandle](https://reactjs.org/docs/hooks-reference.html#useimperativehandle) function is a React hook, that is used for defining functions in a component which can be invoked from outside of the component. +To recap, the [useImperativeHandle](https://react.dev/reference/react/useImperativeHandle) function is a React hook, that is used for defining functions in a component, which can be invoked from outside of the component. -This trick works for changing the state of a component, but it looks a bit unpleasant. We could have accomplished the same functionality with slightly cleaner code using "old React" class-based components. We will take a look at these class components at the part 7 of the course material. So far this is the only situation where using React hooks leads to code that is not cleaner than with class components. +This trick works for changing the state of a component, but it looks a bit unpleasant. We could have accomplished the same functionality with slightly cleaner code using "old React" class-based components. We will take a look at these class components during part 7 of the course material. So far this is the only situation where using React hooks leads to code that is not cleaner than with class components. -There are also [other use cases](https://reactjs.org/docs/refs-and-the-dom.html) for refs than accessing React components. +There are also [other use cases](https://react.dev/learn/manipulating-the-dom-with-refs) for refs than accessing React components. -You can find the code for our current application in its entirety in the part5-6 branch of [this github repository](https://github.com/fullstack-hy2020/part2-notes/tree/part5-6). +You can find the code for our current application in its entirety in the part5-6 branch of [this GitHub repository](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-6). ### One point about components - When we define a component in React: ```js @@ -502,7 +459,6 @@ const Togglable = () => ... } ``` - And use it like this: ```js @@ -521,58 +477,69 @@ And use it like this: ``` +We create three separate instances of the component that all have their separate state: -We create three separate instances of the component that all have their own separate state: +![browser of three togglable components](../../images/5/12e.png) -![](../../images/5/12e.png) +The ref attribute is used for assigning a reference to each of the components in the variables togglable1, togglable2 and togglable3. +### The updated full stack developer's oath -The ref attribute is used for assigning a reference to each of the components in the variables togglable1, togglable2 and togglable3. +The number of moving parts increases. At the same time, the likelihood of ending up in a situation where we are looking for a bug in the wrong place increases. So we need to be even more systematic. - +So we should once more extend our oath: -
    +Full stack development is extremely hard, that is why I will use all the possible means to make it easier +- I will have my browser developer console open all the time +- I will use the network tab of the browser dev tools to ensure that frontend and backend are communicating as I expect +- I will constantly keep an eye on the state of the server to make sure that the data sent there by the frontend is saved there as I expect +- I will keep an eye on the database: does the backend save data there in the right format +- I progress with small steps +- when I suspect that there is a bug in the frontend, I'll make sure that the backend works as expected +- when I suspect that there is a bug in the backend, I'll make sure that the frontend works as expected +- I will write lots of _console.log_ statements to make sure I understand how the code and the tests behave and to help pinpoint problems +- If my code does not work, I will not write more code. Instead, I'll start deleting it until it works or will just return to a state where everything was still working +- If a test does not pass, I'll make sure that the tested functionality works properly in the application +- When I ask for help in the course Discord channel or elsewhere I formulate my questions properly, see [here](/en/part0/general_info#how-to-get-help-in-discord) how to ask for help + +
    -### Exercises 5.5.-5.10. +
    +### Exercises 5.5.-5.11. -#### 5.5 Blog list frontend, step5 +#### 5.5 Blog List Frontend, step 5 Change the form for creating blog posts so that it is only displayed when appropriate. Use functionality similar to what was shown [earlier in this part of the course material](/en/part5/props_children_and_proptypes#displaying-the-login-form-only-when-appropriate). If you wish to do so, you can use the Togglable component defined in part 5. By default the form is not visible -![](../../images/5/13ae.png) +![browser showing new note button with no form](../../images/5/13ae.png) -It expands when button new note is clicked +It expands when button create new blog is clicked -![](../../images/5/13be.png) +![browser showing form with create new](../../images/5/13be.png) -The form closes when a new blog is created. +The form hides again after a new blog is created or the cancel button is pressed. -#### 5.6 Blog list frontend, step6 +#### 5.6 Blog List Frontend, step 6 - -Separate the form for creating a new blog into its own component (if you have not already done so), and -move all the states required for creating a new blog to this component. +Separate the form for creating a new blog into its own component (if you have not already done so), and move all the states required for creating a new blog to this component. -The component must work like the NewNote component from the [material](/en/part5/props_children_and_proptypes) of this part. +The component must work like the NoteForm component from the [material](/en/part5/props_children_and_proptypes#state-of-the-forms) of this part. -#### 5.7* Blog list frontend, step7 +#### 5.7 Blog List Frontend, step 7 - -Let's add each blog a button, which controls if all of the details about the blog are shown or not. +Let's add a button to each blog, which controls whether all of the details about the blog are shown or not. - Full details of the blog open when the button is clicked. -![](../../images/5/13ea.png) +![browser showing full details of a blog with others just having view buttons](../../images/5/13ea.png) - -And the details are hidden when the button is clicked again. +And the details are hidden when the button is clicked again. -At this point the like button does not need to do anything. +At this point, the like button does not need to do anything. The application shown in the picture has a bit of additional CSS to improve its appearance. @@ -598,13 +565,13 @@ const Blog = ({ blog }) => { )} ``` -**NB:** even though the functionality implemented in this part is almost identical to the functionality provided by the Togglable component, the component can not be used directly to achieve the desired behavior. The easiest solution will be to add state to the blog post that controls the displayed form of the blog post. +**NB:** Even though the functionality implemented in this part is almost identical to the functionality provided by the Togglable component, it can't be used directly to achieve the desired behavior. The easiest solution would be to add a state to the blog component that controls if the details are being displayed or not. -#### 5.8*: Blog list frontend, step8 +#### 5.8: Blog List Frontend, step 8 Implement the functionality for the like button. Likes are increased by making an HTTP _PUT_ request to the unique address of the blog post in the backend. -Since the backend operation replaces the entire blog post, you will have to send all of its fields in the request body. If you wanted to add a like to the following blog post: +Since the backend operation replaces the entire blog post, you will have to send all of its fields in the request body. If you wanted to add a like to the following blog post: ```js { @@ -633,233 +600,121 @@ You would have to make an HTTP PUT request to the address /api/blogs/5a43fde2 } ``` -**One last warning:** if you notice that you are using async/await and the _then_-method in the same code, it is almost certain that you are doing something wrong. Stick to using one or the other, and never use both at the same time "just in case". - -#### 5.9*: Blog list frontend, step9 - -Modify the application to list the blog posts by the number of likes. Sorting the blog posts can be done with the array [sort](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) method. - -#### 5.10*: Blog list frontend, step10 - -Add a new button for deleting blog posts. Also implement the logic for deleting blog posts in the backend. - -Your application could look something like this: - -![](../../images/5/14ea.png) - -The confirmation dialog for deleting a blog post is easy to implement with the [window.confirm](https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm) function. - -Show the button for deleting a blog post only if the blog post was added by the user. - -
    - -
    - -### PropTypes - -The Togglable component assumes that it is given the text for the button via the buttonLabel prop. If we forget to define it to the component: - -```js - buttonLabel forgotten... -``` - -The application works, but the browser renders a button that that has no label text. +The backend has to be updated too to handle the user reference. -We would like to enforce that when the Togglable component is used, the button label text prop must be given a value. - -The expected and required props of a component can be defined with the [prop-types](https://github.com/facebook/prop-types) package. Let's install the package: - -```js -npm install --save prop-types -``` +#### 5.9: Blog List Frontend, step 9 -We can define the buttonLabel prop as a mandatory or required string-type prop as shown below: +We notice that something is wrong. When a blog is liked in the app, the name of the user that added the blog is not shown in its details: -```js -import PropTypes from 'prop-types' +![browser showing missing name underneath like button](../../images/5/59put.png) -const Togglable = React.forwardRef((props, ref) => { - // .. -} +When the browser is reloaded, the information of the person is displayed. This is not acceptable, find out where the problem is and make the necessary correction. -Togglable.propTypes = { - buttonLabel: PropTypes.string.isRequired -} -``` +Of course, it is possible that you have already done everything correctly and the problem does not occur in your code. In that case, you can move on. -The console will display the following error message if the prop is left undefined: +#### 5.10: Blog List Frontend, step 10 -![](../../images/5/15.png) +Modify the application to sort the blog posts by the number of likes. The Sorting can be done with the array [sort](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) method. +#### 5.11: Blog List Frontend, step 11 -The application still works and nothing forces us to define props despite the PropTypes definitions. Mind you, it is extremely unprofessional to leave any red output to the browser console. +Add a new button for deleting blog posts. Also, implement the logic for deleting blog posts in the frontend. -Let's also define PropTypes to the LoginForm component: +Your application could look something like this: -```js -import PropTypes from 'prop-types' +![browser of confirmation of blog removal](../../images/5/14ea.png) -const LoginForm = ({ - handleSubmit, - handleUsernameChange, - handlePasswordChange, - username, - password - }) => { - // ... - } +The confirmation dialog for deleting a blog post is easy to implement with the [window.confirm](https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm) function. -LoginForm.propTypes = { - handleSubmit: PropTypes.func.isRequired, - handleUsernameChange: PropTypes.func.isRequired, - handlePasswordChange: PropTypes.func.isRequired, - username: PropTypes.string.isRequired, - password: PropTypes.string.isRequired -} -``` +Show the button for deleting a blog post only if the blog post was added by the user. -If the type of a passed prop is wrong, e.g. if we try to define the handleSubmit prop as a string, then this will result in the following warning: +
    -![](../../images/5/16.png) +
    ### ESlint In part 3 we configured the [ESlint](/en/part3/validation_and_es_lint#lint) code style tool to the backend. Let's take ESlint to use in the frontend as well. -Create-react-app has installed ESlint to the project by default, so all that's left for us to do is to define our desired configuration in the .eslintrc.js file. +Vite has installed ESlint to the project by default, so all that's left for us to do is define our desired configuration in the eslint.config.js file. -*NB:* do not run the _eslint --init_ command. It will install the latest version of ESlint that is not compatible with the configuration file created by create-react-app! - -Next, we will start testing the frontend and in order to avoid undesired and irrelevant linter errors we will install the [eslint-jest-plugin](https://www.npmjs.com/package/eslint-plugin-jest) package: +Let's create a eslint.config.js file with the following contents: ```js -npm add --save-dev eslint-plugin-jest -``` +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' -Let's create a .eslintrc.js file with the following contents: - -```js -module.exports = { - "env": { - "browser": true, - "es6": true, - "jest/globals": true - }, - "extends": [ - "eslint:recommended", - "plugin:react/recommended" - ], - "parserOptions": { - "ecmaFeatures": { - "jsx": true - }, - "ecmaVersion": 2018, - "sourceType": "module" - }, - "plugins": [ - "react", "jest" - ], - "rules": { - "indent": [ - "error", - 2 - ], - "linebreak-style": [ - "error", - "unix" - ], - "quotes": [ - "error", - "single" - ], - "semi": [ - "error", - "never" - ], - "eqeqeq": "error", - "no-trailing-spaces": "error", - "object-curly-spacing": [ - "error", "always" - ], - "arrow-spacing": [ - "error", { "before": true, "after": true } +export default [ + { ignores: ['dist'] }, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module' + } + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh + }, + rules: { + ...js.configs.recommended.rules, + ...reactHooks.configs.recommended.rules, + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true } + // highlight-start ], - "no-console": 0, - "react/prop-types": 0 + indent: ['error', 2], + 'linebreak-style': ['error', 'unix'], + quotes: ['error', 'single'], + semi: ['error', 'never'], + eqeqeq: 'error', + 'no-trailing-spaces': 'error', + 'object-curly-spacing': ['error', 'always'], + 'arrow-spacing': ['error', { before: true, after: true }], + 'no-console': 'off' + //highlight-end + } } -} +] ``` -NOTE: If you are using Visual Studio Code together with ESLint plugin, you might need to add additional workspace setting for it to work. If you are seeing ```Failed to load plugin react: Cannot find module 'eslint-plugin-react' ``` additional configuration is needed. Adding line ```"eslint.workingDirectories": [{ "mode": "auto" }] ``` to settings.json in the workspace seems to work. See [here](https://github.com/microsoft/vscode-eslint/issues/880#issuecomment-578052807) for more information. - -Let's create [.eslintignore](https://eslint.org/docs/user-guide/configuring#ignoring-files-and-directories) file with the following contents to the repository root - -```bash -node_modules -build -``` - -Now the directories build and node_modules will be skipped when linting. - -Let us also create a npm script to run the lint: +NOTE: If you are using Visual Studio Code together with ESLint plugin, you might need to add a workspace setting for it to work. If you are seeing Failed to load plugin react: Cannot find module 'eslint-plugin-react' additional configuration is needed. Adding the following line to settings.json may help: ```js -{ - // ... - { - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject", - "server": "json-server -p3001 db.json", - "eslint": "eslint ." // highlight-line - }, - // ... -} +"eslint.workingDirectories": [{ "mode": "auto" }] ``` -Component _Togglable_ causes a nasty looking warning Component definition is missing display name: - -![](../../images/5/25ea.png) - -The react-devtools also reveals that the component does not have name: +See [here](https://github.com/microsoft/vscode-eslint/issues/880#issuecomment-578052807) for more information. -![](../../images/5/25ea.png) +As usual, you can perform the linting either from the command line with the command -Fortunately this is easy to fix - -```js -import React, { useState, useImperativeHandle } from 'react' -import PropTypes from 'prop-types' - -const Togglable = React.forwardRef((props, ref) => { - // ... -}) - -Togglable.displayName = 'Togglable' // highlight-line - -export default Togglable +```bash +npm run lint ``` -You can find the code for our current application in its entirety in the part5-7 branch of [this github repository](https://github.com/fullstack-hy2020/part2-notes/tree/part5-7). +or using the editor's Eslint plugin. + +You can find the code for our current application in its entirety in the part5-7 branch of [this GitHub repository](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-7).
    -### Exercises 5.11.-5.12. - -#### 5.11: Blog list frontend, step11 +### Exercise 5.12. -Define PropTypes for one of the components of your application. - -#### 5.12: Blog list frontend, step12 +#### 5.12: Blog List Frontend, step 12 Add ESlint to the project. Define the configuration according to your liking. Fix all of the linter errors. -Create-react-app has installed ESlint to the project by default, so all that's left for you to do is to define your desired configuration in the .eslintrc.js file. - -*NB:* do not run the _eslint --init_ command. It will install the latest version of ESlint that is not compatible with the configuration file created by create-react-app! +Vite has installed ESlint to the project by default, so all that's left for you to do is define your desired configuration in the eslint.config.js file.
    diff --git a/src/content/5/en/part5c.md b/src/content/5/en/part5c.md index 8b8d2acee8c..d2c62c0b8ec 100644 --- a/src/content/5/en/part5c.md +++ b/src/content/5/en/part5c.md @@ -7,21 +7,66 @@ lang: en
    - There are many different ways of testing React applications. Let's take a look at them next. +The course previously used the [Jest](http://jestjs.io/) library developed by Facebook to test React components. We are now using the new generation of testing tools from Vite developers called [Vitest](https://vitest.dev/). Apart from the configurations, the libraries provide the same programming interface, so there is virtually no difference in the test code. -Tests will be implemented with the same [Jest](http://jestjs.io/) testing library developed by Facebook that was used in the previous part. Jest is actually configured by default to applications created with create-react-app. +Let's start by installing Vitest and the [jsdom](https://github.com/jsdom/jsdom) library simulating a web browser: -In addition to Jest, we also need another testing library that will help us render components for testing purposes. The current best option for this is[react-testing-library](https://github.com/testing-library/react-testing-library) which has seen rapid growth in popularity in recent times. +``` +npm install --save-dev vitest jsdom +``` +In addition to Vitest, we also need another testing library that will help us render components for testing purposes. The current best option for this is [react-testing-library](https://github.com/testing-library/react-testing-library) which has seen rapid growth in popularity in recent times. It is also worth extending the expressive power of the tests with the library [jest-dom](https://github.com/testing-library/jest-dom). -Let's install the library with the command: +Let's install the libraries with the command: ```js npm install --save-dev @testing-library/react @testing-library/jest-dom ``` +Before we can do the first test, we need some configurations. + +We add a script to the package.json file to run the tests: + +```js +{ + "scripts": { + // ... + "test": "vitest run" + } + // ... +} +``` + +Let's create a file _testSetup.js_ in the project root with the following content + +```js +import { afterEach } from 'vitest' +import { cleanup } from '@testing-library/react' +import '@testing-library/jest-dom/vitest' + +afterEach(() => { + cleanup() +}) +``` + +Now, after each test, the function _cleanup_ is executed to reset jsdom, which is simulating the browser. + +Expand the _vite.config.js_ file as follows + +```js +export default defineConfig({ + // ... + test: { + environment: 'jsdom', + globals: true, + setupFiles: './testSetup.js', + } +}) +``` + +With _globals: true_, there is no need to import keywords such as _describe_, _test_ and _expect_ into the tests. Let's first write tests for the component that is responsible for rendering a note: @@ -40,19 +85,16 @@ const Note = ({ note, toggleImportance }) => { } ``` - -Notice that the li element has the [CSS](https://reactjs.org/docs/dom-elements.html#classname) classname note, that is used to access the component in our tests. +Notice that the li element has the value note for the [CSS](https://react.dev/learn#adding-styles) attribute className, that could be used to access the component in our tests. ### Rendering the component for tests -We will write our test in the src/components/Note.test.js file, which is in the same directory as the component itself. +We will write our test in the src/components/Note.test.jsx file, which is in the same directory as the component itself. The first test verifies that the component renders the contents of the note: ```js -import React from 'react' -import '@testing-library/jest-dom/extend-expect' -import { render } from '@testing-library/react' +import { render, screen } from '@testing-library/react' import Note from './Note' test('renders content', () => { @@ -61,149 +103,229 @@ test('renders content', () => { important: true } - const component = render( - - ) + render() - expect(component.container).toHaveTextContent( - 'Component testing is done with react-testing-library' - ) + const element = screen.getByText('Component testing is done with react-testing-library') + expect(element).toBeDefined() }) ``` - -After the initial configuration, the test renders the component with the [render](https://testing-library.com/docs/react-testing-library/api#render) method provided by the react-testing-library: +After the initial configuration, the test renders the component with the [render](https://testing-library.com/docs/react-testing-library/api#render) function provided by the react-testing-library: ```js -const component = render( - -) +render() ``` -Normally React components are rendered to the DOM. The render method we used renders the components in a format that is suitable for tests without rendering them to the DOM. +Normally React components are rendered to the [DOM](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model). The render method we used renders the components in a format that is suitable for tests without rendering them to the DOM. +We can use the object [screen](https://testing-library.com/docs/queries/about#screen) to access the rendered component. We use screen's method [getByText](https://testing-library.com/docs/queries/bytext) to search for an element that has the note content and ensure that it exists: -_render_ returns an object that has several [properties](https://testing-library.com/docs/react-testing-library/api#render-result). One of the properties is called container, and it contains all of the HTML rendered by the component. +```js + const element = screen.getByText('Component testing is done with react-testing-library') + expect(element).toBeDefined() +``` +The existence of an element is checked using Vitest's [expect](https://vitest.dev/api/expect.html#expect) command. Expect generates an assertion for its argument, the validity of which can be tested using various condition functions. Now we used [toBeDefined](https://vitest.dev/api/expect.html#tobedefined) which tests whether the _element_ argument of expect exists. -In the expectation, we verify that the component renders the correct text, which in this case is the content of the note: +Run the test with command _npm test_: ```js -expect(component.container).toHaveTextContent( - 'Component testing is done with react-testing-library' -) -``` +$ npm test +> notes-frontend@0.0.0 test +> vitest run -### Running tests + RUN v3.2.3 /home/vejolkko/repot/fullstack-examples/notes-frontend -Create-react-app configures tests to be run in watch mode by default, which means that the _npm test_ command will not exit once the tests have finished, and will instead wait for changes to be made to the code. Once new changes to the code are saved, the tests are executed automatically after which Jest goes back to waiting for new changes to be made. + ✓ src/components/Note.test.jsx (1 test) 19ms + ✓ renders content 18ms + Test Files 1 passed (1) + Tests 1 passed (1) + Start at 14:31:54 + Duration 874ms (transform 51ms, setup 169ms, collect 19ms, tests 19ms, environment 454ms, prepare 87ms) +``` -If you want to run tests "normally", you can do so with the command: +Eslint complains about the keywords _test_ and _expect_ in the tests. The problem can be solved by adding the following configuration to the eslint.config.js file: ```js -CI=true npm test +// ... + +export default [ + // ... + // highlight-start + { + files: ['**/*.test.{js,jsx}'], + languageOptions: { + globals: { + ...globals.vitest + } + } + } + // highlight-end +] ``` +This is how ESLint is informed that Vitest keywords are globally available in test files. -**NB:** the console may issue a warning if you have not installed Watchman. Watchman is an application developed by Facebook that watches for changes that are made to files. The program speeds up the execution of tests and at least starting from macOS Sierra, running tests in watch mode issues some warnings to the console, that can be gotten rid of by installing Watchman. +### Test file location +In React there are (at least) [two different conventions](https://medium.com/@JeffLombardJr/organizing-tests-in-jest-17fc431ff850) for the test file's location. We created our test files according to the current standard by placing them in the same directory as the component being tested. -Instructions for installing Watchman on different operating systems can be found on the official Watchman website: https://facebook.github.io/watchman/ +The other convention is to store the test files "normally" in a separate _test_ directory. Whichever convention we choose, it is almost guaranteed to be wrong according to someone's opinion. +I do not like this way of storing tests and application code in the same directory. However, we will follow this approach for now, as it is the most common practice in small projects. -### Test file location +### Searching for content in a component +The react-testing-library package offers many different ways of investigating the content of the component being tested. In reality, the _expect_ in our test is not needed at all: -In React there are (at least) [two different conventions](https://medium.com/@JeffLombardJr/organizing-tests-in-jest-17fc431ff850) for the test file's location. We created our test files according to the current standard by placing them in the same directory as the component being tested. +```js +import { render, screen } from '@testing-library/react' +import Note from './Note' +test('renders content', () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } -The other convention is to store the test files "normally" in their own separate directory. Whichever convention we choose, it is almost guaranteed to be wrong according to someone's opinion. + render() + const element = screen.getByText('Component testing is done with react-testing-library') -Personally, I do not like this way of storing tests and application code in the same directory. The reason we choose to follow this convention is that it is configured by default in applications created by create-react-app. + expect(element).toBeDefined() // highlight-line +}) +``` +Test fails if _getByText_ does not find the element it is looking for. -### Searching for content in a component +The _getByText_ command, by default, searches for an element that contains only the **text provided as a parameter** and nothing else. Let us assume that a component would render text to an HTML element as follows: +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' : 'make important' -The react-testing-library package offers many different ways of investigating the content of the component being tested. Let's slightly expand our test: + return ( +
  • + Your awesome note: {note.content} // highlight-line + +
  • + ) +} + +export default Note +``` + +The _getByText_ method that the test uses does not find the element: ```js test('renders content', () => { const note = { - content: 'Component testing is done with react-testing-library', + content: 'Does not work anymore :(', important: true } - const component = render( - - ) + render() - // method 1 - expect(component.container).toHaveTextContent( - 'Component testing is done with react-testing-library' - ) + const element = screen.getByText('Does not work anymore :(') - // method 2 - const element = component.getByText( - 'Component testing is done with react-testing-library' - ) expect(element).toBeDefined() - - // method 3 - const div = component.container.querySelector('.note') - expect(div).toHaveTextContent( - 'Component testing is done with react-testing-library' - ) }) ``` +If we want to look for an element that contains the text, we could use an extra option: -The first way uses method toHaveTextContent to search for a matching text from the entire HTML code rendered by the component. -toHaveTextContent is one of many "matcher"-methods that are provided by the [jest-dom](https://github.com/testing-library/jest-dom#tohavetextcontent) library. +```js +const element = screen.getByText( + 'Does not work anymore :(', { exact: false } +) +``` +or we could use the _findByText_ method: -The second way uses the [getByText](https://testing-library.com/docs/dom-testing-library/api-queries#bytext) method of the object returned by the render method. The method returns the element that contains the given text. An exception occurs if no such element exists. For this reason, we would technically not need to specify any additional expectation. +```js +const element = await screen.findByText('Does not work anymore :(') +``` -The third way is to search for a specific element that is rendered by the component with the [querySelector](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector) method that receives a [CSS selector](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors) as its parameter. +It is important to notice that, unlike the other _ByText_ methods, _findByText_ returns a promise! - -The last two methods use the methods getByText and querySelector to find an element matching some condition from the rendered component. -There are numerous similiar query methods [available](https://testing-library.com/docs/dom-testing-library/api-queries). +There are situations where yet another form of the _queryByText_ method is useful. The method returns the element but it does not cause an exception if it is not found. -### Debugging tests +We could eg. use the method to ensure that something is not rendered to the component: +```js +test('does not render this', () => { + const note = { + content: 'This is a reminder', + important: true + } -We typically run into many different kinds of problems when writing our tests. + render() + const element = screen.queryByText('do not want this thing to be rendered') + expect(element).toBeNull() +}) +``` + +Other methods also exist, such as [getByTestId](https://testing-library.com/docs/queries/bytestid/), which searches for elements based on id fields specifically created for testing purposes. -The object returned by the render method has a [debug](https://testing-library.com/docs/react-testing-library/api#debug) method that can be used to print the HTML rendered by the component to the console. Let's try this out by making the following changes to our code: +We could also use [CSS-selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors) to find rendered elements by using the method [querySelector](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector) of the object [container](https://testing-library.com/docs/react-testing-library/api/#container-1) that is one of the fields returned by the render: ```js +import { render, screen } from '@testing-library/react' +import Note from './Note' + test('renders content', () => { const note = { content: 'Component testing is done with react-testing-library', important: true } - const component = render( - + const { container } = render() // highlight-line + +// highlight-start + const div = container.querySelector('.note') + expect(div).toHaveTextContent( + 'Component testing is done with react-testing-library' ) + // highlight-end +}) +``` - component.debug() // highlight-line +It is, however, recommended to search for elements primarily using methods other than the container object and CSS selectors. CSS attributes can often be changed without affecting the application's functionality, and users are not aware of them. It is better to search for elements based on properties visible to the user, for example, by using the _getByText_ method. This way, the tests better simulate the actual nature of the component and how a user would find the element on the screen. + +### Debugging tests + +We typically run into many different kinds of problems when writing our tests. + +Object _screen_ has method [debug](https://testing-library.com/docs/dom-testing-library/api-debugging#screendebug) that can be used to print the HTML of a component to the terminal. If we change the test as follows: + +```js +import { render, screen } from '@testing-library/react' +import Note from './Note' + +test('renders content', () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + render() + + screen.debug() // highlight-line // ... + }) ``` - -We can see the HTML generated by the component in the console: +the HTML gets printed to the console: ```js -console.log node_modules/@testing-library/react/dist/index.js:90 +console.log
  • ``` - -It is also possible to search for a smaller part of the component and print its HTML code. In order to do this, we need the _prettyDOM_ method that can be imported from the @testing-library/dom package that is automatically installed with react-testing-library: +It is also possible to use the same method to print a wanted element to console: ```js -import React from 'react' -import '@testing-library/jest-dom/extend-expect' -import { render } from '@testing-library/react' -import { prettyDOM } from '@testing-library/dom' // highlight-line +import { render, screen } from '@testing-library/react' import Note from './Note' test('renders content', () => { @@ -234,20 +352,19 @@ test('renders content', () => { important: true } - const component = render( - - ) - const li = component.container.querySelector('li') - - console.log(prettyDOM(li)) // highlight-line + render() + + const element = screen.getByText('Component testing is done with react-testing-library') + + screen.debug(element) // highlight-line + + expect(element).toBeDefined() }) ``` - -We used the selector to find the li element inside of the component, and printed its HTML to the console: +Now the HTML of the wanted element gets printed: ```js -console.log src/components/Note.test.js:21
  • @@ -262,355 +379,468 @@ console.log src/components/Note.test.js:21 In addition to displaying content, the Note component also makes sure that when the button associated with the note is pressed, the _toggleImportance_ event handler function gets called. +Let us install a library [user-event](https://testing-library.com/docs/user-event/intro) that makes simulating user input a bit easier: + +```bash +npm install --save-dev @testing-library/user-event +``` + Testing this functionality can be accomplished like this: ```js -import React from 'react' -import { render, fireEvent } from '@testing-library/react' // highlight-line -import { prettyDOM } from '@testing-library/dom' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' // highlight-line import Note from './Note' // ... -test('clicking the button calls event handler once', () => { +test('clicking the button calls event handler once', async () => { const note = { content: 'Component testing is done with react-testing-library', important: true } + + const mockHandler = vi.fn() // highlight-line - const mockHandler = jest.fn() - - const component = render( - + render( + // highlight-line ) - const button = component.getByText('make not important') - fireEvent.click(button) + const user = userEvent.setup() // highlight-line + const button = screen.getByText('make not important') // highlight-line + await user.click(button) // highlight-line - expect(mockHandler.mock.calls).toHaveLength(1) + expect(mockHandler.mock.calls).toHaveLength(1) // highlight-line }) ``` -There's a few interesting things related to this test. The event handler is [mock](https://facebook.github.io/jest/docs/en/mock-functions.html) function defined with Jest: +There are a few interesting things related to this test. The event handler is a [mock](https://vitest.dev/api/mock) function defined with Vitest: ```js -const mockHandler = jest.fn() +const mockHandler = vi.fn() ``` -The test finds the button based on the text from the rendered component and clicks the element: +A [session](https://testing-library.com/docs/user-event/setup/) is started to interact with the rendered component: ```js -const button = getByText('make not important') -fireEvent.click(button) +const user = userEvent.setup() ``` -Clicking happens with the [fireEvent](https://testing-library.com/docs/api-events#fireevent) method. +The test finds the button based on the text from the rendered component and clicks the element: +```js +const button = screen.getByText('make not important') +await user.click(button) +``` + +Clicking happens with the method [click](https://testing-library.com/docs/user-event/convenience/#click) of the userEvent-library. -The expectation of the test verifies that the mock function has been called exactly once. +The expectation of the test uses [toHaveLength](https://vitest.dev/api/expect.html#tohavelength) to verify that the mock function has been called exactly once: ```js expect(mockHandler.mock.calls).toHaveLength(1) ``` +The calls to the mock function are saved to the array [mock.calls](https://vitest.dev/api/mock#mock-calls) within the mock function object. -[Mock objects and functions](https://en.wikipedia.org/wiki/Mock_object) are commonly used stub components in testing that are used for replacing dependencies of the components being tested. Mocks make it possible to return hardcoded responses, and to verify the number of times the mock functions are called and with what parameters. - +[Mock objects and functions](https://en.wikipedia.org/wiki/Mock_object) are commonly used [stub](https://en.wikipedia.org/wiki/Method_stub) components in testing that are used for replacing dependencies of the components being tested. Mocks make it possible to return hardcoded responses, and to verify the number of times the mock functions are called and with what parameters. In our example, the mock function is a perfect choice since it can be easily used for verifying that the method gets called exactly once. - ### Tests for the Togglable component -Let's write a few tests for the Togglable component. Let's add the togglableContent CSS classname to the div that returns the child components. +Let's write a few tests for the Togglable component. The tests are shown below: ```js -const Togglable = React.forwardRef((props, ref) => { - // ... - - return ( -
    -
    - -
    -
    // highlight-line - {props.children} - -
    -
    - ) -}) -``` - - -The tests are shown below: - -```js -import React from 'react' -import '@testing-library/jest-dom/extend-expect' -import { render, fireEvent } from '@testing-library/react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import Togglable from './Togglable' describe('', () => { - let component - beforeEach(() => { - component = render( + render( -
    +
    togglable content
    ) }) test('renders its children', () => { - expect( - component.container.querySelector('.testDiv') - ).toBeDefined() + screen.getByText('togglable content') }) test('at start the children are not displayed', () => { - const div = component.container.querySelector('.togglableContent') - - expect(div).toHaveStyle('display: none') + const element = screen.getByText('togglable content') + expect(element).not.toBeVisible() }) - test('after clicking the button, children are displayed', () => { - const button = component.getByText('show...') - fireEvent.click(button) + test('after clicking the button, children are displayed', async () => { + const user = userEvent.setup() + const button = screen.getByText('show...') + await user.click(button) - const div = component.container.querySelector('.togglableContent') - expect(div).not.toHaveStyle('display: none') + const element = screen.getByText('togglable content') + expect(element).toBeVisible() }) +``` -}) +The _beforeEach_ function gets called before each test, which then renders the Togglable component. + +The first test verifies that the Togglable component renders its child component + +```js +
    + togglable content +
    ``` +The remaining tests use the _toBeVisible_ method to verify that the child component of the Togglable component is not visible initially, i.e. that the style of the div element contains _{ display: 'none' }_. Another test verifies that when the button is pressed the component is visible, meaning that the style for hiding it is no longer assigned to the component. -The _beforeEach_ function gets called before each test, which then renders the Togglable component into the _component_ variable +Let's also add a test that can be used to verify that the visible content can be hidden by clicking the second button of the component: + +```js +describe('', () => { + // ... -The first test verifies that the Togglable component renders its child component `
    `. + test('toggled content can be closed', async () => { + const user = userEvent.setup() + const button = screen.getByText('show...') + await user.click(button) + const closeButton = screen.getByText('cancel') + await user.click(closeButton) -The remaining tests use the [toHaveStyle](https://www.npmjs.com/package/@testing-library/jest-dom#tohavestyle) method to verify that the child component of the Togglable component is not visible initially, by checking that the style of the div element contains `{ display: 'none' }`. Another test verifies that when the button is pressed the component is visible, meaning that the style for hiding the component is no longer assigned to the component. + const element = screen.getByText('togglable content') + expect(element).not.toBeVisible() + }) +}) +``` +### Testing the forms -The button is searched for once again based on the text that it contains. The button could have been located also with the help of a CSS selector: +We already used the _click_ function of the [user-event](https://testing-library.com/docs/user-event/intro) in our previous tests to click buttons. ```js -const button = component.container.querySelector('button') +const user = userEvent.setup() +const button = screen.getByText('show...') +await user.click(button) ``` +We can also simulate text input with userEvent. -The component contains two buttons, but since [querySelector](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector) returns the first matching button, we happen to get the button that we wanted. +Let's make a test for the NoteForm component. The code of the component is as follows. +```js +import { useState } from 'react' -Let's also add a test that can be used to verify that the visible content can be hidden by clicking the second button of the component: +const NoteForm = ({ createNote }) => { + const [newNote, setNewNote] = useState('') -```js -test('toggled content can be closed', () => { - const button = component.container.querySelector('button') - fireEvent.click(button) + const addNote = event => { + event.preventDefault() + createNote({ + content: newNote, + important: true + }) - const closeButton = component.container.querySelector( - 'button:nth-child(2)' + setNewNote('') + } + + return ( +
    +

    Create a new note

    + +
    + setNewNote(event.target.value)} + /> + +
    +
    ) - fireEvent.click(closeButton) +} + +export default NoteForm +``` + +The form works by calling the function received as props _createNote_, with the details of the new note. - const div = component.container.querySelector('.togglableContent') - expect(div).toHaveStyle('display: none') +The test is as follows: + +```js +import { render, screen } from '@testing-library/react' +import NoteForm from './NoteForm' +import userEvent from '@testing-library/user-event' + +test(' updates parent state and calls onSubmit', async () => { + const createNote = vi.fn() + const user = userEvent.setup() + + render() + + const input = screen.getByRole('textbox') + const sendButton = screen.getByText('save') + + await user.type(input, 'testing a form...') + await user.click(sendButton) + + expect(createNote.mock.calls).toHaveLength(1) + expect(createNote.mock.calls[0][0].content).toBe('testing a form...') }) ``` +Tests get access to the input field using the function [getByRole](https://testing-library.com/docs/queries/byrole). -We defined a selector that returns the second button `button:nth-child(2)`. It's not a wise move to depend on the order of the buttons in the component, and it is recommended to find the elements based on their text: +The method [type](https://testing-library.com/docs/user-event/utility#type) of the userEvent is used to write text to the input field. + +The first test expectation ensures that submitting the form calls the _createNote_ method. +The second expectation checks that the event handler is called with the right parameters - that a note with the correct content is created when the form is filled. + +It's worth noting that the good old _console.log_ works as usual in the tests. For example, if you want to see what the calls stored by the mock-object look like, you can do the following ```js -test('toggled content can be closed', () => { - const button = component.getByText('show...') - fireEvent.click(button) +test(' updates parent state and calls onSubmit', async() => { + const user = userEvent.setup() + const createNote = vi.fn() + + render() - const closeButton = component.getByText('cancel') - fireEvent.click(closeButton) + const input = screen.getByRole('textbox') + const sendButton = screen.getByText('save') - const div = component.container.querySelector('.togglableContent') - expect(div).toHaveStyle('display: none') + await user.type(input, 'testing a form...') + await user.click(sendButton) + + console.log(createNote.mock.calls) // highlight-line }) ``` +In the middle of running the tests, the following is printed in the console: -The _getByText_ method that we used is just one of the many [queries](https://testing-library.com/docs/api-queries#queries) react-testing-library offers. +``` +[ [ { content: 'testing a form...', important: true } ] ] +``` + +### About finding the elements +Let us assume that the form has two input fields +```js +const NoteForm = ({ createNote }) => { + // ... + return ( +
    +

    Create a new note

    -### Testing the forms +
    + setNewNote(event.target.value)} + /> + // highlight-start + + // highlight-end + +
    +
    + ) +} +``` - -We already used the [fireEvent](https://testing-library.com/docs/api-events#fireevent) function in our previous tests to click buttons. +Now the approach that our test uses to find the input field ```js -const button = component.getByText('show...') -fireEvent.click(button) +const input = screen.getByRole('textbox') ``` - -In practice we used the fireEvent to create a click event for the button component. -We can also simulate text input with fireEvent. +would cause an error: - -Let's make a test for the NoteForm component. The code of the component is as follows +![node error that shows two elements with textbox since we use getByRole](../../images/5/40.png) + +The error message suggests using getAllByRole. The test could be fixed as follows: ```js -import React, { useState } from 'react' +const inputs = screen.getAllByRole('textbox') -const NoteForm = ({ createNote }) => { - const [newNote, setNewNote] = useState('') +await user.type(inputs[0], 'testing a form...') +``` - const handleChange = (event) => { - setNewNote(event.target.value) - } +Method getAllByRole now returns an array and the right input field is the first element of the array. However, this approach is a bit suspicious since it relies on the order of the input fields. - const addNote = (event) => { - event.preventDefault() - createNote({ - content: newNote, - important: Math.random() > 0.5, - }) +If an label were defined for the input field, the input field could be located using it with the getByLabelText method. For example, if we added a label to the input field: - setNewNote('') - } +```js + // ... + // highlight-line + // ... +``` + +The test could locate the input field as follows: + +```js +test(' updates parent state and calls onSubmit', () => { + const createNote = vi.fn() + + render() + + const input = screen.getByLabelText('content') // highlight-line + const sendButton = screen.getByText('save') + + userEvent.type(input, 'testing a form...' ) + userEvent.click(sendButton) + + expect(createNote.mock.calls).toHaveLength(1) + expect(createNote.mock.calls[0][0].content).toBe('testing a form...' ) +}) +``` + +Quite often input fields have a placeholder text that hints user what kind of input is expected. Let us add a placeholder to our form: + +```js +const NoteForm = ({ createNote }) => { + // ... return ( -
    // highlight-line +

    Create a new note

    setNewNote(event.target.value)} + placeholder='write note content here' // highlight-line /> +
    ) } - -export default NoteForm ``` - -The form works by calling the _createNote_ function it received as props with the details of the new note. - - -The test is as follows: +Now finding the right input field is easy with the method [getByPlaceholderText](https://testing-library.com/docs/queries/byplaceholdertext): ```js -import React from 'react' -import { render, fireEvent } from '@testing-library/react' -import '@testing-library/jest-dom/extend-expect' -import NoteForm from './NoteForm' - test(' updates parent state and calls onSubmit', () => { - const createNote = jest.fn() + const createNote = vi.fn() - const component = render( - - ) + render() - const input = component.container.querySelector('input') - const form = component.container.querySelector('form') + const input = screen.getByPlaceholderText('write note content here') // highlight-line + const sendButton = screen.getByText('save') - fireEvent.change(input, { - target: { value: 'testing of forms could be easier' } - }) - fireEvent.submit(form) + userEvent.type(input, 'testing a form...') + userEvent.click(sendButton) expect(createNote.mock.calls).toHaveLength(1) - expect(createNote.mock.calls[0][0].content).toBe('testing of forms could be easier' ) + expect(createNote.mock.calls[0][0].content).toBe('testing a form...') }) ``` - -We can simulate writing to input fields by creating an change event to them, and defining an object, which contains the text 'written' to the field. +Sometimes, finding the correct element using the methods described above can be challenging. In such cases, an alternative is the method querySelector of the _container_ object, which is returned by _render_, as was mentioned [earlier in this part](/en/part5/testing_react_apps#searching-for-content-in-a-component). Any CSS selector can be used with this method for searching elements in tests. - -The form is sent by simulating the submit event to the form. +Consider eg. that we would define a unique _id_ to the input field: - -The first test expectation ensures, that submitting the form calls the _createNote_ method. -The second expectation checks, that the event handler is called with the right parameters - that a note with the correct content is created when the form is filled. +```js +const NoteForm = ({ createNote }) => { + // ... -### Test coverage + return ( +
    +

    Create a new note

    - -We can easily find out the [coverage](https://github.com/facebookincubator/create-react-app/blob/ed5c48c81b2139b4414810e1efe917e04c96ee8d/packages/react-scripts/template/README.md#coverage-reporting) -of our tests by running them with the command +
    + setNewNote(event.target.value)} + id='note-input' // highlight-line + /> + + +
    +
    + ) +} +``` +The input element could now be found in the test as follows: ```js -CI=true npm test -- --coverage +const { container } = render() + +const input = container.querySelector('#note-input') ``` -![](../../images/5/18ea.png) +However, we shall stick to the approach of using _getByPlaceholderText_ in the test. - -A quite primitive HTML report will be generated to the coverage/lcov-report directory. -The report will tell us i.e the lines of untested code in each component: +### Test coverage -![](../../images/5/19ea.png) +We can easily find out the [coverage](https://vitest.dev/guide/coverage.html#coverage) of our tests by running them with the command. +```js +npm test -- --coverage +``` -You can find the code for our current application in its entirety in the part5-8 branch of [this Github repository](https://github.com/fullstack-hy2020/part2-notes/tree/part5-8). -
    +The first time you run the command, Vitest will ask you if you want to install the required library _@vitest/coverage-v8_. Install it, and run the command again: +![terminal output of test coverage](../../images/5/18new.png) -
    +A HTML report will be generated to the coverage directory. +The report will tell us the lines of untested code in each component: -### Exercises 5.13.-5.16. +![HTML report of the test coverage](../../images/5/19newer.png) -#### 5.13: Blog list tests, step1 +Let's add the directory coverage/ to the .gitignore file to exclude its contents from version control: - -Make a test, which checks that the component displaying a blog renders the blog's title and author, but does not render its url or number of likes by default +```js +//... - -Add CSS-classes to the component to help the testing as necessary. +coverage/ +``` -#### 5.14: Blog list tests, step2 +You can find the code for our current application in its entirety in the part5-8 branch of [this GitHub repository](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-8). - -Make a test, which checks that blog's url and number of likes are shown when the button controlling the shown details has been clicked. +
    -#### 5.15: Blog list tests, step3 +
    - -Make a test which ensures that if the like button is clicked twice, the event handler the component received as props is called twice. +### Exercises 5.13.-5.16. -#### 5.16*: Blog list tests, step4 +#### 5.13: Blog List Tests, step 1 - -Make a test for the new blog form. The test should check, that the form calls the event handler it received as props with the right details when a new blog is called. +Make a test, which checks that the component displaying a blog renders the blog's title and author, but does not render its URL or number of likes by default. - -If, for example, you give an input element id 'author': +Add CSS classes to the component to help the testing as necessary. -```js - {}} -/> -``` +#### 5.14: Blog List Tests, step 2 - -You can access the contents of the field with +Make a test, which checks that the blog's URL and number of likes are shown when the button controlling the shown details has been clicked. -```js -const author = component.container.querySelector('#author') -``` +#### 5.15: Blog List Tests, step 3 + +Make a test, which ensures that if the like button is clicked twice, the event handler the component received as props is called twice. + +#### 5.16: Blog List Tests, step 4 + +Make a test for the new blog form. The test should check, that the form calls the event handler it received as props with the right details when a new blog is created.
    @@ -618,21 +848,19 @@ const author = component.container.querySelector('#author') ### Frontend integration tests -In the previous part of the course material, we wrote integration tests for the backend that tested its logic and connected the database through the API provided by the backend. When writing these tests, we made the conscious decision not to write unit tests, as the code for that backend is fairly simple, and it is likely that bugs in our application occur in more complicated scenarios than integration tests are well suited for. +In the previous part of the course material, we wrote integration tests for the backend that tested its logic and connected the database through the API provided by the backend. When writing these tests, we made the conscious decision not to write unit tests, as the code for that backend is fairly simple, and it is likely that bugs in our application occur in more complicated scenarios than unit tests are well suited for. So far all of our tests for the frontend have been unit tests that have validated the correct functioning of individual components. Unit testing is useful at times, but even a comprehensive suite of unit tests is not enough to validate that the application works as a whole. - -We could also make integration tests for the frontend. Integration testing tests the collaboration of multiple components. It is considerably more difficult than unit testing, as we would have to for example mock data from the server. -We chose to concentrate making end to end tests to test the whole application, which we will work on in the last chapter of this part. - +We could also make integration tests for the frontend. Integration testing tests the collaboration of multiple components. It is considerably more difficult than unit testing, as we would have to for example mock data from the server. +We chose to concentrate on making end-to-end tests to test the whole application. We will work on the end-to-end tests in the last chapter of this part. ### Snapshot testing -Jest offers a completely different alternative to "traditional" testing called [snapshot](https://facebook.github.io/jest/docs/en/snapshot-testing.html) testing. The interesting feature of snapshot testing is that developers do not need to define any tests themselves, it is simply enough to adopt snapshot testing. +Vitest offers a completely different alternative to "traditional" testing called [snapshot](https://vitest.dev/guide/snapshot) testing. The interesting feature of snapshot testing is that developers do not need to define any tests themselves, it is simple enough to adopt snapshot testing. The fundamental principle is to compare the HTML code defined by the component after it has changed to the HTML code that existed before it was changed. -If the snapshot notices some change in the HTML defined by the component, then either it is new functionality or a "bug" caused by accident. Snapshot tests notify the developer if the HTML code of the component changes. The developer has to tell Jest if the change was desired or undesired. If the change to the HTML code is unexpected it strongly implicates a bug, and the developer can become aware of these potential issues easily thanks to snapshot testing. +If the snapshot notices some change in the HTML defined by the component, then either it is new functionality or a "bug" caused by accident. Snapshot tests notify the developer if the HTML code of the component changes. The developer has to tell Vitest if the change was desired or undesired. If the change to the HTML code is unexpected, it strongly implies a bug, and the developer can become aware of these potential issues easily thanks to snapshot testing.
    diff --git a/src/content/5/en/part5d.md b/src/content/5/en/part5d.md index a7724cac0b2..12bdd996023 100644 --- a/src/content/5/en/part5d.md +++ b/src/content/5/en/part5d.md @@ -7,460 +7,537 @@ lang: en
    - -So far we have tested the backend as a whole on an API level using integration tests, and tested some frontend components using unit tests. +So far we have tested the backend as a whole on an API level using integration tests and tested some frontend components using unit tests. - -Next we will look into one way to test the [system as a whole](https://en.wikipedia.org/wiki/System_testing) using End to End (E2E) tests. +Next, we will look into one way to test the [system as a whole](https://en.wikipedia.org/wiki/System_testing) using End to End (E2E) tests. - -We can do E2E testing of a web application using a browser and a testing library. There are multiple libraries available, for example [Selenium](http://www.seleniumhq.org/) which can be used with almost any browser. -Another browser option are so called [headless browsers](https://en.wikipedia.org/wiki/Headless_browser), which are browsers with no graphical user interface. -For example Chrome can be used in Headless-mode. +We can do E2E testing of a web application using a browser and a testing library. There are multiple libraries available. One example is [Selenium](http://www.seleniumhq.org/), which can be used with almost any browser. +Another browser option is so-called [headless browsers](https://en.wikipedia.org/wiki/Headless_browser), which are browsers with no graphical user interface. +For example, Chrome can be used in headless mode. - -E2E tests are potentially the most useful category of tests, because they test the system through the same interface as real users use. +E2E tests are potentially the most useful category of tests because they test the system through the same interface as real users use. - -They do have some drawbacks too. Configuring E2E tests is more challenging than unit or integration tests. They also tend to be quite slow, and with a large system their execution time can be minutes, even hours. This is bad for development, because during coding it is beneficial to be able to run tests as often as possible in case of code [regressions](https://en.wikipedia.org/wiki/Regression_testing). +They do have some drawbacks too. Configuring E2E tests is more challenging than unit or integration tests. They also tend to be quite slow, and with a large system, their execution time can be minutes or even hours. This is bad for development because during coding it is beneficial to be able to run tests as often as possible in case of code [regressions](https://en.wikipedia.org/wiki/Regression_testing). +E2E tests can also be [flaky](https://hackernoon.com/flaky-tests-a-war-that-never-ends-9aa32fdef359). +Some tests might pass one time and fail another, even if the code does not change at all. -E2E tests can also be [flaky](https://hackernoon.com/flaky-tests-a-war-that-never-ends-9aa32fdef359). -Some tests might pass one time and fail another, even if the code does not change at all. +Perhaps the two easiest libraries for End to End testing at the moment are [Cypress](https://www.cypress.io/) and [Playwright](https://playwright.dev/). +From the statistics on [npmtrends.com](https://npmtrends.com/cypress-vs-playwright) we can see that Playwright surpassed Cypress in download numbers during 2024, and its popularity continues to grow: -### Cypress +![cypress vs playwright in npm trends](../../images/5/cvsp.png) - -E2E library [Cypress](https://www.cypress.io/) has become popular within the last year. Cypress is exceptionally easy to use, and when compared to Selenium, for example, it requires a lot less hassle and headache. -Its operating principle is radically different than most E2E testing libraries, because Cypress tests are run completely within the browser. -Other libraries run the tests in a Node-process, which is connected to the broswer through an API. +This course has been using Cypress for years. Now Playwright is a new addition. You can choose whether to complete the E2E testing part of the course with Cypress or Playwright. The operating principles of both libraries are very similar, so your choice is not very important. However, Playwright is now the preferred E2E library for the course. -Let's make some end to end tests for our note application. +If your choice is Playwright, please proceed. If you end up using Cypress, go [here](/en/part5/end_to_end_testing_cypress). - -We begin by installing Cypress to the frontend as development dependency +### Playwright + +So [Playwright](https://playwright.dev/) is a newcomer to the End to End tests, which started to explode in popularity towards the end of 2023. Playwright is roughly on a par with Cypress in terms of ease of use. The libraries are slightly different in terms of how they work. Cypress is radically different from most libraries suitable for E2E testing, as Cypress tests are run entirely within the browser. Playwright's tests, on the other hand, are executed in the Node process, which is connected to the browser via programming interfaces. + +Many blogs have been written about library comparisons, e.g. [this](https://www.lambdatest.com/blog/cypress-vs-playwright/) and [this](https://www.browserstack.com/guide/playwright-vs-cypress). + +It is difficult to say which library is better. One advantage of Playwright is its browser support; Playwright supports Chrome, Firefox and Webkit-based browsers like Safari. Currently, Cypress includes support for all these browsers, although Webkit support is experimental and does not support all of Cypress features. At the time of writing (1.3.2024), my personal preference leans slightly towards Playwright. + +Now let's explore Playwright. + +### Initializing tests + +Unlike the backend tests or unit tests done on the React front-end, End to End tests do not need to be located in the same npm project where the code is. Let's make a completely separate project for the E2E tests with the _npm init_ command. Then install Playwright by running in the new project directory the command: ```js -npm install --save-dev cypress +npm init playwright@latest ``` - -and by adding an npm-script to run it: +The installation script will ask a few questions, answer them as follows: + +![answer: javascript, tests, false, true](../../images/5/play0.png) + +Note that when installing Playwright your operating system may not support all of the browsers Playwright offers and you may see an error message like below: +``` +Webkit 18.0 (playwright build v2070) downloaded to /home/user/.cache/ms-playwright/webkit-2070 +Playwright Host validation warning: +╔══════════════════════════════════════════════════════╗ +║ Host system is missing dependencies to run browsers. ║ +║ Missing libraries: ║ +║ libicudata.so.66 ║ +║ libicui18n.so.66 ║ +║ libicuuc.so.66 ║ +║ libjpeg.so.8 ║ +║ libwebp.so.6 ║ +║ libpcre.so.3 ║ +║ libffi.so.7 ║ +╚══════════════════════════════════════════════════════╝ +``` +If this is the case you can either specify specific browsers to test with `--project=` in your _package.json_: + +```js + "test": "playwright test --project=chromium --project=firefox", +``` + +or remove the entry for any problematic browsers from your _playwright.config.js_ file: +```js + projects: [ + // ... + //{ + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + //}, + // ... + ] +``` + +Let's define an npm script for running tests and test reports in _package.json_: ```js { // ... "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject", - "server": "json-server -p3001 db.json", - "cypress:open": "cypress open" // highlight-line + "test": "playwright test", + "test:report": "playwright show-report" }, // ... } ``` - -Unlike the frontend's unit tests, Cypress tests can be in the frontend or the backend repository, or even in their own separate repository. +During installation, the following is printed to the console: + +``` +And check out the following files: + - ./tests/example.spec.js - Example end-to-end test + - ./tests-examples/demo-todo-app.spec.js - Demo Todo App end-to-end tests + - ./playwright.config.js - Playwright Test configuration +``` + +that is, the location of a few example tests for the project that the installation has created. - -The tests require the tested system to be running. Unlike our backend integration tests, Cypress tests do not start the system when they are run. +Let's run the tests: - -Let's add an npm-script to the backend which starts it in test mode, or so that NODE\_ENV is test. +```bash +$ npm test + +> notes-e2e@1.0.0 test +> playwright test + + +Running 6 tests using 5 workers + 6 passed (3.9s) + +To open last HTML report run: + + npx playwright show-report +``` + +The tests pass. A more detailed test report can be opened either with the command suggested by the output, or with the npm script we just defined: + +``` +npm run test:report +``` + +Tests can also be run via the graphical UI with the command: + +``` +npm run test -- --ui +``` + +Sample tests in the file tests/example.spec.js look like this: + +```js +// @ts-check +import { test, expect } from '@playwright/test'; + +test('has title', async ({ page }) => { + await page.goto('https://playwright.dev/'); // highlight-line + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Playwright/); +}); + +test('get started link', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Click the get started link. + await page.getByRole('link', { name: 'Get started' }).click(); + + // Expects page to have a heading with the name of Installation. + await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); +}); +``` + +The first line of the test functions says that the tests are testing the page at https://playwright.dev/. + +### Testing our own code + +Now let's remove the sample tests and start testing our own application. + +Playwright tests assume that the system under test is running when the tests are executed. Unlike, for example, backend integration tests, Playwright tests do not start the system under test during testing. + +Let's make an npm script for the backend, which will enable it to be started in testing mode, i.e. so that NODE\_ENV gets the value test. ```js { // ... "scripts": { "start": "cross-env NODE_ENV=production node index.js", - "dev": "cross-env NODE_ENV=development nodemon index.js", - "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend", - "deploy": "git push heroku master", - "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy", - "logs:prod": "heroku logs --tail", + "dev": "cross-env NODE_ENV=development node --watch index.js", + "test": "cross-env NODE_ENV=test node --test", "lint": "eslint .", - "test": "cross-env NODE_ENV=test jest --verbose --runInBand", - "start:test": "cross-env NODE_ENV=test node index.js" // highlight-line + // ... + "start:test": "cross-env NODE_ENV=test node --watch index.js" // highlight-line }, // ... } ``` - -When both backend and frontend are running, we can start Cypress with the command +Let's start the frontend and backend, and create the first test file for the application tests/note\_app.spec.js: ```js -npm run cypress:open -``` +const { test, expect } = require('@playwright/test') - -When we first run Cypress, it creates a cypress directory. It contains an integrations subdirectory, where we will place our tests. Cypress creates a bunch of example tests for us, but we will delete all those and make our own test in file note\_app.spec.js: +test('front page can be opened', async ({ page }) => { + await page.goto('http://localhost:5173') -```js -describe('Note app', function() { - it('front page can be opened', function() { - cy.visit('http://localhost:3000') - cy.contains('Notes') - cy.contains('Note app, Department of Computer Science, University of Helsinki 2020') - }) + const locator = page.getByText('Notes') + await expect(locator).toBeVisible() + await expect(page.getByText('Note app, Department of Computer Science, University of Helsinki 2024')).toBeVisible() }) ``` - -We start the test from the opened window: +First, the test opens the application with the method [page.goto](https://playwright.dev/docs/writing-tests#navigation). After this, it uses the [page.getByText](https://playwright.dev/docs/api/class-page#page-get-by-text) to get a [locator](https://playwright.dev/docs/locators) that corresponds to the element where the text Notes is found. + +The method [toBeVisible](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-be-visible) ensures that the element corresponding to the locator is visible at the page. -![](../../images/5/40ea.png) +The second check is done without using the auxiliary variable. - -Running the test opens your browser and shows how the application behaves as the test is run: +The test fails because an old year ended up in the test. Playwright opens the test report in the browser and it becomes clear that Playwright has actually performed the tests with three different browsers: Chrome, Firefox and Webkit, i.e. the browser engine used by Safari: -![](../../images/5/32ae.png) +![test report showing the test failing in three different browsers](../../images/5/play2.png) -The structure of the test should look familiar. They use describe blocks to group different test cases like Jest does. The test cases have been defined with the it method. -Cypress borrowed these parts from [Mocha](https://mochajs.org/) testing library it uses under the hood. +By clicking on the report of one of the browsers, we can see a more detailed error message: - -[cy.visit](https://docs.cypress.io/api/commands/visit.html) and [cy.contains](https://docs.cypress.io/api/commands/contains.html) are Cypress commands, and their purpose is quite obvious. -[cy.visit](https://docs.cypress.io/api/commands/visit.html) opens the web address given to it as a parameter in the browser used by the test. [cy.contains](https://docs.cypress.io/api/commands/contains.html) searches for the string it received as a parameter from the page. +![test error message](../../images/5/play3a.png) - -We could have declared the test using an arrow function +In the big picture, it is of course a very good thing that the testing takes place with all three commonly used browser engines, but this is slow, and when developing the tests it is probably best to carry them out mainly with only one browser. You can define the browser engine to be used with the command line parameter: ```js -describe('Note app', () => { // highlight-line - it('front page can be opened', () => { // highlight-line - cy.visit('http://localhost:3000') - cy.contains('Notes') - cy.contains('Note app, Department of Computer Science, University of Helsinki 2020') - }) -}) +npm test -- --project chromium ``` - -However, Mocha [recommends](https://mochajs.org/#arrow-functions) that arrow functions are not used, because they might cause some issues in certain situations. - - -If cy.contains does not find the text is it searching for, the test does not pass. -So if we extend our test like so +Now let's fix the test with the correct year and let's add a _describe_ block to the tests: ```js -describe('Note app', function() { - it('front page can be opened', function() { - cy.visit('http://localhost:3000') - cy.contains('Notes') - cy.contains('Note app, Department of Computer Science, University of Helsinki 2020') - }) +const { test, describe, expect } = require('@playwright/test') -// highlight-start - it('front page contains random text', function() { - cy.visit('http://localhost:3000') - cy.contains('wtf is this app?') +describe('Note app', () => { // highlight-line + test('front page can be opened', async ({ page }) => { + await page.goto('http://localhost:5173') + + const locator = page.getByText('Notes') + await expect(locator).toBeVisible() + await expect(page.getByText('Note app, Department of Computer Science, University of Helsinki 2025')).toBeVisible() }) -// highlight-end }) ``` - -the test fails +Before we move on, let's break the tests one more time. We notice that the execution of the tests is quite fast when they pass, but much slower if the they do not pass. The reason for this is that Playwright's policy is to wait for searched elements until [they are rendered and ready for action](https://playwright.dev/docs/actionability). If the element is not found, a _TimeoutError_ is raised and the test fails. Playwright waits for elements by default for 5 or 30 seconds [depending on the functions used in testing](https://playwright.dev/docs/test-timeouts#introduction). -![](../../images/5/33ea.png) +When developing tests, it may be wiser to reduce the waiting time to a few seconds. According to the [documentation](https://playwright.dev/docs/test-timeouts), this can be done by changing the file _playwright.config.js_ as follows: - -Let's remove the failing code from the test. +```js +export default defineConfig({ + // ... + timeout: 3000, // highlight-line + fullyParallel: false, // highlight-line + workers: 1, // highlight-line + // ... +}) +``` + +We also made two other changes to the file, specifying that all tests [be executed one at a time](https://playwright.dev/docs/test-parallel). With the default configuration, the execution happens in parallel, and since our tests use a database, parallel execution causes problems. -### Writing to a form +### Writing on the form - -Let's extend our tests so, that the test tries to log in to our application. -We assume our backend contains a user with the username mluukkai and password salainen. +Let's write a new test that tries to log into the application. Let's assume that a user is stored in the database, with username mluukkai and password salainen. - -The test begins by opening the login form. +Let's start by opening the login form. ```js -describe('Note app', function() { +describe('Note app', () => { // ... - it('login form can be opened', function() { - cy.visit('http://localhost:3000') - cy.contains('login').click() + test('user can log in', async ({ page }) => { + await page.goto('http://localhost:5173') + + await page.getByRole('button', { name: 'login' }).click() }) }) ``` - -The test first searches for the login button by its text, and clicks the button with the command [cy.click](https://docs.cypress.io/api/commands/click.html#Syntax). +The test first uses the method [page.getByRole](https://playwright.dev/docs/api/class-page#page-get-by-role) to retrieve the button based on its text. The method returns the [Locator](https://playwright.dev/docs/api/class-locator) corresponding to the Button element. Pressing the button is performed using the Locator method [click](https://playwright.dev/docs/api/class-locator#locator-click). + +When developing tests, you could use Playwright's [UI mode](https://playwright.dev/docs/test-ui-mode), i.e. the user interface version. Let's start the tests in UI mode as follows: + +``` +npm test -- --ui +``` + +We now see that the test finds the button + +![playwright UI rendering the notes app while testing it](../../images/5/play4.png) + +After clicking, the form will appear + +![playwright UI rendering the login form of the notes app](../../images/5/play5.png) -Both of our tests begin the same way, by opening the page http://localhost:3000, so we should -separate the shared part into a beforeEach block run before each test: +When the form is opened, the test should look for the text fields and enter the username and password in them. Let's make the first attempt using the method [page.getByRole](https://playwright.dev/docs/api/class-page#page-get-by-role): ```js -describe('Note app', function() { - // highlight-start - beforeEach(function() { - cy.visit('http://localhost:3000') - }) - // highlight-end +describe('Note app', () => { + // ... - it('front page can be opened', function() { - cy.contains('Notes') - cy.contains('Note app, Department of Computer Science, University of Helsinki 2020') - }) + test('user can log in', async ({ page }) => { + await page.goto('http://localhost:5173') - it('login form can be opened', function() { - cy.contains('login').click() + await page.getByRole('button', { name: 'login' }).click() + await page.getByRole('textbox').fill('mluukkai') // highlight-line }) }) ``` - -The login field contains two input fields, which the test should write into. +This results to an error: - -The [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax) command allows for searching elemets by CSS selectors. +```bash +Error: locator.fill: Error: strict mode violation: getByRole('textbox') resolved to 2 elements: + 1) aka locator('div').filter({ hasText: /^username$/ }).getByRole('textbox') + 2) aka locator('input[type="password"]') +``` - -We can access the first and the last input field on the page, and write to them with the command [cy.type](https://docs.cypress.io/api/commands/type.html#Syntax) like so: +The problem now is that _getByRole_ finds two text fields, and calling the [fill](https://playwright.dev/docs/api/class-locator#locator-fill) method fails, because it assumes that there is only one text field found. One way around the problem is to use the methods [first](https://playwright.dev/docs/api/class-locator#locator-first) and [last](https://playwright.dev/docs/api/class-locator#locator-last): ```js -it('user can login', function () { - cy.contains('login').click() - cy.get('input:first').type('mluukkai') - cy.get('input:last').type('salainen') -}) +describe('Note app', () => { + // ... + + test('user can log in', async ({ page }) => { + await page.goto('http://localhost:5173') + + await page.getByRole('button', { name: 'login' }).click() + // highlight-start + await page.getByRole('textbox').first().fill('mluukkai') + await page.getByRole('textbox').last().fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + // highlight-end + }) +}) ``` - -The test works. The problem is if we later add more input fields, the test will break because it expects the fields it needs to be the first and the last on the page. +After writing in the text fields, the test presses the _login_ button and checks that the application renders the logged-in user's information on the screen. - -It would be better to give our inputs unique ids and use those to find them. -We change our login form like so +If there were more than two text fields, using the methods _first_ and _last_ would not be enough. One possibility would be to use the [all](https://playwright.dev/docs/api/class-locator#locator-all) method, which turns the found locators into an array that can be indexed: ```js -const LoginForm = ({ ... }) => { - return ( -
    -

    Login

    -
    -
    - username - -
    -
    - password - -
    - -
    -
    - ) -} -``` +describe('Note app', () => { + // ... + test('user can log in', async ({ page }) => { + await page.goto('http://localhost:5173') - -We also added an id to our submit button so we can access it in our tests. + await page.getByRole('button', { name: 'login' }).click() + // highlight-start + const textboxes = await page.getByRole('textbox').all() - -The test becomes + await textboxes[0].fill('mluukkai') + await textboxes[1].fill('salainen') + // highlight-end -```js -describe('Note app', function() { - // .. - it('user can log in', function() { - cy.contains('login').click() - cy.get('#username').type('mluukkai') // highlight-line - cy.get('#password').type('salainen') // highlight-line - cy.get('#login-button').click() // highlight-line - - cy.contains('Matti Luukkainen logged in') // highlight-line - }) + await page.getByRole('button', { name: 'login' }).click() + + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + }) }) ``` - -The last row ensures that the login was successful. +Both this and the previous version of the test work. However, both are problematic to the extent that if the registration form is changed, the tests may break, as they rely on the fields to be on the page in a certain order. - -Note that the CSS [id-selector](https://developer.mozilla.org/en-US/docs/Web/CSS/ID_selectors) is #, so if we want to search for an element with the id username the CSS selector is #username. +If an element is difficult to locate in tests, you can assign it a separate test-id attribute and find the element in tests using the [getByTestId](https://playwright.dev/docs/api/class-page#page-get-by-test-id) method. -### Some things to note - - -The test first clicks the button opening the login form like so +Let's now take advantage of the existing elements of the login form. The input fields of the login form have been assigned unique labels: ```js -cy.contains('login').click() +// ... +
    +
    + // highlight-line +
    +
    + // highlight-line +
    + +
    +// ... ``` - -When the form has been filled, the form is submitted by clicking the submit button +Input fields can and should be located in tests using labels with the [getByLabel](https://playwright.dev/docs/api/class-page#page-get-by-label) method: ```js -cy.get('#login-button').click() -``` +describe('Note app', () => { + // ... - -Both buttons have the text login, but they are two separate buttons. -Actually both buttons are in the application's DOM the whole time, but only one is visible at a time because of the display:none styling on one of them. + test('user can log in', async ({ page }) => { + await page.goto('http://localhost:5173') - -If we search for a button by its text, [cy.contains](https://docs.cypress.io/api/commands/contains.html#Syntax) will return the first of them, or the one opening the login form. -This will happen even if the button is not visible. -To avoid name conflicts, we gave submit button the id login-button we can use to access it. + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') // highlight-line + await page.getByLabel('password').fill('salainen') // highlight-line + + await page.getByRole('button', { name: 'login' }).click() + + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + }) +}) +``` + +When locating elements, it makes sense to aim to utilize the content visible to the user in the interface, as this best simulates how a user would actually find the desired input field while navigating the application. - -Now we notice that the variable _cy_ our tests use gives us a nasty Eslint error +Note that passing the test at this stage requires that there is a user in the test database of the backend with username mluukkai and password salainen. Create a user if needed! -![](../../images/5/30ea.png) +### Test Initialization - -We can get rid of it by installing [eslint-plugin-cypress](https://github.com/cypress-io/eslint-plugin-cypress) as a development dependency +Since both tests start in the same way, i.e. by opening the page http://localhost:5173, it is recommended to isolate the common part in the beforeEach block that is executed before each test: ```js -npm install eslint-plugin-cypress --save-dev -``` +const { test, describe, expect, beforeEach } = require('@playwright/test') - -and changing the configuration in .eslintrc.js like so: +describe('Note app', () => { + // highlight-start + beforeEach(async ({ page }) => { + await page.goto('http://localhost:5173') + }) + // highlight-end -```js -module.exports = { - "env": { - "browser": true, - "es6": true, - "jest/globals": true, - "cypress/globals": true // highlight-line - }, - "extends": [ - // ... - ], - "parserOptions": { - // ... - }, - "plugins": [ - "react", "jest", "cypress" // highlight-line - ], - "rules": { - // ... - } -} + test('front page can be opened', async ({ page }) => { + const locator = page.getByText('Notes') + await expect(locator).toBeVisible() + await expect(page.getByText('Note app, Department of Computer Science, University of Helsinki 2025')).toBeVisible() + }) + + test('user can log in', async ({ page }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + }) +}) ``` -### Testing new note form +### Testing note creation - -Let's next add tests which test the new note functionality: +Next, let's create a test that adds a new note to the application: ```js -describe('Note app', function() { - // .. - // highlight-start - describe('when logged in', function() { - beforeEach(function() { - cy.contains('login').click() - cy.get('input:first').type('mluukkai') - cy.get('input:last').type('salainen') - cy.get('#login-button').click() - }) - // highlight-end +const { test, describe, expect, beforeEach } = require('@playwright/test') - // highlight-start - it('a new note can be created', function() { - cy.contains('new note').click() - cy.get('input').type('a note created by cypress') - cy.contains('save').click() +describe('Note app', () => { + // ... - cy.contains('a note created by cypress') + describe('when logged in', () => { + beforeEach(async ({ page }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() }) - }) - // highlight-end + + test('a new note can be created', async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('a note created by playwright') + await page.getByRole('button', { name: 'save' }).click() + await expect(page.getByText('a note created by playwright')).toBeVisible() + }) + }) }) ``` - -The test has been defined in its own describe block. -Only logged in users can create new notes, so we added logging in to the application to a beforeEach block. +The test is defined in its own _describe_ block. Creating a note requires that the user is logged in, which is handled in the _beforeEach_ block. - -The test trusts that when creating a new note the page contains only one input, so it searches for it like so +The test trusts that when creating a new note, there is only one input field on the page, so it searches for it as follows: ```js -cy.get('input') +page.getByRole('textbox') ``` - -If the page contained more inputs, the test would break +If there were more fields, the test would break. Because of this, it could be better to add a test-id to the form input and search for it in the test based on this id. + +**Note:** the test will only pass the first time. The reason for this is that its expectation -![](../../images/5/31ea.png) +```js +await expect(page.getByText('a note created by playwright')).toBeVisible() +``` - -Due to this it would again be better to give the input an id and search for the element by its id. +causes problems when the same note is created in the application more than once. The problem will be solved in the next chapter. - -The structure of the tests looks like so: +The structure of the tests looks like this: ```js -describe('Note app', function() { - // ... +const { test, describe, expect, beforeEach } = require('@playwright/test') - it('user can log in', function() { - cy.contains('login').click() - cy.get('#username').type('mluukkai') - cy.get('#password').type('salainen') - cy.get('#login-button').click() +describe('Note app', () => { + // .... - cy.contains('Matti Luukkainen logged in') + test('user can log in', async ({ page }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() }) - describe('when logged in', function() { - beforeEach(function() { - cy.contains('login').click() - cy.get('input:first').type('mluukkai') - cy.get('input:last').type('salainen') - cy.get('#login-button').click() + describe('when logged in', () => { + beforeEach(async ({ page }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() }) - it('a new note can be created', function() { - // ... + test('a new note can be created', async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('a note created by playwright') + await page.getByRole('button', { name: 'save' }).click() + await expect(page.getByText('a note created by playwright')).toBeVisible() }) }) }) ``` - -Cypress runs the tests in the order they are in the code. So first it runs user can log in, where the user logs in. Then cypress will run a new note can be created which's beforeEach block logs in as well. -Why do this? Isn't the user logged in after the first test? -No, because each test starts from zero as far as the browser is concerned. -All changes to the browser's state are reversed after each test. +Since we have prevented the tests from running in parallel, Playwright runs the tests in the order they appear in the test code. That is, first the test user can log in, where the user logs into the application, is performed. After this the test a new note can be created gets executed, which also does a log in, in the beforeEach block. Why is this done, isn't the user already logged in thanks to the previous test? No, because the execution of each test starts from the browser's "zero state", all changes made to the browser's state by the previous tests are reset. ### Controlling the state of the database - -If the tests need to be able to modify the server's database, the situation immediately becomes more complicated. Ideally, the server's database should be the same each time we run the tests, so our tests can be reliably and easily repeatable. +If the tests need to be able to modify the server's database, the situation immediately becomes more complicated. Ideally, the server's database should be the same each time we run the tests, so our tests can be reliably and easily repeatable. - -As with unit and integration tests, with E2E tests it is the best to empty the database and possibly format it before the tests are run. The challenge with E2E tests is that they do not have access to the database. +As with unit and integration tests, with E2E tests it is best to empty the database and possibly format it before the tests are run. The challenge with E2E tests is that they do not have access to the database. - -The solution is to create API endpoints to the backend for the test. -We can empty the database using these endpoints. -Let's create a new router for the tests +The solution is to create API endpoints for the backend tests. +We can empty the database using these endpoints. +Let's create a new router for the tests inside the controllers folder, in the testing.js file ```js const router = require('express').Router() @@ -477,8 +554,7 @@ router.post('/reset', async (request, response) => { module.exports = router ``` - -and add it to the backend only if the application is run on test-mode: +and add it to the backend only if the application is run in test-mode: ```js // ... @@ -500,148 +576,114 @@ app.use(middleware.errorHandler) module.exports = app ``` - -after the changes a HTTP POST request to the /api/testing/reset endpoint empties the database. +After the changes, an HTTP POST request to the /api/testing/reset endpoint empties the database. Make sure your backend is running in test mode by starting it with this command (previously configured in the package.json file): - -The modified backend code can be found from [github](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part5-1) branch part5-1. +```js + npm run start:test +``` + +The modified backend code can be found on the [GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part5-1) branch part5-1. - -Next we will change the beforeEach block so that it empties the server's database before tests are run. +Next, we will change the _beforeEach_ block so that it empties the server's database before tests are run. - -Currently it is not possible to add new users trough the frontend's UI, so we add a new user to the backend from the beforeEach block. +Currently, it is not possible to add new users through the frontend's UI, so we add a new user to the backend from the beforeEach block. ```js -describe('Note app', function() { - beforeEach(function() { - // highlight-start - cy.request('POST', 'http://localhost:3001/api/testing/reset') - const user = { - name: 'Matti Luukkainen', - username: 'mluukkai', - password: 'salainen' - } - cy.request('POST', 'http://localhost:3001/api/users/', user) - // highlight-end - cy.visit('http://localhost:3000') +describe('Note app', () => { + beforeEach(async ({ page, request }) => { + await request.post('http://localhost:3001/api/testing/reset') + await request.post('http://localhost:3001/api/users', { + data: { + name: 'Matti Luukkainen', + username: 'mluukkai', + password: 'salainen' + } + }) + + await page.goto('http://localhost:5173') }) - it('front page can be opened', function() { + test('front page can be opened', () => { // ... }) - it('user can login', function() { + test('user can login', () => { // ... }) - describe('when logged in', function() { + describe('when logged in', () => { // ... }) }) ``` - -During the formatting the test does HTTP requests to the backend with [cy.request](https://docs.cypress.io/api/commands/request.html). +During initialization, the test makes HTTP requests to the backend with the method [post](https://playwright.dev/docs/api/class-apirequestcontext#api-request-context-post) of the parameter _request_. - -Unlike earlier, now the testing starts with the backend in the same state every time. The backend will contain one user and no notes. +Unlike before, now the testing of the backend always starts from the same state, i.e. there is one user and no notes in the database. - -Let's add one more test for checking that we can change the importance of notes. -First we change the frontend so that a new note is unimportant by default, or the important field is false: +Let's make a test that checks that the importance of the notes can be changed. -```js -const NoteForm = ({ createNote }) => { - // ... +There are a few different approaches to taking the test. - const addNote = (event) => { - event.preventDefault() - createNote({ - content: newNote, - important: false // highlight-line - }) - - setNewNote('') - } - // ... -} -``` - - -There are multiple ways to test this. In the following example we first search for a note and click its make important button. Then we check that the note now contains a make not important button. +In the following, we first look for a note and click on its button that has text make not important. After this, we check that the note contains the button with make important. ```js -describe('Note app', function() { +describe('Note app', () => { // ... - describe('when logged in', function() { + describe('when logged in', () => { // ... - describe('and a note exists', function () { - beforeEach(function () { - cy.contains('new note').click() - cy.get('input').type('another note cypress') - cy.contains('save').click() + // highlight-start + describe('and a note exists', () => { + beforeEach(async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('another note by playwright') + await page.getByRole('button', { name: 'save' }).click() }) - - it('it can be made important', function () { - cy.contains('another note cypress') - .contains('make important') - .click() - - cy.contains('another note cypress') - .contains('make not important') + + test('importance can be changed', async ({ page }) => { + await page.getByRole('button', { name: 'make not important' }).click() + await expect(page.getByText('make important')).toBeVisible() }) + // highlight-end }) }) }) ``` - -The first command searches for a component containing the text another note cypress, and then for a make important button within it. It then clicks the button. +The first command first searches for the component where there is the text another note by playwright and inside it the button make not important and clicks on it. - -The second command checks that the text on the button has changed to make not important. +The second command ensures that the text of the same button has changed to make important. - -The tests and the current frontend code can be found from [github](https://github.com/fullstack-hy2020/part2-notes/tree/part5-9) branch part5-9. +The current code for the tests is on [GitHub](https://github.com/fullstack-hy2020/notes-e2e/tree/part5-1), in branch part5-1. -### Failed login test +### Test for failed login - -Let's make a test to ensure that a login attempt fails if the password is wrong. +Now let's do a test that ensures that the login attempt fails if the password is wrong. - -Cypress will run all tests each time by default, and as the number of tests increases it starts to become quite time consuming. -When developing a new test or when debugging a broken test, we can define the test with it.only instead of it, so that Cypress will only run the required test. -When the test is working, we can remove .only. - - -First version of our tests is as follows: +The first version of the test looks like this: ```js -describe('Note app', function() { +describe('Note app', () => { // ... - it.only('login fails with wrong password', function() { - cy.contains('login').click() - cy.get('#username').type('mluukkai') - cy.get('#password').type('wrong') - cy.get('#login-button').click() + test('login fails with wrong password', async ({ page }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('wrong') + await page.getByRole('button', { name: 'login' }).click() - cy.contains('wrong credentials') + await expect(page.getByText('wrong credentials')).toBeVisible() }) // ... -)} +}) ``` - -The test uses [cy.contains](https://docs.cypress.io/api/commands/contains.html#Syntax) to ensure that the application prints an error message. +The test verifies with the method [page.getByText](https://playwright.dev/docs/api/class-page#page-get-by-text) that the application prints an error message. - -The application renders the error message to a component with the CSS class error: +The application renders the error message to an element containing the CSS class error: ```js const Notification = ({ message }) => { @@ -657,231 +699,191 @@ const Notification = ({ message }) => { } ``` - -We could make the test ensure, that the error message is rendered to the correct component, or the component with the CSS class error: - +We could refine the test to ensure that the error message is printed exactly in the right place, i.e. in the element containing the CSS class error: ```js -it('login fails with wrong password', function() { +test('login fails with wrong password', async ({ page }) => { // ... - cy.get('.error').contains('wrong credentials') // highlight-line + const errorDiv = page.locator('.error') // highlight-line + await expect(errorDiv).toContainText('wrong credentials') }) ``` - -First we use [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax) to search for a component with the CSS class error. Then we check that the error message can be found from this component. -Note that the [CSS class selector](https://developer.mozilla.org/en-US/docs/Web/CSS/Class_selectors) starts with a full stop, so the selector for the class error is .error. +So the test uses the [page.locator](https://playwright.dev/docs/api/class-page#page-locator) method to find the component containing the CSS class error and stores it in a variable. The correctness of the text associated with the component can be verified with the expectation [toContainText](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-contain-text). Note that the [CSS class selector](https://developer.mozilla.org/en-US/docs/Web/CSS/Class_selectors) starts with a dot, so the error class selector is .error. - -We could do the same using the [should](https://docs.cypress.io/api/commands/should.html) syntax: +It is possible to test the application's CSS styles with matcher [toHaveCSS](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-css). We can, for example, make sure that the color of the error message is red, and that there is a border around it: ```js -it('login fails with wrong password', function() { +test('login fails with wrong password', async ({ page }) => { // ... - cy.get('.error').should('contain', 'wrong credentials') // highlight-line + const errorDiv = page.locator('.error') + await expect(errorDiv).toContainText('wrong credentials') + await expect(errorDiv).toHaveCSS('border-style', 'solid') // highlight-line + await expect(errorDiv).toHaveCSS('color', 'rgb(255, 0, 0)') // highlight-line }) ``` - -Using should is a bit trickier than using contains, but it allows for more diverse tests than contains which works based on text content only. - - -List of the most common assertions which can be used with should can be found [here](https://docs.cypress.io/guides/references/assertions.html#Common-Assertions). +Colors must be defined to Playwright as [rgb](https://rgbcolorcode.com/color/red) codes. - -We can, for example, make sure that the error message is red and it has a border: +Let's finalize the test so that it also ensures that the application **does not render** the text describing a successful login 'Matti Luukkainen logged in': ```js -it('login fails with wrong password', function() { - // ... - - cy.get('.error').should('contain', 'wrong credentials') - cy.get('.error').should('have.css', 'color', 'rgb(255, 0, 0)') - cy.get('.error').should('have.css', 'border-style', 'solid') +test('login fails with wrong password', async ({ page }) =>{ + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('wrong') + await page.getByRole('button', { name: 'login' }).click() + + const errorDiv = page.locator('.error') + await expect(errorDiv).toContainText('wrong credentials') + await expect(errorDiv).toHaveCSS('border-style', 'solid') + await expect(errorDiv).toHaveCSS('color', 'rgb(255, 0, 0)') + + await expect(page.getByText('Matti Luukkainen logged in')).not.toBeVisible() // highlight-line }) ``` - -Cypress requires the colors to be given as [rgb](https://rgbcolorcode.com/color/red). +### Running tests one by one - -Because all tests are for the same component we accessed using [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax), we can chain them using [and](https://docs.cypress.io/api/commands/and.html). +By default, Playwright always runs all tests, and as the number of tests increases, it becomes time-consuming. When developing a new test or debugging a broken one, the test can be defined instead than with the command test, with the command test.only, in which case Playwright will run only that test: ```js -it('login fails with wrong password', function() { - // ... +describe(() => { + // this is the only test executed! + test.only('login fails with wrong password', async ({ page }) => { // highlight-line + // ... + }) - cy.get('.error') - .should('contain', 'wrong credentials') - .and('have.css', 'color', 'rgb(255, 0, 0)') - .and('have.css', 'border-style', 'solid') -}) -``` - -Let's finish the test so that it also checks that the application does not render the success message 'Matti Luukkainen logged in': + // this test is skipped... + test('user can login with correct credentials', async ({ page }) => { + // ... + }) -```js -it.only('login fails with wrong password', function() { - cy.contains('login').click() - cy.get('#username').type('mluukkai') - cy.get('#password').type('wrong') - cy.get('#login-button').click() - - cy.get('.error') - .should('contain', 'wrong credentials') - .and('have.css', 'color', 'rgb(255, 0, 0)') - .and('have.css', 'border-style', 'solid') - - cy.get('html').should('not.contain', 'Matti Luukkainen logged in') // highlight-line + // ... }) ``` - -Should should always be chained with get (or another chainable command). -We used cy.get('html') to access the whole visible content of the application. +When the test is ready, only can and **should** be deleted. + +Another option to run a single test is to use a command line parameter: -### Bypassing the UI +``` +npm test -- -g "login fails with wrong password" +``` + +### Helper functions for tests - -Currently we have the following tests: +Our application tests currently look like this: ```js -describe('Note app', function() { - it('user can login', function() { - cy.contains('login').click() - cy.get('#username').type('mluukkai') - cy.get('#password').type('salainen') - cy.get('#login-button').click() +const { test, describe, expect, beforeEach } = require('@playwright/test') + +describe('Note app', () => { + // ... - cy.contains('Matti Luukkainen logged in') + test('user can login with correct credentials', async ({ page }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() }) - it.only('login fails with wrong password', function() { + test('login fails with wrong password', async ({ page }) =>{ // ... }) - describe('when logged in', function() { - beforeEach(function() { - cy.contains('login').click() - cy.get('input:first').type('mluukkai') - cy.get('input:last').type('salainen') - cy.get('#login-button').click() + describe('when logged in', () => { + beforeEach(async ({ page, request }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() }) - it('a new note can be created', function() { - // ... + test('a new note can be created', async ({ page }) => { + // ... }) - - }) + + // ... + }) }) ``` - -First we test logging in. Then, in their own describe block, we have a bunch of tests which expect the user to be logged in. User is logged in in the beforeEach block. - - -As we said above, each test starts from zero! Tests do not start from the state where the previous tests ended. - - -The Cypress documentation gives us the following advice: [Fully test the login flow – but only once!](https://docs.cypress.io/guides/getting-started/testing-your-app.html#Logging-in). -So instead of logging in a user using the form in the beforeEach block, Cypress recommends that we [bypass the UI](https://docs.cypress.io/guides/getting-started/testing-your-app.html#Bypassing-your-UI) and do a HTTP request to the backend to log in. The reason for this is that logging in with a HTTP request is much faster than filling a form. +First, the login function is tested. After this, another _describe_ block contains a set of tests that assume that the user is logged in, the login is handled inside the initializing _beforeEach_ block. +As already stated earlier, each test is executed starting from the initial state (where the database is cleared and one user is created there), so even though the test is defined after another test in the code, it does not start from the same state where the tests in the code executed earlier have left! - -Our situation is a bit more complicated than in the example in the Cypress documentation, because when a user logs in, our application saves their details to the localStorage. -However Cypress can handle that as well. -The code is the following +It is also worth striving for having non-repetitive code in tests. Let's isolate the code that handles the login as a helper function, which is placed e.g. in the file _tests/helper.js_: ```js -describe('when logged in', function() { - beforeEach(function() { - // highlight-start - cy.request('POST', 'http://localhost:3001/api/login', { - username: 'mluukkai', password: 'salainen' - }).then(response => { - localStorage.setItem('loggedNoteappUser', JSON.stringify(response.body)) - cy.visit('http://localhost:3000') - }) - // highlight-end - }) - - it('a new note can be created', function() { - // ... - }) +const loginWith = async (page, username, password) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill(username) + await page.getByLabel('password').fill(password) + await page.getByRole('button', { name: 'login' }).click() +} - // ... -}) +export { loginWith } ``` - -We can access the response to a [cy.request](https://docs.cypress.io/api/commands/request.html) with the _then_ method. Under the hood cy.request, like all Cypress commands, are [promises](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Commands-Are-Promises). -The callback function saves the details of a logged in user to localStorage, and reloads the page. -Now there is no difference to user logging in with the login form. +The tests becomes simpler and clearer: - -If and when we write new tests to our application, we have to use the login code in multiple places. -We should make it a [custom command](https://docs.cypress.io/api/cypress-api/custom-commands.html). +```js +const { test, describe, expect, beforeEach } = require('@playwright/test') +const { loginWith } = require('./helper') // highlight-line - -Custom commands are declared in cypress/support/commands.js. -The code for logging in is as follows: +describe('Note app', () => { + // ... -```js -Cypress.Commands.add('login', ({ username, password }) => { - cy.request('POST', 'http://localhost:3001/api/login', { - username, password - }).then(({ body }) => { - localStorage.setItem('loggedNoteappUser', JSON.stringify(body)) - cy.visit('http://localhost:3000') + test('user can log in', async ({ page }) => { + await loginWith(page, 'mluukkai', 'salainen') // highlight-line + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() }) -}) -``` - -Using our custom command is easy, and our test becomes cleaner: + test('login fails with wrong password', async ({ page }) => { + await loginWith(page, 'mluukkai', 'wrong') // highlight-line -```js -describe('when logged in', function() { - beforeEach(function() { - // highlight-start - cy.login({ username: 'mluukkai', password: 'salainen' }) - // highlight-end + const errorDiv = page.locator('.error') + // ... }) - it('a new note can be created', function() { + describe('when logged in', () => { + beforeEach(async ({ page }) => { + await loginWith(page, 'mluukkai', 'salainen') // highlight-line + }) + // ... }) - - // ... }) ``` - -The same applies to creating a new note now that we think about it. We have a test which makes a new note using the form. We also make a new note in the beforeEach block of the test testing changing the importance of a note: +Playwright also offers a [solution](https://playwright.dev/docs/auth) where the login is performed once before the tests, and each test starts from a state where the application is already logged in. In order for us to take advantage of this method, the initialization of the application's test data should be done a bit differently than now. In the current solution, the database is reset before each test, and because of this, logging in just once before the tests is impossible. In order for us to use the pre-test login provided by Playwright, the user should be initialized only once before the tests. We stick to our current solution for the sake of simplicity. + +The corresponding repeating code actually also applies to creating a new note. For that, there is a test that creates a note using a form. Also in the _beforeEach_ initialization block of the test that tests changing the importance of the note, a note is created using the form: ```js describe('Note app', function() { // ... - describe('when logged in', function() { - it('a new note can be created', function() { - cy.contains('new note').click() - cy.get('input').type('a note created by cypress') - cy.contains('save').click() - - cy.contains('a note created by cypress') + describe('when logged in', () => { + test('a new note can be created', async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('a note created by playwright') + await page.getByRole('button', { name: 'save' }).click() + await expect(page.getByText('a note created by playwright')).toBeVisible() }) - - describe('and a note exists', function () { - beforeEach(function () { - cy.contains('new note').click() - cy.get('input').type('another note cypress') - cy.contains('save').click() + + describe('and a note exists', () => { + beforeEach(async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('another note by playwright') + await page.getByRole('button', { name: 'save' }).click() }) - - it('it can be made important', function () { + + test('it can be made important', async ({ page }) => { // ... }) }) @@ -889,126 +891,150 @@ describe('Note app', function() { }) ``` - -Let's make a new custom command for making a new note. The command will make a new note with a HTTP POST request: +Creation of a note is also isolated as its helper function. The file _tests/helper.js_ expands as follows: ```js -Cypress.Commands.add('createNote', ({ content, important }) => { - cy.request({ - url: 'http://localhost:3001/api/notes', - method: 'POST', - body: { content, important }, - headers: { - 'Authorization': `bearer ${JSON.parse(localStorage.getItem('loggedNoteappUser')).token}` - } - }) +const loginWith = async (page, username, password) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill(username) + await page.getByLabel('password').fill(password) + await page.getByRole('button', { name: 'login' }).click() +} - cy.visit('http://localhost:3000') -}) -``` +// highlight-start +const createNote = async (page, content) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill(content) + await page.getByRole('button', { name: 'save' }).click() +} +// highlight-end - -The command expects user to be logged in and the user's details to be saved to localStorage. +export { loginWith, createNote } // highlight-line +``` - -Now the formatting block becomes: +The tests are simplified as follows: ```js -describe('Note app', function() { +const { test, describe, expect, beforeEach } = require('@playwright/test') +const { createNote, loginWith } = require('./helper') // highlight-line + +describe('Note app', () => { // ... - describe('when logged in', function() { - it('a new note can be created', function() { - // ... + describe('when logged in', () => { + beforeEach(async ({ page }) => { + await loginWith(page, 'mluukkai', 'salainen') }) - describe('and a note exists', function () { - beforeEach(function () { - // highlight-start - cy.createNote({ - content: 'another note cypress', - important: false - }) - // highlight-end - }) + test('a new note can be created', async ({ page }) => { + await createNote(page, 'a note created by playwright') // highlight-line + await expect(page.getByText('a note created by playwright')).toBeVisible() + }) - it('it can be made important', function () { - // ... + describe('and a note exists', () => { + beforeEach(async ({ page }) => { + await createNote(page, 'another note by playwright') // highlight-line + }) + + test('importance can be changed', async ({ page }) => { + await page.getByRole('button', { name: 'make not important' }).click() + await expect(page.getByText('make important')).toBeVisible() }) }) }) }) ``` - -The tests and the frontend code can be found from [github](https://github.com/fullstack-hy2020/part2-notes/tree/part5-10) branch part5-10. - -### Changing the importance of a note - - -Lastly let's take a look at the test we did for changing the importance of a note. -First we'll change the formatting block so that it creates three notes instead of one: +There is one more annoying feature in our tests. The frontend address http:localhost:5173 and the backend address http:localhost:3001 are hardcoded for tests. Of these, the address of the backend is actually useless, because a proxy has been defined in the Vite configuration of the frontend, which forwards all requests made by the frontend to the address http:localhost:5173/api to the backend: ```js -describe('when logged in', function() { - describe('and several notes exist', function () { - beforeEach(function () { - // highlight-start - cy.createNote({ content: 'first note', important: false }) - cy.createNote({ content: 'second note', important: false }) - cy.createNote({ content: 'third note', important: false }) - // highlight-end - }) +export default defineConfig({ + server: { + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + } + }, + // ... +}) +``` - it('one of those can be made important', function () { - cy.contains('second note') - .contains('make important') - .click() +So we can replace all the addresses in the tests from _http://localhost:3001/api/..._ to _http://localhost:5173/api/..._ - cy.contains('second note') - .contains('make not important') - }) - }) +We can now define the _baseUrl_ for the application in the tests configuration file playwright.config.js: + +```js +export default defineConfig({ + // ... + use: { + baseURL: 'http://localhost:5173', + // ... + }, + // ... }) ``` - -How does the [cy.contains](https://docs.cypress.io/api/commands/contains.html) command actually work? +All the commands in the tests that use the application url, e.g. - -When we click the _cy.contains('second note')_ command in Cypress [Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner.html), we see that the command searches for the element containing the text second note: +```js +await page.goto('http://localhost:5173') +await page.post('http://localhost:5173/api/testing/reset') +``` -![](../../images/5/34ea.png) +can now be transformed into: +```js +await page.goto('/') +await page.post('/api/testing/reset') +``` - -By clicking the next line _.contains('make important')_ we see that the test uses - -the 'make important' button corresponding to second note: +The current code for the tests is on [GitHub](https://github.com/fullstack-hy2020/notes-e2e/tree/part5-2), branch part5-2. -![](../../images/5/35ea.png) +### Note importance change revisited - -When chained, the second contains command continues the search from within the component found by the first command. +Let's take a look at the test we did earlier, which verifies that it is possible to change the importance of a note. - -If we had not chained the commands, and instead wrote +Let's change the initialization block of the test so that it creates two notes instead of one: ```js -cy.contains('second note') -cy.contains('make important').click() +describe('when logged in', () => { + // ... + describe('and several notes exists', () => { // highlight-line + beforeEach(async ({ page }) => { + // highlight-start + await createNote(page, 'first note') + await createNote(page, 'second note') + // highlight-end + }) + + test('one of those can be made nonimportant', async ({ page }) => { + const otherNoteElement = page.getByText('first note') + + await otherNoteElement + .getByRole('button', { name: 'make not important' }).click() + await expect(otherNoteElement.getByText('make important')).toBeVisible() + }) + }) +}) ``` - -the result would have been totally different. The second line of the test would click the button of a wrong note: +The test first searches for the element corresponding to the first created note using the method _page.getByText_ and stores it in a variable. After this, a button with the text _make not important_ is searched inside the element and the button is pressed. Finally, the test verifies that the button's text has changed to _make important_. + +The test could also have been written without the auxiliary variable: -![](../../images/5/36ea.png) +```js +test('one of those can be made nonimportant', async ({ page }) => { + page.getByText('first note') + .getByRole('button', { name: 'make not important' }).click() - -When coding tests, you should check in the test runner that the tests use the right components! + await expect(page.getByText('first note').getByText('make important')) + .toBeVisible() +}) +``` - -Let's change the _Note_ component so that the text of the note is rendered to a span. +Let's change the _Note_ component so that the note text is rendered inside a _span_ element ```js const Note = ({ note, toggleImportance }) => { @@ -1024,238 +1050,299 @@ const Note = ({ note, toggleImportance }) => { } ``` - -Our tests break! As the test runner reveals, _cy.contains('second note')_ now returns the component containing the text, and the button is not in it. +Tests break! The reason for the problem is that the command _page.getByText('first note')_ now returns a _span_ element containing only text, and the button is outside of it. -![](../../images/5/37ea.png) - - -One way to fix this is the following: +One way to fix the problem is as follows: ```js -it('other of those can be made important', function () { - cy.contains('second note').parent().find('button').click() - cy.contains('second note').parent().find('button') - .should('contain', 'make not important') +test('one of those can be made nonimportant', async ({ page }) => { + const otherNoteText = page.getByText('first note') // highlight-line + const otherNoteElement = otherNoteText.locator('..') // highlight-line + + await otherNoteElement.getByRole('button', { name: 'make not important' }).click() + await expect(otherNoteElement.getByText('make important')).toBeVisible() }) ``` - -In the first line, we use the [parent](https://docs.cypress.io/api/commands/parent.htm) command to access the parent element of the element containing second note and find the button from within it. -Then we click the button, and check that the text on it changes. - - -Note that we use the command [find](https://docs.cypress.io/api/commands/find.html#Syntax) to search for the button. We cannot use [cy.get](https://docs.cypress.io/api/commands/get.html) here, because it always searches from the whole page and would return all 5 buttons on the page. +The first line now looks for the _span_ element containing the text associated with the first created note. In the second line, the function _locator_ is used and _.._ is given as an argument, which retrieves the element's parent element. The locator function is very flexible, and we take advantage of the fact that accepts [as argument](https://playwright.dev/docs/locators#locate-by-css-or-xpath) not only CSS selectors but also [XPath](https://developer.mozilla.org/en-US/docs/Web/XPath) selector. It would be possible to express the same with CSS, but in this case XPath provides the simplest way to find the parent of an element. - -Unfortunately, we have some copypaste in the tests now, because the code for searching for the right button is always the same. - -In these kinds of situations, it is possible to use the [as](https://docs.cypress.io/api/commands/as.html) command: +Of course, the test can also be written using only one auxiliary variable: ```js -it.only('other of those can be made important', function () { - cy.contains('second note').parent().find('button').as('theButton') - cy.get('@theButton').click() - cy.get('@theButton').should('contain', 'make not important') +test('one of those can be made nonimportant', async ({ page }) => { + const secondNoteElement = page.getByText('second note').locator('..') + await secondNoteElement.getByRole('button', { name: 'make not important' }).click() + await expect(secondNoteElement.getByText('make important')).toBeVisible() }) ``` - -Now the first line finds the right button, and uses as to save it as theButton. The followings lines can use the named element with cy.get('@theButton'). +Let's change the test so that three notes are created, the importance is changed in the second created note: -### Running and debugging the tests +```js +describe('when logged in', () => { + beforeEach(async ({ page }) => { + await loginWith(page, 'mluukkai', 'salainen') + }) -Finally, some notes on how Cypress works and debugging your tests. + test('a new note can be created', async ({ page }) => { + await createNote(page, 'a note created by playwright', true) + await expect(page.getByText('a note created by playwright')).toBeVisible() + }) - -The form of the Cypress tests gives the impression that the tests are normal JavaScript code, and we could for example try this: + describe('and several notes exists', () => { + beforeEach(async ({ page }) => { + await createNote(page, 'first note') + await createNote(page, 'second note') + await createNote(page, 'third note') // highlight-line + }) -```js -const button = cy.contains('login') -button.click() -debugger() -cy.contains('logout').click() + test('one of those can be made nonimportant', async ({ page }) => { + const otherNoteText = page.getByText('second note') // highlight-line + const otherNoteElement = otherNoteText.locator('..') + + await otherNoteElement.getByRole('button', { name: 'make not important' }).click() + await expect(otherNoteElement.getByText('make important')).toBeVisible() + }) + }) +}) ``` - -This won't work however. When Cypress runs a test, it adds each _cy_ command to an execution queue. -When the code of the test method has been executed, Cypres will execute each command in the queue one by one. +For some reason the test starts working unreliably, sometimes it passes and sometimes it doesn't. It's time to roll up your sleeves and learn how to debug tests. + +### Test development and debugging + +If, and when the tests don't pass and you suspect that the fault is in the tests instead of in the code, you should run the tests in [debug](https://playwright.dev/docs/debug#run-in-debug-mode-1) mode. - -Cypress commands always return _undefined_, so _button.click()_ in the above code would cause an error. An attempt to start the debugger would not stop the code between executing the commands, but before any commands have been executed. +The following command runs the problematic test in debug mode: - -Cypress commands are like promises, so if we want to access their return values, we have to do it using the [then](https://docs.cypress.io/api/commands/then.html) command. -For example, the following test would print the number of buttons in the application, and click the first button: +``` +npm test -- -g'one of those can be made nonimportant' --debug +``` + +Playwright-inspector shows the progress of the tests step by step. The arrow-dot button at the top takes the tests one step further. The elements found by the locators and the interaction with the browser are visualized in the browser: + +![playwright inspector highlighting element found by the selected locator in the application](../../images/5/play6a.png) + +By default, debug steps through the test command by command. If it is a complex test, it can be quite a burden to step through the test to the point of interest. This can be avoided by using the command _await page.pause()_: ```js -it('then example', function() { - cy.get('button').then( buttons => { - console.log('number of buttons', buttons.length) - cy.wrap(buttons[0]).click() +describe('Note app', () => { + beforeEach(async ({ page, request }) => { + // ... + }) + + describe('when logged in', () => { + beforeEach(async ({ page }) => { + // ... + }) + + describe('and several notes exists', () => { + beforeEach(async ({ page }) => { + await createNote(page, 'first note') + await createNote(page, 'second note') + await createNote(page, 'third note') + }) + + test('one of those can be made nonimportant', async ({ page }) => { + await page.pause() // highlight-line + const otherNoteText = page.getByText('second note') + const otherNoteElement = otherNoteText.locator('..') + + await otherNoteElement.getByRole('button', { name: 'make not important' }).click() + await expect(otherNoteElement.getByText('make important')).toBeVisible() + }) + }) }) }) ``` - -Stopping the test execution with the debugger is [possible](https://docs.cypress.io/api/commands/debug.html). The debugger starts only if Cypress test runner's developer console is open. +Now in the test you can go to _page.pause()_ in one step, by pressing the green arrow symbol in the inspector. + +When we now run the test and jump to the _page.pause()_ command, we find an interesting fact: - -The developer console is all sorts of useful when debugging your tests. -You can see the HTTP requests done by the tests on the Network tab, and the console tab will show you information about your tests: +![playwright inspector showing the state of the application at page.pause](../../images/5/play6b.png) -![](../../images/5/38ea.png) +It seems that the browser does not render all the notes created in the block _beforeEach_. What is the problem? - -So far we have run our Cypress tests using the graphical test runner. -It is also possible to run them [from the command line](https://docs.cypress.io/guides/guides/command-line.html). We just have to add an npm script for it: +The reason for the problem is that when the test creates one note, it starts creating the next one even before the server has responded, and the added note is rendered on the screen. This in turn can cause some notes to be lost (in the picture, this happened to the second note created), since the browser is re-rendered when the server responds, based on the state of the notes at the start of that insert operation. + +The problem can be solved by "slowing down" the insert operations by using the [waitFor](https://playwright.dev/docs/api/class-locator#locator-wait-for) command after the insert to wait for the inserted note to render: ```js - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject", - "server": "json-server -p3001 --watch db.json", - "cypress:open": "cypress open", - "test:e2e": "cypress run" // highlight-line - }, +const createNote = async (page, content) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill(content) + await page.getByRole('button', { name: 'save' }).click() + await page.getByText(content).waitFor() // highlight-line +} +``` + +Instead of, or alongside debugging mode, running tests in UI mode can be useful. As already mentioned, tests are started in UI mode as follows: + +``` +npm run test -- --ui ``` - -Now we can run our tests from the command line with the command npm run test:e2e +Almost the same as UI mode is use of the Playwright's [Trace Viewer](https://playwright.dev/docs/trace-viewer-intro). The idea is that a "visual trace" of the tests is saved, which can be viewed if necessary after the tests have been completed. A trace is saved by running the tests as follows: -![](../../images/5/39ea.png) +``` +npm run test -- --trace on +``` + +If necessary, Trace can be viewed with the command + +``` +npx playwright show-report +``` - -Note that video of the test execution will be saved to cypress/videos/, so you should probably git ignore this directory. +or with the npm script we defined _npm run test:report_ + +Trace looks practically the same as running tests in UI mode. + +UI mode and Trace Viewer also offer the possibility of assisted search for locators. This is done by pressing the double circle on the left side of the lower bar, and then by clicking on the desired user interface element. Playwright displays the element locator: + +![playwright's trace viewer with red arrows pointing at the locator assisted search location and to the element selected with it showing a suggested locator for the element](../../images/5/play8.png) + +Playwright suggests the following as the locator for the third note + +```js +page.locator('li').filter({ hasText: 'third note' }).getByRole('button') +``` + +The method [page.locator](https://playwright.dev/docs/api/class-page#page-locator) is called with the argument _li_, i.e. we search for all li elements on the page, of which there are three in total. After this, using the [locator.filter](https://playwright.dev/docs/api/class-locator#locator-filter) method, we narrow down to the li element that contains the text third note and the button element inside it is taken using the [locator.getByRole](https://playwright.dev/docs/api/class-locator#locator-get-by-role) method. + +The locator generated by Playwright is somewhat different from the locator used by our tests, which was + +```js +page.getByText('first note').locator('..').getByRole('button', { name: 'make not important' }) +``` + +Which of the locators is better is probably a matter of taste. + +Playwright also includes a [test generator](https://playwright.dev/docs/codegen-intro) that makes it possible to "record" a test through the user interface. The test generator is started with the command: + +``` +npx playwright codegen http://localhost:5173/ +``` - -The frontend- and the test code can be found from [github](https://github.com/fullstack-hy2020/part2-notes/tree/part5-11) branch part5-11. +When the _Record_ mode is on, the test generator "records" the user's interaction in the Playwright inspector, from where it is possible to copy the locators and actions to the tests: + +![playwright's record mode enabled with its output in the inspector after user interaction](../../images/5/play9.png) + +Instead of the command line, Playwright can also be used via the [VS Code](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) plugin. The plugin offers many convenient features, e.g. use of breakpoints when debugging tests. + +To avoid problem situations and increase understanding, it is definitely worth browsing Playwright's high-quality [documentation](https://playwright.dev/docs/intro). The most important sections are listed below: +- the section about [locators](https://playwright.dev/docs/locators) gives good hints for finding elements in test +- section [actions](https://playwright.dev/docs/input) tells how it is possible to simulate the interaction with the browser in tests +- the section about [assertions](https://playwright.dev/docs/test-assertions) demonstrates the different expectations Playwright offers for testing + +In-depth details can be found in the [API](https://playwright.dev/docs/api/class-playwright) description, particularly useful are the class [Page](https://playwright.dev/docs/api/class-page) corresponding to the browser window of the application under test, and the class [Locator](https://playwright.dev/docs/api/class-locator) corresponding to the elements searched for in the tests. + +The final version of the tests is in full on [GitHub](https://github.com/fullstack-hy2020/notes-e2e/tree/part5-3), in branch part5-3. + +The final version of the frontend code is in its entirety on [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-9), in branch part5-9.
    -### Exercises 5.17.-5.22. +### Exercises 5.17.-5.23. -In the last exercises of this part we will do some E2E tests for our blog application. -The material of this part should be enough to complete the exercises. -You should absolutely also check out the Cypress [documentation](https://docs.cypress.io/guides/overview/why-cypress.html#In-a-nutshell). It is probably the best documentation I have ever seen for an open source project. +In the last exercises of this part, let's do some E2E tests for the blog application. The material above should be enough to do most of the exercises. However, you should definitely read Playwright's [documentation](https://playwright.dev/docs/intro) and [API description](https://playwright.dev/docs/api/class-playwright), at least the sections mentioned at the end of the previous chapter. - -I especially recommend reading [Introduction to Cypress](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Cypress-Can-Be-Simple-Sometimes), which states +#### 5.17: Blog List End To End Testing, step 1 -> This is the single most important guide for understanding how to test with Cypress. Read it. Understand it. +Create a new npm project for tests and configure Playwright there. -#### 5.17: bloglist end to end testing, step1 +Make a test to ensure that the application displays the login form by default. - -Configure Cypress to your project. Make a test for checking that the application displays the login form by default. - - -The structure of the test must be as follows +The body of the test should be as follows: ```js -describe('Blog app', function() { - beforeEach(function() { - cy.request('POST', 'http://localhost:3001/api/testing/reset') - cy.visit('http://localhost:3000') +const { test, expect, beforeEach, describe } = require('@playwright/test') + +describe('Blog app', () => { + beforeEach(async ({ page }) => { + await page.goto('http://localhost:5173') }) - it('Login from is shown', function() { + test('Login form is shown', async ({ page }) => { // ... }) }) -``` - - -The beforeEach formatting blog must empty the database using for example the method we used in the [material](/en/part5/end_to_end_testing#controlling-the-state-of-the-database). +``` -#### 5.18: bloglist end to end testing, step2 +#### 5.18: Blog List End To End Testing, step 2 - -Make tests for logging in. Test both successful and unsuccessful log in attempts. - -Make a new user in the beforeEach block for the tests. +Do the tests for login. Test both successful and failed login. For tests, create a user in the _beforeEach_ block. - -The test structure extends like so +The body of the tests expands as follows ```js -describe('Blog app', function() { - beforeEach(function() { - cy.request('POST', 'http://localhost:3001/api/testing/reset') - // create here a user to backend - cy.visit('http://localhost:3000') +const { test, expect, beforeEach, describe } = require('@playwright/test') + +describe('Blog app', () => { + beforeEach(async ({ page, request }) => { + // empty the db here + // create a user for the backend here + // ... }) - it('Login from is shown', function() { + test('Login form is shown', async ({ page }) => { // ... }) - describe('Login',function() { - it('succeeds with correct credentials', function() { + describe('Login', () => { + test('succeeds with correct credentials', async ({ page }) => { // ... }) - it('fails with wrong credentials', function() { + test('fails with wrong credentials', async ({ page }) => { // ... }) }) }) ``` - -Optional bonus exercise: Check that the notification shown with unsuccessful login is displayed red. +The _beforeEach_ block must empty the database using, for example, the reset method we used in the [material](/en/part5/end_to_end_testing_playwright#controlling-the-state-of-the-database). -#### 5.19: bloglist end to end testing, step3 +#### 5.19: Blog List End To End Testing, step 3 - -Make a test which checks, that a logged in user can create a new blog. -The structure of the test could be as follows +Create a test that verifies that a logged in user can create a blog. The body of the test may look like the following ```js -describe('Blog app', function() { - // ... - - describe.only('When logged in', function() { - beforeEach(function() { - // log in user here - }) - - it('A blog can be created', function() { - // ... - }) +describe('When logged in', () => { + beforeEach(async ({ page }) => { + // ... }) + test('a new blog can be created', async ({ page }) => { + // ... + }) }) ``` - -The test has to ensure, that a new blog is added to the list of all blogs. +The test should ensure that the created blog is visible in the list of blogs. -#### 5.20: bloglist end to end testing, step4 +#### 5.20: Blog List End To End Testing, step 4 - -Make a test which checks that user can like a blog. +Do a test that makes sure the blog can be liked. -#### 5.21: bloglist end to end testing, step5 +#### 5.21: Blog List End To End Testing, step 5 - -Make a test for ensuring, that the user who created a blog can delete it. +Make a test that ensures that the user who added the blog can delete the blog. If you use the _window.confirm_ dialog in the delete operation, you may have to Google how to use the dialog in the Playwright tests. - -Optional bonus exercise: also check that other users cannot delete the blog. +#### 5.22: Blog List End To End Testing, step 6 -#### 5.22: bloglist end end testing, step 6 +Make a test that ensures that only the user who added the blog sees the blog's delete button. -Make a test which checks, that the blogs are ordered according to likes with the blog with the most likes being first. +#### 5.23: Blog List End To End Testing, step 7 -This exercise might be a bit trickier. One solution is to find all of the blogs and then compare them in the callback function of a [then](https://docs.cypress.io/api/commands/then.html#DOM-element) command. +Do a test that ensures that the blogs are arranged in the order according to the likes, the blog with the most likes first. -This was the last exercise of this part, and its time to push your code to github and mark the exercises you completed in the [exercise submission system](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). +This task is significantly more challenging than the previous ones. +This was the last task of the section and it's time to push the code to GitHub and mark the completed tasks in the [exercise submission system](https://studies.cs.helsinki.fi/stats/courses/fullstackopen).
    diff --git a/src/content/5/en/part5e.md b/src/content/5/en/part5e.md new file mode 100644 index 00000000000..96e4c9743ff --- /dev/null +++ b/src/content/5/en/part5e.md @@ -0,0 +1,1171 @@ +--- +mainImage: ../../../images/part-5.svg +part: 5 +letter: e +lang: en +--- + +
    + +[Cypress](https://www.cypress.io/) has been the most popular E2E testing library for the past few years, but Playwright is rapidly gaining ground. This course has been using Cypress for years. Now Playwright is a new addition. You can choose whether to complete the E2E testing part of the course with Cypress or Playwright. The operating principles of both libraries are very similar, so your choice is not very important. However, Playwright is now the preferred E2E library for the course. + +If your choice is Cypress, please proceed. If you end up using Playwright, go [here](/en/part5/end_to_end_testing_playwright). + +### Cypress + +E2E library [Cypress](https://www.cypress.io/) has become popular within the last years. Cypress is exceptionally easy to use, and when compared to Selenium, for example, it requires a lot less hassle and headache. Its operating principle is radically different than most E2E testing libraries because Cypress tests are run completely within the browser. Other libraries run the tests in a Node process, which is connected to the browser through an API. + +Let's make some end-to-end tests for our note application. + +Unlike the backend tests or unit tests done on the React front-end, End to End tests do not need to be located in the same npm project where the code is. Let's make a completely separate project for the E2E tests with the _npm init_ command. Then install Cypress to the new project as a development dependency + +```js +npm install --save-dev cypress +``` + +and by adding an npm-script to run it: + +```js +{ + // ... + "scripts": { + "cypress:open": "cypress open" // highlight-line + }, + // ... +} +``` + +We also made a small change to the script that starts the application, without the change Cypress can not access the app. + +Unlike the frontend's unit tests, Cypress tests can be in the frontend or the backend repository, or even in their separate repository. + +The tests require that the system being tested is running. Unlike our backend integration tests, Cypress tests do not start the system when they are run. + +Let's add an npm script to the backend which starts it in test mode, or so that NODE\_ENV is test. + +```js +{ + // ... + "scripts": { + "start": "cross-env NODE_ENV=production node index.js", + "dev": "cross-env NODE_ENV=development node --watch index.js", + "test": "cross-env NODE_ENV=test node --test", + "lint": "eslint .", + // ... + "start:test": "cross-env NODE_ENV=test node --watch index.js" // highlight-line + }, + // ... +} +``` + +**NB** To get Cypress working with WSL2 one might need to do some additional configuring first. These two [links](https://docs.cypress.io/guides/references/advanced-installation#Windows-Subsystem-for-Linux) are great places to [start](https://nickymeuleman.netlify.app/blog/gui-on-wsl2-cypress). + +When both the backend and frontend are running, we can start Cypress with the command + +```js +npm run cypress:open +``` + +Cypress asks what type of tests we are doing. Let us answer "E2E Testing": + +![cypress arrow towards e2e testing option](../../images/5/51new.png) + +Next a browser is selected (e.g. Chrome) and then we click "Create new spec": + +![create new spec with arrow pointing towards it](../../images/5/52new.png) + +Let us create the test file cypress/e2e/note\_app.cy.js: + +![cypress with path cypress/e2e/note_app.cy.js](../../images/5/53new.png) + +We could edit the tests in Cypress but let us rather use VS Code: + +![vscode showing edits of test and cypress showing spec added](../../images/5/54new.png) + +We can now close the edit view of Cypress. + +Let us change the test content as follows: + +```js +describe('Note app', function() { + it('front page can be opened', function() { + cy.visit('http://localhost:5173') + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2025') + }) +}) +``` + +The test is run by clicking on the test in Cypress: + +Running the test shows how the application behaves as the test is run: + +![cypress showing automation of note test](../../images/5/56new.png) + +The structure of the test should look familiar. They use describe blocks to group different test cases, just like Vitest. The test cases have been defined with the it method. Cypress borrowed these parts from the [Mocha](https://mochajs.org/) testing library that it uses under the hood. + +[cy.visit](https://docs.cypress.io/api/commands/visit.html) and [cy.contains](https://docs.cypress.io/api/commands/contains.html) are Cypress commands, and their purpose is quite obvious. +[cy.visit](https://docs.cypress.io/api/commands/visit.html) opens the web address given to it as a parameter in the browser used by the test. [cy.contains](https://docs.cypress.io/api/commands/contains.html) searches for the string it received as a parameter in the page. + +We could have declared the test using an arrow function + +```js +describe('Note app', () => { // highlight-line + it('front page can be opened', () => { // highlight-line + cy.visit('http://localhost:5173') + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2025') + }) +}) +``` + +However, Mocha [recommends](https://mochajs.org/#arrow-functions) that arrow functions are not used, because they might cause some issues in certain situations. + +If cy.contains does not find the text it is searching for, the test does not pass. So if we extend our test like so + +```js +describe('Note app', function() { + it('front page can be opened', function() { + cy.visit('http://localhost:5173') + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2025') + }) + +// highlight-start + it('front page contains random text', function() { + cy.visit('http://localhost:5173') + cy.contains('wtf is this app?') + }) +// highlight-end +}) +``` + +the test fails + +![cypress showing failure expecting to find wtf but no](../../images/5/57new.png) + +Let's remove the failing code from the test. + +### Writing to a form + +Let's extend our tests so that our new test tries to login to our application. +We assume our backend contains a user with the username mluukkai and password salainen. + +The test begins by opening the login form. + +```js +describe('Note app', function() { + // ... + + it('user can login', function() { + cy.visit('http://localhost:5173') + cy.contains('button', 'login').click() + }) +}) +``` + +The test first searches for a _button_ element with the desired text and clicks the button with the command [cy.click](https://docs.cypress.io/api/commands/click.html#Syntax). + +Both of our tests begin the same way, by opening the page , so we should extract the shared code into a beforeEach block run before each test: + +```js +describe('Note app', function() { + // highlight-start + beforeEach(function() { + cy.visit('http://localhost:5173') + }) + // highlight-end + + it('front page can be opened', function() { + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2025') + }) + + it('user can login', function() { + cy.contains('button', 'login').click() + }) +}) +``` + +The login field contains two input fields, which the test should write into. + +The [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax) command allows for searching elements by CSS selectors. + +We can access the first and the last input field on the page, and write to them with the command [cy.type](https://docs.cypress.io/api/commands/type.html#Syntax) like so: + +```js +it('user can login', function () { + cy.contains('button', 'login').click() + cy.get('input:first').type('mluukkai') + cy.get('input:last').type('salainen') +}) +``` + +The test works. The problem is if we later add more input fields, the test will break because it expects the fields it needs to be the first and the last on the page. + +Let's take advantage of the existing elements of the login form. The input fields of the login form have been assigned unique labels: + +```js +// ... +
    +
    + // highlight-line +
    +
    + // highlight-line +
    + +
    +// ... +``` + +Input fields can and should be located in tests using labels: + +```js +describe('Note app', function () { + // ... + + it('user can login', function () { + cy.contains('button', 'login').click() + cy.contains('label', 'username').type('mluukkai') // highlight-line + cy.contains('label', 'password').type('salainen') // highlight-line + }) +}) +``` + +When locating elements, it makes sense to aim to utilize the content visible to the user in the interface, as this best simulates how a user would actually find the desired input field while navigating the application. + +When the username and password have been entered into the form, the next step is to press the login button. However, this causes a bit of a headache, since there are actually two login buttons on the page. The Togglable component we are using also contains a button with the same name, which is hidden by giving it the visibility attribute style="display: none" when the login form is visible. + +To ensure that the test clicks the correct button, we assign a unique id attribute to the login form’s login button: + +```js +const LoginForm = ({ ... }) => { + return ( +
    +

    Login

    +
    + // + + +
    +
    + ) +} +``` + +The test becomes: + +```js +describe('Note app', function() { + // .. + it('user can login', function () { + cy.contains('button', 'login').click() + cy.contains('label', 'username').type('mluukkai') + cy.contains('label', 'password').type('salainen') + cy.get('#login-button').click() // highlight-line + + cy.contains('Matti Luukkainen logged in') // highlight-line + }) +}) +``` + +The last row ensures that the login was successful. + +Note that the CSS's [ID selector](https://developer.mozilla.org/en-US/docs/Web/CSS/ID_selectors) is #, so if we want to search for an element with the ID login-button the CSS selector is #login-button. + +Please note that passing the test at this stage requires that there is a user in the test database of the backend test environment, whose username is mluukkai and the password is salainen. Create a user if needed! + +### Testing new note form + +Next, let's add tests to test the "new note" functionality: + +```js +describe('Note app', function() { + // .. + // highlight-start + describe('when logged in', function() { + beforeEach(function() { + cy.contains('button', 'login').click() + cy.contains('label', 'username').type('mluukkai') + cy.contains('label', 'password').type('salainen') + cy.get('#login-button').click() + }) + // highlight-end + + // highlight-start + it('a new note can be created', function() { + cy.contains('new note').click() + cy.get('input').type('a note created by cypress') + cy.contains('save').click() + + cy.contains('a note created by cypress') + }) + }) + // highlight-end +}) +``` + +The test has been defined in its own describe block. +Only logged-in users can create new notes, so we added logging in to the application to a beforeEach block. + +The test trusts that when creating a new note the page contains only one input, so it searches for it like so: + +```js +cy.get('input') +``` + +If the page contained more inputs, the test would break + +![cypress error - cy.type can only be called on a single element](../../images/5/31x.png) + +Due to this problem, it would again be better to give the input an ID and search for the element by its ID. Let's stick with the simplest solution for now. + +The structure of the tests looks like so: + +```js +describe('Note app', function() { + // ... + + it('user can login', function() { + cy.contains('button', 'login').click() + cy.contains('label', 'username').type('mluukkai') + cy.contains('label', 'password').type('salainen') + cy.get('#login-button').click() + + cy.contains('Matti Luukkainen logged in') + }) + + describe('when logged in', function() { + beforeEach(function() { + cy.contains('button', 'login').click() + cy.contains('label', 'username').type('mluukkai') + cy.contains('label', 'password').type('salainen') + cy.get('#login-button').click() + }) + + it('a new note can be created', function() { + // ... + }) + }) +}) +``` + +Cypress runs the tests in the order they are in the code. So first it runs user can login, where the user logs in. Then cypress will run a new note can be created for which a beforeEach block logs in as well. +Why do this? Isn't the user logged in after the first test? +No, because each test starts from zero as far as the browser is concerned. +All changes to the browser's state are reversed after each test. + +### Controlling the state of the database + +If the tests need to be able to modify the server's database, the situation immediately becomes more complicated. Ideally, the server's database should be the same each time we run the tests, so our tests can be reliably and easily repeatable. + +As with unit and integration tests, with E2E tests it is best to empty the database and possibly format it before the tests are run. The challenge with E2E tests is that they do not have access to the database. + +The solution is to create API endpoints for the backend tests. +We can empty the database using these endpoints. +Let's create a new router for the tests inside the controllers folder, in the testing.js file + +```js +const testingRouter = require('express').Router() +const Note = require('../models/note') +const User = require('../models/user') + +testingRouter.post('/reset', async (request, response) => { + await Note.deleteMany({}) + await User.deleteMany({}) + + response.status(204).end() +}) + +module.exports = testingRouter +``` + +and add it to the backend only if the application is run in test-mode: + +```js +// ... + +app.use('/api/login', loginRouter) +app.use('/api/users', usersRouter) +app.use('/api/notes', notesRouter) + +// highlight-start +if (process.env.NODE_ENV === 'test') { + const testingRouter = require('./controllers/testing') + app.use('/api/testing', testingRouter) +} +// highlight-end + +app.use(middleware.unknownEndpoint) +app.use(middleware.errorHandler) + +module.exports = app +``` + +After the changes, an HTTP POST request to the /api/testing/reset endpoint empties the database. Make sure your backend is running in test mode by starting it with this command (previously configured in the package.json file): + +```js + npm run start:test +``` + +The modified backend code can be found on the [GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part5-1) branch part5-1. + +Next, we will change the beforeEach block so that it empties the server's database before tests are run. + +Currently, it is not possible to add new users through the frontend's UI, so we add a new user to the backend from the beforeEach block. + +```js +describe('Note app', function() { + beforeEach(function() { + // highlight-start + cy.request('POST', 'http://localhost:3001/api/testing/reset') + const user = { + name: 'Matti Luukkainen', + username: 'mluukkai', + password: 'salainen' + } + cy.request('POST', 'http://localhost:3001/api/users/', user) + // highlight-end + cy.visit('http://localhost:5173') + }) + + it('front page can be opened', function() { + // ... + }) + + it('user can login', function() { + // ... + }) + + describe('when logged in', function() { + // ... + }) +}) +``` + +During the formatting, the test does HTTP requests to the backend with [cy.request](https://docs.cypress.io/api/commands/request.html). + +Unlike earlier, now the testing starts with the backend in the same state every time. The backend will contain one user and no notes. + +Let's add one more test for checking that we can change the importance of notes. + +A while ago we changed the frontend so that a new note is important by default, so the important field is true: + +```js +const NoteForm = ({ createNote }) => { + // ... + + const addNote = (event) => { + event.preventDefault() + createNote({ + content: newNote, + important: true // highlight-line + }) + + setNewNote('') + } + // ... +} +``` + +There are multiple ways to test this. In the following example, we first search for a note and click its make not important button. Then we check that the note now contains a make important button. + +```js +describe('Note app', function() { + // ... + + describe('when logged in', function() { + // ... + + describe('and a note exists', function () { + beforeEach(function () { + cy.contains('new note').click() + cy.get('input').type('another note cypress') + cy.contains('save').click() + }) + + it('it can be made not important', function () { + cy.contains('another note cypress') + .contains('button', 'make not important') + .click() + + cy.contains('another note cypress') + .contains('button', 'make important') + }) + }) + }) +}) +``` + +The first command does several things. First, it searches for an element containing the text another note cypress. Then, within the found element, it searches for the make not important button and clicks it. + +The second command checks that the text on the button has changed to make important. + +### Failed login test + +Let's make a test to ensure that a login attempt fails if the password is wrong. + +Cypress will run all tests each time by default, and as the number of tests increases, it starts to become quite time-consuming. +When developing a new test or when debugging a broken test, we can define the test with it.only instead of it, so that Cypress will only run the required test. +When the test is working, we can remove .only. + +First version of our tests is as follows: + +```js +describe('Note app', function() { + // ... + + it.only('login fails with wrong password', function() { + cy.contains('button', 'login').click() + cy.contains('label', 'username').type('mluukkai') + cy.contains('label', 'password').type('wrong') + cy.get('#login-button').click() + + cy.contains('wrong credentials') + }) + + // ... +)} +``` + +The test uses [cy.contains](https://docs.cypress.io/api/commands/contains.html#Syntax) to ensure that the application prints an error message. + +The application renders the error message to a component with the CSS class error: + +```js +const Notification = ({ message }) => { + if (message === null) { + return null + } + + return ( +
    // highlight-line + {message} +
    + ) +} +``` + +We could make the test ensure that the error message is rendered to the correct component, that is, the component with the CSS class error: + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error').contains('wrong credentials') // highlight-line +}) +``` + +First, we use [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax) to search for a component with the CSS class error. Then we check that the error message can be found in this component. +Note that the [CSS class selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/Class_selectors) start with a full stop, so the selector for the class error is .error. + +We could do the same using the [should](https://docs.cypress.io/api/commands/should.html) syntax: + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error').should('contain', 'wrong credentials') // highlight-line +}) +``` + +Using should is a bit trickier than using contains, but it allows for more diverse tests than contains which works based on text content only. + +A list of the most common assertions which can be used with _should_ can be found [here](https://docs.cypress.io/guides/references/assertions.html#Common-Assertions). + +We can, for example, make sure that the error message is red and it has a border: + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error').should('contain', 'wrong credentials') + cy.get('.error').should('have.css', 'color', 'rgb(255, 0, 0)') + cy.get('.error').should('have.css', 'border-style', 'solid') +}) +``` + +Cypress requires the colors to be given as [rgb](https://rgbcolorcode.com/color/red). + +Because all tests are for the same component we accessed using [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax), we can chain them using [and](https://docs.cypress.io/api/commands/and.html). + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error') + .should('contain', 'wrong credentials') + .and('have.css', 'color', 'rgb(255, 0, 0)') + .and('have.css', 'border-style', 'solid') +}) +``` + +Let's finish the test so that it also checks that the application does not render the success message 'Matti Luukkainen logged in': + +```js +it('login fails with wrong password', function() { + cy.contains('button', 'login').click() + cy.contains('label', 'username').type('mluukkai') + cy.contains('label', 'password').type('wrong') + cy.get('#login-button').click() + + cy.get('.error') + .should('contain', 'wrong credentials') + .and('have.css', 'color', 'rgb(255, 0, 0)') + .and('have.css', 'border-style', 'solid') + + cy.get('html').should('not.contain', 'Matti Luukkainen logged in') // highlight-line +}) +``` + +The command should is most often used by chaining it after the command get (or another similar command that can be chained). The cy.get('html') used in the test practically means the visible content of the entire application. + +We would also check the same by chaining the command contains with the command should with a slightly different parameter: + +```js +cy.contains('Matti Luukkainen logged in').should('not.exist') +``` + +**NOTE:** Some CSS properties [behave differently on Firefox](https://github.com/cypress-io/cypress/issues/9349). If you run the tests with Firefox: + + ![running](https://user-images.githubusercontent.com/4255997/119015927-0bdff800-b9a2-11eb-9234-bb46d72c0368.png) + + then tests that involve, for example, `border-style`, `border-radius` and `padding`, will pass in Chrome or Electron, but fail in Firefox: + + ![borderstyle](https://user-images.githubusercontent.com/4255997/119016340-7b55e780-b9a2-11eb-82e0-bab0418244c0.png) + +### Bypassing the UI + +Currently, we have the following tests: + +```js +describe('Note app', function() { + it('user can login', function() { + cy.contains('button', 'login').click() + cy.contains('label', 'username').type('mluukkai') + cy.contains('label', 'password').type('salainen') + cy.get('#login-button').click() + + cy.contains('Matti Luukkainen logged in') + }) + + it('login fails with wrong password', function() { + // ... + }) + + describe('when logged in', function() { + beforeEach(function() { + cy.contains('button', 'login').click() + cy.contains('label', 'username').type('mluukkai') + cy.contains('label', 'password').type('salainen') + cy.get('#login-button').click() + }) + + it('a new note can be created', function() { + // ... + }) + + }) +}) +``` + +First, we test logging in. Then, in their own describe block, we have a bunch of tests, which expect the user to be logged in. User is logged in in the beforeEach block. + +As we said above, each test starts from zero! Tests do not start from the state where the previous tests ended. + +The Cypress documentation gives us the following advice: [Fully test the login flow – but only once](https://docs.cypress.io/guides/end-to-end-testing/testing-your-app#Fully-test-the-login-flow----but-only-once). +So instead of logging in a user using the form in the beforeEach block, we are going to bypass the UI and do a HTTP request to the backend to login. The reason for this is that logging in with a HTTP request is much faster than filling out a form. + +Our situation is a bit more complicated than in the example in the Cypress documentation because when a user logs in, our application saves their details to the localStorage. +However, Cypress can handle that as well. +The code is the following: + +```js +describe('when logged in', function() { + beforeEach(function() { + // highlight-start + cy.request('POST', 'http://localhost:3001/api/login', { + username: 'mluukkai', password: 'salainen' + }).then(response => { + localStorage.setItem('loggedNoteappUser', JSON.stringify(response.body)) + cy.visit('http://localhost:5173') + }) + // highlight-end + }) + + it('a new note can be created', function() { + // ... + }) + + // ... +}) +``` + +We can access to the response of a [cy.request](https://docs.cypress.io/api/commands/request.html) with the [_then_](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress#The-Cypress-Command-Queue) method. Under the hood cy.request, like all Cypress commands, are [asynchronous](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress#Commands-Are-Asynchronous). +The callback function saves the details of a logged-in user to localStorage, and reloads the page. +Now there is no difference to a user logging in with the login form. + +If and when we write new tests to our application, we have to use the login code in multiple places, we should make it a [custom command](https://docs.cypress.io/api/cypress-api/custom-commands.html). + +Custom commands are declared in cypress/support/commands.js. +The code for logging in is as follows: + +```js +Cypress.Commands.add('login', ({ username, password }) => { + cy.request('POST', 'http://localhost:3001/api/login', { + username, password + }).then(({ body }) => { + localStorage.setItem('loggedNoteappUser', JSON.stringify(body)) + cy.visit('http://localhost:5173') + }) +}) +``` + +Using our custom command is easy, and our test becomes cleaner: + +```js +describe('when logged in', function() { + beforeEach(function() { + // highlight-start + cy.login({ username: 'mluukkai', password: 'salainen' }) + // highlight-end + }) + + it('a new note can be created', function() { + // ... + }) + + // ... +}) +``` + +The same applies to creating a new note now that we think about it. We have a test, which makes a new note using the form. We also make a new note in the beforeEach block of the test that changes the importance of a note: + +```js +describe('Note app', function() { + // ... + + describe('when logged in', function() { + beforeEach(function () { + cy.login({ username: 'mluukkai', password: 'salainen' }) + }) + + it('a new note can be created', function() { + cy.contains('new note').click() + cy.get('input').type('a note created by cypress') + cy.contains('save').click() + + cy.contains('a note created by cypress') + }) + + describe('and a note exists', function () { + beforeEach(function () { + // highlight-start + cy.contains('new note').click() + cy.get('input').type('another note cypress') + cy.contains('save').click() + // highlight-end + }) + + it('it can be made important', function () { + // ... + }) + }) + }) +}) +``` + +Let's make a new custom command for making a new note. The command will make a new note with an HTTP POST request: + +```js +Cypress.Commands.add('createNote', ({ content, important }) => { + cy.request({ + url: 'http://localhost:3001/api/notes', + method: 'POST', + body: { content, important }, + headers: { + 'Authorization': `Bearer ${JSON.parse(localStorage.getItem('loggedNoteappUser')).token}` + } + }) + + cy.visit('http://localhost:5173') +}) +``` + +The command expects the user to be logged in and the user's details to be saved to localStorage. + +Now the note beforeEach block becomes: + +```js +describe('Note app', function() { + // ... + + describe('when logged in', function() { + // ... + + describe('and a note exists', function () { + beforeEach(function () { + // highlight-start + cy.createNote({ + content: 'another note cypress', + important: true + }) + // highlight-end + }) + + it('it can be made important', function () { + // ... + }) + }) + }) +}) +``` + +There is one more annoying feature in our tests. The frontend address http://localhost:5173 and the backend address http://localhost:3001 are hardcoded for tests. Of these, the address of the backend is actually useless, because a proxy has been defined in the Vite configuration of the frontend, which forwards all requests made by the frontend to the address http:localhost:5173/api to the backend: + +```js +export default defineConfig({ + server: { + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + } + }, + // ... +}) +``` + +So we can replace all the addresses in the tests from _http://localhost:3001/api/..._ to _http://localhost:5173/api/..._ + +Let's define the baseUrl for the application in the Cypress pre-generated [configuration file](https://docs.cypress.io/guides/references/configuration) cypress.config.js: + +```js +const { defineConfig } = require("cypress") + +module.exports = defineConfig({ + e2e: { + setupNodeEvents(on, config) { + }, + baseUrl: 'http://localhost:5173' // highlight-line + }, +}) +``` + +All commands in the tests and in the command.js file that use the application's address + +```js +cy.visit('http://localhost:5173') +``` + +can be transformed into + +```js +cy.visit('') +``` + +### Changing the importance of a note + +Lastly, let's take a look at the test we did for changing the importance of a note. +First, we'll change the beforeEach block so that it creates three notes instead of one. The tests change as follows: + +```js +describe('when logged in', function () { + beforeEach(function () { + cy.login({ username: 'mluukkai', password: 'salainen' }) + }) + + it('a new note can be created', function () { + cy.contains('new note').click() + cy.get('input').type('a note created by cypress') + cy.contains('save').click() + cy.contains('a note created by cypress') + }) + describe('and several notes exist', function () { // highlight-line + beforeEach(function () { + cy.createNote({ content: 'first note', important: true }) // highlight-line + cy.createNote({ content: 'second note', important: true }) // highlight-line + cy.createNote({ content: 'third note', important: true }) // highlight-line + }) + + it('one of those can be made non important', function () { // highlight-line + cy.contains('second note') // highlight-line + .contains('button', 'make not important') + .click() + + cy.contains('second note') // highlight-line + .contains('button', 'make important') + }) + }) +}) +``` + +How does the [cy.contains](https://docs.cypress.io/api/commands/contains.html) command actually work? + +When we click the _-contains 'second note'_ command in Cypress [Test Runner](https://docs.cypress.io/guides/core-concepts/cypress-app#Test-Runner), we see that the command searches for the element containing the text second note: + +![cypress test runner clicking second note](../../images/5/34eb.png) + +By clicking the next line _-contains 'button, make not important'_ we see that the test uses the make not important button corresponding to the second note: + +![cypress test runner clicking make important](../../images/5/35a.png) + +When chained, the second contains command continues the search from within the component found by the first command. + +If we had not chained the commands, and instead write: + +```js +cy.contains('second note') +cy.contains('button', 'make not important').click() +``` + +the result would have been entirely different. The second line of the test would click the button of a wrong note: + +![cypress showing error and incorrectly trying to click first button](../../images/5/36.png) + +When coding tests, you should check in the test runner that the tests use the right components! + +Let's change the _Note_ component so that the text of the note is rendered to a span. + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' : 'make important' + + return ( +
  • + {note.content} // highlight-line + +
  • + ) +} +``` + +Our tests break! As the test runner reveals, _cy.contains('second note')_ now returns the component containing the text, and the button is not in it. + +![cypress showing test is broken trying to click make important](../../images/5/37.png) + +One way to fix this is the following: + +```js +it('one of those can be made non important', function () { + cy.contains('second note').parent().find('button').click() + cy.contains('second note') + .parent() + .find('button') + .should('contain', 'make important') +}) +``` + +In the first line, we use the [parent](https://docs.cypress.io/api/commands/parent.html) command to access the parent element of the element containing second note and find the button from within it. +Then we click the button and check that the text on it changes. + +Note that we use the command [find](https://docs.cypress.io/api/commands/find.html#Syntax) to search for the button. We cannot use [cy.get](https://docs.cypress.io/api/commands/get.html) here, because it always searches from the whole page and would return all 5 buttons on the page. + +Unfortunately, we have some copy-paste in the tests now, because the code for searching for the right button is always the same. + +In these kinds of situations, it is possible to use the [as](https://docs.cypress.io/api/commands/as.html) command: + +```js +it('one of those can be made non important', function () { + cy.contains('second note').parent().find('button').as('theButton') + cy.get('@theButton').click() + cy.get('@theButton').should('contain', 'make important') +}) +``` + +Now the first line finds the right button and uses as to save it as theButton. The following lines can use the named element with _cy.get('@theButton')_. + +### Running and debugging the tests + +Finally, some notes on how Cypress works and debugging your tests. + +Because of the form of the Cypress tests, it gives the impression that they are normal JavaScript code, and we could for example try this: + +```js +const button = cy.contains('button', 'login') +button.click() +debugger +cy.contains('logout').click() +``` + +This won't work, however. When Cypress runs a test, it adds each _cy_ command to an execution queue. +When the code of the test method has been executed, Cypress will execute each command in the queue one by one. + +Cypress commands always return _undefined_, so _button.click()_ in the above code would cause an error. An attempt to start the debugger would not stop the code between executing the commands, but before any commands have been executed. + +Cypress commands are like promises, so if we want to access their return values, we have to do it using the [then](https://docs.cypress.io/api/commands/then.html) command. +For example, the following test would print the number of buttons in the application, and click the first button: + +```js +it('then example', function() { + cy.get('button').then( buttons => { + console.log('number of buttons', buttons.length) + cy.wrap(buttons[0]).click() + }) +}) +``` + +Stopping the test execution with the debugger is [possible](https://docs.cypress.io/api/commands/debug.html). The debugger starts only if Cypress test runner's developer console is open. + +The developer console is all sorts of useful when debugging your tests. +You can see the HTTP requests done by the tests on the Network tab, and the console tab will show you information about your tests: + +![developer console while running cypress](../../images/5/38.png) + +So far we have run our Cypress tests using the graphical test runner. It is also possible to run them [from the command line](https://docs.cypress.io/guides/guides/command-line.html). We just have to add an npm script for it: + +```js + "scripts": { + "cypress:open": "cypress open", + "test:e2e": "cypress run" // highlight-line + }, +``` + +Now we can run our tests from the command line with the command npm run test:e2e + +![terminal output of running npm e2e tests showing passed](../../images/5/39.png) + +It is also possible to record a video of the test execution in Cypress. Recording a video can be especially useful, for example, when debugging or in a CI/CD pipeline, as the video allows you to easily review what happened in the browser before an error occurred. The feature is disabled by default; instructions for enabling it can be found in the Cypress [documentation](https://docs.cypress.io/guides/guides/screenshots-and-videos#Videos). + +Tests are found in [GitHub](https://github.com/fullstack-hy2020/notes-e2e-cypress/). + +Final version of the frontend code can be found on the [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-9) branch part5-9. + +
    + +
    + +### Exercises 5.17.-5.23. + +In the last exercises of this part, we will do some E2E tests for our blog application. +The material of this part should be enough to complete the exercises. +You **must check out the Cypress [documentation](https://docs.cypress.io/guides/overview/why-cypress.html#In-a-nutshell)**. It is probably the best documentation I have ever seen for an open-source project. + +I especially recommend reading [Introduction to Cypress](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Cypress-Can-Be-Simple-Sometimes), which states + +> This is the single most important guide for understanding how to test with Cypress. Read it. Understand it. + +#### 5.17: Blog List End To End Testing, step 1 + +Configure Cypress for your project. Make a test for checking that the application displays the login form by default. + +The structure of the test must be as follows: + +```js +describe('Blog app', function() { + beforeEach(function() { + cy.visit('http://localhost:5173') + }) + + it('Login form is shown', function() { + // ... + }) +}) +``` + +#### 5.18: Blog List End To End Testing, step 2 + +Make tests for logging in. Test both successful and unsuccessful login attempts. +Make a new user in the beforeEach block for the tests. + +The test structure extends like so: + +```js +describe('Blog app', function() { + beforeEach(function() { + // empty the db here + // create a user for the backend here + cy.visit('http://localhost:5173') + }) + + it('Login form is shown', function() { + // ... + }) + + describe('Login',function() { + it('succeeds with correct credentials', function() { + // ... + }) + + it('fails with wrong credentials', function() { + // ... + }) + }) +}) +``` + +The _beforeEach_ block must empty the database using, for example, the reset method we used in the [material](/en/part5/end_to_end_testing_cypress#controlling-the-state-of-the-database). + +Optional bonus exercise: Check that the notification shown with unsuccessful login is displayed red. + +#### 5.19: Blog List End To End Testing, step 3 + +Make a test that verifies a logged-in user can create a new blog. +The structure of the test could be as follows: + +```js +describe('Blog app', function() { + // ... + + describe('When logged in', function() { + beforeEach(function() { + // ... + }) + + it('A blog can be created', function() { + // ... + }) + }) + +}) +``` + +The test has to ensure that a new blog is added to the list of all blogs. + +#### 5.20: Blog List End To End Testing, step 4 + +Make a test that confirms users can like a blog. + +#### 5.21: Blog List End To End Testing, step 5 + +Make a test for ensuring that the user who created a blog can delete it. + +#### 5.22: Blog List End To End Testing, step 6 + +Make a test for ensuring that only the creator can see the delete button of a blog, not anyone else. + +#### 5.23: Blog List End To End Testing, step 7 + +Make a test that checks that the blogs are ordered by likes, with the most liked blog being first. + +This exercise is quite a bit trickier than the previous ones. One solution is to add a certain class for the element which wraps the blog's content and use the [eq](https://docs.cypress.io/api/commands/eq#Syntax) method to get the blog element in a specific index: + +```js +cy.get('.blog').eq(0).should('contain', 'The title with the most likes') +cy.get('.blog').eq(1).should('contain', 'The title with the second most likes') +``` + +Note that you might end up having problems if you click a like button many times in a row. It might be that cypress does the clicking so fast that it does not have time to update the app state in between the clicks. One remedy for this is to wait for the number of likes to update in between all clicks. + +This was the last exercise of this part, and it's time to push your code to GitHub and mark the exercises you completed in the [exercise submission system](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    diff --git a/src/content/5/es/part5.md b/src/content/5/es/part5.md new file mode 100644 index 00000000000..796bcf836d1 --- /dev/null +++ b/src/content/5/es/part5.md @@ -0,0 +1,14 @@ +--- +mainImage: ../../../images/part-5.svg +part: 5 +lang: es +--- + +
    + +En esta parte volvemos al frontend, primero mirando a diferentes posibilidades para probar el código React. También implementaremos la autenticación basada en tokens que permitirá a los usuarios iniciar sesión en nuestra aplicación. + +Parte actualizada el 3 de Marzo de 2024 +- Jest reemplazado por Vitest + +
    \ No newline at end of file diff --git a/src/content/5/es/part5a.md b/src/content/5/es/part5a.md new file mode 100644 index 00000000000..05b84c9bf32 --- /dev/null +++ b/src/content/5/es/part5a.md @@ -0,0 +1,641 @@ +--- +mainImage: ../../../images/part-5.svg +part: 5 +letter: a +lang: es +--- + +
    + +En las dos últimas partes, nos hemos concentrado principalmente en el backend. El frontend, que desarrollamos en la [parte 2](/es/part2) aún no es compatible con la administración de usuarios que implementamos en el backend en la parte 4. + +Por el momento, el frontend muestra las notas existentes y permite a los usuarios cambiar el estado de una nota de importante a no importante y viceversa. Ya no se pueden agregar nuevas notas debido a los cambios realizados en el backend en la parte 4: el backend ahora espera que se envíe un token que verifique la identidad de un usuario con la nueva nota. + +Ahora implementaremos una parte de la funcionalidad de administración de usuarios requerida en el frontend. Comencemos con el inicio de sesión del usuario. A lo largo de esta parte, asumiremos que no se agregarán nuevos usuarios desde el frontend. + +### Controlando el inicio de sesión + +Ahora se ha agregado un formulario de inicio de sesión en la parte superior de la página. + +![navegador mostrando login de usuario para app de notas](../../images/5/1new.png) + +El código del componente App ahora tiene el siguiente aspecto: + +```js +const App = () => { + const [notes, setNotes] = useState([]) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + const [errorMessage, setErrorMessage] = useState(null) + // highlight-start + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') +// highlight-end + + useEffect(() => { + noteService + .getAll().then(initialNotes => { + setNotes(initialNotes) + }) + }, []) + + // ... + +// highlight-start + const handleLogin = (event) => { + event.preventDefault() + console.log('logging in with', username, password) + } + // highlight-end + + return ( +
    +

    Notes

    + + + + // highlight-start +
    +
    + username + setUsername(target.value)} + /> +
    +
    + password + setPassword(target.value)} + /> +
    + +
    + // highlight-end + + // ... +
    + ) +} + +export default App +``` + +El código de aplicación actual se puede encontrar en [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-1), en la rama part5-1. Si clonas el repositorio, no olvides ejecutar el comando _npm install_ antes de intentar ejecutar el frontend. + +El frontend no mostrara ninguna nota si no se conecta al backend. Puedes iniciar el backend con el comando _npm run dev_ en su directorio de la Parte 4. Esto ejecutara el backend en el puerto 3001. Mientras esté activo, en una ventana diferente del terminal puedes ejecutar el frontend con _npm start_, y ahora veras las notas que están guardadas en tu base de datos MongoDB de la Parte 4. + +Recuerda esto de ahora en más. + +El formulario de inicio de sesión se maneja de la misma manera que manejamos los formularios en la [parte 2](/es/part2/formularios). El estado de la aplicación tiene los campos username y password para almacenar los datos del formulario. Los campos de formulario tienen controladores de eventos, que sincronizan los cambios en el campo con el estado del componente App. Los controladores de eventos son simples: se les da un objeto como parámetro, y desestructuran el campo target del objeto y guardan su valor en el estado. + +```js +({ target }) => setUsername(target.value) +``` + +El método _handleLogin_, que se encarga de manejar los datos en el formulario, aún no se ha implementado. + +El inicio de sesión se realiza enviando una solicitud HTTP POST a la dirección del servidor api/login. Separemos el código responsable de esta solicitud en su propio módulo, en el archivo services/login.js. + +Usaremos la sintaxis async/await en lugar de promesas para la solicitud HTTP: + +```js +import axios from 'axios' +const baseUrl = '/api/login' + +const login = async credentials => { + const response = await axios.post(baseUrl, credentials) + return response.data +} + +export default { login } +``` + +El método para manejar el inicio de sesión se puede implementar de la siguiente manera: + +```js +import loginService from './services/login' // highlight-line + +const App = () => { + // ... + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') +// highlight-start + const [user, setUser] = useState(null) +// highlight-end + + // highlight-start + const handleLogin = async (event) => { + event.preventDefault() + + try { + const user = await loginService.login({ + username, password, + }) + + setUser(user) + setUsername('') + setPassword('') + } catch (exception) { + setErrorMessage('Wrong credentials') + setTimeout(() => { + setErrorMessage(null) + }, 5000) + } + // highlight-end + } + + // ... +} +``` + +Si la conexión es exitosa, los campos de formulario se vacían y la respuesta del servidor (incluyendo un token y los datos del usuario) se guardan en el campo user del estado de la aplicación. + +Si el inicio de sesión falla, o la ejecución de la función _loginService.login_ da como resultado un error, se notifica al usuario. + +No se notifica al usuario acerca de un inicio de sesión exitoso de ninguna manera. Modifiquemos la aplicación para que muestre el formulario de inicio de sesión solo si el usuario no ha iniciado sesión, cuando _user === null_. El formulario para agregar nuevas notas se muestra solo si el usuario ha iniciado sesión, por lo que user contiene los detalles del usuario. + +Agreguemos dos funciones auxiliares al componente App para generar los formularios: + +```js +const App = () => { + // ... + + const loginForm = () => ( +
    +
    + username + setUsername(target.value)} + /> +
    +
    + password + setPassword(target.value)} + /> +
    + +
    + ) + + const noteForm = () => ( +
    + + +
    + ) + + return ( + // ... + ) +} +``` + +y renderizarlos condicionalmente: + +```js +const App = () => { + // ... + + const loginForm = () => ( + // ... + ) + + const noteForm = () => ( + // ... + ) + + return ( +
    +

    Notes

    + + + + {user === null && loginForm()} // highlight-line + {user !== null && noteForm()} // highlight-line + +
    + +
    +
      + {notesToShow.map((note, i) => + toggleImportanceOf(note.id)} + /> + )} +
    + +
    +
    + ) +} +``` + +Un [truco de React](https://es.react.dev/learn/conditional-rendering#logical-and-operator-) ligeramente extraño, pero de uso común, se usa para renderizar los formularios de forma condicional: + +```js +{ + user === null && loginForm() +} +``` + +Si la primera declaración se evalúa como falsa, o es [falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy), la segunda declaración (que genera el formulario) no se ejecuta en absoluto. + +Podemos hacer esto aún más sencillo usando el [operador condicional](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Operators/Conditional_Operator): + +```js +return ( +
    +

    Notes

    + + + + {user === null ? + loginForm() : + noteForm() + } + +

    Notes

    + + // ... + +
    +) +``` + +Si _user === null_ es [truthy](https://developer.mozilla.org/es/docs/Glossary/Truthy) (verdadero), se ejecuta _loginForm()_. Si no es así, se ejecuta _noteForm()_. + +Hagamos una modificación más. Si el usuario ha iniciado sesión, su nombre se muestra en la pantalla: + +```js +return ( +
    +

    Notes

    + + + + {user === null ? + loginForm() : +
    +

    {user.name} logged-in

    + {noteForm()} +
    + } + +

    Notes

    + + // ... + +
    +) +``` + +La solución no es perfecta, pero la dejaremos así por ahora. + +Nuestro componente principal App es demasiado grande en este momento. Los cambios que hicimos ahora son una clara señal de que los formularios deben ser refactorizados en sus propios componentes. Sin embargo, lo dejaremos para un ejercicio opcional. + +El código de la aplicación actual se puede encontrar en [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-2), en la rama part5-2. + +### Creando nuevas notas + +El token devuelto con un inicio de sesión exitoso se guarda en el estado de la aplicación, en el campo token de user: + +```js +const handleLogin = async (event) => { + event.preventDefault() + try { + const user = await loginService.login({ + username, password, + }) + + setUser(user) // highlight-line + setUsername('') + setPassword('') + } catch (exception) { + // ... + } +} +``` + +Arreglemos la creación de nuevas notas para que funcione con el backend. Esto significa agregar el token del usuario que inició sesión en el encabezado de Autorización de la solicitud HTTP. + +El módulo noteService cambia así: + +```js +import axios from 'axios' +const baseUrl = '/api/notes' + +let token = null // highlight-line + +// highlight-start +const setToken = newToken => { + token = `Bearer ${newToken}` +} +// highlight-end + +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} + +// highlight-start +const create = async newObject => { + const config = { + headers: { Authorization: token }, + } +// highlight-end + + const response = await axios.post(baseUrl, newObject, config) // highlight-line + return response.data +} + +const update = (id, newObject) => { + const request = axios.put(`${ baseUrl }/${id}`, newObject) + return request.then(response => response.data) +} + +export default { getAll, create, update, setToken } // highlight-line +``` + +El módulo noteService contiene una variable privada llamada _token_. Su valor se puede cambiar con la función _setToken_, que es exportada por el módulo. _create_, ahora con la sintaxis async/await, establece el token en el encabezado Authorization. El header se le da a axios como el tercer parámetro del método post. + +El controlador de eventos responsable del inicio de sesión debe cambiarse para llamar al método noteService.setToken(user.token) con un inicio de sesión exitoso: + +```js +const handleLogin = async (event) => { + event.preventDefault() + try { + const user = await loginService.login({ + username, password, + }) + + noteService.setToken(user.token) // highlight-line + setUser(user) + setUsername('') + setPassword('') + } catch (exception) { + // ... + } +} +``` + +Y ahora, ¡agregar nuevas notas funciona otra vez! + +### Guardando el token en el local storage del navegador + +Nuestra aplicación tiene un pequeño defecto: si el navegador es refrescado (por ejemplo, al apretar F5), la información de login del usuario desaparece. + +Este problema se resuelve fácilmente guardando los datos de inicio de sesión en el [local storage](https://developer.mozilla.org/es/docs/Web/API/Storage)(almacenamiento local). Local Storage es una base de datos de [clave-valor](https://es.wikipedia.org/wiki/Base_de_datos_clave-valor) en el navegador. + +Es muy fácil de usar. Un valor correspondiente a una determinada clave se guarda en la base de datos con el método [setItem](https://developer.mozilla.org/es/docs/Web/API/Storage/setItem). Por ejemplo: + +```js +window.localStorage.setItem('name', 'juha tauriainen') +``` + +guarda el string dado como segundo parámetro como el valor de la clave name. + +El valor de una clave se puede obtener con el método [getItem](https://developer.mozilla.org/es/docs/Web/API/Storage/getItem): + +```js +window.localStorage.getItem('name') +``` + +mientras que [removeItem](https://developer.mozilla.org/es/docs/Web/API/Storage/removeItem) elimina una clave. + +Los valores del local storage se conservan incluso cuando se vuelve a renderizar la página. El almacenamiento es específico de [origen](https://developer.mozilla.org/en-US/docs/Glossary/Origin), por lo que cada aplicación web tiene su propio almacenamiento. + +Extendamos nuestra aplicación para que guarde los detalles de un usuario que inició sesión en local storage. + +Los valores guardados en el storage son [DOMstrings](https://docs.w3cub.com/dom/domstring), por lo que no podemos guardar un objeto JavaScript tal cual. El objeto debe formatearse primero como JSON, con el método _JSON.stringify_. En consecuencia, cuando se lee un objeto JSON del almacenamiento local, debe formatearse de nuevo a JavaScript con _JSON.parse_. + +Los cambios en el método de inicio de sesión son los siguientes: + +```js + const handleLogin = async (event) => { + event.preventDefault() + try { + const user = await loginService.login({ + username, password, + }) + + // highlight-start + window.localStorage.setItem( + 'loggedNoteappUser', JSON.stringify(user) + ) + // highlight-end + noteService.setToken(user.token) + setUser(user) + setUsername('') + setPassword('') + } catch (exception) { + // ... + } + } +``` + +Los detalles de un usuario que inició sesión ahora se guardan en local storage y se pueden ver en la consola (al escribir _window.localStorage_ en ella): + +![consola del navegador mostrando datos de usuario guardados en local storage](../../images/5/3e.png) + +También puedes inspeccionar el local storage con las herramientas de desarrollo. En Chrome, ve a la pestaña Application y selecciona Local Storage (más detalles [aquí](https://developer.chrome.com/docs/devtools/storage/localstorage?hl=es-419)). En Firefox, ve a la pestaña Storage y selecciona Local Storage (detalles [aquí](https://firefox-source-docs.mozilla.org/devtools-user/storage_inspector/index.html)). + +Aún tenemos que modificar nuestra aplicación para que cuando ingresemos a la página, la aplicación verifique si los detalles de un usuario que inició sesión ya se pueden encontrar en el local storage. Si se encuentran allí, los detalles se guardan en el estado de la aplicación y en noteService. + +La forma correcta de hacer esto es con un [effect hook](https://es.react.dev/reference/react/useEffect): un mecanismo que encontramos por primera vez en la [parte 2](/es/part2/obteniendo_datos_del_servidor#effect-hooks) y que usamos para obtener notas desde el servidor. + +Podemos tener múltiples effect hooks, así que creemos otro para manejar la primera carga de la página: + +```js +const App = () => { + const [notes, setNotes] = useState([]) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + const [errorMessage, setErrorMessage] = useState(null) + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [user, setUser] = useState(null) + + useEffect(() => { + noteService + .getAll().then(initialNotes => { + setNotes(initialNotes) + }) + }, []) + + // highlight-start + useEffect(() => { + const loggedUserJSON = window.localStorage.getItem('loggedNoteappUser') + if (loggedUserJSON) { + const user = JSON.parse(loggedUserJSON) + setUser(user) + noteService.setToken(user.token) + } + }, []) + // highlight-end + + // ... +} +``` + +El array vacío como parámetro del effect hook asegura que el hook se ejecute solo cuando el componente es renderizado [por primera vez](https://es.react.dev/reference/react/useEffect#parameters). + +Ahora un usuario permanece conectado a la aplicación para siempre. Probablemente deberíamos agregar funcionalidad para cerrar sesión que elimine los detalles de inicio de sesión del almacenamiento local. Sin embargo, lo dejaremos para un ejercicio. + +Es posible cerrar la sesión de un usuario usando la consola, y eso es suficiente por ahora. +Puedes cerrar sesión con el comando: + +```js +window.localStorage.removeItem('loggedNoteappUser') +``` + +o con el comando que vacía el localstorage por completo: + +```js +window.localStorage.clear() +``` + +El código de la aplicación actual se puede encontrar en [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-3), en la rama part5-3. + +
    + +
    + +### Ejercicios 5.1.-5.4. + +Ahora crearemos un frontend para el backend de la lista de blogs que creamos en la última parte. Puedes usar [esta aplicación](https://github.com/fullstack-hy2020/bloglist-frontend) de GitHub como base para tu solución. Debes conectar tu backend con un proxy como se muestra en [la parte 3](/es/part3/despliegue_de_la_aplicacion_a_internet#proxy). + +Es suficiente con enviar tu solución terminada. Puedes hacer un commit después de cada ejercicio, pero eso no es necesario. + +Los primeros ejercicios revisan todo lo que hemos aprendido sobre React hasta ahora. Pueden ser un desafío, especialmente si tu backend está incompleto. +Podría ser mejor usar el backend de la respuesta modelo de la parte 4. + +Mientras realizas los ejercicios, recuerda todos los métodos de depuración de los que hemos hablado, especialmente mirar a la consola. + +**Advertencia:** Si notas que estás mezclando los comandos _async/await_ y _then_, hay un 99.9% de probabilidades de que estés haciendo algo mal. Utiliza uno u otro, nunca ambos. + +#### 5.1: Frontend de la Lista de Blogs, paso 1 + +Clona la aplicación de [GitHub](https://github.com/fullstack-hy2020/bloglist-frontend) con el comando: + +```bash +git clone https://github.com/fullstack-hy2020/bloglist-frontend +``` + +Elimina la configuración de git de la aplicación clonada + +```bash +cd bloglist-frontend // ve al repositorio clonado +rm -rf .git +``` + +La aplicación se inicia de la forma habitual, pero primero debes instalar sus dependencias: + +```bash +npm install +npm run dev +``` + +Implementa la funcionalidad de inicio de sesión en el frontend. El token devuelto con un inicio de sesión exitoso se guarda en el estado user de la aplicación. + +Si un usuario no ha iniciado sesión, solo se verá el formulario de inicio de sesión. + +![navegador mostrando solamente el formulario de login](../../images/5/4e.png) + +Si el usuario ha iniciado sesión, se muestra el nombre del usuario y una lista de blogs. + +![navegador mostrando blogs y a quien ha iniciado sesión](../../images/5/5e.png) + +Los detalles de usuario del usuario que inició sesión no tienen que guardarse todavía en el local storage. + +**NB** Puedes implementar el rendering condicional del formulario de inicio de sesión así, por ejemplo: + +```js + if (user === null) { + return ( +
    +

    Log in to application

    +
    + //... +
    +
    + ) + } + + return ( +
    +

    blogs

    + {blogs.map(blog => + + )} +
    + ) +} +``` + +#### 5.2: Frontend de la Lista de Blogs, paso 2 + +Haz que el inicio de sesión sea "permanente" mediante el use de local storage. También implementa una forma de cerrar sesión. + +![navegador mostrando botón de logout luego de iniciar sesión](../../images/5/6e.png) + +Asegúrate de que el navegador no recuerde los detalles del usuario después de cerrar la sesión. + +#### 5.3: Frontend de la Lista de Blogs, paso 3 + +Expande tu aplicación para permitir que un usuario que haya iniciado sesión agregue nuevos blogs: + +![navegador mostrando formulario de nuevo blog](../../images/5/7e.png) + +#### 5.4: Frontend de la Lista de Blogs, paso 4 + +Implementa notificaciones que informen al usuario sobre operaciones exitosas y no exitosas en la parte superior de la página. Por ejemplo, cuando se agrega un nuevo blog, se puede mostrar la siguiente notificación: + +![navegador mostrando notificación de operación exitosa](../../images/5/8e.png) + +Un inicio de sesión fallido puede mostrar la siguiente notificación: + +![navegador mostrando notificación de intento de login fallido](../../images/5/9e.png) + +Las notificaciones deben estar visibles durante unos segundos. No es obligatorio agregar colores. + +
    + +
    + +### Nota sobre el uso de local storage + +Al final de la última [parte](/es/part4/autenticacion_basada_en_token#problemas-de-la-autenticacion-basada-en-tokens) mencionamos que el desafío de la autenticación basada en tokens es cómo afrontar la situación en la cual el acceso a la API del poseedor del token necesita ser revocado. + +Hay dos soluciones para este problema. La primera es limitar el periodo de validez de un token. Esto fuerza al usuario a iniciar sesión nuevamente cuando el token ha expirado. El otro enfoque es guardar la información de validez de cada token en la base de datos del backend. Esta solución es llamada frecuentemente server side session. + +No importa cómo la validez de los tokens es revisada y asegurada, guardar un token en el almacenamiento local puede significar un riesgo de seguridad si la aplicación tiene una vulnerabilidad que permite un ataque de [Cross Site Scripting (XSS)](https://owasp.org/www-community/attacks/xss/). Un ataque XSS es posible si la aplicación permite al usuario inyectar arbitrariamente código de JavaScript (ej. usando un formulario), que la aplicación luego puede ejecutar. Si usamos React correctamente, esto no debería ser posible, ya que [React desinfecta](https://es.legacy.reactjs.org/docs/introducing-jsx.html#jsx-prevents-injection-attacks) todo el texto que renderiza, lo que significa que no está ejecutando el contenido renderizado como JavaScript. + +Si uno quiere estar seguro, la mejor opción es no almacenar un token en el almacenamiento local. Esta puede ser una opción en situaciones en las que filtrar un token puede tener consecuencias trágicas. + +Ha sido sugerido que la identidad de un usuario que ha iniciado sesión debería guardarse como [httpOnly cookies](https://developer.mozilla.org/es/docs/Web/HTTP/Cookies#cookies_secure_y_httponly), para que el código de JavaScript no pueda tener ningún acceso al token. El inconveniente de esta solución es que haría la implementación de aplicaciones SPA un poco mas compleja. Necesitaríamos implementar al menos una pagina separada para el inicio de sesión. + +Sin embargo, es importante notar que incluso el uso de cookies httpOnly no garantiza nada. Incluso se ha sugerido que las cookies httpOnly [no son mas seguras](https://academind.com/tutorials/localstorage-vs-cookies-xss/) que el uso del almacenamiento local. + +Al fin y al cabo, no importa la solución utilizada, lo mas importante es [minimizar el riesgo](https://cheatsheetseries.owasp.org/cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.html) de ataques XSS por completo. + +
    diff --git a/src/content/5/es/part5b.md b/src/content/5/es/part5b.md new file mode 100644 index 00000000000..dd6f90ae574 --- /dev/null +++ b/src/content/5/es/part5b.md @@ -0,0 +1,821 @@ +--- +mainImage: ../../../images/part-5.svg +part: 5 +letter: b +lang: es +--- + +
    + +### Mostrando el formulario de inicio de sesión solo cuando sea apropiado + +Modifiquemos la aplicación para que el formulario de inicio de sesión no se muestre por defecto: + +![navegador mostrando botón de login por defecto](../../images/5/10e.png) + +El formulario de inicio de sesión aparece cuando el usuario presiona el botón login: + +![usuario en vista de formulario de login a punto de presionar el botón cancel](../../images/5/11e.png) + +El usuario puede cerrar el formulario de inicio de sesión haciendo clic en el botón cancel. + +Comencemos extrayendo el formulario de inicio de sesión en su propio componente: + +```js +const LoginForm = ({ + handleSubmit, + handleUsernameChange, + handlePasswordChange, + username, + password + }) => { + return ( +
    +

    Login

    + +
    +
    + username + +
    +
    + password + +
    + +
    +
    + ) +} + +export default LoginForm +``` + +El estado y todas las funciones relacionadas con él se definen fuera del componente y se pasan al componente como props. + +Ten en cuenta que los props se asignan a las variables mediante la desestructuración, lo que significa que en lugar de escribir: + +```js +const LoginForm = (props) => { + return ( +
    +

    Login

    +
    +
    + username + +
    + // ... + +
    +
    + ) +} +``` + +donde se accede a las propiedades del objeto _props_ mediante, por ejemplo, _props.handleSubmit_, las propiedades se asignan directamente a sus propias variables. + +Una forma rápida de implementar la funcionalidad es cambiar la función _loginForm_ del componente App así: + +```js +const App = () => { + const [loginVisible, setLoginVisible] = useState(false) // highlight-line + + // ... + + const loginForm = () => { + const hideWhenVisible = { display: loginVisible ? 'none' : '' } + const showWhenVisible = { display: loginVisible ? '' : 'none' } + + return ( +
    +
    + +
    +
    + setUsername(target.value)} + handlePasswordChange={({ target }) => setPassword(target.value)} + handleSubmit={handleLogin} + /> + +
    +
    + ) + } + + // ... +} +``` + +El estado del componente App ahora contiene el booleano loginVisible, que define si el formulario de inicio de sesión se debe mostrar al usuario o no. + +El valor de loginVisible se alterna con dos botones. Ambos botones tienen sus controladores de eventos definidos directamente en el componente: + +```js + + + +``` + +La visibilidad del componente se define dándole al componente una regla de estilo [en línea](/es/part2/agregar_estilos_a_la_aplicacion_react#estilos-en-linea), donde el valor de la propiedad [display](https://developer.mozilla.org/es/docs/Web/CSS/display) es none si no queremos que se muestre el componente: + +```js +const hideWhenVisible = { display: loginVisible ? 'none' : '' } +const showWhenVisible = { display: loginVisible ? '' : 'none' } + +
    + // button +
    + +
    + // button +
    +``` + +Una vez más estamos utilizando el operador ternario "signo de interrogación". Si _loginVisible_ es true, entonces la regla CSS del componente será: + +```css +display: 'none'; +``` + +Si _loginVisible_ es false, entonces display no recibirá ningún valor relacionado con la visibilidad del componente. + +### Los componentes hijos, también conocidos como props.children + +El código relacionado con la gestión de la visibilidad del formulario de inicio de sesión podría considerarse su propia entidad lógica, y por esta razón, sería bueno extraerlo del componente App en su propio componente independiente. + +Nuestro objetivo es implementar un nuevo componente Togglable que se pueda usar de la siguiente manera: + +```js + + setUsername(target.value)} + handlePasswordChange={({ target }) => setPassword(target.value)} + handleSubmit={handleLogin} + /> + +``` + +La forma en que se utiliza el componente es ligeramente diferente a la de nuestros componentes anteriores. El componente tiene etiquetas de apertura y cierre que rodean un componente LoginForm. En la terminología de React, LoginForm es un componente hijo de Togglable. + +Podemos agregar cualquier elemento de React que queramos entre las etiquetas de apertura y cierre de Togglable, como este, por ejemplo: + +```js + +

    this line is at start hidden

    +

    also this is hidden

    +
    +``` + +El código para el componente Togglable se muestra a continuación: + +```js +import { useState } from 'react' + +const Togglable = (props) => { + const [visible, setVisible] = useState(false) + + const hideWhenVisible = { display: visible ? 'none' : '' } + const showWhenVisible = { display: visible ? '' : 'none' } + + const toggleVisibility = () => { + setVisible(!visible) + } + + return ( +
    +
    + +
    +
    + {props.children} + +
    +
    + ) +} + +export default Togglable +``` + +La parte nueva e interesante del código es [props.children](https://es.react.dev/learn/passing-props-to-a-component#passing-jsx-as-children), que se utiliza para hacer referencia a los componentes hijos del componente. Los componentes hijos son los elementos de React que definimos entre las etiquetas de apertura y cierre de un componente. + +Esta vez, los hijos son renderizados en el código que se utiliza para renderizar el componente en sí: + +```js +
    + {props.children} + +
    +``` + +A diferencia de los props "normales" que hemos visto antes, React agrega automáticamente children y siempre existe. Si un componente se define con una etiqueta _/>_ de cierre automático, como esta: + +```js + toggleImportanceOf(note.id)} +/> +``` + +Entonces props.children es un array vacío. + +El componente Togglable es reutilizable y podemos usarlo para agregar una funcionalidad de alternancia de visibilidad similar al formulario que se usa para crear nuevas notas. + +Antes de hacer eso, extraigamos el formulario para crear notas en su propio componente: + +```js +const NoteForm = ({ onSubmit, handleChange, value}) => { + return ( +
    +

    Create a new note

    + +
    + + +
    +
    + ) +} +``` + +A continuación, definamos el componente de formulario dentro de un componente Togglable: + +```js + + + +``` + +Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part5-4 de [este repositorio de GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-4). + +### Estado de los formularios + +El estado de la aplicación se encuentra actualmente en el componente _App_. + +La documentación de React dice lo [siguiente](https://es.react.dev/learn/sharing-state-between-components) sobre dónde colocar el estado: + +A veces, quieres que el estado de dos componentes cambie siempre al mismo tiempo. Para hacerlo, elimina el estado de ambos, muévelo al componente padre más cercano que tengan en común, y luego pásalo a ellos a través de props. Esto se conoce como "elevar el estado", y es una de las cosas más comunes que harás al escribir código React. + +Si pensamos en el estado de los formularios, por ejemplo, el contenido de una nueva nota antes de que se haya creado, el componente _App_ no lo necesita para nada. +También podríamos mover el estado de los formularios a los componentes correspondientes. + +El componente para crear una nueva nota cambia así: + +```js +import { useState } from 'react' + +const NoteForm = ({ createNote }) => { + const [newNote, setNewNote] = useState('') + + const addNote = (event) => { + event.preventDefault() + createNote({ + content: newNote, + important: true + }) + + setNewNote('') + } + + return ( +
    +

    Create a new note

    + +
    + setNewNote(event.target.value)} + /> + +
    +
    + ) +} + +export default NoteForm +``` + +**NOTA:** Al mismo tiempo, cambiamos el comportamiento de la aplicación para que las nuevas notas sean importantes por defecto, es decir, el campo important obtiene el valor true. + +La variable de estado newNote y el controlador de eventos responsable de cambiarlo se han movido del componente _App_ al componente responsable del formulario de la nota. + +Solo queda un prop, la función _createNote_, que el formulario llama cuando se crea una nueva nota. + +El componente _App_ se vuelve más simple ahora que nos hemos deshecho del estado newNote y su controlador de eventos. +La función _addNote_ para crear nuevas notas recibe una nueva nota como parámetro, y la función es el único prop que enviamos al formulario: + +```js +const App = () => { + // ... + const addNote = (noteObject) => { // highlight-line + noteService + .create(noteObject) + .then(returnedNote => { + setNotes(notes.concat(returnedNote)) + }) + } + // ... + const noteForm = () => ( + + + + ) + + // ... +} +``` + +Podríamos hacer lo mismo con el formulario de inicio de sesión, pero lo dejaremos para un ejercicio opcional. + +El código de la aplicación se puede encontrar en [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-5), rama part5-5. + +### Referencias a componentes con ref + +Nuestra implementación actual es bastante buena, pero tiene un aspecto que podría mejorarse. + +Después de crear una nueva nota, tendría sentido ocultar el formulario de nueva nota. Actualmente, el formulario permanece visible. Hay un pequeño problema al ocultarlo, la visibilidad se controla con la variable visible dentro del componente Togglable. + +Una solución a esto sería mover el control del estado del componente Togglable fuera del componente. Sin embargo, no lo haremos ahora, porque queremos que el componente sea responsable de su propio estado. Por lo tanto, tenemos que encontrar otra solución y hallar un mecanismo para cambiar el estado del componente externamente. + +Hay varias formas diferentes de implementar el acceso a las funciones de un componente desde fuera del componente, pero usemos el mecanismo de [ref](https://es.react.dev/learn/referencing-values-with-refs) de React, que ofrece una referencia al componente. + +Hagamos los siguientes cambios en el componente App: + +```js +import { useState, useEffect, useRef } from 'react' // highlight-line + +const App = () => { + // ... + const noteFormRef = useRef() // highlight-line + + const noteForm = () => ( + // highlight-line + + + ) + + // ... +} +``` + +El hook [useRef](https://es.react.dev/reference/react/useRef) se utiliza para crear una referencia noteFormRef, que se asigna al componente Togglable que contiene el formulario para crear la nota. La variable noteFormRef actúa como referencia al componente. Este hook asegura que se mantenga la misma referencia (ref) en todas las re-renderizaciones del componente. + +También realizamos los siguientes cambios en el componente Togglable: + +```js +import { useState, forwardRef, useImperativeHandle } from 'react' // highlight-line + +const Togglable = forwardRef((props, refs) => { // highlight-line + const [visible, setVisible] = useState(false) + + const hideWhenVisible = { display: visible ? 'none' : '' } + const showWhenVisible = { display: visible ? '' : 'none' } + + const toggleVisibility = () => { + setVisible(!visible) + } + +// highlight-start + useImperativeHandle(refs, () => { + return { + toggleVisibility + } + }) +// highlight-end + + return ( +
    +
    + +
    +
    + {props.children} + +
    +
    + ) +}) // highlight-line + +export default Togglable +``` + +La función que crea el componente está envuelta dentro de una llamada a la función [forwardRef](https://es.react.dev/reference/react/forwardRef). De esta manera el componente puede acceder a la referencia que le fue asignada. + +El componente usa el hook [useImperativeHandle](https://es.react.dev/reference/react/useImperativeHandle) para que su función toggleVisibility esté disponible fuera del componente. + +Ahora podemos ocultar el formulario llamando a noteFormRef.current.toggleVisibility() después de que se haya creado una nueva nota: + +```js +const App = () => { + // ... + const addNote = (noteObject) => { + noteFormRef.current.toggleVisibility() // highlight-line + noteService + .create(noteObject) + .then(returnedNote => { + setNotes(notes.concat(returnedNote)) + }) + } + // ... +} +``` + +En resumen, la función [useImperativeHandle](https://es.react.dev/reference/react/useImperativeHandle) es un hook de React, que se usa para definir funciones en un componente que se pueden invocar desde fuera del componente. + +Este truco funciona para cambiar el estado de un componente, pero parece un poco desagradable. Podríamos haber logrado la misma funcionalidad con código un poco más limpio usando los "viejos" componentes de clase de React. Analizaremos estos componentes de clase durante la parte 7 del material del curso. Hasta ahora, esta es la única situación en la que el uso de hooks de React conduce a un código que no es más limpio que con los componentes de clase. + +También [hay otros casos de uso](https://es.react.dev/learn/manipulating-the-dom-with-refs) para las refs además de acceder a los componentes de React. + +Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part5-6 de [este repositorio de GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-6). + +### Un punto sobre los componentes + +Cuando definimos un componente en React: + +```js +const Togglable = () => ... + // ... +} +``` + +Y lo usamos así: + +```js +
    + + first + + + + second + + + + third + +
    +``` + +Creamos tres instancias separadas del componente que tienen su propio estado separado: + +![tres componentes togglable en el navegador](../../images/5/12e.png) + +El atributo ref se utiliza para asignar una referencia a cada uno de los componentes en las variables togglable1, togglable2 y togglable3. + +### El juramento actualizado del desarrollador full stack + +El número de componentes aumenta. Al mismo tiempo, aumenta la probabilidad de encontrarnos en una situación en la que buscamos un error en el lugar equivocado. Por lo tanto, necesitamos ser aún más sistemáticos. + +Entonces, debemos extender una vez más nuestro juramento: + +El desarrollo full stack es extremadamente difícil, por eso utilizaré todos los medios posibles para hacerlo lo más fácil posible + +- Mantendré abierta la consola de desarrollo del navegador todo el tiempo +- Utilizaré la pestaña network de las herramientas de desarrollo del navegador para asegurarme de que el frontend y el backend estén comunicándose como espero +- Mantendré un ojo constantemente en el estado del servidor para asegurarme de que los datos enviados por el frontend se guarden allí como espero +- Mantendré un ojo en la base de datos: ¿el backend guarda los datos allí en el formato correcto? +- Progresaré con pequeños pasos +- cuando sospeche que hay un error en el frontend, me asegurare de que el backend funcione correctamente +- cuando sospeche que hay un error en el backend, me asegurare de que el frontend funcione correctamente +- Escribiré muchos _console.log_ para asegurarme de entender cómo se comportan el código y las pruebas y para ayudar a localizar problemas +- Si mi código no funciona, no escribiré más código. En cambio, empezare a eliminarlo hasta que funcione o simplemente regresare a un estado en el que todo funcionaba +- Si una prueba no pasa, me asegurare de que la funcionalidad probada funciona correctamente en la aplicación +- Cuando pida ayuda en el canal de Discorddel curso o en cualquier otro lugar, formularé mis preguntas correctamente, consulta [aquí](/es/part0/informacion_general#como-obtener-ayuda-en-discord) cómo pedir ayuda + +
    + +
    + +### Ejercicios 5.5.-5.11. + +#### 5.5 Frontend de la Lista de Blogs, paso 5 + +Cambia el formulario para crear publicaciones de blog para que solo se muestre cuando sea apropiado. Utiliza una funcionalidad similar a la que se mostró [anteriormente en esta parte del material del curso](/es/part5/props_children_y_proptypes#mostrando-el-formulario-de-inicio-de-sesion-solo-cuando-sea-apropiado). Si lo deseas, puedes utilizar el componente Togglable definido en la parte 5. + +Por defecto, el formulario no es visible + +![navegador mostrando el botón de nueva nota sin mostrar su formulario](../../images/5/13ae.png) + +Se expande cuando se hace clic en el botón create new blog + +![navegador mostrando formulario con botón create new](../../images/5/13be.png) + +El formulario se esconde otra vez luego de que un nuevo blog es creado. + +#### 5.6 Frontend de la Lista de Blogs, paso 6 + +Separa el formulario para crear un nuevo blog en su propio componente (si aún no lo has hecho) y mueve todos los estados necesarios para crear un nuevo blog a este componente. + +El componente debe funcionar como el componente NoteForm del [material](/es/part5/props_children_y_proptypes) de esta parte. + +#### 5.7 Frontend de la Lista de Blogs, paso 7 + +Agreguemos un botón a cada blog, que controla si se muestran o no todos los detalles sobre el blog. + +Los detalles completos del blog se abren cuando se hace clic en el botón. + +![navegador mostrando todos los detalles de un blog mientras otros solo tienen botones para ver más](../../images/5/13ea.png) + +Y los detalles se ocultan cuando se vuelve a hacer clic en el botón. + +En este punto, el botón like no necesita hacer nada. + +La aplicación que se muestra en la imagen tiene un poco de CSS adicional para mejorar su apariencia. + +Es fácil agregar estilos a la aplicación como se muestra en la parte 2 usando estilos [en línea](/es/part2/agregar_estilos_a_la_aplicacion_react#estilos-en-linea): + +```js +const Blog = ({ blog }) => { + const blogStyle = { + paddingTop: 10, + paddingLeft: 2, + border: 'solid', + borderWidth: 1, + marginBottom: 5 + } + + return ( +
    // highlight-line +
    + {blog.title} {blog.author} +
    + // ... +
    +)} +``` + +**NB:** Aunque la funcionalidad implementada en esta parte es casi idéntica a la funcionalidad proporcionada por el componente Togglable, no se puede usar directamente para lograr el comportamiento deseado. La solución más fácil sería agregar un estado al componente blog que controle si todos los detalles están siendo mostrados o no. + +#### 5.8: Frontend de la Lista de Blogs, paso 8 + +Implementa la funcionalidad para el botón like. Los likes aumentan al hacer un solicitud HTTP _PUT_ a la dirección única de la publicación del blog en el backend. + +Dado que la operación de backend reemplaza toda la publicación del blog, deberás enviar todos sus campos en el cuerpo de la solicitud. Si deseas agregar un like a la siguiente publicación de blog: + +```js +{ + _id: "5a43fde2cbd20b12a2c34e91", + user: { + _id: "5a43e6b6c37f3d065eaaa581", + username: "mluukkai", + name: "Matti Luukkainen" + }, + likes: 0, + author: "Joel Spolsky", + title: "The Joel Test: 12 Steps to Better Code", + url: "https://www.joelonsoftware.com/2000/08/09/the-joel-test-12-steps-to-better-code/" +}, +``` + +Deberías realizar una solicitud HTTP PUT a la dirección /api/blogs/5a43fde2cbd20b12a2c34e91 con los siguientes datos de solicitud: + +```js +{ + user: "5a43e6b6c37f3d065eaaa581", + likes: 1, + author: "Joel Spolsky", + title: "The Joel Test: 12 Steps to Better Code", + url: "https://www.joelonsoftware.com/2000/08/09/the-joel-test-12-steps-to-better-code/" +} +``` + +El Backend también debe ser actualizado para manejar la referencia al usuario. + +#### 5.9: Frontend de la lista de Blogs, paso 9 + +Nos damos cuenta de que algo está mal. Cuando se da "me gusta" a un blog en la app, el nombre del usuario que añadió el blog no se muestra en sus detalles: + +![navegador mostrando nombre faltante debajo del botón de me gusta](../../images/5/59put.png) + +Cuando se recarga el navegador, la información de la persona se muestra. Esto no es aceptable, averigua dónde está el problema y realiza la corrección necesaria. + +Por supuesto, es posible que ya hayas hecho todo correctamente y el problema no ocurra en tu código. En ese caso, puedes continuar. + +#### 5.10: Frontend de la Lista de Blogs, paso 10 + +Modifica la aplicación para enumerar las publicaciones de blog por el número de likes. La clasificación se puede hacer con el método de array [sort](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array/sort). + +#### 5.11: Frontend de la Lista de Blogs, paso 11 + +Agrega un nuevo botón para eliminar publicaciones de blog. También implementa la lógica para eliminar publicaciones de blog en el backend. + +Tu aplicación podría verse así: + +![dialogo de confirmación de eliminación de blog en el navegador](../../images/5/14ea.png) + +El cuadro de diálogo de confirmación para eliminar una publicación de blog es fácil de implementar con la función [window.confirm](https://developer.mozilla.org/es/docs/Web/API/Window/confirm). + +Muestra el botón para eliminar una publicación de blog solo si la publicación de blog fue agregada por el usuario. + +
    + +
    + +### PropTypes + +El componente Togglable asume que se le da el texto para el botón a través del prop buttonLabel. Si nos olvidamos de definir este prop al componente: + +```js + buttonLabel forgotten... +``` + +La aplicación funciona, pero el navegador muestra un botón sin texto. + +Nos gustaría hacer cumplir que cuando se usa el componente Togglable, se debe dar un valor al prop de texto del botón. + +Los props esperados y requeridos de un componente se pueden definir con el paquete [prop-types](https://github.com/facebook/prop-types). Instalemos el paquete: + +```shell +npm install prop-types +``` + +Podemos definir el prop buttonLabel como un prop obligatorio o required de tipo string como se muestra a continuación: + +```js +import PropTypes from 'prop-types' + +const Togglable = React.forwardRef((props, ref) => { + // .. +}) + +Togglable.propTypes = { + buttonLabel: PropTypes.string.isRequired +} +``` + +La consola mostrará el siguiente mensaje de error si el prop se deja sin definir: + +![error en la consola, buttonLabel es undefined](../../images/5/15.png) + +La aplicación todavía funciona y nada nos obliga a definir props a pesar de las definiciones de PropTypes. Eso sí, es extremadamente poco profesional dejar cualquier output de color rojo en la consola del navegador. + +También definamos PropTypes para el componente LoginForm: + +```js +import PropTypes from 'prop-types' + +const LoginForm = ({ + handleSubmit, + handleUsernameChange, + handlePasswordChange, + username, + password + }) => { + // ... + } + +LoginForm.propTypes = { + handleSubmit: PropTypes.func.isRequired, + handleUsernameChange: PropTypes.func.isRequired, + handlePasswordChange: PropTypes.func.isRequired, + username: PropTypes.string.isRequired, + password: PropTypes.string.isRequired +} +``` + +Si el tipo de un prop pasado es incorrecto, por ejemplo, si intentamos definir el prop handleSubmit como un string, esto resultará en la siguiente advertencia: + +![error de consola, handleSubmit espera una función](../../images/5/16.png) + +### ESlint + +En la parte 3 configuramos la herramienta de estilo de código para el backend [ESlint](/es/part3/validacion_y_es_lint#lint). Utilicemos ESlint también en el frontend. + +Vite ha instalado ESlint en el proyecto de forma predeterminada, por lo que todo lo que nos queda por hacer es definir nuestra configuración deseada en el archivo .eslintrc.cjs. + +Creemos un archivo .eslintrc.js con el siguiente contenido: + +```js +module.exports = { + root: true, + env: { + browser: true, + es2020: true, + }, + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + settings: { react: { version: '18.2' } }, + plugins: ['react-refresh'], + rules: { + "indent": [ + "error", + 2 + ], + "linebreak-style": [ + "error", + "unix" + ], + "quotes": [ + "error", + "single" + ], + "semi": [ + "error", + "never" + ], + "eqeqeq": "error", + "no-trailing-spaces": "error", + "object-curly-spacing": [ + "error", "always" + ], + "arrow-spacing": [ + "error", { "before": true, "after": true } + ], + "no-console": 0, + "react/react-in-jsx-scope": "off", + "react/prop-types": 0, + "no-unused-vars": 0 + }, +} +``` + +NOTA: Si estás utilizando Visual Studio Code junto con el plugin ESLint, es posible que debas agregar una configuración de espacio de trabajo adicional para que funcione. Si ves ```Failed to load plugin react: Cannot find module 'eslint-plugin-react'```, necesitas una configuración adicional. Agregar la línea ```"eslint.workingDirectories": [{ "mode": "auto" }]``` a settings.json en el espacio de trabajo parece funcionar. Mira [esto](https://github.com/microsoft/vscode-eslint/issues/880#issuecomment-578052807) para obtener más información. + +Vamos a crear un archivo [.eslintignore](https://eslint.org/docs/latest/use/configure/ignore#the-eslintignore-file) con el siguiente contenido en la raíz del repositorio + +```bash +node_modules +dist +.eslintrc.cjs +vite.config.js +``` + +Ahora los directorios dist y node_modules se omitirán al realizar el linting. + +Como de costumbre, puedes realizar el linting desde la línea de comandos con el siguiente comando: + +```bash +npm run lint +``` + +o usando el plugin de Eslint del editor. + +El componente _Togglable_ está causando una advertencia desagradable Component definition is missing display name: + +![vscode mostrando error en la definición del componente](../../images/5/25x.png) + +Las react-devtools también muestran que el componente no tiene un nombre: + +![react devtools mostrando forwardRef como anónimo](../../images/5/26ea.png) + +Afortunadamente, esto es fácil de solucionar. + +```js +import { useState, useImperativeHandle } from 'react' +import PropTypes from 'prop-types' + +const Togglable = React.forwardRef((props, ref) => { + // ... +}) + +Togglable.displayName = 'Togglable' // highlight-line + +export default Togglable +``` + +Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part5-7 de [este repositorio de GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-7). + +
    + +
    + +### Ejercicio 5.12. + +#### 5.12: Frontend de la Lista de Blogs, paso 12 + +Define PropTypes para uno de los componentes de tu aplicación y agrega ESlint al proyecto. Define la configuración según tu preferencia. Corrige todos los errores del linter. + +Vite ha instalado ESlint en el proyecto por defecto, así que todo lo que queda por hacer es definir tu configuración deseada en el archivo .eslintrc.cjs. + +
    diff --git a/src/content/5/es/part5c.md b/src/content/5/es/part5c.md new file mode 100644 index 00000000000..aa00d6cc802 --- /dev/null +++ b/src/content/5/es/part5c.md @@ -0,0 +1,868 @@ +--- +mainImage: ../../../images/part-5.svg +part: 5 +letter: c +lang: es +--- + +
    + +La librería de pruebas utilizada en esta parte se cambió el 3 de marzo de 2024 de Jest a Vitest. Si ya comenzaste esta parte usando Jest, puedes ver [aquí](https://github.com/fullstack-hy2020/fullstack-hy2020.github.io/blob/02d8be28b1c9190f48976fbbd2435b63261282df/src/content/5/es/part5c.md) el contenido antiguo. + +
    + +
    + +Hay muchas formas diferentes de probar aplicaciones React. Echemos un vistazo a ellas a continuación. + +Anteriormente, el curso utilizaba la librería [Jest](http://jestjs.io/) desarrollada por Facebook para probar componentes de React. Ahora estamos utilizando la nueva generación de herramientas de prueba de los desarrolladores de Vite llamada [Vitest](https://vitest.dev/). Aparte de las configuraciones, las librerías proporcionan la misma interfaz de programación, por lo que prácticamente no hay diferencia en el código de prueba. + +Comencemos instalando Vitest y la librería [jsdom](https://github.com/jsdom/jsdom) que simula un navegador web: + +``` +npm install --save-dev vitest jsdom +``` + +Además de Vitest, también necesitamos otra librería de pruebas que nos ayudará a renderizar componentes para fines de prueba. La mejor opción actual para esto es [react-testing-library](https://github.com/testing-library/react-testing-library), que ha visto un rápido crecimiento en popularidad recientemente. También vale la pena extender el poder expresivo de las pruebas con la librería [jest-dom](https://github.com/testing-library/jest-dom). + +Instalemos las librerías con el comando: + +```js +npm install --save-dev @testing-library/react @testing-library/jest-dom +``` + +Antes de que podamos hacer la primera prueba, necesitamos algunas configuraciones. + +Agregamos un script al archivo package.json para ejecutar las pruebas: + +```js +{ + "scripts": { + // ... + "test": "vitest run" + } + // ... +} +``` + +Vamos a crear un archivo _testSetup.js_ en la raíz del proyecto con el siguiente contenido + +```js +import { afterEach } from 'vitest' +import { cleanup } from '@testing-library/react' +import '@testing-library/jest-dom/vitest' + +afterEach(() => { + cleanup() +}) +``` + +Ahora, luego de cada prueba, la función _cleanup_ es ejecutada para resetear jsdom, que esta simulando al navegador. + +Expandamos el archivo _vite.config.js_ de la siguiente manera: + +```js +export default defineConfig({ + // ... + test: { + environment: 'jsdom', + globals: true, + setupFiles: './testSetup.js', + } +}) +``` + +Con _globals: true_, no es necesario importar palabras clave como _describe_, _test_ y _expect_ en las pruebas. + +Primero escribamos pruebas para el componente que es responsable de renderizar una nota: + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' + : 'make important' + + return ( +
  • // highlight-line + {note.content} + +
  • + ) +} +``` + +Observa que el elemento li tiene el valor note para el atributo de [CSS](https://es.react.dev/learn#adding-styles) className, que podríamos usar para acceder al componente en nuestras pruebas. + +### Renderizando el componente para pruebas + +Escribiremos nuestra prueba en el archivo src/components/Note.test.jsx, que está en el mismo directorio que el componente Note. + +La primera prueba verifica que el componente muestra el contenido de la nota: + +```js +import { render, screen } from '@testing-library/react' +import Note from './Note' + +test('renders content', () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + render() + + const element = screen.getByText('Component testing is done with react-testing-library') + expect(element).toBeDefined() +}) +``` + +Después de la configuración inicial, la prueba renderiza el componente con el método [render](https://testing-library.com/docs/react-testing-library/api#render) proporcionado por react-testing-library: + +```js +render() +``` + +Normalmente, los componentes de React se procesan en el [DOM](https://developer.mozilla.org/es/docs/Web/API/Document_Object_Model). El método de renderizado que usamos renderiza los componentes en un formato que es adecuado para pruebas sin renderizarlos al DOM. + +Podemos usar el objeto [screen](https://testing-library.com/docs/queries/about#screen) para acceder al componente renderizado. Utilizamos el método [getByText](https://testing-library.com/docs/queries/bytext) de screen para buscar un elemento que tenga el contenido de la nota y asegurarnos de que existe: + +```js + const element = screen.getByText('Component testing is done with react-testing-library') + expect(element).toBeDefined() +``` + +La existencia de un elemento se verifica utilizando el comando [expect](https://vitest.dev/api/expect.html#expect) de Vitest. Expect genera una aserción para su argumento, cuya validez se puede probar utilizando varias funciones de condición. Ahora utilizamos [toBeDefined](https://vitest.dev/api/expect.html#tobedefined) que prueba si el argumento _element_ de expect existe. + +Ejecuta el test con el comando _npm test_: + +```js +$ npm test + +> notes-frontend@0.0.0 test +> vitest + + + DEV v1.3.1 /Users/mluukkai/opetus/2024-fs/part3/notes-frontend + + ✓ src/components/Note.test.jsx (1) + ✓ renders content + + Test Files 1 passed (1) + Tests 1 passed (1) + Start at 17:05:37 + Duration 812ms (transform 31ms, setup 220ms, collect 11ms, tests 14ms, environment 395ms, prepare 70ms) + + + PASS Waiting for file changes... +``` + +Eslint se queja de las palabras clave _test_ y _expect_ en las pruebas. El problema se puede resolver instalando [eslint-plugin-vitest-globals](https://www.npmjs.com/package/eslint-plugin-vitest-globals): + +``` +npm install --save-dev eslint-plugin-vitest-globals +``` + +y habilitando el plugin al editar el archivo _.eslint.cjs_ de la siguiente manera: + +```js +module.exports = { + root: true, + env: { + browser: true, + es2020: true, + "vitest-globals/env": true // highlight-line + }, + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + 'plugin:react-hooks/recommended', + 'plugin:vitest-globals/recommended', // highlight-line + ], + // ... +} +``` + +### Ubicación del archivo de prueba + +En React hay (al menos) [dos convenciones diferentes](https://medium.com/@JeffLombardJr/organizing-tests-in-jest-17fc431ff850) para la ubicación de los archivos de prueba. Creamos nuestros archivos de prueba de acuerdo con el estándar actual colocándolos en el mismo directorio que el componente que se está probando. + +La otra convención es almacenar los archivos de prueba "normalmente" en su propio directorio separado _test_. Cualquiera que sea la convención que elijamos, es casi seguro que estará equivocada según la opinión de alguien. + +Personalmente, no me gusta esta forma de almacenar pruebas y código de aplicación en el mismo directorio. La razón por la que elegimos seguir esta convención es que está configurada de forma predeterminada en las aplicaciones creadas por Vite o create-react-app. + +### Búsqueda de contenido en un componente + +El paquete react-testing-library ofrece muchas formas diferentes de investigar el contenido del componente que se está probando. En realidad, el _expect_ en nuestra prueba no es necesario en absoluto: + +```js +import { render, screen } from '@testing-library/react' +import Note from './Note' + +test('renders content', () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + render() + + const element = screen.getByText('Component testing is done with react-testing-library') + + expect(element).toBeDefined() // highlight-line +}) +``` + +La prueba falla si _getByText_ no encuentra el elemento que está buscando. + +También podríamos usar [selectores CSS](https://developer.mozilla.org/es/docs/Web/CSS/Selectores_CSS) para encontrar elementos renderizados mediante el uso del método [querySelector](https://developer.mozilla.org/es/docs/Web/API/Document/querySelector) del objeto [container](https://testing-library.com/docs/react-testing-library/api/#container-1), que es uno de los campos devueltos por el renderizado: + +```js +import { render, screen } from '@testing-library/react' +import Note from './Note' + +test('renders content', () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + const { container } = render() // highlight-line + +// highlight-start + const div = container.querySelector('.note') + expect(div).toHaveTextContent( + 'Component testing is done with react-testing-library' + ) + // highlight-end +}) +``` + +**NB:** Una forma más consistente de seleccionar elementos es usando un [atributo de datos](https://developer.mozilla.org/es/docs/Web/HTML/Global_attributes/data-*) que esté específicamente definido para propósitos de prueba. Usando _react-testing-library_, podemos aprovechar el método [getByTestId](https://testing-library.com/docs/queries/bytestid/) para seleccionar elementos con un atributo _data-testid_ especificado. + +### Depurando pruebas + +Normalmente nos encontramos con muchos tipos diferentes de problemas al escribir nuestras pruebas. + +El objeto _screen_ tiene el método [debug](https://testing-library.com/docs/dom-testing-library/api-debugging#screendebug) que se puede utilizar para imprimir el HTML de un componente en el terminal. Si cambiamos la prueba de la siguiente manera: + +```js +import { render, screen } from '@testing-library/react' +import Note from './Note' + +test('renders content', () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + render() + + screen.debug() // highlight-line + + // ... + +}) +``` + +podemos ver el HTML generado por el componente en la consola: + +```js +console.log + +
    +
  • + Component testing is done with react-testing-library + +
  • +
    + +``` + +También es posible utilizar el mismo método para imprimir el elemento que queramos en la consola: + +```js +import { render, screen } from '@testing-library/react' +import Note from './Note' + +test('renders content', () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + render() + + const element = screen.getByText('Component testing is done with react-testing-library') + + screen.debug(element) // highlight-line + + expect(element).toBeDefined() +}) +``` + +Ahora el HTML del elemento que queríamos ver se imprime: + +```js +
  • + Component testing is done with react-testing-library + +
  • +``` + +### Clicando botones en las pruebas + +Además de mostrar el contenido, el componente Note también se asegura de que cuando se clica al botón asociado con la nota, se llama a la función del controlador de eventos _toggleImportance_. + +Instalemos la librería [user-event](https://testing-library.com/docs/user-event/intro) que facilita un poco la simulación del input del usuario: + +```bash +npm install --save-dev @testing-library/user-event +``` + +La prueba de esta funcionalidad se puede lograr así: + +```js +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' // highlight-line +import Note from './Note' + +// ... + +test('clicking the button calls event handler once', async () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + const mockHandler = vi.fn() // highlight-line + + render( + // highlight-line + ) + + const user = userEvent.setup() // highlight-line + const button = screen.getByText('make not important') // highlight-line + await user.click(button) // highlight-line + + expect(mockHandler.mock.calls).toHaveLength(1) // highlight-line +}) +``` + +Hay algunas cosas interesantes relacionadas con esta prueba. El controlador de eventos es la función [mock](https://vitest.dev/api/mock) definida con Vitest: + +```js +const mockHandler = vi.fn() +``` + +Se inicia una [session](https://testing-library.com/docs/user-event/setup/) (sesión) para interactuar con el componente renderizado: + +```js +const user = userEvent.setup() +``` + +La prueba encuentra el botón basada en el texto del componente renderizado y hace clic en el elemento: + +```js +const button = screen.getByText('make not important') +await user.click(button) +``` + +El clic ocurre con el método [click](https://testing-library.com/docs/user-event/convenience/#click) de la librería userEvent. + +La expectativa de la prueba utiliza [toHaveLength](https://vitest.dev/api/expect.html#tohavelength) para verificar que la mock function (función simulada) se haya llamado exactamente una vez: + +```js +expect(mockHandler.mock.calls).toHaveLength(1) +``` + +Las llamadas a la mock function son guardadas en el array [mock.calls](https://vitest.dev/api/mock#mock-calls) dentro del objeto de la mock function. + +[Mock objects and functions](https://es.wikipedia.org/wiki/Objeto_simulado) (Objetos y funciones simulados) son componentes [stub](https://es.wikipedia.org/wiki/Stub) (código auxiliar) comúnmente utilizados en pruebas que se utilizan para reemplazar las dependencias de los componentes que se están probando. Los simulacros permiten devolver respuestas codificadas de manera rígida y verificar cuántas veces se llaman las funciones simuladas y con qué parámetros. + +En nuestro ejemplo, la función simulada es una opción perfecta ya que se puede utilizar fácilmente para verificar que el método se llame exactamente una vez. + +### Pruebas para el componente Togglable + +Escribamos algunas pruebas para el componente Togglable. Agreguemos el nombre de clase CSS togglableContent al div que devuelve los componentes hijos. + +```js +const Togglable = forwardRef((props, ref) => { + // ... + + return ( +
    +
    + +
    +
    // highlight-line + {props.children} + +
    +
    + ) +}) +``` + +Las pruebas se muestran a continuación: + +```js +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Togglable from './Togglable' + +describe('', () => { + let container + + beforeEach(() => { + container = render( + +
    + togglable content +
    +
    + ).container + }) + + test('renders its children', async () => { + await screen.findAllByText('togglable content') + }) + + test('at start the children are not displayed', () => { + const div = container.querySelector('.togglableContent') + expect(div).toHaveStyle('display: none') + }) + + test('after clicking the button, children are displayed', async () => { + const user = userEvent.setup() + const button = screen.getByText('show...') + await user.click(button) + + const div = container.querySelector('.togglableContent') + expect(div).not.toHaveStyle('display: none') + }) +}) +``` + +La función _beforeEach_ se llama antes de cada prueba, la cual luego renderiza el componente Togglable y guarda el campo _container_ del valor devuelto. + +La primera prueba verifica que el componente Togglable renderiza su componente hijo. + +```js +
    + togglable content +
    +``` + +Las pruebas restantes utilizan el método [toHaveStyle](https://www.npmjs.com/package/@testing-library/jest-dom#tohavestyle) para verificar que el componente hijo del componente Togglable no es visible inicialmente, comprobando que el estilo del elemento div contiene _{ display: 'none' }_. Otra prueba verifica que cuando se presiona el botón, el componente es visible, lo que significa que el estilo para ocultarlo ya no está asignado al componente. + +Agreguemos también una prueba que se pueda usar para verificar que el contenido visible se puede ocultar haciendo clic en el segundo botón del componente: + +```js +describe('', () => { + + // ... + + test('toggled content can be closed', async () => { + const user = userEvent.setup() + const button = screen.getByText('show...') + await user.click(button) + + const closeButton = screen.getByText('cancel') + await user.click(closeButton) + + const div = container.querySelector('.togglableContent') + expect(div).toHaveStyle('display: none') + }) +}) +``` + +### Probando los formularios + +Ya usamos la función _click_ de [user-event](https://testing-library.com/docs/user-event/intro/) en nuestras pruebas anteriores para hacer clic en los botones. + +```js +const user = userEvent.setup() +const button = screen.getByText('show...') +await user.click(button) +``` + +También podemos simular la entrada de texto con userEvent. + +Hagamos una prueba para el componente NoteForm. El código del componente es el siguiente: + +```js +import { useState } from 'react' + +const NoteForm = ({ createNote }) => { + const [newNote, setNewNote] = useState('') + + const handleChange = (event) => { + setNewNote(event.target.value) + } + + const addNote = (event) => { + event.preventDefault() + createNote({ + content: newNote, + important: true, + }) + + setNewNote('') + } + + return ( +
    +

    Create a new note

    + +
    + + +
    +
    + ) +} + +export default NoteForm +``` + +El formulario funciona llamando a la función recibida como props _createNote_, con los detalles de la nueva nota. + +La prueba es la siguiente: + +```js +import { render, screen } from '@testing-library/react' +import NoteForm from './NoteForm' +import userEvent from '@testing-library/user-event' + +test(' updates parent state and calls onSubmit', async () => { + const createNote = vi.fn() + const user = userEvent.setup() + + render() + + const input = screen.getByRole('textbox') + const sendButton = screen.getByText('save') + + await user.type(input, 'testing a form...') + await user.click(sendButton) + + expect(createNote.mock.calls).toHaveLength(1) + expect(createNote.mock.calls[0][0].content).toBe('testing a form...') +}) +``` + +Las pruebas tienen acceso al campo de input utilizando la función [getByRole](https://testing-library.com/docs/queries/byrole). + +El método [type](https://testing-library.com/docs/user-event/utility#type) de userEvent se utiliza para escribir texto en el campo de input. + +La primera expectativa de la prueba asegura que al enviar el formulario el método _createNote_ es llamado. +La segunda expectativa verifica que el controlador de eventos se llama con los parámetros correctos, es decir, que se crea una nota con el contenido correcto cuando se llena el formulario. + +Vale la pena mencionar que el buen viejo _console.log_ funciona como de costumbre en las pruebas. Por ejemplo, si quieres ver cómo se ven las llamadas almacenadas por el objeto simulado, puedes hacer lo siguiente: + +```js +test(' updates parent state and calls onSubmit', async() => { + const user = userEvent.setup() + const createNote = vi.fn() + + render() + + const input = screen.getByRole('textbox') + const sendButton = screen.getByText('save') + + await user.type(input, 'testing a form...') + await user.click(sendButton) + + console.log(createNote.mock.calls) // highlight-line +}) +``` + +En el medio de la ejecución de las pruebas, lo siguiente se imprime en la consola: + +``` +[ [ { content: 'testing a form...', important: true } ] ] +``` + +### Sobre la búsqueda de elementos + +Supongamos que el formulario tiene dos campos de input. + +```js +const NoteForm = ({ createNote }) => { + // ... + + return ( +
    +

    Create a new note

    + +
    + + // highlight-start + + // highlight-end + +
    +
    + ) +} +``` + +Ahora la forma en la que nuestra prueba encuentra el campo de input + +```js +const input = screen.getByRole('textbox') +``` + +causaría un error: + +![error de node que muestra dos elementos con rol textbox ya que usamos getByRole](../../images/5/40.png) + +El mensaje de error sugiere utilizar getAllByRole. El test podría arreglarse de la siguiente forma: + +```js +const inputs = screen.getAllByRole('textbox') + +await user.type(inputs[0], 'testing a form...') +``` + +El método getAllByRole ahora devuelve un array y el campo de input correcto es el primer elemento del array. Sin embargo, este enfoque es un poco sospechoso ya que depende del orden de los campos de input. + +A menudo, los campos de input tienen un texto de placeholder que indica al usuario qué tipo de input se espera. Agreguemos un placeholder a nuestro formulario: + +```js +const NoteForm = ({ createNote }) => { + // ... + + return ( +
    +

    Create a new note

    + +
    + + + +
    +
    + ) +} +``` + +Ahora encontrar el campo de input correcto es fácil con el método [getByPlaceholderText](https://testing-library.com/docs/queries/byplaceholdertext): + +```js +test(' updates parent state and calls onSubmit', () => { + const createNote = vi.fn() + + render() + + const input = screen.getByPlaceholderText('write note content here') // highlight-line + const sendButton = screen.getByText('save') + + userEvent.type(input, 'testing a form...') + userEvent.click(sendButton) + + expect(createNote.mock.calls).toHaveLength(1) + expect(createNote.mock.calls[0][0].content).toBe('testing a form...') +}) +``` + +La forma más flexible de encontrar elementos en pruebas es el método querySelector del objeto _container_, que es devuelto por _render_, como se mencionó [anteriormente en esta parte](/es/part5/probando_aplicaciones_react#busqueda-de-contenido-en-un-componente). Se puede usar cualquier selector CSS con este método para buscar elementos en las pruebas. + +Por ejemplo, podríamos definir un _id_ único para el campo de input: + +```js +const NoteForm = ({ createNote }) => { + // ... + + return ( +
    +

    Create a new note

    + +
    + + + +
    +
    + ) +} +``` + +El elemento input ahora podría ser encontrado en la prueba de la siguiente manera: + +```js +const { container } = render() + +const input = container.querySelector('#note-input') +``` + +Sin embargo, nos adheriremos al enfoque de usar _getByPlaceholderText_ en la prueba. + +Antes de continuar, analicemos un par de detalles. Supongamos que un componente renderiza texto en un elemento HTML de la siguiente manera: + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' : 'make important' + + return ( +
  • + Your awesome note: {note.content} // highlight-line + +
  • + ) +} + +export default Note +``` + +el método _getByText_ que la prueba utiliza no encuentra al elemento + +```js +test('renders content', () => { + const note = { + content: 'Does not work anymore :(', + important: true + } + + render() + + const element = screen.getByText('Does not work anymore :(') + + expect(element).toBeDefined() +}) +``` + +El método _getByText_ busca a un elemento que tenga **exactamente el mismo texto** que se proporciona como parámetro, y nada más. Si queremos buscar un elemento que contenga el texto, podríamos usar una opción adicional: + +```js +const element = screen.getByText( + 'Does not work anymore :(', { exact: false } +) +``` + +o podríamos usar el método _findByText_: + +```js +const element = await screen.findByText('Does not work anymore :(') +``` + +Es importante tener en cuenta que, a diferencia de los otros métodos _ByText_, _findByText_ ¡devuelve una promesa! + +Existen situaciones en las que otra forma del método _queryByText_ es útil. El método devuelve el elemento pero no genera una excepción si no se lo encuentra. + +Por ejemplo, podríamos utilizar el método para asegurarnos de que algo no se está renderizando en el componente: + +```js +test('does not render this', () => { + const note = { + content: 'This is a reminder', + important: true + } + + render() + + const element = screen.queryByText('do not want this thing to be rendered') + expect(element).toBeNull() +}) +``` + +### Cobertura de las pruebas + +Podemos encontrar fácilmente la [cobertura](https://vitest.dev/guide/coverage.html#coverage) de nuestras pruebas ejecutándolas con el comando + +```js +npm test -- --coverage +``` + +La primera vez que ejecutes el comando, Vitest te preguntará si quieres instalar la librería requerida _@vitest/coverage-v8_. Instálala y ejecuta el comando de nuevo: + +![salida del terminal de cobertura de las pruebas](../../images/5/18new.png) + +Se generará un informe HTML en el directorio coverage. +El informe nos dirá las líneas de código no probado en cada componente: + +![reporte HTML de cobertura de las pruebas](../../images/5/19newer.png) + +Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part5-8 de [este repositorio de GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-8). + +
    + +
    + +### Ejercicios 5.13.-5.16. + +#### 5.13: Pruebas de Listas de Blogs, paso 1 + +Realiza una prueba que verifique que el componente que muestra un blog muestre el título y el autor del blog, pero no muestre su URL o el número de likes por defecto + +Agrega clases de CSS al componente para ayudar con las pruebas según sea necesario. + +#### 5.14: Pruebas de Listas de Blogs, paso 2 + +Realiza una prueba que verifique que la URL del blog y el número de likes se muestran cuando se hace clic en el botón que controla los detalles mostrados. + +#### 5.15: Pruebas de Listas de Blogs, paso 3 + +Realiza una prueba que garantice que si se hace clic dos veces en el botón like, se llama dos veces al controlador de eventos que el componente recibió como props. + +#### 5.16: Pruebas de Listas de Blogs, paso 4 + +Haz una prueba para el nuevo formulario de blog. La prueba debe verificar que el formulario llama al controlador de eventos que recibió como props con los detalles correctos cuando se crea un nuevo blog. + +
    + +
    + +### Pruebas de integración del Frontend + +En la parte anterior del material del curso, escribimos pruebas de integración para el backend que probaron su lógica y conectaron la base de datos a través de la API proporcionada por el backend. Al escribir estas pruebas, tomamos la decisión consciente de no escribir pruebas unitarias, ya que el código para ese backend es bastante simple, y es probable que los errores en nuestra aplicación ocurran en escenarios más complicados para los que las pruebas unitarias no son adecuadas. + +Hasta ahora, todas nuestras pruebas para el frontend han sido pruebas unitarias que han validado el correcto funcionamiento de componentes individuales. Las pruebas unitarias son útiles a veces, pero incluso un conjunto completo de pruebas unitarias no es suficiente para validar que la aplicación funciona como un todo. + +También podríamos realizar pruebas de integración para el frontend. Las pruebas de integración prueban la colaboración de múltiples componentes. Es considerablemente más difícil que las pruebas unitarias, ya que por ejemplo, tendríamos que simular datos del servidor. +Elegimos concentrarnos en hacer pruebas de extremo a extremo para probar toda la aplicación, en la que trabajaremos en el último capítulo de esta parte. + +### Pruebas de instantáneas + +Vitest ofrece una alternativa completamente diferente a las pruebas "tradicionales" llamada pruebas de [instantáneas](https://vitest.dev/guide/snapshot) o snapshot testing. La característica interesante de las pruebas de instantáneas es que los desarrolladores no necesitan definir ninguna prueba ellos mismos, simplemente es suficiente adoptar las pruebas de instantáneas. + +El principio fundamental es comparar el código HTML definido por el componente después de que haya cambiado con el código HTML que existía antes de que se cambiara. + +Si la instantánea nota algún cambio en el HTML definido por el componente, entonces es una nueva funcionalidad o un "error" causado por accidente. Las pruebas instantáneas notifican al desarrollador si cambia el código HTML del componente. El desarrollador tiene que decirle a Jest si el cambio fue deseado o no. Si el cambio en el código HTML es inesperado, implica la gran posibilidad de tener un error y el desarrollador puede darse cuenta de estos problemas potenciales fácilmente gracias a las pruebas de instantáneas. + +
    diff --git a/src/content/5/es/part5d.md b/src/content/5/es/part5d.md new file mode 100644 index 00000000000..62171dd9d3d --- /dev/null +++ b/src/content/5/es/part5d.md @@ -0,0 +1,1320 @@ +--- +mainImage: ../../../images/part-5.svg +part: 5 +letter: d +lang: es +--- + +
    + +Hasta ahora, hemos probado el backend como un todo a nivel de API usando pruebas de integración, y probado algunos componentes frontend usando pruebas unitarias. + +A continuación, veremos una forma de probar el [sistema como un todo](https://en.wikipedia.org/wiki/System_testing) usando pruebas de Extremo a Extremo (End to End o E2E). + +Podemos hacer pruebas E2E de una aplicación web usando un navegador y una librería de pruebas. Hay varias librerías disponibles, por ejemplo [Selenium](http://www.seleniumhq.org/) que se puede utilizar con casi cualquier navegador. +Otra opción de navegador son los denominados [headless browsers](https://es.wikipedia.org/wiki/Navegador_sin_interfaz_gr%C3%A1fica) (navegadores sin cabeza), que son navegadores sin interfaz gráfica de usuario. +Por ejemplo, Chrome se puede utilizar en modo sin cabeza. + +Las pruebas E2E son potencialmente la categoría de pruebas más útil, porque prueban el sistema a través de la misma interfaz que usan los usuarios reales. + +También tienen algunos inconvenientes. Configurar las pruebas E2E es más complicado que las pruebas unitarias o de integración. También tienden a ser bastante lentas y, con un sistema grande, su tiempo de ejecución puede ser de minutos, incluso horas. Esto es malo para el desarrollo, porque durante el desarrollo es beneficioso poder ejecutar pruebas con la mayor frecuencia posible en caso de sufrir [regresiones](https://es.wikipedia.org/wiki/Pruebas_de_regresi%C3%B3n) de código. + +Las pruebas E2E también pueden ser [flaky](https://hackernoon.com/flaky-tests-a-war-that-never-ends-9aa32fdef359) (inestables). +Algunas pruebas pueden pasar una vez y fallar en otra, incluso si el código no cambia en absoluto. + +Quizás las dos librerías más fáciles para pruebas de extremo a extremo en este momento son [Cypress](https://www.cypress.io/) y [Playwright](https://playwright.dev/). + +De las estadísticas en [npmtrends.com](https://npmtrends.com/cypress-vs-playwright) vemos que Cypress, que dominó el mercado durante los últimos cinco años, sigue siendo claramente el número uno, pero Playwright está en un rápido ascenso: + +![cypress vs playwright en tendencias de npm](../../images/5/cvsp.png) + +Este curso ha estado usando Cypress durante años. Ahora Playwright es una nueva adición. Puedes elegir si completar la parte de pruebas E2E del curso con Cypress o Playwright. Los principios operativos de ambas librerías son muy similares, así que tu elección no es muy importante. Sin embargo, ahora Playwright es la librería E2E preferida para el curso. + +Si tu elección es Playwright, por favor continúa. Si terminas usando Cypress, ve [aquí](/es/part5/pruebas_de_extremo_a_extremo_cypress). + +### Playwright + +[Playwright](https://playwright.dev/) es un recién llegado a las pruebas de extremo a extremo, que comenzó a explotar en popularidad hacia finales de 2023. Playwright está aproximadamente a la par con Cypress en términos de facilidad de uso. Las librerías son ligeramente diferentes en términos de cómo funcionan. Cypress es radicalmente diferente de la mayoría de las librerías de pruebas E2E, ya que las pruebas de Cypress se ejecutan completamente dentro del navegador. Las pruebas de Playwright, por otro lado, se ejecutan en el proceso de Node, que está conectado al navegador a través de interfaces de programación. + +Se han escrito muchos blogs sobre comparaciones de librerías, por ejemplo, [este](https://www.lambdatest.com/blog/cypress-vs-playwright/) y [este](https://www.browserstack.com/guide/playwright-vs-cypress). + +Es difícil decir qué librería es mejor. Una ventaja de Playwright es su soporte de navegadores; Playwright soporta Chrome, Firefox y navegadores basados en Webkit como Safari. Actualmente, Cypress incluye soporte para todos estos navegadores, aunque el soporte de Webkit es experimental y no soporta todas las funcionalidades de Cypress. Al momento de escribir (1.3.2024), mi preferencia personal se inclina ligeramente hacia Playwright. + +Ahora exploremos Playwright. + +### Inicializando pruebas + +A diferencia de las pruebas de backend o las pruebas unitarias realizadas en el front-end de React, las pruebas de extremo a extremo no necesitan estar ubicadas en el mismo proyecto npm donde está el código. Hagamos un proyecto completamente separado para las pruebas E2E con el comando _npm init_. Luego instala Playwright ejecutando en el directorio del nuevo proyecto el comando: + +```js +npm init playwright@latest +``` + +El script de instalación hará algunas preguntas, responde de la siguiente manera: + +![respuesta: javascript, tests, false, true](../../images/5/play0.png) + +Definamos un script npm para ejecutar pruebas e informes de pruebas en _package.json_: + +```js +{ + // ... + "scripts": { + "test": "playwright test", + "test:report": "playwright show-report" + }, + // ... +} +``` + +Durante la instalación, lo siguiente se imprime en la consola: + +``` +And check out the following files: + - ./tests/example.spec.js - Example end-to-end test + - ./tests-examples/demo-todo-app.spec.js - Demo Todo App end-to-end tests + - ./playwright.config.js - Playwright Test configuration +``` + +esto es, la ubicación de algunas pruebas de ejemplo para el proyecto que la instalación ha creado. + +Ejecutemos algunas pruebas: + +```bash +$ npm test + +> notes-e2e@1.0.0 test +> playwright test + + +Running 6 tests using 5 workers + 6 passed (3.9s) + +To open last HTML report run: + + npx playwright show-report +``` + +Las pruebas pasan. Un reporte más detallado puede abrirse tanto con el comando sugerido en la consola, como con el script de npm que acabamos de definir: + +``` +npm run test:report +``` + +Las pruebas también pueden ejecutarse a través de la interfaz gráfica con el comando: + +``` +npm run test -- --ui +``` + +Las pruebas de muestra se ven así: + +```js +const { test, expect } = require('@playwright/test'); + +test('has title', async ({ page }) => { + await page.goto('https://playwright.dev/'); // highlight-line + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Playwright/); +}); + +test('get started link', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Click the get started link. + await page.getByRole('link', { name: 'Get started' }).click(); + + // Expects page to have a heading with the name of Installation. + await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); +}); +``` + +La primera linea de las funciones test dicen que las pruebas están probando la pagina https://playwright.dev/. + +### Probando nuestro propio código + +Ahora eliminemos las pruebas de ejemplo y comencemos a probar nuestra propia aplicación. + +Las pruebas de Playwright asumen que el sistema bajo prueba está en funcionamiento cuando se ejecutan las pruebas. A diferencia de, por ejemplo, las pruebas de integración de backend, las pruebas de Playwright no inician el sistema bajo prueba durante las pruebas. + +Hagamos un script npm para el backend, que permitirá iniciarlo en modo de prueba, es decir, de modo que NODE_ENV obtenga el valor test. + +```js +{ + // ... + "scripts": { + "start": "NODE_ENV=production node index.js", + "dev": "NODE_ENV=development nodemon index.js", + "build:ui": "rm -rf build && cd ../frontend/ && npm run build && cp -r build ../backend", + "deploy": "fly deploy", + "deploy:full": "npm run build:ui && npm run deploy", + "logs:prod": "fly logs", + "lint": "eslint .", + "test": "NODE_ENV=test node --test", + "start:test": "NODE_ENV=test node index.js" // highlight-line + }, + // ... +} +``` + +Iniciemos el frontend y el backend, y creemos el primer archivo de prueba para la aplicación tests/note\_app.spec.js: + +```js +const { test, expect } = require('@playwright/test') + +test('front page can be opened', async ({ page }) => { + await page.goto('http://localhost:5173') + + const locator = await page.getByText('Notes') + await expect(locator).toBeVisible() + await expect(page.getByText('Note app, Department of Computer Science, University of Helsinki 2023')).toBeVisible() +}) +``` + +Primero, la prueba abre la aplicación con el método [page.goto](https://playwright.dev/docs/writing-tests#navigation). Después de esto, utiliza el método [page.getByText](https://playwright.dev/docs/api/class-page#page-get-by-text) para obtener un [locator](https://playwright.dev/docs/locators) (localizador) que corresponde al elemento donde se encuentra el texto Notes. + +El método [toBeVisible](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-be-visible) asegura que el elemento correspondiente al localizador sea visible en la página. + +La segunda comprobación se realiza sin usar la variable auxiliar. + +Nos damos cuenta de que el año ha cambiado. Cambiemos la prueba de la siguiente manera: + +```js +const { test, expect } = require('@playwright/test') + +test('front page can be opened', async ({ page }) => { + await page.goto('http://localhost:5173') + + const locator = await page.getByText('Notes') + await expect(locator).toBeVisible() + await expect(page.getByText('Note app, Department of Computer Science, University of Helsinki 2024')).toBeVisible() // highlight-line +}) +``` + +Como se esperaba, la prueba falla. Playwright abre el informe de la prueba en el navegador y se hace evidente que Playwright ha realizado las pruebas con tres navegadores diferentes: Chrome, Firefox y Webkit, es decir, el motor de navegador utilizado por Safari: + +![informe de prueba mostrando la prueba fallida en tres navegadores diferentes](../../images/5/play2.png) + +Al hacer clic en el informe de uno de los navegadores, podemos ver un mensaje de error más detallado: + +![mensaje de error de la prueba](../../images/5/play3a.png) + +Generalmente, es por supuesto algo muy bueno que las pruebas se lleven a cabo con los tres motores de navegador más comúnmente utilizados, pero esto es lento, y al desarrollar las pruebas probablemente sea mejor realizarlas principalmente con solo un navegador. Puedes definir el motor de navegador a utilizar con el parámetro de línea de comando: + +```js +npm test -- --project chromium +``` + +Ahora corrijamos el año desactualizado en el código del frontend que causó el error. + +Antes de continuar, agreguemos un bloque _describe_ a las pruebas: + +```js +const { test, describe, expect } = require('@playwright/test') + +describe('Note app', () => { // highlight-line + test('front page can be opened', async ({ page }) => { + await page.goto('http://localhost:5173') + + const locator = await page.getByText('Notes') + await expect(locator).toBeVisible() + await expect(page.getByText('Note app, Department of Computer Science, University of Helsinki 2024')).toBeVisible() + }) +}) +``` + +Antes de continuar, rompamos las pruebas una vez más. Notamos que la ejecución de las pruebas es bastante rápida cuando pasan, pero mucho más lenta si no pasan. La razón de esto es que la política de Playwright es esperar a que los elementos buscados estén [renderizados y listos para la acción](https://playwright.dev/docs/actionability). Si el elemento no se encuentra, se genera un _TimeoutError_ y la prueba falla. Playwright espera por los elementos por defecto durante 5 o 30 segundos [dependiendo de las funciones utilizadas en la prueba](https://playwright.dev/docs/test-timeouts#introduction). + +Al desarrollar pruebas, puede ser más prudente reducir el tiempo de espera a unos pocos segundos. Según la [documentación](https://playwright.dev/docs/test-timeouts), esto se puede hacer cambiando el archivo _playwright.config.js_ de la siguiente manera: + +```js +module.exports = defineConfig({ + timeout: 3000, // highlight-line + fullyParallel: false, // highlight-line + workers: 1, // highlight-line + // ... +}) +``` + +También hicimos dos cambios más en el archivo, y especificamos que todas las pruebas [se ejecuten una a una](https://playwright.dev/docs/test-parallel). Con la configuración predeterminada, la ejecución ocurre en paralelo, y dado que nuestras pruebas utilizan una base de datos, la ejecución en paralelo causa problemas. + +### Escribiendo en un formulario + +Escribamos una nueva prueba para intentar iniciar sesión en la aplicación. Supongamos que un usuario está guardado en la base de datos, con el nombre de usuario mluukkai y la contraseña salainen. + +Comencemos abriendo el formulario de inicio de sesión. + +```js +describe('Note app', () => { + // ... + + test('login form can be opened', async ({ page }) => { + await page.goto('http://localhost:5173') + + await page.getByRole('button', { name: 'log in' }).click() + }) +}) +``` + +La prueba primero utiliza el método [page.getByRole](https://playwright.dev/docs/api/class-page#page-get-by-role) para encontrar el botón basado en su texto. El método devuelve el [Locator](https://playwright.dev/docs/api/class-locator) correspondiente al elemento Button. Presionar el botón se realiza utilizando el método [click](https://playwright.dev/docs/api/class-locator#locator-click) del Locator. + +Al desarrollar pruebas, podrías usar el [modo UI](https://playwright.dev/docs/test-ui-mode) de Playwright, es decir, la versión de la interfaz de usuario. Comencemos las pruebas en modo UI de la siguiente manera: + +``` +npm test -- --ui +``` + +Ahora vemos que la prueba encuentra el botón + +![UI de Playwright renderizando la aplicación de notas mientras la prueba](../../images/5/play4.png) + +Después de hacer clic, aparecerá el formulario + +![UI de Playwright renderizando el formulario de inicio de sesión de la aplicación de notas](../../images/5/play5.png) + +Cuando se abre el formulario, la prueba debe buscar los campos de texto e introducir el nombre de usuario y la contraseña en ellos. Hagamos el primer intento utilizando el método [page.getByRole](https://playwright.dev/docs/api/class-page#page-get-by-role): + +```js +describe('Note app', () => { + // ... + + test('login form can be opened', async ({ page }) => { + await page.goto('http://localhost:5173') + + await page.getByRole('button', { name: 'log in' }).click() + await page.getByRole('textbox').fill('mluukkai') // highlight-line + }) +}) +``` + +Esto resulta en un error: + +```bash +Error: locator.fill: Error: strict mode violation: getByRole('textbox') resolved to 2 elements: + 1) aka locator('div').filter({ hasText: /^username$/ }).getByRole('textbox') + 2) aka locator('input[type="password"]') +``` + +El problema ahora es que _getByRole_ encuentra dos campos de texto, y al llamar al método [fill](https://playwright.dev/docs/api/class-locator#locator-fill) falla, porque asume que solo se encontró un campo de texto. Una manera de solucionar el problema es utilizar los métodos [first](https://playwright.dev/docs/api/class-locator#locator-first) y [last](https://playwright.dev/docs/api/class-locator#locator-last): + +```js +describe('Note app', () => { + // ... + + test('login form can be opened', async ({ page }) => { + await page.goto('http://localhost:5173') + + await page.getByRole('button', { name: 'log in' }).click() + // highlight-start + await page.getByRole('textbox').first().fill('mluukkai') + await page.getByRole('textbox').last().fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + // highlight-end + }) +}) +``` + +Después de escribir en los campos de texto, la prueba presiona el botón _login_ y verifica que la aplicación muestre la información del usuario que ha iniciado sesión en la pantalla. + +Si hubiera más de dos campos de texto, utilizar los métodos _first_ y _last_ no sería suficiente. Una posibilidad sería usar el método [all](https://playwright.dev/docs/api/class-locator#locator-all), que convierte a los localizadores encontrados en un array que puede ser indexado: + +```js +describe('Note app', () => { + // ... + test('login form can be opened', async ({ page }) => { + await page.goto('http://localhost:5173') + + await page.getByRole('button', { name: 'log in' }).click() + // highlight-start + const textboxes = await page.getByRole('textbox').all() + + await textboxes[0].fill('mluukkai') + await textboxes[1].fill('salainen') + // highlight-end + + await page.getByRole('button', { name: 'login' }).click() + + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + }) +}) +``` + +Ambas versiones de la prueba funcionan. Sin embargo, ambas son problemáticas en la medida en que si el formulario de registro cambia, las pruebas pueden fallar, ya que dependen de que los campos estén en la página en un cierto orden. + +Una mejor solución es definir atributos de id de prueba únicos para los campos, y buscarlos en las pruebas utilizando el método [getByTestId](https://playwright.dev/docs/api/class-page#page-get-by-test-id). + +Ampliemos el formulario de inicio de sesión de la siguiente manera + +```js +const LoginForm = ({ ... }) => { + return ( +
    +

    Login

    +
    +
    + username + +
    +
    + password + +
    + +
    +
    + ) +} +``` + +La prueba cambia de la siguiente manera: + +```js +describe('Note app', () => { + // ... + + test('login form can be opened', async ({ page }) => { + await page.goto('http://localhost:5173') + + await page.getByRole('button', { name: 'log in' }).click() + await page.getByTestId('username').fill('mluukkai') // highlight-line + await page.getByTestId('password').fill('salainen') // highlight-line + + await page.getByRole('button', { name: 'login' }).click() + + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + }) +}) +``` + +Ten en cuenta que para que la prueba pase en esta etapa, es necesario que haya un usuario en la base de datos de test del backend con el nombre de usuario mluukkai y la contraseña salainen. ¡Crea un usuario si es necesario! + +Dado que ambas pruebas comienzan de la misma manera, es decir, abriendo la página http://localhost:5173, se recomienda aislar la parte común en el bloque beforeEach que se ejecuta antes de cada prueba: + +```js +const { test, describe, expect, beforeEach } = require('@playwright/test') + +describe('Note app', () => { + // highlight-start + beforeEach(async ({ page }) => { + await page.goto('http://localhost:5173') + }) + // highlight-end + + test('front page can be opened', async ({ page }) => { + const locator = await page.getByText('Notes') + await expect(locator).toBeVisible() + await expect(page.getByText('Note app, Department of Computer Science, University of Helsinki 2024')).toBeVisible() + }) + + test('login form can be opened', async ({ page }) => { + await page.getByRole('button', { name: 'log in' }).click() + await page.getByTestId('username').fill('mluukkai') + await page.getByTestId('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + }) +}) +``` + +### Probando el formulario para agregar notas + +A continuación, agreguemos pruebas para probar la funcionalidad "new note": + +```js +const { test, describe, expect, beforeEach } = require('@playwright/test') + +describe('Note app', () => { + // ... + + describe('when logged in', () => { + beforeEach(async ({ page }) => { + await page.getByRole('button', { name: 'log in' }).click() + await page.getByTestId('username').fill('mluukkai') + await page.getByTestId('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + }) + + test('a new note can be created', async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('a note created by playwright') + await page.getByRole('button', { name: 'save' }).click() + await expect(page.getByText('a note created by playwright')).toBeVisible() + }) + }) +}) +``` + +La prueba se ha definido en su propio bloque _describe_. Crear una nota requiere que el usuario haya iniciado sesión, lo cual es controlado en el bloque _beforeEach_. + +La prueba confía en que al crear una nueva nota, la página contiene solo un campo de texto, por lo que lo busca así: + +```js +page.getByRole('textbox') +``` + +Si la página tuviera más campos, la prueba se rompería. Debido a esto, sería mejor agregarle un test-id a los inputs del formulario y buscarlo basado en su id. + +**Nota:** la prueba solo pasara la primera vez. La razón de esto es que su aserción + +```js +await expect(page.getByText('a note created by playwright')).toBeVisible() +``` + +causa problemas cuando la misma nota es creada en la aplicación más de una vez. El problema será resuelto en el próximo capitulo. + +La estructura de las pruebas se ve así: + +```js +const { test, describe, expect, beforeEach } = require('@playwright/test') + +describe('Note app', () => { + // .... + + test('user can log in', async ({ page }) => { + await page.getByRole('button', { name: 'log in' }).click() + await page.getByTestId('username').fill('mluukkai') + await page.getByTestId('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + }) + + describe('when logged in', () => { + beforeEach(async ({ page }) => { + await page.getByRole('button', { name: 'log in' }).click() + await page.getByTestId('username').fill('mluukkai') + await page.getByTestId('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + }) + + test('a new note can be created', async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('a note created by playwright') + await page.getByRole('button', { name: 'save' }).click() + await expect(page.getByText('a note created by playwright')).toBeVisible() + }) + }) +}) +``` + +Dado que hemos evitado que las pruebas se ejecuten en paralelo, Playwright ejecuta las pruebas en el orden en que aparecen en el código de prueba. Es decir, primero se realiza la prueba user can log in, donde el usuario inicia sesión en la aplicación. Después de esto se ejecuta la prueba a new note can be created, que también realiza un inicio de sesión, en el bloque beforeEach. ¿Por qué se hace esto, no está ya el usuario conectado gracias a la prueba anterior? No, porque la ejecución de cada prueba comienza desde el "estado cero" del navegador, todos los cambios realizados en el estado del navegador por las pruebas anteriores se resetean. + +### Controlando el estado de la base de datos + +Si las pruebas necesitan poder modificar la base de datos del servidor, la situación inmediatamente se vuelve más complicada. Idealmente, la base de datos del servidor debería ser la misma cada vez que ejecutamos las pruebas, para que nuestras pruebas se puedan repetir de forma fiable y sencilla. + +Al igual que con las pruebas unitarias y de integración, con las pruebas E2E es mejor vaciar la base de datos y posiblemente formatearla antes de ejecutar las pruebas. El desafío con las pruebas E2E es que no tienen acceso a la base de datos. + +La solución es crear endpoints de API en el backend para la prueba. +Podemos vaciar la base de datos usando estos endpoints. +Creemos un nuevo enrutador para las pruebas dentro del directorio controllers, en el archivo testing.js + +```js +const testingRouter = require('express').Router() +const Note = require('../models/note') +const User = require('../models/user') + +testingRouter.post('/reset', async (request, response) => { + await Note.deleteMany({}) + await User.deleteMany({}) + + response.status(204).end() +}) + +module.exports = testingRouter +``` + +y agrégalo al backend solo si la aplicación se ejecuta en modo de prueba: + +```js +// ... + +app.use('/api/login', loginRouter) +app.use('/api/users', usersRouter) +app.use('/api/notes', notesRouter) + +// highlight-start +if (process.env.NODE_ENV === 'test') { + const testingRouter = require('./controllers/testing') + app.use('/api/testing', testingRouter) +} +// highlight-end + +app.use(middleware.unknownEndpoint) +app.use(middleware.errorHandler) + +module.exports = app +``` + +Después de los cambios, una solicitud POST HTTP al endpoint /api/testing/reset vacía la base de datos. Asegúrate de que tu backend esté ejecutándose en modo de prueba iniciándolo con este comando (previamente configurado en el archivo package.json): + +```js + npm run start:test +``` + +El código de backend modificado se puede encontrar en [GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part5-1), rama part5-1. + +A continuación, cambiaremos el bloque _beforeEach_ para que vacíe la base de datos del servidor antes de ejecutar las pruebas. + +Actualmente no es posible agregar nuevos usuarios a través de la interfaz de usuario del frontend, por lo que agregamos un nuevo usuario al backend desde el bloque beforeEach. + +```js +describe('Note app', () => { + beforeEach(async ({ page, request }) => { + await request.post('http:localhost:3001/api/testing/reset') + await request.post('http://localhost:3001/api/users', { + data: { + name: 'Matti Luukkainen', + username: 'mluukkai', + password: 'salainen' + } + }) + + await page.goto('http://localhost:5173') + }) + + test('front page can be opened', () => { + // ... + }) + + test('user can login', () => { + // ... + }) + + describe('when logged in', () => { + // ... + }) +}) +``` + +Durante la inicialización, la prueba realiza solicitudes HTTP al backend con el método [post](https://playwright.dev/docs/api/class-apirequestcontext#api-request-context-post) del parámetro _request_. + +A diferencia de antes, ahora la prueba del backend siempre comienza desde el mismo estado, es decir, hay un usuario y no hay notas en la base de datos. + +Hagamos una prueba que verifique que la importancia de las notas pueda cambiarse. + +Hay algunos enfoques diferentes para realizar la prueba. + +A continuación, primero buscamos una nota y hacemos clic en su botón que tiene el texto make not important. Después de esto, comprobamos que la nota contiene el botón con make important. + +```js +describe('Note app', () => { + // ... + + describe('when logged in', () => { + // ... + + // highlight-start + describe('and a note exists', () => { + beforeEach(async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('another note by playwright') + await page.getByRole('button', { name: 'save' }).click() + }) + + test('importance can be changed', async ({ page }) => { + await page.getByRole('button', { name: 'make not important' }).click() + await expect(page.getByText('make important')).toBeVisible() + }) + // highlight-end + }) + }) +}) +``` + +El primer comando primero busca al componente donde está el texto another note by playwright y dentro de él al botón make not important y hace clic en él. + +El segundo comando asegura que el texto del mismo botón haya cambiado a make important. + +El código actual para las pruebas está en [GitHub](https://github.com/fullstack-hy2020/notes-e2e/tree/part5-1), en la rama part5-1. + +### Prueba de inicio de sesión fallida + +Ahora hagamos una prueba que asegure que el intento de inicio de sesión falla si la contraseña es incorrecta. + +La primera versión de la prueba se ve así: + +```js +describe('Note app', () => { + // ... + + test('login fails with wrong password', async ({ page }) => { + await page.getByRole('button', { name: 'log in' }).click() + await page.getByTestId('username').fill('mluukkai') + await page.getByTestId('password').fill('wrong') + await page.getByRole('button', { name: 'login' }).click() + + await expect(page.getByText('wrong credentials')).toBeVisible() + }) + + // ... +)} +``` + +La prueba verifica con el método [page.getByText](https://playwright.dev/docs/api/class-page#page-get-by-text) que la aplicación muestra un mensaje de error. + +La aplicación renderiza el mensaje de error en un elemento que contiene la clase CSS error: + +```js +const Notification = ({ message }) => { + if (message === null) { + return null + } + + return ( +
    // highlight-line + {message} +
    + ) +} +``` + +Podríamos refinar la prueba para asegurar que el mensaje de error se muestre exactamente en el lugar correcto, es decir, en el elemento que contiene a la clase CSS error: + +```js + test('login fails with wrong password', async ({ page }) => { + // ... + + const errorDiv = await page.locator('.error') // highlight-line + await expect(errorDiv).toContainText('wrong credentials') +}) +``` + +La prueba utiliza el método [page.locator](https://playwright.dev/docs/api/class-page#page-locator) para encontrar el componente que contiene la clase CSS error y lo almacena en una variable. La verificación del texto asociado con el componente se puede verificar con la aserción [toContainText](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-contain-text). Ten en cuenta que el [selector de clase CSS](https://developer.mozilla.org/es/docs/Web/CSS/Class_selectors) comienza con un punto, por lo que el selector de la clase error es .error. + +Es posible probar los estilos CSS de la aplicación con el comparador [toHaveCSS](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-css). Podemos, por ejemplo, asegurarnos de que el color del mensaje de error sea rojo y que haya un borde alrededor de él: + +```js + test('login fails with wrong password', async ({ page }) => { + // ... + + const errorDiv = await page.locator('.error') + await expect(errorDiv).toContainText('wrong credentials') + await expect(errorDiv).toHaveCSS('border-style', 'solid') // highlight-line + await expect(errorDiv).toHaveCSS('color', 'rgb(255, 0, 0)') // highlight-line +}) +``` + +En Playwright los colores deben definirse cómo códigos [rgb](https://rgbcolorcode.com/color/red). + +Terminemos la prueba para que también asegure que la aplicación **no renderiza** al texto describiendo un inicio de de sesión exitoso 'Matti Luukkainen logged in': + +```js +test('login fails with wrong password', async ({ page }) =>{ + await page.getByRole('button', { name: 'log in' }).click() + await page.getByTestId('username').fill('mluukkai') + await page.getByTestId('password').fill('wrong') + await page.getByRole('button', { name: 'login' }).click() + + const errorDiv = await page.locator('.error') + await expect(errorDiv).toContainText('wrong credentials') + await expect(errorDiv).toHaveCSS('border-style', 'solid') + await expect(errorDiv).toHaveCSS('color', 'rgb(255, 0, 0)') + + await expect(page.getByText('Matti Luukkainen logged in')).not.toBeVisible() // highlight-line +}) +``` + +### Ejecutando pruebas una por una + +Por defecto, Playwright siempre ejecuta todas las pruebas, y a medida que el número de pruebas aumenta, cada vez consume más tiempo. Al desarrollar una nueva prueba o depurar una rota, la prueba se puede definir en lugar de con el comando test, con el comando test.only, en cuyo caso Playwright ejecutará solo esa prueba: + +```js +describe(() => { + // esta es la única prueba ejecutada! + test.only('login fails with wrong password', async ({ page }) => { // highlight-line + // ... + }) + + // esta prueba es omitida... + test('user can login with correct credentials', async ({ page }) => { + // ... + } + + // ... +}) +``` + +Cuando la prueba esta lista, only puede y **debe** ser eliminado. + +Otra opción para ejecutar una sola prueba es utilizar un parámetro de la linea de comandos: + +``` +npm test -- -g "login fails with wrong password" +``` + +### Funciones auxiliares para las pruebas + +Las pruebas de nuestra aplicación actualmente se ven así: + +```js +const { test, describe, expect, beforeEach } = require('@playwright/test') + +describe('Note app', () => { + // ... + + test('user can login with correct credentials', async ({ page }) => { + await page.getByRole('button', { name: 'log in' }).click() + await page.getByTestId('username').fill('mluukkai') + await page.getByTestId('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + }) + + test('login fails with wrong password', async ({ page }) =>{ + // ... + }) + + describe('when logged in', () => { + beforeEach(async ({ page, request }) => { + await page.getByRole('button', { name: 'log in' }).click() + await page.getByTestId('username').fill('mluukkai') + await page.getByTestId('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + }) + + test('a new note can be created', async ({ page }) => { + // ... + }) + + // ... + }) +}) +``` + +Primero, se prueba la función de inicio de sesión. Después de esto, otro bloque _describe_ contiene un conjunto de pruebas que asumen que el usuario ha iniciado sesión, el inicio de sesión se maneja dentro del bloque inicializador _beforeEach_. + +Como ya se mencionó anteriormente, cada prueba se ejecuta comenzando desde el estado inicial (donde la base de datos se limpia y se crea un usuario allí), por lo tanto, aunque la prueba esté definida después de otra prueba en el código, ¡no comienza desde el mismo estado que han dejado las pruebas ejecutadas anteriormente en el código! + +También vale la pena esforzarse por tener un código no repetitivo en las pruebas. Aislemos el código que maneja el inicio de sesión como una función auxiliar, que se coloca, por ejemplo, en el archivo _tests/helper.js_: + +```js +const loginWith = async (page, username, password) => { + await page.getByRole('button', { name: 'log in' }).click() + await page.getByTestId('username').fill(username) + await page.getByTestId('password').fill(password) + await page.getByRole('button', { name: 'login' }).click() +} + +export { loginWith } +``` + +La prueba se vuelve mucho más simple y clara: + +```js +const { loginWith } = require('./helper') + +describe('Note app', () => { + test('user can log in', async ({ page }) => { + await loginWith(page, 'mluukkai', 'salainen') // highlight-line + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + }) + + describe('when logged in', () => { + beforeEach(async ({ page }) => { + await loginWith(page, 'mluukkai', 'salainen') // highlight-line + }) + + test('a new note can be created', () => { + // ... + }) + + // ... +}) +``` + +Playwright también ofrece una [solución](https://playwright.dev/docs/auth) donde el inicio de sesión se realiza una vez antes de las pruebas, y cada prueba comienza desde un estado en el que la aplicación ya ha iniciado sesión. Para que podamos aprovechar este método, la inicialización de los datos de prueba de la aplicación debería hacerse de manera un poco diferente a la actual. En la solución actual, la base de datos se resetea antes de cada prueba, y debido a esto, iniciar sesión solo una vez antes de las pruebas es imposible. Para que podamos usar el inicio de sesión previo a la prueba proporcionado por Playwright, el usuario debería inicializarse solo una vez antes de las pruebas. Nos adherimos a nuestra solución actual por simplicidad. + +El código repetitivo correspondiente también se aplica a la creación de una nueva nota. Para eso, hay una prueba que crea una nota usando un formulario. También en el bloque de inicialización _beforeEach_ de la prueba que evalúa el cambio de importancia de la nota, se crea una nota utilizando el formulario: + +```js +describe('Note app', function() { + // ... + + describe('when logged in', () => { + test('a new note can be created', async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('a note created by playwright') + await page.getByRole('button', { name: 'save' }).click() + await expect(page.getByText('a note created by playwright')).toBeVisible() + }) + + describe('and a note exists', () => { + beforeEach(async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('another note by playwright') + await page.getByRole('button', { name: 'save' }).click() + }) + + test('it can be made important', async ({ page }) => { + // ... + }) + }) + }) +}) +``` + +La creación de una nota también es aislada como una función auxiliar. El archivo _tests/helper.js_ se expande de la siguiente manera: + +```js +const loginWith = async (page, username, password) => { + await page.getByRole('button', { name: 'log in' }).click() + await page.getByTestId('username').fill(username) + await page.getByTestId('password').fill(password) + await page.getByRole('button', { name: 'login' }).click() +} + +// highlight-start +const createNote = async (page, content) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill(content) + await page.getByRole('button', { name: 'save' }).click() +} +// highlight-end + +export { loginWith, createNote } +``` + +Las pruebas se simplifican de la siguiente manera: + +```js +describe('Note app', () => { + // ... + + describe('when logged in', () => { + beforeEach(async ({ page }) => { + await loginWith(page, 'mluukkai', 'salainen') + }) + + test('a new note can be created', async ({ page }) => { + await createNote(page, 'a note created by playwright', true) // highlight-line + await expect(page.getByText('a note created by playwright')).toBeVisible() + }) + + describe('and a note exists', () => { + beforeEach(async ({ page }) => { + await createNote(page, 'another note by playwright', true) // highlight-line + }) + + test('importance can be changed', async ({ page }) => { + await page.getByRole('button', { name: 'make not important' }).click() + await expect(page.getByText('make important')).toBeVisible() + }) + }) + }) +}) +``` + +Hay otra característica molesta en nuestras pruebas. Las direcciones del frontend http://localhost:5173 y del backend http://localhost:3001 están hardcodeadas en las pruebas. De estas, la dirección del backend en realidad es inútil, porque se ha definido un proxy en la configuración de Vite del frontend, que redirige todas las solicitudes hechas por el frontend a la dirección http://localhost:5173/api hacia el backend: + +```js +export default defineConfig({ + server: { + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + } + }, + // ... +}) +``` + +Así que podemos reemplazar todas las direcciones en las pruebas de _http://localhost:3001/api/..._ a _http://localhost:5173/api/..._ + +Ahora podemos definir la _baseUrl_ para la aplicación en el archivo de configuración de las pruebas playwright.config.js: + +```js +module.exports = defineConfig({ + // ... + use: { + baseURL: 'http://localhost:5173', + }, + // ... +} +``` + +Todos los comandos en las pruebas que usan la URL de la aplicación, por ejemplo: + +```js +await page.goto('http://localhost:5173') +await page.post('http://localhost:5173/api/tests/reset') +``` + +se pueden transformar en: + +```js +await page.goto('/') +await page.post('/api/tests/reset') +``` + +El código actual para las pruebas está en [GitHub](https://github.com/fullstack-hy2020/notes-e2e/tree/part5-2), en la rama part5-2. + +### Revisión del cambio de importancia de la nota + +Echemos un vistazo a la prueba que hicimos anteriormente, que verifica que es posible cambiar la importancia de una nota. + +Cambiemos el bloque de inicialización de la prueba para que cree dos notas en lugar de una: + +```js +describe('when logged in', () => { + // ... + describe('and several notes exists', () => { + beforeEach(async ({ page }) => { + // highlight-start + await createNote(page, 'first note', true) + await createNote(page, 'second note', true) + // highlight-end + }) + + test('one of those can be made nonimportant', async ({ page }) => { + const otherNoteElement = await page.getByText('first note') + + await otherNoteElement + .getByRole('button', { name: 'make not important' }).click() + await expect(otherNoteElement.getByText('make important')).toBeVisible() + }) + }) +}) +``` + +La prueba primero busca el elemento correspondiente a la primera nota creada utilizando el método _page.getByText_ y lo almacena en una variable. Después de esto, busca dentro del elemento un botón con el texto _make not important_ y presiona al botón. Finalmente, la prueba verifica que el texto del botón haya cambiado a _make important_. + +La prueba también podría haberse escrito sin la variable auxiliar: + +```js +test('one of those can be made nonimportant', async ({ page }) => { + await page.getByText('first note') + .getByRole('button', { name: 'make not important' }).click() + + await expect(page.getByText('first note').getByText('make important')) + .toBeVisible() +}) +``` + +Cambiemos el componente _Note_ para que el texto de la nota se renderice dentro de un elemento _span_ + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' : 'make important' + + return ( +
  • + {note.content} // highlight-line + +
  • + ) +} +``` + +¡Las pruebas fallan! La razón del problema es que el comando _await page.getByText('second note')_ ahora devuelve un elemento _span_ que contiene solo texto, y el botón está fuera de él. + +Una forma de solucionar el problema es la siguiente: + +```js +test('one of those can be made nonimportant', async ({ page }) => { + const otherNoteText = await page.getByText('first note') // highlight-line + const otherNoteElement = await otherNoteText.locator('..') // highlight-line + + await otherNoteElement.getByRole('button', { name: 'make not important' }).click() + await expect(otherNoteElement.getByText('make important')).toBeVisible() +}) +``` + +La primera línea ahora busca el elemento _span_ que contiene el texto asociado con la primera nota creada. En la segunda línea, se utiliza la función _locator_ y se da _.._ como argumento, que obtiene el elemento padre del elemento. La función locator es muy flexible, y aprovechamos el hecho de que acepta [como argumento](https://playwright.dev/docs/locators#locate-by-css-or-xpath) no solo selectores CSS sino también selectores [XPath](https://developer.mozilla.org/es/docs/Web/XPath). Sería posible expresar lo mismo con CSS, pero en este caso XPath proporciona la manera más sencilla de encontrar el padre de un elemento. + +Por supuesto, la prueba también puede escribirse usando solo una variable auxiliar: + +```js +test('one of those can be made nonimportant', async ({ page }) => { + const secondNoteElement = await page.getByText('second note').locator('..') + await secondNoteElement.getByRole('button', { name: 'make not important' }).click() + await expect(secondNoteElement.getByText('make important')).toBeVisible() +}) +``` + +Cambiemos la prueba para que tres notas sean creadas, la importancia se cambia en la segunda nota creada: + +```js +describe('when logged in', () => { + beforeEach(async ({ page }) => { + await loginWith(page, 'mluukkai', 'salainen') + }) + + test('a new note can be created', async ({ page }) => { + await createNote(page, 'a note created by playwright', true) + await expect(page.getByText('a note created by playwright')).toBeVisible() + }) + + describe('and a note exists', () => { + beforeEach(async ({ page }) => { + await createNote(page, 'first note', true) + await createNote(page, 'second note', true) + await createNote(page, 'third note', true) // highlight-line + }) + + test('importance can be changed', async ({ page }) => { + const otherNoteText = await page.getByText('second note') // highlight-line + const otherdNoteElement = await otherNoteText.locator('..') + + await otherdNoteElement.getByRole('button', { name: 'make not important' }).click() + await expect(otherdNoteElement.getByText('make important')).toBeVisible() + }) + }) +}) +``` + +Por alguna razón, la prueba comienza a funcionar de manera poco confiable, a veces pasa y a veces no. Si la prueba te esta dando problemas, es hora de arremangarse y aprender cómo depurar pruebas. + +### Ejecutando y depurando tus pruebas + +Si, y cuando las pruebas no pasan y sospechas que la falla está en las pruebas en lugar de en el código, deberías ejecutar las pruebas en modo [debug](https://playwright.dev/docs/debug#run-in-debug-mode-1). + +El siguiente comando ejecuta la prueba problemática en modo debug: + +``` +npm test -- -g'importance can be changed' --debug +``` + +El inspector de Playwright muestra el progreso de las pruebas paso a paso. El botón de flecha-punto en la parte superior lleva las pruebas un paso más adelante. Los elementos encontrados por los localizadores y la interacción con el navegador se visualizan en el navegador: + +![inspector de Playwright destacando el elemento encontrado por el localizador seleccionado en la aplicación](../../images/5/play6a.png) + +Por defecto, el debug avanza a través de la prueba comando por comando. Si es una prueba compleja, puede ser bastante pesado avanzar hasta el punto de interés. Esto se puede evitar utilizando el comando _await page.pause()_: + +```js +describe('Note app', () => { + beforeEach(async ({ page, request }) => { + // ... + } + + describe('when logged in', () => { + beforeEach(async ({ page }) => { + // ... + }) + + describe('and several notes exists', () => { + beforeEach(async ({ page }) => { + await createNote(page, 'first note') + await createNote(page, 'second note') + await createNote(page, 'third note') + }) + + test('one of those can be made nonimportant', async ({ page }) => { + await page.pause() // highlight-line + const otherNoteText = await page.getByText('second note') + const otherdNoteElement = await otherNoteText.locator('..') + + await otherdNoteElement.getByRole('button', { name: 'make not important' }).click() + await expect(otherdNoteElement.getByText('make important')).toBeVisible() + }) + }) + }) +}) +``` + +Ahora en la prueba puedes ir a _page.pause()_ en un paso, presionando el símbolo de flecha verde en el inspector. + +Cuando ahora ejecutamos la prueba y saltamos al comando _page.pause()_, descubrimos un hecho interesante: + +![inspector de Playwright mostrando el estado de la aplicación en page.pause](../../images/5/play6b.png) + +Parece que el navegador no renderiza todas las notas creadas en el bloque _beforeEach_. ¿Cuál es el problema? + +La razón del problema es que cuando la prueba crea una nota, comienza a crear la siguiente incluso antes de que el servidor haya respondido, y la nota agregada se renderiza en la pantalla. Esto a su vez puede causar que algunas notas se pierdan (en la imagen, esto ocurrió con la segunda nota creada), ya que el navegador se vuelve a renderizar cuando el servidor responde, basado en el estado de las notas al inicio de esa operación de inserción. + +El problema se puede resolver "ralentizando" las operaciones de inserción usando el comando [waitFor](https://playwright.dev/docs/api/class-locator#locator-wait-for) después de la inserción para esperar a que la nota insertada se renderice: + +```js +const createNote = async (page, content) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill(content) + await page.getByRole('button', { name: 'save' }).click() + await page.getByText(content).waitFor() // highlight-line +} +``` + +En lugar de, o junto con el modo de depuración, ejecutar pruebas en modo UI puede ser útil. Como ya se mencionó, las pruebas se inician en modo UI de la siguiente manera: + +``` +npm run test -- --ui +``` + +Casi lo mismo que el modo UI es el uso del [Trace Viewer](https://playwright.dev/docs/trace-viewer-intro) de Playwright. La idea es que se guarde un "rastro visual" de las pruebas, que se puede visualizar si es necesario después de que las pruebas se hayan completado. Un rastro se guarda ejecutando las pruebas de la siguiente manera: + +``` +npm run test -- --trace on +``` + +Si es necesario, Trace puede verse con el comando + +``` +npx playwright show report +``` + +o con el script npm que definimos _npm run test:report_ + +Trace se ve prácticamente igual que ejecutar pruebas en modo UI. + +El modo UI y Trace Viewer también ofrecen la posibilidad de búsqueda asistida de locators. Esto se hace presionando el doble círculo en el lado izquierdo de la barra inferior, y luego haciendo clic en el elemento de la interfaz de usuario deseado. Playwright muestra el locator del elemento: + +![trace viewer de Playwright con flechas rojas apuntando al lugar de búsqueda asistida del locator y al elemento seleccionado con él mostrando un locator sugerido para el elemento](../../images/5/play8.png) + +Playwright sugiere lo siguiente como el locator para la tercera nota + +```js +page.locator('li').filter({ hasText: 'third note' }).getByRole('button') +``` + +El método [page.locator](https://playwright.dev/docs/api/class-page#page-locator) se llama con el argumento _li_, es decir, buscamos todos los elementos li en la página, de los cuales hay un total de tres. Después de esto, utilizando el método [locator.filter](https://playwright.dev/docs/api/class-locator#locator-filter), nos centramos en el elemento li que contiene el texto third note y el elemento del botón dentro de él se toma usando el método [locator.getByRole](https://playwright.dev/docs/api/class-locator#locator-get-by-role). + +El localizador generado por Playwright es algo diferente del localizador utilizado por nuestras pruebas, que era + +```js +page.getByText('first note').locator('..').getByRole('button', { name: 'make not important' }) +``` + +Cuál de los localizadores es mejor probablemente es cuestión de gustos. + +Playwright también incluye un [generador de pruebas](https://playwright.dev/docs/codegen-intro) que hace posible "grabar" una prueba a través de la interfaz de usuario. El generador de pruebas se inicia con el comando: + +``` +npx playwright codegen http://localhost:5173/ +``` + +Cuando el modo _Record_ está activado, el generador de pruebas "registra" la interacción del usuario en el inspector de Playwright, desde donde es posible copiar los localizadores y acciones a las pruebas: + +![modo record de Playwright activado con su registro en el inspector después de la interacción del usuario](../../images/5/play9.png) + +En lugar de la línea de comandos, Playwright también se puede utilizar a través del plugin de [VS Code](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright). El plugin ofrece muchas características convenientes, por ejemplo, el uso de breakpoints al depurar pruebas. + +Para evitar situaciones problemáticas y aumentar la comprensión, definitivamente vale la pena explorar la [documentación](https://playwright.dev/docs/intro) de alta calidad de Playwright. Las secciones más importantes se enumeran a continuación: +- la sección sobre [locators](https://playwright.dev/docs/locators) ofrece buenos consejos para encontrar elementos en las pruebas +- la sección [actions](https://playwright.dev/docs/input) explica cómo es posible simular la interacción con el navegador en las pruebas +- la sección sobre [assertions](https://playwright.dev/docs/test-assertions) demuestra las diferentes aserciones que Playwright ofrece para las pruebas + +Puedes encontrar más detalles en la descripción de la [API](https://playwright.dev/docs/api/class-playwright), siendo particularmente útiles la clase [Page](https://playwright.dev/docs/api/class-page) que corresponde a la ventana del navegador de la aplicación bajo prueba, y la clase [Locator](https://playwright.dev/docs/api/class-locator) que corresponde a los elementos buscados en las pruebas. + +La versión final de las pruebas está completa en [GitHub](https://github.com/fullstack-hy2020/notes-e2e/tree/part5-3), en la rama part5-3. + +La versión final del código del frontend está en su totalidad en [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-9), en la rama part5-9. + +
    + +
    + +### Ejercicios 5.17.-5.23. + +En los últimos ejercicios de esta parte, hagamos algunas pruebas E2E para la aplicación de blog. El material anterior debería ser suficiente para hacer la mayoría de los ejercicios. Sin embargo, definitivamente deberías leer la [documentación](https://playwright.dev/docs/intro) de Playwright y la [descripción de la API](https://playwright.dev/docs/api/class-playwright), al menos las secciones mencionadas al final del capítulo anterior. + +#### 5.17: Pruebas de Extremo a Extremo de la Lista de Blogs, paso 1 + +Crea un nuevo proyecto npm para pruebas y configura Playwright allí. + +Haz una prueba para asegurarte de que la aplicación muestra el formulario de inicio de sesión por defecto. + +El cuerpo de la prueba debería ser el siguiente: + +```js +const { test, expect, beforeEach, describe } = require('@playwright/test') + +describe('Blog app', () => { + beforeEach(async ({ page }) => { + await page.goto('http://localhost:5173') + }) + + test('Login form is shown', async ({ page }) => { + // ... + }) +}) + +``` + +#### 5.18: Pruebas de Extremo a Extremo de la Lista de Blogs, paso 2 + +Realiza pruebas para iniciar sesión. Prueba tanto los intentos de inicio de sesión exitosos y los no exitosos. Crea un nuevo usuario en el bloque _beforeEach_ para las pruebas. + +El cuerpo de las pruebas se extiende de la siguiente manera + +```js +const { test, expect, beforeEach, describe } = require('@playwright/test') + +describe('Blog app', () => { + beforeEach(async ({ page, request }) => { + // vacía la base de datos aquí + // crea un usuario para el backend aquí + // ... + }) + + test('Login form is shown', async ({ page }) => { + // ... + }) + + describe('Login', () => { + test('succeeds with correct credentials', async ({ page }) => { + // ... + }) + + test('fails with wrong credentials', async ({ page }) => { + // ... + }) + }) +}) +``` + +El bloque beforeEach debe vaciar la base de datos utilizando, por ejemplo, el método de formateo que usamos en el [material](/es/part5/pruebas_de_extremo_a_extremo_playwright#controlando-el-estado-de-la-base-de-datos). + +#### 5.19: Pruebas de Extremo a Extremo de la Lista de Blogs, paso 3 + +Crea una prueba que compruebe que un usuario que ha iniciado sesión puede crear un nuevo blog. El cuerpo de la prueba podría ser el siguiente + +```js +describe('When logged in', () => { + beforeEach(async ({ page }) => { + // ... + }) + + test('a new blog can be created', async ({ page }) => { + // ... + }) +}) +``` + +La prueba debe garantizar que un nuevo blog es visible en la lista de todos los blogs. + +#### 5.20: Pruebas de Extremo a Extremo de la Lista de Blogs, paso 4 + +Haz una prueba que compruebe que el blog puede editarse. + +#### 5.21: Pruebas de Extremo a Extremo de la Lista de Blogs, paso 5 + +Realiza una prueba para asegurarte de que el usuario que creó un blog pueda eliminarlo. Si utilizas el dialogo _window.confirm_ en la operación de eliminación, quizás tengas que googlear como usar el dialogo en las pruebas de Playwright + +#### 5.22: Pruebas de Extremo a Extremo de la Lista de Blogs, paso 6 + +Realiza una prueba para asegurarte de que solo el creador puede ver el botón delete de un blog, nadie más. + +#### 5.23: Pruebas de Extremo a Extremo de la Lista de Blogs, paso 7 + +Realiza una prueba que verifique que los blogs estén ordenados de acuerdo con los likes, el blog con más likes en primer lugar. + +Este ejercicio puede ser un poco más complicado que los anteriores. + +Este fue el último ejercicio de esta parte, y es hora de enviar tu código a GitHub y marcar los ejercicios que has completado en el [sistema de envío de ejercicios](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    diff --git a/src/content/5/es/part5e.md b/src/content/5/es/part5e.md new file mode 100644 index 00000000000..77eb9e1de6a --- /dev/null +++ b/src/content/5/es/part5e.md @@ -0,0 +1,1188 @@ +--- +mainImage: ../../../images/part-5.svg +part: 5 +letter: e +lang: es +--- + +
    + +[Cypress](https://www.cypress.io/) ha sido la librería de pruebas E2E más popular durante los últimos años, pero Playwright está ganando terreno rápidamente. Este curso ha estado usando Cypress durante años. Ahora, Playwright es una nueva adición. Puedes elegir si completar la parte de pruebas E2E del curso con Cypress o Playwright. Los principios operativos de ambas librerías son muy similares, así que tu elección no es muy importante. Sin embargo, ahora Playwright es la librería E2E preferida para el curso. + +Si tu elección es Cypress, por favor continúa. Si terminas usando Playwright, ve [aquí](/es/part5/pruebas_de_extremo_a_extremo_playwright). + +### Cypress + +La librería E2E [Cypress](https://www.cypress.io/) se ha vuelto popular durante los últimos años. Cypress es excepcionalmente fácil de usar y, en comparación con Selenium, por ejemplo, requiere mucha menos molestia y dolor de cabeza. Su principio operativo es radicalmente diferente al de la mayoría de las librerías de prueba E2E, porque las pruebas Cypress se ejecutan completamente dentro del navegador. Otras librerías ejecutan las pruebas en un proceso de Node, que está conectado al navegador a través de una API. + +Hagamos algunas pruebas de extremo a extremo para nuestra aplicación de notas. + +A diferencia de las pruebas de backend o las pruebas unitarias realizadas en el front-end de React, las pruebas de extremo a extremo no necesitan estar ubicadas en el mismo proyecto npm donde está el código. Hagamos un proyecto completamente separado para las pruebas E2E con el comando _npm init_. Luego instala Cypress en el nuevo proyecto como una dependencia de desarrollo. + +```js +npm install --save-dev cypress +``` + +y agregando un script npm para ejecutarlo: + +```js +{ + // ... + "scripts": { + "cypress:open": "cypress open" // highlight-line + }, + // ... +} +``` + +También le hacemos un pequeño cambio al script que inicia la aplicación, sin este cambio Cypress no puede acceder a ella. + +A diferencia de las pruebas unitarias del frontend, las pruebas de Cypress pueden estar en el repositorio frontend o backend, o incluso en su propio repositorio separado. + +Las pruebas requieren que el sistema bajo prueba esté funcionando. A diferencia de nuestras pruebas de integración de backend, las pruebas de Cypress no inician el sistema cuando se ejecutan. + +Agreguemos un script npm al backend que lo inicia en modo de prueba, o para que NODE\\_ENV sea test. + +```js +{ + // ... + "scripts": { + "start": "NODE_ENV=production node index.js", + "dev": "NODE_ENV=development nodemon index.js", + "build:ui": "rm -rf build && cd ../frontend/ && npm run build && cp -r build ../backend", + "deploy": "fly deploy", + "deploy:full": "npm run build:ui && npm run deploy", + "logs:prod": "fly logs", + "lint": "eslint .", + "test": "jest --verbose --runInBand", + "start:test": "NODE_ENV=test node index.js" // highlight-line + }, + // ... +} +``` + +**NB** Para conseguir que Cypress funcione con WSL2 se debe realizar una configuración preliminar. Estos dos [enlaces](https://docs.cypress.io/guides/references/advanced-installation#Windows-Subsystem-for-Linux) son buenos lugares para [iniciar](https://nickymeuleman.netlify.app/blog/gui-on-wsl2-cypress). + +Cuando tanto el backend como el frontend están ejecutándose, podemos iniciar Cypress con el comando + +```js +npm run cypress:open +``` + +Cypress nos pregunta qué tipo de prueba realizaremos. Debemos elegir "E2E Testing": + +![flecha apuntando a opción e2e en menú de cypress](../../images/5/51new.png) + +A continuación debemos elegir un navegador (por ejemplo Chrome) y luego debemos hacer click en "Create new spec": + +![flecha apuntando a crear nuevo spec en menú de cypress](../../images/5/52new.png) + +Creemos el archivo de prueba cypress/e2e/note\_app.cy.js: + +![ubicación de archivo de prueba de cypress en cypress/e2e/note_app.cy.js](../../images/5/53new.png) + +Podemos editar la prueba en Cypress, pero usemos en cambio VS Code: + +![vscode mostrando cambios en la prueba y cypress mostrando que la prueba fue agregada](../../images/5/54new.png) + +Ahora podemos cerrar la vista de edición de Cypress. + +Cambiemos el contenido de la prueba como se muestra a continuación: + +```js +describe('Note app', function() { + it('front page can be opened', function() { + cy.visit('http://localhost:5173') + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2023') + }) +}) +``` + +La prueba se ejecuta haciendo clic en ella en Cypress: + +Ejecutar la prueba muestra cómo se comporta la aplicación mientras esta se ejecuta: + +![cypress mostrando la automatización de la prueba de notas](../../images/5/56new.png) + +La estructura de la prueba debería resultar familiar. Utilizan bloques describe para agrupar diferentes casos de prueba, al igual que Jest. Los casos de prueba se han definido con el método it. Cypress tomó estas partes de la librería de pruebas [Mocha](https://mochajs.org/) a la que utiliza bajo el capó. + +[cy.visit](https://docs.cypress.io/api/commands/visit.html) y [cy.contains](https://docs.cypress.io/api/commands/contains.html) son comandos de Cypress, y su propósito es bastante obvio. +[cy.visit](https://docs.cypress.io/api/commands/visit.html) abre la dirección web dada como parámetro en el navegador utilizado por la prueba. [cy.contains](https://docs.cypress.io/api/commands/contains.html) busca la cadena que recibió como parámetro en la página. + +Podríamos haber declarado la prueba usando una función de flecha + +```js +describe('Note app', () => { // highlight-line + it('front page can be opened', () => { // highlight-line + cy.visit('http://localhost:5173') + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2023') + }) +}) +``` + +Sin embargo, Mocha [recomienda](https://mochajs.org/#arrow-functions) que no se utilicen funciones de flecha, porque podrían causar algunos problemas en ciertas situaciones. + +Si cy.contains no encuentra el texto que está buscando, la prueba no pasa. Por lo tanto, si extendemos nuestra prueba de la siguiente manera + +```js +describe('Note app', function() { + it('front page can be opened', function() { + cy.visit('http://localhost:5173') + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2023') + }) + +// highlight-start + it('front page contains random text', function() { + cy.visit('http://localhost:5173') + cy.contains('wtf is this app?') + }) +// highlight-end +}) +``` + +la prueba falla + +![cypress mostrando falla al esperar encontrar wtf pero no](../../images/5/57new.png) + +Eliminemos el código que falla de la prueba. + +La variable _cy_ que utilizan nuestras pruebas nos da un error Eslint molesto + +![captura de pantalla de vscode mostrando que cy no está definido](../../images/5/58new.png) + +Podemos deshacernos de él instalando [eslint-plugin-cypress](https://github.com/cypress-io/eslint-plugin-cypress) como una dependencia de desarrollo + +```js +npm install eslint-plugin-cypress --save-dev +``` + +y cambiando la configuración en .eslintrc.cjs de la siguiente manera: + +```js +module.exports = { + "env": { + browser: true, + es2020: true, + "jest/globals": true, + "cypress/globals": true // highlight-line + }, + "extends": [ + // ... + ], + "parserOptions": { + // ... + }, + "plugins": [ + "react", "jest", "cypress" // highlight-line + ], + "rules": { + // ... + } +} +``` + +### Escribiendo en un formulario + +Extendamos nuestras pruebas para que nuestra nueva prueba intente iniciar sesión en nuestra aplicación. +Suponemos que nuestro backend contiene un usuario con el nombre de usuario mluukkai y la contraseña salainen. + +La prueba comienza abriendo el formulario de inicio de sesión. + +```js +describe('Note app', function() { + // ... + + it('login form can be opened', function() { + cy.visit('http://localhost:5173') + cy.contains('log in').click() + }) +}) +``` + +La prueba primero busca el botón de inicio de sesión por su texto y hace clic en el botón con el comando [cy.click](https://docs.cypress.io/api/commands/click.html#Syntax). + +Nuestras dos pruebas comienzan de la misma manera, abriendo la página http://localhost:5173, por lo que deberíamos extraer el código compartido en un bloque beforeEach que se ejecuta antes de cada prueba: + +```js +describe('Note app', function() { + // highlight-start + beforeEach(function() { + cy.visit('http://localhost:5173') + }) + // highlight-end + + it('front page can be opened', function() { + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2023') + }) + + it('login form can be opened', function() { + cy.contains('log in').click() + }) +}) +``` + +El campo de inicio de sesión contiene dos campos de input, en los que la prueba debe escribir. + +El comando [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax) permite buscar elementos mediante selectores CSS. + +Podemos acceda al primer y último campo de input de la página, y escribir en ellos con el comando [cy.type](https://docs.cypress.io/api/commands/type.html#Syntax) así: + +```js +it('user can login', function () { + cy.contains('log in').click() + cy.get('input:first').type('mluukkai') + cy.get('input:last').type('salainen') +}) +``` + +La prueba funciona. El problema es que si luego agregamos más campos de input, la prueba se interrumpirá porque espera que los campos que necesita sean el primero y el último en la página. + +Sería mejor dar a nuestros inputs IDs únicos y usarlos para encontrarlos. +Cambiamos nuestro formulario de inicio de sesión de la siguiente manera: + +```js +const LoginForm = ({ ... }) => { + return ( +
    +

    Login

    +
    +
    + username + +
    +
    + password + +
    + +
    +
    + ) +} +``` + +También agregamos una ID a nuestro botón submit para que podamos acceder a él en nuestras pruebas. + +La prueba se convierte en: + +```js +describe('Note app', function() { + // .. + it('user can log in', function() { + cy.contains('log in').click() + cy.get('#username').type('mluukkai') // highlight-line + cy.get('#password').type('salainen') // highlight-line + cy.get('#login-button').click() // highlight-line + + cy.contains('Matti Luukkainen logged in') // highlight-line + }) +}) +``` + +La última línea asegura que el inicio de sesión fue exitoso. + +Ten en cuenta que el [selector de ID](https://developer.mozilla.org/es/docs/Web/CSS/ID_selectors) de CSS es #, así que si queremos buscar un elemento con el ID username el selector de CSS es #username. + +Por favor, ten en cuenta que para que la prueba pase en esta etapa, es necesario que haya un usuario en la base de datos de pruebas del entorno de test del backend, cuyo nombre de usuario sea mluukkai y la contraseña sea salainen. ¡Crea un usuario si es necesario! + +### Probando el formulario para agregar notas + +A continuación, agreguemos pruebas para probar la funcionalidad "new note": + +```js +describe('Note app', function() { + // .. + // highlight-start + describe('when logged in', function() { + beforeEach(function() { + cy.contains('log in').click() + cy.get('input:first').type('mluukkai') + cy.get('input:last').type('salainen') + cy.get('#login-button').click() + }) + // highlight-end + + // highlight-start + it('a new note can be created', function() { + cy.contains('new note').click() + cy.get('input').type('a note created by cypress') + cy.contains('save').click() + + cy.contains('a note created by cypress') + }) + }) + // highlight-end +}) +``` + +La prueba se ha definido en su propio bloque describe. +Solo los usuarios registrados pueden crear nuevas notas, por lo que agregamos el inicio de sesión en la aplicación en un bloque beforeEach. + +La prueba confía en que al crear una nueva nota, la página contiene solo una entrada, por lo que la busca así: + +```js +cy.get('input') +``` + +Si la página tuviera más inputs, la prueba se rompería + +![error de cypress: cy.type solo puede ser llamado en un elemento individual](../../images/5/31x.png) + +Debido a esto, nuevamente sería mejor darle al input un ID y buscar el elemento por su ID. + +La estructura de las pruebas se ve así: + +```js +describe('Note app', function() { + // ... + + it('user can log in', function() { + cy.contains('log in').click() + cy.get('#username').type('mluukkai') + cy.get('#password').type('salainen') + cy.get('#login-button').click() + + cy.contains('Matti Luukkainen logged in') + }) + + describe('when logged in', function() { + beforeEach(function() { + cy.contains('log in').click() + cy.get('input:first').type('mluukkai') + cy.get('input:last').type('salainen') + cy.get('#login-button').click() + }) + + it('a new note can be created', function() { + // ... + }) + }) +}) +``` + +Cypress ejecuta las pruebas en el orden en que están en el código. Entonces, primero ejecuta user can log in, donde el usuario inicia sesión. Entonces cypress ejecutará a new note can be created para la cual el bloque beforeEach también inicia sesión. +¿Por qué hacer esto? ¿No inició sesión el usuario después de la primera prueba? +No, porque cada prueba comienza desde cero en lo que respecta al navegador. +Todos los cambios en el estado del navegador se invierten después de cada prueba. + +### Controlando el estado de la base de datos + +Si las pruebas necesitan poder modificar la base de datos del servidor, la situación inmediatamente se vuelve más complicada. Idealmente, la base de datos del servidor debería ser la misma cada vez que ejecutamos las pruebas, para que nuestras pruebas se puedan repetir de forma fiable y sencilla. + +Al igual que con las pruebas unitarias y de integración, con las pruebas E2E es mejor vaciar la base de datos y posiblemente formatearla antes de ejecutar las pruebas. El desafío con las pruebas E2E es que no tienen acceso a la base de datos. + +La solución es crear endpoints de API en el backend para la prueba. +Podemos vaciar la base de datos usando estos endpoints. +Creemos un nuevo enrutador para las pruebas dentro de la carpeta controllers, en el archivo testing.js + +```js +const testingRouter = require('express').Router() +const Note = require('../models/note') +const User = require('../models/user') + +testingRouter.post('/reset', async (request, response) => { + await Note.deleteMany({}) + await User.deleteMany({}) + + response.status(204).end() +}) + +module.exports = testingRouter +``` + +y agrégalo al backend solo si la aplicación se ejecuta en modo de prueba: + +```js +// ... + +app.use('/api/login', loginRouter) +app.use('/api/users', usersRouter) +app.use('/api/notes', notesRouter) + +// highlight-start +if (process.env.NODE_ENV === 'test') { + const testingRouter = require('./controllers/testing') + app.use('/api/testing', testingRouter) +} +// highlight-end + +app.use(middleware.unknownEndpoint) +app.use(middleware.errorHandler) + +module.exports = app +``` + +Después de los cambios, una solicitud POST HTTP al endpoint /api/testing/reset vacía la base de datos. Asegúrate de que tu backend esté ejecutándose en modo de prueba iniciándolo con este comando (previamente configurado en el archivo package.json): + +```js + npm run start:test +``` + +El código de backend modificado se puede encontrar en [GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part5-1), rama part5-1. + +A continuación, cambiaremos el bloque beforeEach para que vacíe la base de datos del servidor antes de ejecutar las pruebas. + +Actualmente no es posible agregar nuevos usuarios a través de la interfaz de usuario del frontend, por lo que agregamos un nuevo usuario al backend desde el bloque beforeEach. + +```js +describe('Note app', function() { + beforeEach(function() { + // highlight-start + cy.request('POST', 'http://localhost:3001/api/testing/reset') + const user = { + name: 'Matti Luukkainen', + username: 'mluukkai', + password: 'salainen' + } + cy.request('POST', 'http://localhost:3001/api/users/', user) + // highlight-end + cy.visit('http://localhost:5173') + }) + + it('front page can be opened', function() { + // ... + }) + + it('user can login', function() { + // ... + }) + + describe('when logged in', function() { + // ... + }) +}) +``` + +Durante el formateo, la prueba realiza solicitudes HTTP al backend con [cy.request](https://docs.cypress.io/api/commands/request.html). + +A diferencia de antes, ahora la prueba comienza con el backend en el mismo estado cada vez. El backend contendrá un usuario y ninguna nota. + +Agreguemos una prueba más para verificar que podemos cambiar la importancia de notas. + +Anteriormente cambiamos el frontend para que una nueva nota se importante por defecto, por lo que el campo important es true: + +```js +const NoteForm = ({ createNote }) => { + // ... + + const addNote = (event) => { + event.preventDefault() + createNote({ + content: newNote, + important: true // highlight-line + }) + + setNewNote('') + } + // ... +} +``` + +Hay varias formas de probar esto. En el siguiente ejemplo, primero buscamos una nota y hacemos clic en su botón make not important. Luego verificamos que la nota ahora contenga un botón make important. + +```js +describe('Note app', function() { + // ... + + describe('when logged in', function() { + // ... + + describe('and a note exists', function () { + beforeEach(function () { + cy.contains('new note').click() + cy.get('input').type('another note cypress') + cy.contains('save').click() + }) + + it('it can be made not important', function () { + cy.contains('another note cypress') + .contains('make not important') + .click() + + cy.contains('another note cypress') + .contains('make important') + }) + }) + }) +}) +``` + +El primer comando busca un componente que contenga el texto another note cypress, y luego busca un botón make not important dentro de él. Luego hace clic en el botón. + +El segundo comando comprueba que el texto del botón haya cambiado a make important. + +### Prueba de inicio de sesión fallida + +Hagamos una prueba para asegurarnos de que un intento de inicio de sesión falla si la contraseña es incorrecta. + +Cypress ejecutará todas las pruebas cada vez de forma predeterminada y, a medida que aumenta el número de pruebas, comienza a consumir bastante tiempo. +Al desarrollar una nueva prueba o al depurar una prueba rota, podemos definir la prueba con it.only en lugar de it, de modo que Cypress solo ejecutará la prueba requerida. +Cuando la prueba esté funcionando, podemos eliminar .only. + +La primera versión de nuestras pruebas es la siguiente: + +```js +describe('Note app', function() { + // ... + + it.only('login fails with wrong password', function() { + cy.contains('log in').click() + cy.get('#username').type('mluukkai') + cy.get('#password').type('wrong') + cy.get('#login-button').click() + + cy.contains('wrong credentials') + }) + + // ... +)} +``` + +La prueba utiliza [cy.contains](https://docs.cypress.io/api/commands/contains.html#Syntax) para garantizar que la aplicación imprima un mensaje de error. + +La aplicación muestra el mensaje de error en un componente con la clase CSS error: + +```js +const Notification = ({ message }) => { + if (message === null) { + return null + } + + return ( +
    // highlight-line + {message} +
    + ) +} +``` + +Podríamos hacer que la prueba asegure que el mensaje de error se renderiza al componente correcto, es decir, al componente con la clase CSS error: + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error').contains('wrong credentials') // highlight-line +}) +``` + +Primero usamos [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax) para buscar un componente con la clase CSS error. Luego verificamos que el mensaje de error se pueda encontrar en este componente. +Ten en cuenta que los [selectores de clase CSS](https://developer.mozilla.org/es/docs/Web/CSS/Class_selectors) comienzan con un punto final, por lo que el selector para la clase error es .error. + +Podríamos hacer lo mismo usando la sintaxis [should](https://docs.cypress.io/api/commands/should.html): + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error').should('contain', 'wrong credentials') // highlight-line +}) +``` + +Usar should es un poco más complicado que usar contains, pero permite pruebas más diversas que contains, que funciona solo con contenido de texto. + +La lista de las aserciones más comunes con las que se puede usar _should_ se puede encontrar [aquí](https://docs.cypress.io/guides/references/assertions.html#Common-Assertions). + +Podemos, por ejemplo, asegurarnos de que el mensaje de error sea rojo y tenga un borde: + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error').should('contain', 'wrong credentials') + cy.get('.error').should('have.css', 'color', 'rgb(255, 0, 0)') + cy.get('.error').should('have.css', 'border-style', 'solid') +}) +``` + +Cypress requiere que los colores se den como [rgb](https://rgbcolorcode.com/color/red). + +Debido a que todas las pruebas son para el mismo componente al que accedimos usando [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax), podemos encadenarlos usando [and](https://docs.cypress.io/api/commands/and.html). + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error') + .should('contain', 'wrong credentials') + .and('have.css', 'color', 'rgb(255, 0, 0)') + .and('have.css', 'border-style', 'solid') +}) +``` + +Terminemos la prueba para que también verifique que la aplicación no muestre el mensaje de éxito 'Matti Luukkainen logged in': + +```js +it('login fails with wrong password', function() { + cy.contains('log in').click() + cy.get('#username').type('mluukkai') + cy.get('#password').type('wrong') + cy.get('#login-button').click() + + cy.get('.error') + .should('contain', 'wrong credentials') + .and('have.css', 'color', 'rgb(255, 0, 0)') + .and('have.css', 'border-style', 'solid') + + cy.get('html').should('not.contain', 'Matti Luukkainen logged in') // highlight-line +}) +``` + +El comando should se usa más frecuentemente encadenándolo después del comando get (o otro comando similar que pueda ser encadenado). El cy.get('html') usado en la prueba prácticamente significa el contenido visible de toda la aplicación. + +También podríamos verificar lo mismo encadenando el comando contains con el comando should con un parámetro ligeramente diferente: + +```js +cy.contains('Matti Luukkainen logged in').should('not.exist') +``` + +**NOTA:** Algunas propiedades CSS se comportan de manera diferente en Firefox. Si ejecutas las pruebas con Firefox: + + ![running](https://user-images.githubusercontent.com/4255997/119015927-0bdff800-b9a2-11eb-9234-bb46d72c0368.png) + + entonces las pruebas que involucran, por ejemplo, `border-style`, `border-radius` y `padding`, pasarán en Chrome o Electron, pero fallarán en Firefox: + + ![borderstyle](https://user-images.githubusercontent.com/4255997/119016340-7b55e780-b9a2-11eb-82e0-bab0418244c0.png) + +### Omitiendo la interfaz de usuario + +Actualmente tenemos las siguientes pruebas: + +```js +describe('Note app', function() { + it('user can login', function() { + cy.contains('log in').click() + cy.get('#username').type('mluukkai') + cy.get('#password').type('salainen') + cy.get('#login-button').click() + + cy.contains('Matti Luukkainen logged in') + }) + + it('login fails with wrong password', function() { + // ... + }) + + describe('when logged in', function() { + beforeEach(function() { + cy.contains('log in').click() + cy.get('input:first').type('mluukkai') + cy.get('input:last').type('salainen') + cy.get('#login-button').click() + }) + + it('a new note can be created', function() { + // ... + }) + + }) +}) +``` + +Primero probamos el inicio de sesión. Luego, en su propio bloque de descripción, tenemos un montón de pruebas que esperan que el usuario inicie sesión. El usuario ha iniciado sesión en el bloque beforeEach. + +Como dijimos anteriormente, ¡cada prueba comienza desde cero! Las pruebas no comienzan en el estado donde terminaron las pruebas anteriores. + +La documentación de Cypress nos da el siguiente consejo: [Prueba completamente el flujo de inicio de sesión, ¡pero solo una vez!](https://docs.cypress.io/guides/end-to-end-testing/testing-your-app#Fully-test-the-login-flow----but-only-once). +Por lo tanto, en lugar de iniciar sesión como usuario mediante el formulario en el bloque beforeEach, vamos a omitir la interfaz de usuario y realizaremos una solicitud HTTP al backend para iniciar sesión. La razón de esto es que iniciar sesión con una solicitud HTTP es mucho más rápido que completar un formulario. + +Nuestra situación es un poco más complicada que en el ejemplo de la documentación de Cypress, porque cuando un usuario inicia sesión, nuestra aplicación guarda sus detalles en localStorage. +Sin embargo, Cypress también puede manejar esto. +El código es el siguiente: + +```js +describe('when logged in', function() { + beforeEach(function() { + // highlight-start + cy.request('POST', 'http://localhost:3001/api/login', { + username: 'mluukkai', password: 'salainen' + }).then(response => { + localStorage.setItem('loggedNoteappUser', JSON.stringify(response.body)) + cy.visit('http://localhost:5173') + }) + // highlight-end + }) + + it('a new note can be created', function() { + // ... + }) + + // ... +}) +``` + +Podemos acceder a la respuesta de un [cy.request](https://docs.cypress.io/api/commands/request.html) con el método [_then_](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress#The-Cypress-Command-Queue). Debajo del capó, cy.request, al igual que todos los comandos de Cypress, son [asíncronos](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress#Commands-Are-Asynchronous). +La función de callback guarda los detalles de un usuario conectado en localStorage y recarga la página. +Ahora no hay diferencia con un usuario que inicia sesión a través del formulario de inicio de sesión. + +Si cuando escribimos nuevas pruebas en nuestra aplicación, tenemos que usar el código de inicio de sesión en varios lugares, deberíamos convertirlo en un [comando personalizado](https://docs.cypress.io/api/cypress-api/custom-commands.html). + +Los comandos personalizados se declaran en cypress/support/commands.js. +El código para iniciar sesión es el siguiente: + +```js +Cypress.Commands.add('login', ({ username, password }) => { + cy.request('POST', 'http://localhost:3001/api/login', { + username, password + }).then(({ body }) => { + localStorage.setItem('loggedNoteappUser', JSON.stringify(body)) + cy.visit('http://localhost:5173') + }) +}) +``` + +Usar nuestro comando personalizado es fácil y nuestra prueba se vuelve más limpia: + +```js +describe('when logged in', function() { + beforeEach(function() { + // highlight-start + cy.login({ username: 'mluukkai', password: 'salainen' }) + // highlight-end + }) + + it('a new note can be created', function() { + // ... + }) + + // ... +}) +``` + +Lo mismo se aplica a la creación de una nueva nota ahora que pensamos sobre ello. Tenemos una prueba que hace una nueva nota usando el formulario. También hacemos una nueva nota en el bloque beforeEach de la prueba que cambia la importancia de una nota: + +```js +describe('Note app', function() { + // ... + + describe('when logged in', function() { + it('a new note can be created', function() { + cy.contains('new note').click() + cy.get('input').type('a note created by cypress') + cy.contains('save').click() + + cy.contains('a note created by cypress') + }) + + describe('and a note exists', function () { + beforeEach(function () { + cy.contains('new note').click() + cy.get('input').type('another note cypress') + cy.contains('save').click() + }) + + it('it can be made important', function () { + // ... + }) + }) + }) +}) +``` + +Creemos un nuevo comando personalizado para crear una nueva nota. El comando creará una nueva nota con una solicitud HTTP POST: + +```js +Cypress.Commands.add('createNote', ({ content, important }) => { + cy.request({ + url: 'http://localhost:3001/api/notes', + method: 'POST', + body: { content, important }, + headers: { + 'Authorization': `Bearer ${JSON.parse(localStorage.getItem('loggedNoteappUser')).token}` + } + }) + + cy.visit('http://localhost:5173') +}) +``` + +El comando espera que el usuario haya iniciado sesión y que los detalles del usuario estén guardados en localStorage. + +Ahora el bloque beforeEach de la nota se convierte en: + +```js +describe('Note app', function() { + // ... + + describe('when logged in', function() { + it('a new note can be created', function() { + // ... + }) + + describe('and a note exists', function () { + beforeEach(function () { + // highlight-start + cy.createNote({ + content: 'another note cypress', + important: true + }) + // highlight-end + }) + + it('it can be made important', function () { + // ... + }) + }) + }) +}) +``` + +Hay otra cosa en nuestras pruebas que es molesta. La URL de nuestra aplicación http://localhost:5173 esta codificada literalmente en varios lugares. + +Definamos la URL de nuestra aplicación baseUrl en el [archivo de configuración](https://docs.cypress.io/guides/references/configuration) pre-generado de Cypress cypress.config.js: + +```js +const { defineConfig } = require("cypress") + +module.exports = defineConfig({ + e2e: { + setupNodeEvents(on, config) { + }, + baseUrl: 'http://localhost:5173' // highlight-line + }, +}) +``` + +Todos los comandos en las pruebas que usan la dirección de la aplicación + +```js +cy.visit('http://localhost:5173') +``` + +se pueden cambiar a + +```js +cy.visit('') +``` + +La dirección codificada del backend, http://localhost:3001, todavía está en las pruebas. La [documentación](https://docs.cypress.io/guides/guides/environment-variables) de Cypress recomienda definir otras direcciones utilizadas por las pruebas como variables de entorno. + +Expandamos el archivo de configuración cypress.config.js de la siguiente manera: + +```js +const { defineConfig } = require("cypress") + +module.exports = defineConfig({ + e2e: { + setupNodeEvents(on, config) { + }, + baseUrl: 'http://localhost:5173', + env: { + BACKEND: 'http://localhost:3001/api' // highlight-line + } + }, +}) +``` + +Reemplacemos todas las direcciones del backend en las pruebas de la siguiente manera: + +```js +describe('Note ', function() { + beforeEach(function() { + + cy.request('POST', `${Cypress.env('BACKEND')}/testing/reset`) // highlight-line + const user = { + name: 'Matti Luukkainen', + username: 'mluukkai', + password: 'secret' + } + cy.request('POST', `${Cypress.env('BACKEND')}/users`, user) // highlight-line + cy.visit('') + }) + // ... +}) +``` + +### Cambiando la importancia de una nota + +Por último, echemos un vistazo a la prueba que hicimos para cambiar la importancia de una nota. +Primero cambiaremos el bloque beforeEach para que cree tres notas en lugar de una: + +```js +describe('when logged in', function() { + describe('and several notes exist', function () { + beforeEach(function () { + // highlight-start + cy.login({ username: 'mluukkai', password: 'salainen' }) + cy.createNote({ content: 'first note', important: false }) + cy.createNote({ content: 'second note', important: false }) + cy.createNote({ content: 'third note', important: false }) + // highlight-end + }) + + it('one of those can be made important', function () { + cy.contains('second note') + .contains('make important') + .click() + + cy.contains('second note') + .contains('make not important') + }) + }) +}) +``` + +¿Cómo funciona realmente el comando [cy.contains](https://docs.cypress.io/api/commands/contains.html)? + +Cuando hacemos clic en el comando _cy.contains('second note')_ en Cypress [Test Runner](https://docs.cypress.io/guides/core-concepts/cypress-app#Test-Runner), vemos que ese comando busca el elemento que contiene el texto second note: + +![cypress test runner haciendo clic en la segunda nota](../../images/5/34new.png) + +Al hacer clic en la línea siguiente _.contains('make important')_ vemos que la prueba utiliza el botón 'make important' correspondiente a la segunda nota: + +![cypress test runner haciendo clic en make important](../../images/5/35new.png) + +Cuando está encadenado, el segundo comando contains continúa la búsqueda desde dentro del componente encontrado por el primer comando. + +Si no hubiéramos encadenado los comandos, y en su lugar hubiéramos escrito + +```js +cy.contains('second note') +cy.contains('make important').click() +``` + +el resultado habría sido totalmente diferente. La segunda línea de la prueba haría clic en el botón de una nota incorrecta: + +![cypress mostrando error e intentando hacer clic incorrectamente en el primer botón](../../images/5/36new.png) + +Al escribir pruebas, ¡debes verificar en el ejecutor de pruebas que las pruebas utilicen los componentes correctos! + +Cambiemos el componente _Note_ para que el texto de la nota se renderice en un span . + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' : 'make important' + + return ( +
  • + {note.content} // highlight-line + +
  • + ) +} +``` + +¡Nuestras pruebas se rompen! Como revela el test runner, _cy.contains('second note')_ ahora devuelve el componente que contiene el texto y el botón no está en él. + +![cypress mostrando que la prueba está rota intentando hacer clic en "make important"](../../images/5/37new.png) + +Una forma de solucionarlo es la siguiente: + +```js +it('one of those can be made important', function () { + cy.contains('second note').parent().find('button').click() + cy.contains('second note').parent().find('button') + .should('contain', 'make not important') +}) +``` + +En la primera línea, usamos el comando [parent](https://docs.cypress.io/api/commands/parent.html) para acceder al elemento padre del elemento que contiene second note y buscamos el botón dentro de él. +Luego hacemos clic en el botón y verificamos que el texto cambie. + +Ten en cuenta que usamos el comando [find](https://docs.cypress.io/api/commands/find.html#Syntax) para buscar el botón. No podemos usar [cy.get](https://docs.cypress.io/api/commands/get.html) aquí, porque siempre busca desde la página completa y devolvería los 5 botones en la pagina. + +Desafortunadamente, ahora tenemos algo de copia-pega en las pruebas, porque el código para buscar el botón correcto es siempre el mismo. + +En este tipo de situaciones, es posible usar el comando [as](https://docs.cypress.io/api/commands/as.html): + +```js +it('one of those can be made important', function () { + cy.contains('second note').parent().find('button').as('theButton') + cy.get('@theButton').click() + cy.get('@theButton').should('contain', 'make not important') +}) +``` + +Ahora la primera línea encuentra el botón correcto y usa as para guardarlo como theButton. Las siguientes líneas pueden usar el elemento nombrado con cy.get('@theButton'). + +### Ejecutando y depurando tus pruebas + +Finalmente, algunas notas sobre cómo funciona Cypress y la depuración de tus pruebas. + +Debido a la forma de las pruebas de Cypress, da la impresión de que son código JavaScript normal y, por ejemplo, podríamos intentar esto: + +```js +const button = cy.contains('log in') +button.click() +debugger +cy.contains('logout').click() +``` + +Sin embargo, esto no funcionará. Cuando Cypress ejecuta una prueba, agrega cada comando _cy_ a una cola de ejecución. +Cuando se haya ejecutado el código del método de prueba, Cypress ejecutará cada comando en la cola uno por uno. + +Los comandos de Cypress siempre devuelven _undefined_, por lo que _button.click()_ en el código anterior causaría un error. Un intento de iniciar el depurador no detendría el código entre la ejecución de los comandos, sino antes de que se haya ejecutado algún comando. + +Los comandos de Cypress son como promesas, así que si queremos acceder a sus valores de retorno, tenemos que hacerlo usando el comando [then](https://docs.cypress.io/api/commands/then.html). +Por ejemplo, la siguiente prueba imprime el número de botones en la aplicación y hace clic en el primer botón: + +```js +it('then example', function() { + cy.get('button').then( buttons => { + console.log('number of buttons', buttons.length) + cy.wrap(buttons[0]).click() + }) +}) +``` + +Detener la ejecución de la prueba con el depurador es [posible](https://docs.cypress.io/api/commands/debug.html). El depurador se inicia solo si la consola para desarrolladores del test runner de Cypress está abierta. + +La Consola para desarrolladores es muy útil para depurar tus pruebas. +Puedes ver las solicitudes HTTP realizadas por las pruebas en la pestaña Network, y la pestaña Console te mostrará información sobre tus pruebas: + +![consola para desarrolladores mientras se ejecuta Cypress](../../images/5/38new.png) + +Hasta ahora hemos ejecutado nuestras pruebas Cypress usando el test runner gráfico. También es posible ejecutarlas [desde la línea de comandos](https://docs.cypress.io/guides/guides/command-line.html). Solo tenemos que agregarle un script npm: + +```js + "scripts": { + "cypress:open": "cypress open", + "test:e2e": "cypress run" // highlight-line + }, +``` + +Ahora podemos ejecutar nuestras pruebas desde la línea de comandos con el comando npm run test:e2e + +![Salida de terminal al ejecutar las pruebas npm e2e mostrando aprobadas](../../images/5/39new.png) + +Ten en cuenta que los videos de la ejecución de las pruebas se guardarán en cypress/videos/, por lo que probablemente deberías ignorar este directorio en git. También es posible [desactivar](https://docs.cypress.io/guides/guides/screenshots-and-videos#Videos) la creación de videos. + +Las pruebas se encuentran en [GitHub](https://github.com/fullstack-hy2020/notes-e2e-cypress/). + +La versión final del código frontend se puede encontrar en la rama [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-9) *part5-9*. + +
    + +
    + +### Ejercicios 5.17.-5.23. + +En los últimos ejercicios de esta parte haremos algunas pruebas E2E para nuestra aplicación de blog. +El material de esta parte debería ser suficiente para completar los ejercicios. +También deberías consultar la [documentación](https://docs.cypress.io/guides/overview/why-cypress.html#In-a-nutshell) de Cypress. Probablemente sea la mejor documentación que he visto para un proyecto de código abierto. + +Recomiendo especialmente leer [Introducción a Cypress](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Cypress-Can-Be-Simple-Sometimes), que afirma que + +> Esta es la guía más importante para comprender cómo realizar pruebas con Cypress. Léela. Entiéndela. + +#### 5.17: Pruebas de End To End de la Lista de Blogs, paso 1 + +Configura Cypress para tu proyecto. Realiza una prueba para comprobar que la aplicación muestra el formulario de inicio de sesión de forma predeterminada. + +La estructura de la prueba debe ser la siguiente + +```js +describe('Blog app', function() { + beforeEach(function() { + cy.visit('http://localhost:5173') + }) + + it('Login form is shown', function() { + // ... + }) +}) +``` + +#### 5.18: Pruebas de End To End de la Lista de Blogs, paso 2 + +Realiza pruebas para iniciar sesión. Prueba tanto los intentos de inicio de sesión exitosos y los no exitosos. +Crea un nuevo usuario en el bloque beforeEach para las pruebas. + +El cuerpo de las pruebas se extiende de la siguiente manera + +```js +describe('Blog app', function() { + beforeEach(function() { + // vacía la base de datos aquí + // crea un usuario para el backend aquí + cy.visit('http://localhost:5173') + }) + + it('Login form is shown', function() { + // ... + }) + + describe('Login',function() { + it('succeeds with correct credentials', function() { + // ... + }) + + it('fails with wrong credentials', function() { + // ... + }) + }) +}) +``` + +El bloque beforeEach debe vaciar la base de datos utilizando, por ejemplo, el método de formateo que usamos en el [material](/es/part5/pruebas_de_extremo_a_extremo_cypress#controlando-el-estado-de-la-base-de-datos). + +Ejercicio adicional opcional: comprueba que la notificación que se muestra con el inicio de sesión fallido se muestra en rojo. + +#### 5.19: Pruebas de End To End de la Lista de Blogs, paso 3 + +Realiza una prueba que compruebe que un usuario que ha iniciado sesión puede crear un nuevo blog. +La estructura de la prueba podría ser la siguiente + +```js +describe('Blog app', function() { + // ... + + describe('When logged in', function() { + beforeEach(function() { + // ... + }) + + it('A blog can be created', function() { + // ... + }) + }) + +}) +``` + +La prueba debe garantizar que se agregue un nuevo blog a la lista de todos los blogs. + +#### 5.20: Pruebas de End To End de la Lista de Blogs, paso 4 + +Haz una prueba que compruebe que al usuario le puede gustar ("like") un blog. + +#### 5.21: Pruebas de End To End de la Lista de Blogs, paso 5 + +Realiza una prueba para asegurarte de que el usuario que creó un blog pueda eliminarlo. + +#### 5.22: Pruebas de End To End de la Lista de Blogs, paso 6 + +Realiza una prueba para asegurarte de que solo el creador puede ver el botón delete de un blog, nadie más. + +#### 5.23: Pruebas de End To End de la Lista de Blogs, paso 7 + +Realiza una prueba que verifique que los blogs estén ordenados de acuerdo con los likes, con el blog con más likes en primer lugar. + +Este ejercicio puede ser un poco más complicado que los anteriores. Una posible solución es agregar cierta clase para el elemento que cubre el contenido del blog y luego usar el método [eq](https://docs.cypress.io/api/commands/eq#Syntax) para obtener el elemento en un índice específico: + +```js +cy.get('.blog').eq(0).should('contain', 'The title with the most likes') +cy.get('.blog').eq(1).should('contain', 'The title with the second most likes') +``` + +Ten en cuenta que podrías terminar teniendo problemas si haces clic en el botón "Like" muchas veces seguidas. Puede ser que Cypress haga clic tan rápido que no tenga tiempo de actualizar el estado de la aplicación entre los clics. Una solución para esto es esperar a que se actualice la cantidad de Likes entre todos los clics. + +Este fue el último ejercicio de esta parte, y es hora de enviar tu código a GitHub y marcar los ejercicios que has completado en el [sistema de envío de ejercicios](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    diff --git a/src/content/5/fi/osa5.md b/src/content/5/fi/osa5.md index f6c55876d56..d08c158cba7 100644 --- a/src/content/5/fi/osa5.md +++ b/src/content/5/fi/osa5.md @@ -8,4 +8,12 @@ lang: fi Tässä osassa palataan frontendin pariin, ensin tarkastellaan erilaisia tarjolla olevia mahdollisuuksia React-sovelluksen testaamiseen. Osassa myös toteutetaan frontendiin tokeneihin perustuva autentikaatio, joka mahdollistaa käyttäjien kirjautumisen sovellukseen. +Osa päivitetty 21.8.2025 + +- React-versio päivitetty v18 -> v19. Proptypes ja forwardRef poistuneet käytöstä +- Kirjautumislomakkeen kentille on lisätty label-elementti ja käytetty sitä myöhemmin testeissä kenttien identifiointiin +- .eslintrc.cjs korvattu eslint.config.js-tiedostolla +- .eslintignore korvattu eslint.config.js-määrittelyllä + +
    diff --git a/src/content/5/fi/osa5a.md b/src/content/5/fi/osa5a.md index 3a1962ecde5..a95f42ba51e 100644 --- a/src/content/5/fi/osa5a.md +++ b/src/content/5/fi/osa5a.md @@ -7,15 +7,17 @@ lang: fi
    -Kaksi edellistä osaa keskittyivät lähinnä backendin toiminnallisuuteen. Edellisessä osassa backendiin toteutettua käyttäjänhallintaa ei ole tällä hetkellä tuettuna frontendissa millään tavalla. +Kaksi edellistä osaa keskittyivät lähinnä backendin toiminnallisuuteen. Edellisessä osassa backendiin toteutettua käyttäjänhallintaa ei ole tällä hetkellä tuettuna [osassa 2](/osa2)  kehitetyssä frontendissa millään tavalla. Frontend näyttää tällä hetkellä olemassaolevat muistiinpanot ja antaa muuttaa niiden tilaa. Uusia muistiinpanoja ei kuitenkaan voi lisätä, sillä osan 4 muutosten myötä backend edellyttää, että lisäyksen mukana on käyttäjän identiteetin varmistava token. Toteutetaan nyt osa käyttäjienhallinnan edellyttämästä toiminnallisuudesta frontendiin. Aloitetaan käyttäjän kirjautumisesta. Oletetaan vielä tässä osassa, että käyttäjät luodaan suoraan backendiin. -Sovelluksen yläosaan on nyt lisätty kirjautumislomake, myös uuden muistiinpanon lisäämisestä huolehtiva lomake on siirretty muistiinpanojen yläpuolelle: +### Kirjautumislomakkeen lisääminen -![](../../images/5/1e.png) +Sovelluksen yläosaan on nyt lisätty kirjautumislomake: + +![Sovellus koostuu syötekentät username ja password koostuvasta kirjautumislomakkeesta, muistiinpanojen listasta, sekä lomakkeesta joka mahdollistaa uuden muistiinpanon luomisen (ainoastaan yksi syötekenttä muistiinpanon sisällölle). Jokaisen listalla olevan muistiinpanon kohdalla on nappi, jonka avulla muistiinpano voidaan merkata tärkeäksi/epätärkeäksi](../../images/5/1new.png) Komponentin App koodi näyttää seuraavalta: @@ -49,30 +51,30 @@ const App = () => { return (

    Notes

    - - -

    Login

    - + // highlight-start +

    Login

    - username +
    - password +
    @@ -84,12 +86,11 @@ const App = () => { } export default App - ``` -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part2-notes/tree/part5-1), branchissa part5-1. +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-1), branchissa part5-1. -Kirjautumislomakkeen käsittely noudattaa samaa periaatetta kuin [osassa 2](/osa2#lomakkeet). Lomakkeen kenttiä varten on lisätty komponentin tilaan username ja password. Molemmille kentille on määritelty muutoksenkäsittelijä, joka synkronoi kenttään tehdyt muutokset komponentin App tilaan. Muutoksenkäsittelijä on yksinkertainen, se destrukturoi parametrina tulevasta oliosta kentän target ja asettaa sen arvon vastaavaan tilaan: +Kirjautumislomakkeen käsittely noudattaa samaa periaatetta kuin [osassa 2](/osa2#lomakkeet). Lomakkeen kenttiä varten on lisätty komponentin tilaan username ja password. Molemmille kentille on määritelty muutoksenkäsittelijä, joka synkronoi kenttään tehdyt muutokset komponentin App tilaan. Muutoksenkäsittelijä on yksinkertainen, se destrukturoi parametrina tulevasta oliosta kentän target ja asettaa sen arvon vastaavaan tilaan: ```js ({ target }) => setUsername(target.value) @@ -97,9 +98,11 @@ Kirjautumislomakkeen käsittely noudattaa samaa periaatetta kuin [osassa 2](/osa Kirjautumislomakkeen lähettämisestä vastaava metodi _handleLogin_ ei tee vielä mitään. -Kirjautuminen tapahtuu tekemällä HTTP POST -pyyntö palvelimen osoitteeseen api/login. Eristetään pyynnön tekevä koodi omaan moduuliin, tiedostoon services/login.js. +### Logiikan lisääminen kirjautumislomakkeelle + +Kirjautuminen tapahtuu tekemällä HTTP POST ‑pyyntö palvelimen osoitteeseen api/login. Eristetään pyynnön tekevä koodi omaan moduuliinsa, tiedostoon services/login.js. -Käytetään nyt promisejen sijaan async/await-syntaksia HTTP-pyynnön tekemiseen: +Käytetään HTTP-pyynnön tekemiseen nyt promisejen sijaan async/await-syntaksia: ```js import axios from 'axios' @@ -116,7 +119,7 @@ export default { login } Kirjautumisen käsittelystä huolehtiva metodi voidaan toteuttaa seuraavasti: ```js -import loginService from './services/login' +import loginService from './services/login' // highlight-line const App = () => { // ... @@ -126,22 +129,24 @@ const App = () => { const [user, setUser] = useState(null) // highlight-end - const handleLogin = async (event) => { + // ... + + const handleLogin = async event => { // highlight-line event.preventDefault() + + // highlight-start try { - const user = await loginService.login({ - username, password, - }) - + const user = await loginService.login({ username, password }) setUser(user) setUsername('') setPassword('') - } catch (exception) { + } catch { setErrorMessage('wrong credentials') setTimeout(() => { setErrorMessage(null) }, 5000) } + // highlight-end } // ... @@ -152,7 +157,9 @@ Kirjautumisen onnistuessa nollataan kirjautumislomakkeen kentät ja talle Jos kirjautuminen epäonnistuu, eli funktion _loginService.login_ suoritus aiheuttaa poikkeuksen, ilmoitetaan siitä käyttäjälle. -Onnistunut kirjautuminen ei nyt näy sovelluksen käyttäjälle mitenkään. Muokataan sovellusta vielä siten, että kirjautumislomake näkyy vain jos käyttäjä ei ole kirjautuneena eli _user === null_ ja uuden muistiinpanon luomislomake vain jos käyttäjä on kirjautuneena, eli user sisältää kirjautuneen käyttäjän tiedot. +### Kirjautumislomakkeen ehdollinen renderöinti + +Onnistunut kirjautuminen ei nyt näy sovelluksen käyttäjälle mitenkään. Muokataan sovellusta vielä siten, että kirjautumislomake näkyy vain jos käyttäjä ei ole kirjautuneena eli _user === null_. Uuden muistiinpanon luomislomake puolestaan näytetään vain jos käyttäjä on kirjautuneena, eli sovelluksen tila user sisältää kirjautuneen käyttäjän tiedot. Määritellään ensin komponenttiin App apufunktiot lomakkeiden generointia varten: @@ -163,35 +170,34 @@ const App = () => { const loginForm = () => (
    - username +
    - password +
    -
    + ) const noteForm = () => (
    - + -
    + ) return ( @@ -200,7 +206,7 @@ const App = () => { } ``` -ja renderöidään ne ehdollisesti komponenttiin App : +Renderöidään funktiot ehdollisesti komponenttiin App: ```js const App = () => { @@ -217,11 +223,10 @@ const App = () => { return (

    Notes

    - - {user === null && loginForm()} // highlight-line - {user !== null && noteForm()} // highlight-line + {!user && loginForm()} // highlight-line + {user && noteForm()} // highlight-line
      - {notesToShow.map((note, i) => + {notesToShow.map(note => ( toggleImportanceOf(note.id)} /> - )} + ))}
    @@ -244,39 +249,13 @@ const App = () => { } ``` -Lomakkeiden ehdolliseen renderöintiin käytetään hyväksi aluksi hieman erikoiselta näyttävää, mutta Reactin yhteydessä [yleisesti käytettyä kikkaa](https://reactjs.org/docs/conditional-rendering.html#inline-if-with-logical--operator): +Lomakkeiden ehdolliseen renderöintiin käytetään hyväksi aluksi hieman erikoiselta näyttävää, mutta Reactin yhteydessä [yleisesti käytettyä kikkaa](https://react.dev/learn/conditional-rendering#logical-and-operator-): ```js -{ - user === null && loginForm() -} +{!user && loginForm()} ``` -Jos ensimmäinen osa evaluoituu epätodeksi eli on [falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy), ei toista osaa eli lomakkeen generoivaa koodia suoriteta ollenkaan. - -Voimme suoraviivaistaa edellistä vielä hieman käyttämällä [kysymysmerkkioperaattoria](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator): - -```js -return ( -
    -

    Notes

    - - - - {user === null ? - loginForm() : - noteForm() - } - -

    Notes

    - - // ... - -
    -) -``` - -Eli jos _user === null_ on [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy), suoritetaan _loginForm_ ja muussa tapauksessa _noteForm_. +Jos ensimmäinen osa evaluoituu epätodeksi eli on [falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy) (eli user ei ole määritelty), ei toista osaa eli lomakkeen generoivaa koodia suoriteta ollenkaan. Tehdään vielä sellainen muutos, että jos käyttäjä on kirjautunut, renderöidään kirjautuneen käyttäjän nimi: @@ -284,23 +263,21 @@ Tehdään vielä sellainen muutos, että jos käyttäjä on kirjautunut, render return (

    Notes

    - - {user === null ? - loginForm() : + {!user && loginForm()} + // highlight-start + {user && (

    {user.name} logged in

    {noteForm()}
    - } - -

    Notes

    + )} + // highlight-end +
    +
    -) ``` Ratkaisu näyttää hieman rumalta, mutta jätämme sen koodiin toistaiseksi. @@ -308,8 +285,45 @@ Ratkaisu näyttää hieman rumalta, mutta jätämme sen koodiin toistaiseksi. Sovelluksemme pääkomponentti App on tällä hetkellä jo aivan liian laaja ja nyt tekemämme muutokset ovat ilmeinen signaali siitä, että lomakkeet olisi syytä refaktoroida omiksi komponenteikseen. Jätämme sen kuitenkin vapaaehtoiseksi harjoitustehtäväksi. -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part2-notes/tree/part5-2), branchissa part5-2. +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-2), branchissa part5-2. + +### Huomio label-elementin käytöstä + +Käytimme kirjautumislomakkeen syötekenttien yhteydessä [label](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/label)-elementtiä. Käyttäjänimen vastaanottava input-kenttä on sijoitettu sitä kuvaavan label-elementin sisään: +```js +
    + +
    +// ... +``` + +Miksi toteutimme lomakkeen näin? Visuaalisesti samaan lopputulokseen näyttäisi pääsevän myös yksinkertaisemmalla koodilla ilman erillistä label-elementtiä: + +```js +
    + username + setUsername(target.value)} + /> +
    +// ... +``` + +Label-elementtiä käytetään lomakkeissa kuvaamaan ja nimeämään syötekenttiä. Se määrittelee syötekentälle kuvauksen, jonka avulla käyttäjä voi päätellä, mitä tietoa kuhunkin kenttään tulee syöttää. Kuvaus sidotaan kuhunkin syötekenttään ohjelmallisesti, mikä parantaa lomakkeen saavutettavuutta. + +Näin ruudunlukijaohjelmat osaavat lukea kentän nimen käyttäjälle, kun syötekenttä valitaan, ja labelin tekstiä klikattaessa kohdistus siirtyy automaattisesti oikeaan syötekenttään. Label-elementin käyttö syötekenttien yhteydessä on aina suositeltavaa, vaikka visuaalisesti samaan lopputulokseen olisi mahdollista päästä myös ilman sitä. + +On olemassa [joitakin eri tapoja](https://react.dev/reference/react-dom/components/input#providing-a-label-for-an-input) sitoa tietty label viittaamaan input-elementtiin. Helpoiten se onnistuu sijoittamalla input-elementti sitä vastaavan label-elementin sisään, kuten tässä materiaalissa on tehty. Tällöin kyseinen label kohdistuu automaattisesti oikeaan syötekenttään, eikä muita määrittelyjä tarvita. ### Muistiinpanojen luominen @@ -332,7 +346,7 @@ const handleLogin = async (event) => { } ``` -Korjataan uusien muistiinpanojen luominen siihen muotoon, mitä backend edellyttää, eli lisätään kirjautuneen käyttäjän token HTTP-pyynnön Authorization-headeriin. +Korjataan uusien muistiinpanojen luominen backendin edellyttämään muotoon, eli lisätään kirjautuneen käyttäjän token HTTP-pyynnön Authorization-headeriin. noteService-moduuli muuttuu seuraavasti: @@ -344,7 +358,7 @@ let token = null // highlight-line // highlight-start const setToken = newToken => { - token = `bearer ${newToken}` + token = `Bearer ${newToken}` } // highlight-end @@ -356,7 +370,7 @@ const getAll = () => { const create = async newObject => { // highlight-start const config = { - headers: { Authorization: token }, + headers: { Authorization: token } } // highlight-end @@ -365,30 +379,28 @@ const create = async newObject => { } const update = (id, newObject) => { - const request = axios.put(`${ baseUrl } /${id}`, newObject) + const request = axios.put(`${ baseUrl }/${id}`, newObject) return request.then(response => response.data) } export default { getAll, create, update, setToken } // highlight-line ``` -Moduulille on määritelty vain moduulin sisällä näkyvä muuttuja _token_, jolle voidaan asettaa arvo moduulin exporttaamalla funktiolla _setToken_. Async/await-syntaksiin muutettu _create_ asettaa moduulin tallessa pitämän tokenin Authorization-headeriin, jonka se antaa axiosille metodin post kolmantena parametrina. +Moduulille on määritelty vain moduulin sisällä näkyvä muuttuja _token_, jolle voidaan asettaa arvo moduulin exporttaamalla funktiolla _setToken_. Async/await-syntaksiin muutettu _create_ asettaa moduulin tallessa pitämän tokenin Authorization-headeriin, jonka se antaa Axiosille metodin post kolmantena parametrina. Kirjautumisesta huolehtivaa tapahtumankäsittelijää pitää vielä viilata sen verran, että se kutsuu metodia noteService.setToken(user.token) onnistuneen kirjautumisen yhteydessä: ```js const handleLogin = async (event) => { event.preventDefault() - try { - const user = await loginService.login({ - username, password, - }) + try { + const user = await loginService.login({ username, password }) noteService.setToken(user.token) // highlight-line setUser(user) setUsername('') setPassword('') - } catch (exception) { + } catch { // ... } } @@ -398,11 +410,11 @@ Uusien muistiinpanojen luominen onnistuu taas! ### Tokenin tallettaminen selaimen local storageen -Sovelluksessamme on ikävä piirre: kun sivu uudelleenladataan, tieto käyttäjän kirjautumisesta katoaa. Tämä hidastaa melkoisesti myös sovelluskehitystä, esim. testatessamme uuden muistiinpanon luomista, joudumme joka kerta kirjautumaan järjestelmään. +Sovelluksessamme on ikävä piirre: kun sivu uudelleenladataan, tieto käyttäjän kirjautumisesta katoaa. Tämä hidastaa melkoisesti myös sovelluskehitystä, sillä esim. testatessamme uuden muistiinpanon luomista, joudumme joka kerta kirjautumaan järjestelmään. Ongelma korjaantuu helposti tallettamalla kirjautumistiedot [local storageen](https://developer.mozilla.org/en-US/docs/Web/API/Storage) eli selaimessa olevaan avain-arvo- eli [key-value](https://en.wikipedia.org/wiki/Key-value_database)-periaatteella toimivaan tietokantaan. -Local storage on erittäin helppokäyttöinen. Metodilla [setItem](https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem) talletetaan tiettyä avainta vastaava arvo, esim: +Local storage on erittäin helppokäyttöinen. Metodilla [setItem](https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem) talletetaan tiettyä avainta vastaava arvo. Esim. ```js window.localStorage.setItem('name', 'juha tauriainen') @@ -416,13 +428,13 @@ Avaimen arvo selviää metodilla [getItem](https://developer.mozilla.org/en-US/d window.localStorage.getItem('name') ``` -ja [removeItem](https://developer.mozilla.org/en-US/docs/Web/API/Storage/removeItem) poistaa avaimen. +[removeItem](https://developer.mozilla.org/en-US/docs/Web/API/Storage/removeItem) poistaa avaimen. Storageen talletetut arvot säilyvät vaikka sivu uudelleenladattaisiin. Storage on ns. [origin](https://developer.mozilla.org/en-US/docs/Glossary/Origin)-kohtainen, eli jokaisella selaimella käytettävällä web-sovelluksella on oma storagensa. Laajennetaan sovellusta siten, että se asettaa kirjautuneen käyttäjän tiedot local storageen. -Koska storageen talletettavat arvot ovat [merkkijonoja](https://developer.mozilla.org/en-US/docs/Web/API/DOMString), emme voi tallettaa storageen suoraan Javascript-oliota, vaan ne on muutettava ensin JSON-muotoon metodilla _JSON.stringify_. Vastaavasti kun JSON-muotoinen olio luetaan local storagesta, on se parsittava takaisin Javascript-olioksi metodilla _JSON.parse_. +Koska storageen talletettavat arvot ovat [merkkijonoja](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage#description), emme voi tallettaa storageen suoraan JavaScript-oliota, vaan ne on muutettava ensin JSON-muotoon metodilla _JSON.stringify_. Vastaavasti kun JSON-muotoinen olio luetaan local storagesta, on se parsittava takaisin JavaScript-olioksi metodilla _JSON.parse_. Kirjautumisen yhteyteen tehtävä muutos on seuraava: @@ -430,9 +442,7 @@ Kirjautumisen yhteyteen tehtävä muutos on seuraava: const handleLogin = async (event) => { event.preventDefault() try { - const user = await loginService.login({ - username, password, - }) + const user = await loginService.login({ username, password }) // highlight-start window.localStorage.setItem( @@ -449,34 +459,32 @@ Kirjautumisen yhteyteen tehtävä muutos on seuraava: } ``` -Kirjautuneen käyttäjän tiedot tallentuvat nyt local storageen ja niitä voidaan tarkastella konsolista: +Kirjautuneen käyttäjän tiedot tallentuvat nyt local storageen ja niitä voidaan tarkastella konsolista (kirjoittamalla konsoliin _window.localStorage_): -![](../../images/5/3e.png) +![Selaimen konsoliin on evaluoitu window.localStorage-objektin arvo](../../images/5/3e.png) Sovellusta on vielä laajennettava siten, että kun sivulle tullaan uudelleen, esim. selaimen uudelleenlataamisen yhteydessä, tulee sovelluksen tarkistaa löytyykö local storagesta tiedot kirjautuneesta käyttäjästä. Jos löytyy, asetetaan ne sovelluksen tilaan ja noteServicelle. - -Oikea paikka asian hoitamiselle on [effect hook](https://reactjs.org/docs/hooks-effect.html), eli [osasta 2](/osa2/palvelimella_olevan_datan_hakeminen#effect-hookit) tuttu mekanismi, jonka avulla haemme frontendiin palvelimelle talletetut muistiinpanot. +Oikea paikka asian hoitamiselle on [effect hook](https://react.dev/reference/react/useEffect) eli [osasta 2](/osa2/palvelimella_olevan_datan_hakeminen#effect-hookit) tuttu mekanismi, jonka avulla haemme palvelimelle talletetut muistiinpanot frontendiin. Effect hookeja voi olla useita, joten tehdään oma hoitamaan kirjautuneen käyttäjän ensimmäinen sivun lataus: ```js const App = () => { - const [notes, setNotes] = useState([]) + const [notes, setNotes] = useState([]) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) const [errorMessage, setErrorMessage] = useState(null) - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [user, setUser] = useState(null) + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [user, setUser] = useState(null) useEffect(() => { - noteService - .getAll().then(initialNotes => { - setNotes(initialNotes) - }) + noteService.getAll().then(initialNotes => { + setNotes(initialNotes) + }) }, []) - + // highlight-start useEffect(() => { const loggedUserJSON = window.localStorage.getItem('loggedNoteappUser') @@ -492,23 +500,23 @@ const App = () => { } ``` -Efektin parametrina oleva tyhjä taulukko varmistaa sen, että efekti suoritetaan ainoastaan kun komponentti renderöidään [ensimmäistä kertaa](https://reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect). +Efektin parametrina oleva tyhjä taulukko varmistaa sen, että efekti suoritetaan ainoastaan kun komponentti renderöidään [ensimmäistä kertaa](https://react.dev/reference/react/useEffect#parameters). Nyt käyttäjä pysyy kirjautuneena sovellukseen ikuisesti. Sovellukseen olisikin kenties syytä lisätä logout-toiminnallisuus, joka poistaisi kirjautumistiedot local storagesta. Jätämme kuitenkin uloskirjautumisen harjoitustehtäväksi. -Meille riittää se, että sovelluksesta on mahdollista kirjautua ulos kirjoittamalla konsoliin +Meille riittää se, että sovelluksesta on mahdollista kirjautua ulos kirjoittamalla konsoliin: ```js window.localStorage.removeItem('loggedNoteappUser') ``` -tai local storagen tilan kokonaan nollaavan komennon +Toinen tapa on käyttää local storagen tilan kokonaan nollaavaa komentoa: ```js window.localStorage.clear() ``` -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part2-notes/tree/part5-3), branchissa part5-3. +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-3), branchissa part5-3.
    @@ -518,36 +526,42 @@ Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://gith Teemme nyt edellisen osan tehtävissä tehtyä bloglist-backendia käyttävän frontendin. -Voit ottaa tehtävien pohjaksi [Githubista](https://github.com/fullstack-hy2020/bloglist-frontend) olevan sovellusrungon. Sovellus olettaa, että backend on käynnissä koneesi portissa 3001. +Voit ottaa tehtävien pohjaksi [Githubissa](https://github.com/fullstack-hy2020/bloglist-frontend) olevan sovellusrungon. Sovellus olettaa, että backend on käynnissä koneesi portissa 3003. -Lopullisen version palauttaminen riittää, voit toki halutessasi tehdä commitin jokaisen tehtävän jälkeisestä tilanteesta, mutta se ei ole välttämätöntä. +Lopullisen version palauttaminen riittää. Voit toki halutessasi tehdä commitin jokaisen tehtävän jälkeisestä tilanteesta, mutta se ei ole välttämätöntä. -Tämän osan alun tehtävät käytännössä kertaavat kaiken oleellisen tämän kurssin puitteissa Reactista läpikäydyn asian ja voivat siinä mielessä olla kohtuullisen haastavia, erityisesti jos edellisen osan tehtävissä toteuttamasi backend toimii puutteellisesti. Saattaakin olla varminta siirtyä käyttämään osan 4 mallivastauksen backendia. +Tämän osan alun tehtävät kertaavat käytännössä kaiken oleellisen tämän kurssin puitteissa Reactista läpikäydyn asian ja voivat siinä mielessä olla kohtuullisen haastavia, erityisesti jos edellisen osan tehtävissä toteuttamasi backend toimii puutteellisesti. Saattaakin olla varminta siirtyä käyttämään osan 4 mallivastauksen backendia. Muista tehtäviä tehdessäsi kaikki debuggaukseen liittyvät käytänteet, erityisesti konsolin tarkkailu. -**Varoitus:** jos huomaat kirjoittavasi sekaisin async/awaitia ja _then_-kutsuja, on 99.9% varmaa, että teet jotain väärin. Käytä siis jompaa kumpaa tapaa, älä missään tapauksessa "varalta" molempia. +**Varoitus:** Jos huomaat kirjoittavasi samaan funktioon sekaisin async/awaitia ja _then_-kutsuja, on 99,9-prosenttisen varmaa, että teet jotain väärin. Käytä siis jompaa kumpaa tapaa, älä missään tapauksessa "varalta" molempia. #### 5.1: blogilistan frontend, step1 -Ota tehtävien pohjaksi [Githubissa](https://github.com/fullstack-hy2020/bloglist-frontend) olevan sovellusrunko kloonaamalla se sopivaan paikkaan komennolla +Ota tehtävien pohjaksi [GitHubissa](https://github.com/fullstack-hy2020/bloglist-frontend) oleva sovellusrunko kloonaamalla se sopivaan paikkaan: ```bash git clone https://github.com/fullstack-hy2020/bloglist-frontend ``` -Poista kloonatun sovelluksen git-konfiguraatio +Seuraavaksi poista kloonatun sovelluksen Git-konfiguraatio: ```bash cd bloglist-frontend // mene kloonatun repositorion hakemistoon rm -rf .git ``` +Windows käyttäjille: + +```bash +cd bloglist-frontend // mene kloonatun repositorion hakemistoon +Remove-Item -Path .git -Recurse -Force +``` -Sovellus käynnistyy normaaliin tapaan, mutta joudut ensin asentamaan sovelluksen riippuvuudet: +Sovellus käynnistyy normaaliin tapaan, mutta joudut ensin asentamaan riippuvuudet: ```bash npm install -npm start +npm run dev ``` **Toteuta frontendiin kirjautumisen mahdollistava toiminnallisuus.** @@ -556,11 +570,11 @@ Kirjautumisen yhteydessä backendin palauttama token tallennetaan sovellu Jos käyttäjä ei ole kirjautunut, sivulla näytetään pelkästään kirjautumislomake: -![](../../images/5/4e.png) +![Näkyvillä kirjautumislomake, jolla syötekentät username ja password sekä nappi "login"](../../images/5/4e.png) -Kirjautuneelle käyttäjälle näytetään kirjautuneen käyttäjän nimi sekä blogien lista +Kirjautuneelle käyttäjälle näytetään kirjautuneen käyttäjän nimi sekä blogien lista: -![](../../images/5/5e.png) +![Blogit listattuna riveittän muodossa "blogin nimi", "kiroittaja", ruudulla lisäksi tieto kirjautuneesta käyttäjästä, esim. "Matti Luukkainen logged in"](../../images/5/5e.png) Tässä vaiheessa kirjautuneiden käyttäjien tietoja ei vielä tarvitse muistaa local storagen avulla. @@ -586,14 +600,13 @@ Tässä vaiheessa kirjautuneiden käyttäjien tietoja ei vielä tarvitse muistaa )}
    ) -} ``` #### 5.2: blogilistan frontend, step2 -Tee kirjautumisesta "pysyvä" local storagen avulla. Tee sovellukseen myös mahdollisuus uloskirjautumiseen +Tee kirjautumisesta "pysyvä" local storagen avulla. Tee sovellukseen myös mahdollisuus uloskirjautumiseen: -![](../../images/5/6e.png) +![Sovellukseen lisätty nappi "logout"](../../images/5/6e.png) Uloskirjautumisen jälkeen selain ei saa muistaa kirjautunutta käyttäjää reloadauksen jälkeen. @@ -601,18 +614,37 @@ Uloskirjautumisen jälkeen selain ei saa muistaa kirjautunutta käyttäjää rel Laajenna sovellusta siten, että kirjautunut käyttäjä voi luoda uusia blogeja: -![](../../images/5/7e.png) +![Sovellukseen lisätty lomake uusien blogien luomiseen. Lomakkeella kentät title, author ja url. Lomake näytetään ainoastaan kun käyttäjä on kirjaantunut sovellukseen.](../../images/5/7e.png) -#### 5.4*: blogilistan frontend, step4 +#### 5.4: blogilistan frontend, step4 -Toteuta sovellukseen notifikaatiot, jotka kertovat sovelluksen yläosassa onnistuneista ja epäonnistuneista toimenpiteistä. Esim. blogin lisäämisen yhteydessä voi antaa seuraavan notifikaation +Toteuta sovellukseen notifikaatiot, jotka kertovat sovelluksen yläosassa onnistuneista ja epäonnistuneista toimenpiteistä. Esim. blogin lisäämisen yhteydessä voi antaa seuraavan notifikaation: -![](../../images/5/8e.png) +![Sovellus näyttää notifikaation "a new blog ... by ... added"](../../images/5/8e.png) -epäonnistunut kirjautuminen taas johtaa notifikaatioon +Epäonnistunut kirjautuminen taas johtaa virhenotifikaatioon: -![](../../images/5/9e.png) +![Sovellus näyttää notifikaation "wrong username/password"](../../images/5/9e.png) Notifikaation tulee olla näkyvillä muutaman sekunnin ajan. Värien lisääminen ei ole pakollista.
    + + +
    + +### Huomio local storagen käytöstä + +Edellisen osan [lopussa](/osa4/token_perustainen_kirjautuminen#token-perustaisen-kirjautumisen-ongelmat) todettiin, että token-perustaisen kirjautumisen haasteena on se, miten toimia tilanteissa, joissa tokenin haltijalta pitäisi poistaa pääsy API:n tarjoamaan dataan. + +Ratkaisuja ongelmaan on kaksi. Tokenille voidaan asettaa voimassaoloaika, jonka päätyttyä käyttäjä pakotetaan kirjautumaan järjestelmään uudelleen. Toinen ratkaisu on tallentaa tokeniin liittyvät tiedot palvelimen tietokantaan ja tarkastaa jokaisen API-kutsun yhteydessä, onko tokeniin liittyvä käyttöoikeus tai "sessio" edelleen voimassa. Jälkimmäistä tapaa kutsutaan usein palvelinpuolen sessioksi. + +Riippumatta siitä miten palvelin hoitaa tokenin voimassaolon tarkastuksen, saattaa tokenin tallentaminen local storageen olla pienimuotoinen turvallisuusriski jos sovelluksessa on ns. [Cross Site Scripting (XSS)](https://owasp.org/www-community/attacks/xss/) ‑hyökkäyksen mahdollistava tietoturva-aukko. XSS-hyökkäys mahdollistuu, jos sovelluksen suoritettavaksi on mahdollista ujuttaa mielivaltaista JavaScript-koodia, minkä taas ei pitäisi olla "normaalisti" Reactia käyttäen mahdollista sillä [React sanitoi](https://legacy.reactjs.org/docs/introducing-jsx.html#jsx-prevents-injection-attacks) renderöimänsä sisällön, eli ei suorita sitä koodina. + +Toki jos haluaa pelata varman päälle, ei tokenia kannata tallettaa local storageen ainakaan niissä tapauksissa, joissa potentiaalisella tokenin vääriin käsiin joutumisella olisi traagisia seurauksia. + +Erääksi turvallisemmaksi ratkaisuksi kirjautuneen käyttäjän muistamiseen on tarjottu [httpOnly-evästeitä](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies) (engl. httpOnly cookies), joita käytettäessä JavaScript-koodi ei pääse ollenkaan käsiksi session muistavaan tunnisteeseen. Pelkästään yhden sivun renderöivien SPA-sovellusten toteuttaminen HttpOnly-evästeiden avulla ei kuitenkaan ole helppoa. Niiden käyttö edellyttäisi erillistä näkymää kirjautumista varten. + +Täytyy kuitenkin huomata, että httpOnly-evästeisiinkään perustuva ratkaisu ei ole vedenpitävä. Joidenkin mukaan se on itse asiassa [yhtä "turvaton"](https://academind.com/tutorials/localstorage-vs-cookies-xss/) kuin local storage. Tärkeintä on siis joka tapauksessa ohjelmoida sovellukset [tavoilla](https://cheatsheetseries.owasp.org/cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.html), jotka minimoivat XSS-hyökkäysten riskit. + +
    diff --git a/src/content/5/fi/osa5b.md b/src/content/5/fi/osa5b.md index cafb18697e7..51a8c47de7d 100644 --- a/src/content/5/fi/osa5b.md +++ b/src/content/5/fi/osa5b.md @@ -11,19 +11,17 @@ lang: fi Muutetaan sovellusta siten, että kirjautumislomaketta ei oletusarvoisesti näytetä: -![](../../images/5/10e.png) +![Oletusarvoisesti sovellus näytää ainoastaan muistiinpanojen listan sekä napin "log in"](../../images/5/10e.png) Lomake aukeaa, jos käyttäjä painaa nappia login: -![](../../images/5/11e.png) +![Kun nappia "log in" painetaan, avautuu kirjaantumislomake (jolla kentät username ja password sekä nappi kirjautumisen tekemiseen). Näkyviin tulee myös nappi "cancel", jota painamalla kirjaantumislomake suljetaan tekemättä kirjautumista](../../images/5/11e.png) Napilla cancel käyttäjä saa tarvittaessa suljettua lomakkeen. Aloitetaan eristämällä kirjautumislomake omaksi komponentikseen: ```js -import React from 'react' - const LoginForm = ({ handleSubmit, handleUsernameChange, @@ -60,7 +58,7 @@ const LoginForm = ({ export default LoginForm ``` -Tila ja tilaa käsittelevät funktiot on kaikki määritelty komponentin ulkopuolella ja välitetään komponentille propseina. +Tila ja tilaa käsittelevät funktiot on kaikki määritelty komponentin ulkopuolella ja ne välitetään komponentille propseina. Huomaa, että propsit otetaan vastaan destrukturoimalla, eli sen sijaan että määriteltäisiin @@ -123,9 +121,9 @@ const App = () => { } ``` -Komponentin App tilaan on nyt lisätty totuusarvo loginVisible joka määrittelee sen, näytetäänkö kirjautumislomake. +Komponentin App tilaan on nyt lisätty totuusarvo loginVisible, joka määrittelee sen, näytetäänkö kirjautumislomake. -Näkyvyyttä säätelevää tilaa vaihdellaan kahden napin avulla, molempiin on kirjoitettu tapahtumankäsittelijän koodi suoraan: +Näkyvyyttä säätelevää tilaa vaihdellaan kahden napin avulla, joihin molempiin on kirjoitettu tapahtumankäsittelijän koodi suoraan: ```js @@ -139,26 +137,26 @@ Komponenttien näkyvyys on määritelty asettamalla komponentille [inline](/osa2 const hideWhenVisible = { display: loginVisible ? 'none' : '' } const showWhenVisible = { display: loginVisible ? '' : 'none' } -
    +
    // nappi
    -
    +
    // lomake
    ``` -Käytössä on kysymysmerkkioperaattori, eli jos _loginVisible_ on true, tulee napin CSS-määrittelyksi +Käytössä on kysymysmerkkioperaattori eli jos _loginVisible_ on true, tulee napin CSS-määrittelyksi ```css display: 'none'; ``` -jos _loginVisible_ on false, ei display saa mitään napin näkyvyyteen liittyvää arvoa. +Jos _loginVisible_ on false, ei display saa mitään napin näkyvyyteen liittyvää arvoa. -### Komponentin lapset, eli props.children +### Komponentin lapset eli props.children -Kirjautumislomakkeen näkyvyyttä ympäröivän koodin voi ajatella olevan oma looginen kokonaisuutensa ja se onkin hyvä eristää pois komponentista App omaksi komponentikseen. +Kirjautumislomakkeen näkyvyyttä ympäröivän koodin voi ajatella olevan oma looginen kokonaisuutensa, ja se onkin hyvä eristää pois komponentista App omaksi komponentikseen. Tavoitteena on luoda komponentti Togglable, jota käytetään seuraavalla tavalla: @@ -185,10 +183,10 @@ Komponentin käyttö poikkeaa aiemmin näkemistämme siinä, että käytössä o ``` -Komponentin koodi on seuraavassa: +Komponentin koodi on tällainen: ```js -import React, { useState } from 'react' +import { useState } from 'react' const Togglable = (props) => { const [visible, setVisible] = useState(false) @@ -216,7 +214,7 @@ const Togglable = (props) => { export default Togglable ``` -Mielenkiintoista ja meille uutta on [props.children](https://reactjs.org/docs/glossary.html#propschildren), jonka avulla koodi viittaa komponentin lapsiin, eli avaavan ja sulkevan tagin sisällä määriteltyihin React-elementteihin. +Mielenkiintoista ja meille uutta on [props.children](https://react.dev/learn/passing-props-to-a-component#passing-jsx-as-children), jonka avulla koodi viittaa komponentin lapsiin eli avaavan ja sulkevan tagin sisällä määriteltyihin React-elementteihin. Tällä kertaa lapset ainoastaan renderöidään komponentin oman renderöivän koodin seassa: @@ -239,12 +237,12 @@ Toisin kuin "normaalit" propsit, children on Reactin automaattisesti mä on props.children tyhjä taulukko. -Komponentti Togglable on uusiokäytettävä ja voimme käyttää sitä tekemään myös uuden muistiinpanon luomisesta huolehtivan formin vastaavalla tavalla tarpeen mukaan näytettäväksi. +Komponentti Togglable on uusiokäytettävä, ja voimme käyttää sitä tekemään myös uuden muistiinpanon luomisesta huolehtivan formin vastaavalla tavalla tarpeen mukaan näytettäväksi. Eristetään ensin muistiinpanojen luominen omaksi komponentiksi ```js -const NoteForm = ({ onSubmit, handleChange, value}) => { +const NoteForm = ({ onSubmit, handleChange, value }) => { return (

    Create a new note

    @@ -259,6 +257,8 @@ const NoteForm = ({ onSubmit, handleChange, value}) => {
    ) } + +export default NoteForm ``` ja määritellään lomakkeen näyttävä koodi komponentin Togglable sisällä @@ -273,35 +273,31 @@ ja määritellään lomakkeen näyttävä koodi komponentin Togglable sis ``` -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part2-notes/tree/part5-4), branchissa part5-4. +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-4), branchissa part5-4. ### Lomakkeiden tila Koko sovelluksen tila on nyt sijoitettu komponenttiin _App_. -Reactin dokumentaatio antaa seuraavan [ohjeen](https://reactjs.org/docs/lifting-state-up.html) tilan sijoittamisesta: +Reactin dokumentaatio antaa seuraavan [ohjeen](https://react.dev/learn/sharing-state-between-components) tilan sijoittamisesta: > Often, several components need to reflect the same changing data. We recommend lifting the shared state up to their closest common ancestor. -Jos mietitään lomakkeiden tilaa, eli esimerkiksi uuden muistiinpanon sisältöä sillä hetkellä kun muistiinpanoa ei vielä ole luotu, ei komponentti _App_ oikeastaan tarvitse niitä mihinkään, ja voisimme aivan hyvin siirtää tilan lomakkeisiin liittyvän tilan niitä vastaaviin komponentteihin. +Jos mietitään lomakkeiden tilaa eli esimerkiksi uuden muistiinpanon sisältöä sillä hetkellä kun muistiinpanoa ei vielä ole luotu, ei komponentti _App_ oikeastaan tarvitse niitä mihinkään, ja voisimme aivan hyvin siirtää lomakkeisiin liittyvän tilan niitä vastaaviin komponentteihin. -Muistiinpanosta huolehtiva komponentti muuttuu seuraavasti: +Muistiinpanon luomisesta huolehtiva komponentti muuttuu seuraavasti: ```js -import React, {useState} from 'react' +import { useState } from 'react' const NoteForm = ({ createNote }) => { - const [newNote, setNewNote] = useState('') - - const handleChange = (event) => { - setNewNote(event.target.value) - } + const [newNote, setNewNote] = useState('') const addNote = (event) => { event.preventDefault() createNote({ content: newNote, - important: Math.random() > 0.5, + important: true }) setNewNote('') @@ -314,25 +310,29 @@ const NoteForm = ({ createNote }) => {
    setNewNote(event.target.value)} />
    ) } + +export default NoteForm ``` -Tilan muuttuja newNote ja sen muutokseta huolehtiva tapahtumankäsittelijä on siirretty komponentista _App_ lomakkeesta huolehtivaan komponenttiin. +**HUOM** muutimme samalla sovelluksen toimintaa siten, että uudet muistiinpanot ovat oletusarvoisesti tärkeitä, eli important saa arvon true. + +Tilan muuttuja newNote ja sen muutoksesta huolehtiva tapahtumankäsittelijä on siirretty komponentista _App_ lomakkeesta huolehtivaan komponenttiin. -Propseja on enää yksi, funktio _createNote_, jota lomake kutsuu kun uusi muistiinpano luodaan. +Propseja on enää yksi eli funktio _createNote_, jota lomake kutsuu kun uusi muistiinpano luodaan. -Komponentti _App_ yksintertaistuu, tilasta newNote ja sen käsittelijäfunktiosta on päästy eroon. Uuden muistiinpanon luomisesta huolehtiva funktio _addNote_ saa suoraan parametriksi uuden muistiinpanon ja funktio on ainoa props, joka välitetään lomakkeelle: +Komponentti _App_ yksinkertaistuu, koska tilasta newNote ja sen käsittelijäfunktiosta on päästy eroon. Uuden muistiinpanon luomisesta huolehtiva funktio _addNote_ saa suoraan parametriksi uuden muistiinpanon ja funktio on ainoa props, joka välitetään lomakkeelle: ```js const App = () => { // ... - const addNote = (noteObject) => { + const addNote = (noteObject) => { // highlight-line noteService .create(noteObject) .then(returnedNote => { @@ -340,53 +340,59 @@ const App = () => { }) } // ... - const noteForm = () => ( - - - - ) + return ( +
    +

    Notes

    + // ... - // ... + + // highlight-line + + + // ... +
    +
    + ) } ``` Vastaava muutos voitaisiin tehdä myös kirjautumislomakkeelle, mutta jätämme sen vapaaehtoiseksi harjoitustehtäväksi. -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part2-notes/tree/part5-5), branchissa part5-5. +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-5), branchissa part5-5. ### ref eli viite komponenttiin -Ratkaisu on melko hyvä, haluaisimme kuitenkin parantaa sitä erään seikan osalta. - -Kun uusi muistiinpano luodaan, olisi loogista jos luomislomake menisi piiloon. Nyt lomake pysyy näkyvillä. Lomakkeen piilottamiseen sisältyy kuitenkin pieni ongelma, sillä näkyvyyttä kontrolloidaan Togglable-komponentin tilassa olevalla muuttujalla visible. Miten pääsemme tilaan käsiksi komponentin ulkopuolelta? +Ratkaisu on melko hyvä, mutta haluamme kuitenkin parantaa sitä. Kun uusi muistiinpano luodaan, olisi loogista jos luomislomake menisi piiloon. Nyt lomake pysyy näkyvillä. Lomakkeen piilottamiseen sisältyy kuitenkin pieni ongelma, sillä näkyvyyttä kontrolloidaan Togglable-komponentin tilassa olevalla muuttujalla visible. Eräs ratkaisu tähän olisi siirtää Togglable-komponentin tilan kontrollointi komponentin ulkopuolelle. Emme kuitenkaan nyt tee sitä, sillä haluamme että komponentti on itse vastuussa tilastaan. Meidän on siis etsittävä jokin muu ratkaisu, ja löydettävä mekanismi komponentin tilan muuttamiseen ulkopuolelta käsin. -On useita erilaisia tapoja toteuttaa pääsy komponentin funktioihin sen ulkopuolelta, käytetään nyt -Reactin [ref](https://reactjs.org/docs/refs-and-the-dom.html)-mekanismia, joka tarjoaa eräänlaisen viitteen komponenttiin. +On useita erilaisia tapoja toteuttaa pääsy komponentin funktioihin sen ulkopuolelta. Käytetään nyt Reactin [ref](https://react.dev/learn/referencing-values-with-refs)-mekanismia, joka tarjoaa eräänlaisen viitteen komponenttiin. -Tehdään komponenttiin App seuraavat muutokset +Tehdään komponenttiin App seuraavat muutokset: ```js +import { useState, useEffect, useRef } from 'react' // highlight-line + const App = () => { // ... - const noteFormRef = React.createRef() // highlight-line + const noteFormRef = useRef() // highlight-line - const noteForm = () => ( + return ( + // ... // highlight-line + // ... ) - // ... } ``` -Metodilla [createRef](https://reactjs.org/docs/react-api.html#reactcreateref) luodaan ref noteFormRef, joka kiinnitetään muistiinpanojen luomislomakkeen sisältävälle Togglable-komponentille. Nyt siis muuttuja noteFormRef toimii viitteenä komponenttiin. +[useRef](https://react.dev/reference/react/useRef) hookilla luodaan ref noteFormRef, joka kiinnitetään muistiinpanojen luomislomakkeen sisältävälle Togglable-komponentille. Nyt siis muuttuja noteFormRef toimii viitteenä komponenttiin. Komponenttia Togglable laajennetaan seuraavasti ```js -import React, { useState, useImperativeHandle } from 'react' // highlight-line +import { useState, useImperativeHandle } from 'react' // highlight-line -const Togglable = React.forwardRef((props, ref) => { // highlight-line +const Togglable = (props) => { // highlight-line const [visible, setVisible] = useState(false) const hideWhenVisible = { display: visible ? 'none' : '' } @@ -397,10 +403,8 @@ const Togglable = React.forwardRef((props, ref) => { // highlight-line } // highlight-start - useImperativeHandle(ref, () => { - return { - toggleVisibility - } + useImperativeHandle(props.ref, () => { + return { toggleVisibility } }) // highlight-end @@ -415,15 +419,12 @@ const Togglable = React.forwardRef((props, ref) => { // highlight-line
    ) -}) // highlight-line +} export default Togglable ``` -Komponentin luova funktio on kääritty funktiokutsun [forwardRef](https://reactjs.org/docs/react-api.html#reactforwardref) sisälle, näin komponentti pääsee käsiksi sille määriteltyyn refiin. - -Komponentti tarjoaa [useImperativeHandle -](https://reactjs.org/docs/hooks-reference.html#useimperativehandle)-hookin avulla sisäisesti määritellyn funktionsa toggleVisibility ulkopuolelta kutsuttavaksi. +Komponentti tarjoaa [useImperativeHandle](https://react.dev/reference/react/useImperativeHandle)-hookin avulla sisäisesti määritellyn funktionsa toggleVisibility ulkopuolelta kutsuttavaksi. Voimme nyt piilottaa lomakkeen kutsumalla noteFormRef.current.toggleVisibility() samalla kun uuden muistiinpanon luominen tapahtuu: @@ -442,26 +443,25 @@ const App = () => { } ``` -Käyttämämme [useImperativeHandle -](https://reactjs.org/docs/hooks-reference.html#useimperativehandle) on siis React hook, jonka avulla funktiona määritellylle komponentille voidaan määrittää funktioita, joita on mahdollista kutsua sen ulkopuolelta. +Käyttämämme [useImperativeHandle](https://react.dev/reference/react/useImperativeHandle) on siis React hook, jonka avulla komponentille voidaan määrittää funktioita, joita on mahdollista kutsua sen ulkopuolelta. -Käyttämämme kikka komponentin tilan muuttamikseksi toimii, mutta se vaikuttaa hieman ikävältä. Saman olisi saanut aavistuksen siistimmin toteutettua "vanhan Reactin" class-perustaisilla komponenteilla, joihin tutustumme osassa 7. Tämä on toistaiseksi ainoa tapaus, jossa Reactin hook-syntaksiin nojaava ratkaisu on aavistuksen likaisemman oloinen kuin class-komponenttien tarjoama ratkaisu. +Käyttämämme kikka komponentin tilan muuttamiseksi toimii, mutta se vaikuttaa hieman ikävältä. Saman olisi saanut aavistuksen siistimmin toteutettua "vanhan Reactin" class-komponenteilla, joihin tutustumme osassa 7. Tämä on toistaiseksi ainoa tapaus, jossa Reactin hook-syntaksiin nojaava ratkaisu on aavistuksen likaisemman oloinen kuin class-komponenttien tarjoama ratkaisu. -Refeille on myös [muita käyttötarkoituksia](https://reactjs.org/docs/refs-and-the-dom.html) kuin React-komponentteihin käsiksi pääseminen. +Refeille on myös [muita käyttötarkoituksia](https://react.dev/learn/manipulating-the-dom-with-refs) kuin React-komponentteihin käsiksi pääseminen. -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part2-notes/tree/part5-6), branchissa part5-6. +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-6), branchissa part5-6. ### Huomio komponenteista Kun Reactissa määritellään komponentti ```js -const Togglable = () => ... +const Togglable = () => { // ... } ``` -ja otetaan se käyttöön seuraavasti, +ja otetaan se käyttöön ```js
    @@ -481,43 +481,61 @@ ja otetaan se käyttöön seuraavasti, syntyy kolme erillistä komponenttiolioa, joilla on kaikilla oma tilansa: -![](../../images/5/12.png) +![Kuva havainnollistaa, että selain renderöi kolme erillistä komponenttia, jotka voivat olla toisistaan riippumatta "togglattuina" näkyville](../../images/5/12.png) ref-attribuutin avulla on talletettu viite jokaiseen komponentin muuttujaan togglable1, togglable2 ja togglable3. +### Full stack ‑sovelluskehittäjän päivitetty vala + +Liikkuvien osien määrä nousee. Samalla kasvaa myös todennäköisyys sille, että päädymme tilanteeseen, missä etsimme vikaa täysin väärästä paikasta. Systemaattisuutta on siis lisättävä vielä pykälän verran. + +Full stack ‑ohjelmointi on todella hankalaa, ja sen takia lupaan hyödyntää kaikkia ohjelmointia helpottavia keinoja: + +- pidän selaimen konsolin koko ajan auki +- tarkkailen säännöllisesti selaimen network-välilehdeltä, että frontendin ja backendin välinen kommunikaatio tapahtuu oletusteni mukaan +- tarkkailen säännöllisesti palvelimella olevan datan tilaa, ja varmistan että frontendin lähettämä data siirtyy sinne kuten oletin +- pidän silmällä tietokannan tilaa: varmistan että backend tallentaa datan sinne oikeaan muotoon +- etenen pienin askelin +- kun epäilen että bugi on frontendissa, varmistan että backend toimii varmasti +- kun epäilen että bugi on backendissa, varmistan että frontend toimii varmasti +- käytän koodissa ja testeissä runsaasti _console.log_-komentoja varmistamaan sen, että varmasti ymmärrän jokaisen kirjoittamani rivin, sekä etsiessäni koodista tai testeistä mahdollisia ongelman aiheuttajia +- jos koodini ei toimi, en kirjoita enää yhtään lisää koodia, vaan alan poistamaan toiminnan rikkoneita rivejä tai palaan suosiolla tilanteeseen, missä koodi vielä toimi +- jos testit eivät mene läpi, varmistan että testien testaama toiminnallisuus varmasti toimii sovelluksessa +- kun kysyn apua kurssin Discord-kanavalla, tai muualla internetissä, muotoilen kysymyksen järkevästi, esim. [täällä](/en/part0/general_info#how-to-get-help-in-discord) esiteltyyn tapaan +
    -### Tehtävät 5.5.-5.10. +### Tehtävät 5.5.-5.11. #### 5.5 blogilistan frontend, step5 -Tee blogin luomiseen käytettävästä lomakkeesta ainoastaan tarvittaessa näytettävä osan 5 luvun [Kirjautumislomakkeen näyttäminen vain tarvittaessa](/osa5#kirjautumislomakkeen-näyttäminen-vain-tarvittaessa) tapaan. Voit halutessasi hyödyntää osassa 5 määriteltyä komponenttia Togglable. +Tee blogin luomiseen käytettävästä lomakkeesta ainoastaan tarvittaessa näytettävä osan 5 luvun [Kirjautumislomakkeen näyttäminen vain tarvittaessa](/osa5/props_children_ja_proptypet#kirjautumislomakkeen-nayttaminen-vain-tarvittaessa) tapaan. Voit halutessasi hyödyntää osassa 5 määriteltyä komponenttia Togglable. -Lomake ei ole oletusarvoisesti näkyvillä +Lomake ei ole oletusarvoisesti näkyvillä: -![](../../images/5/13ae.png) +![Oletusarvoisesti näytetään ainoastaan nappi "create new blog"](../../images/5/13ae.png) -Klikkaamalla nappia new note lomake aukeaa +Klikkaamalla nappia create new blog lomake aukeaa: -![](../../images/5/13be.png) +![kun nappia painetaan, avautuu uuden blogin luomisen mahdollistava komponentti joka sisältää napin cancel, jota painamalla lomakkeen voi piilottaa](../../images/5/13be.png) -Lomakkeen tulee sulkeutua kun uusi blogi luodaan. +Lomakkeen tulee sulkeutua, kun cancel-painiketta painetaan tai kun uusi blogi luodaan. #### 5.6 blogilistan frontend, step6 Eriytä uuden blogin luomisesta huolehtiva lomake omaan komponenttiinsa (jos et jo ole niin tehnyt), ja siirrä kaikki uuden blogin luomiseen liittyvä tila komponentin vastuulle. -Komponentin tulee siis toimia samaan tapaan kuin tämän osan [materiaalin](https://fullstack-hy2020.github.io/osa5/props_children_ja_proptypet#lomakkeiden-tila) komponentin NewNote. +Komponentin tulee siis toimia samaan tapaan kuin tämän osan [materiaalin](https://fullstack-hy2020.github.io/osa5/props_children_ja_proptypet#lomakkeiden-tila) komponentin NoteForm. -#### 5.7* blogilistan frontend, step7 +#### 5.7 blogilistan frontend, step7 -Lisää yksittäiselle blogille nappi, jonka avulla voi kontrolloida näytetäänkö kaikki blogiin liittyvät tiedot. +Lisää yksittäiselle blogille nappi, jonka avulla voi kontrolloida, näytetäänkö kaikki blogiin liittyvät tiedot. -Klikkaamalla nappia sen täydelliset tiedot aukeavat. +Klikkaamalla nappia sen täydelliset tiedot aukeavat: -![](../../images/5/13ea.png) +![Oletusarvoisesti kustakin blogista näytetään nimi ja kirjoittaja sekä nappi view. Nappia painamalla näytetään myös blogin url, sen likejen määrä, nappi "likettämiseen" sekä blogin lisännyt käyttäjä ja nappi tarkempien tietojen piilottamiseen.](../../images/5/13ea.png) Uusi napin klikkaus pienentää näkymän. @@ -547,15 +565,13 @@ const Blog = ({ blog }) => { )} ``` -**Huom1:** voit tehdä blogin nimestä klikattavan korostetun koodirivin tapaan. - -**Huom2:** vaikka tämän tehtävän toiminnallisuus on melkein samanlainen kuin komponentin Togglable tarjoama toiminnallisuus, ei Togglable kuitenkaan sovi tarkoitukseen sellaisenaan. Helpoin ratkaisu lienee lisätä blogille tila, joka kontrolloi sitä missä muodossa blogi näytetään. +**Huom:** Vaikka tämän tehtävän toiminnallisuus on melkein samanlainen kuin komponentin Togglable tarjoama toiminnallisuus, ei Togglable kuitenkaan sovi tarkoitukseen sellaisenaan. Helpoin ratkaisu lienee lisätä blogille tila, joka kontrolloi sitä missä muodossa blogi näytetään. -#### 5.8*: blogilistan frontend, step8 +#### 5.8: blogilistan frontend, step8 Toteuta like-painikkeen toiminnallisuus. Like lisätään backendiin blogin yksilöivään urliin tapahtuvalla _PUT_-pyynnöllä. -Koska backendin operaatio korvaa aina koko blogin, joudut lähettämään operaation mukana blogin kaikki kentät, eli jos seuraavaa blogia liketetään, +Koska backendin operaatio korvaa aina koko blogin, joudut lähettämään operaation mukana blogin kaikki kentät. Eli jos seuraavaa blogia liketetään, ```js { @@ -584,22 +600,29 @@ tulee palvelimelle tehdä PUT-pyyntö osoitteeseen /api/blogs/5a43fde2cbd20b1 } ``` -**Varoitus vielä kerran:** jos huomaat kirjoittavasi sekaisin async/awaitia ja _then_-kutsuja, on 99.9% varmaa, että teet jotain väärin. Käytä siis jompaa kumpaa tapaa, älä missään tapauksessa "varalta" molempia. - #### 5.9*: blogilistan frontend, step9 +Huomaamme, että jotain on pielessä. Kun blogia liketetään, ei blogin lisääjän nimeä näytetä enää blogin tarkempien tietojen joukossa: + +![](../../images/5/59put.png) + +Kun selain uudelleenladataan, lisääjän tieto tulee näkyviin. Tämä ei ole hyväksyttävää, selvitä missä vika on ja tee tarvittava korjaus. + +On toki mahdollista, että olet jo tehnyt kaiken oikein, ja ongelmaa ei koodissasi ilmene. Tässä tapauksessa voit siirtyä eteenpäin. + +#### 5.10: blogilistan frontend, step10 + Järjestä sovellus näyttämään blogit likejen mukaisessa suuruusjärjestyksessä. Järjestäminen onnistuu taulukon metodilla [sort](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort). -#### 5.10*: blogilistan frontend, step10 +#### 5.11: blogilistan frontend, step11 Lisää nappi blogin poistamiselle. Toteuta myös poiston tekevä logiikka. Ohjelmasi voi näyttää esim. seuraavalta: -![](../../images/5/14ea.png) +![Blogin tarkemman näkymän (avautuu kun painetaan view) mukana on nappi delete, jota painamalla blogin voi poistaa. Poisto varmistetaan window.confirm:n avulla toteutetulla dialogilla](../../images/5/14ea.png) -Kuvassa näkyvä poiston varmistus on helppo toteuttaa funktiolla -[window.confirm](https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm). +Kuvassa näkyvä poiston varmistus on helppo toteuttaa funktiolla [window.confirm](https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm). Näytä poistonappi ainoastaan jos kyseessä on kirjautuneen käyttäjän lisäämä blogi. @@ -607,208 +630,90 @@ Näytä poistonappi ainoastaan jos kyseessä on kirjautuneen käyttäjän lisä
    -### PropTypes - -Komponentti Togglable olettaa, että sille määritellään propsina buttonLabel napin teksti. Jos määrittely unohtuu, - -```js - buttonLabel unohtui... -``` - -sovellus kyllä toimii, mutta selaimeen renderöityy hämäävästi nappi, jolla ei ole mitään tekstiä. - -Haluaisimmekin varmistaa että jos Togglable-komponenttia käytetään, on propsille "pakko" antaa arvo. - -Komponentin olettamat ja edellyttämät propsit ja niiden tyypit voidaan määritellä kirjaston [prop-types](https://github.com/facebook/prop-types) avulla. Asennetaan kirjasto - -```js -npm install --save prop-types -``` - -buttonLabel voidaan määritellä pakolliseksi string-tyyppiseksi propsiksi seuraavasti: - -```js -import PropTypes from 'prop-types' - -const Togglable = React.forwardRef((props, ref) => { - // .. -} - -Togglable.propTypes = { - buttonLabel: PropTypes.string.isRequired -} -``` - -Jos propsia ei määritellä, seurauksena on konsoliin tulostuva virheilmoitus - -![](../../images/5/15.png) - -Koodi kuitenkin toimii edelleen, eli mikään ei pakota määrittelemään propseja PropTypes-määrittelyistä huolimatta. On kuitenkin erittäin epäammattimaista jättää konsoliin mitään punaisia tulosteita. - -Määritellään Proptypet myös LoginForm-komponentille: - -```js -import PropTypes from 'prop-types' - -const LoginForm = ({ - handleSubmit, - handleUsernameChange, - handlePasswordChange, - username, - password - }) => { - // ... - } - -LoginForm.propTypes = { - handleSubmit: PropTypes.func.isRequired, - handleUsernameChange: PropTypes.func.isRequired, - handlePasswordChange: PropTypes.func.isRequired, - username: PropTypes.string.isRequired, - password: PropTypes.string.isRequired -} -``` - -Jos propsin tyyppi on väärä, esim. yritetään määritellä propsiksi handleSubmit merkkijono, seurauksena on varoitus: - -![](../../images/5/16.png) +### ESLint -### ESlint +Konfiguroimme osassa 3 koodin tyylistä huolehtivan [ESLintin](/osa3/validointi_ja_es_lint) backendiin. Otetaan nyt ESLint käyttöön myös frontendissa. -Konfiguroimme osassa 3 koodin tyylistä huolehtivan [ESlintin](/osa3/validointi_ja_es_lint) backendiin. Otetaan nyt ESlint käyttöön myös frontendissa. +Vite on asentanut projektille ESLintin valmiiksi, joten ei tarvitse muuta kuin muokata tiedostossa eslint.config.js oleva konfiguraatio halutun kaltaiseksi. -Create-react-app on asentanut projektille eslintin valmiiksi, joten ei tarvita muuta kuin sopiva konfiguraatio tiedostoon .eslintrc.js. -**HUOM:** älä suorita komentoa _eslint --init_. Se asentaa uuden version eslintistä joka on epäsopiva create-react-app:in konfiguraatioiden kanssa! - -Aloitamme seuraavaksi testaamisen, ja jotta pääsemme eroon testeissä olevista turhista huomautuksista asennetaan plugin [eslint-jest-plugin](https://www.npmjs.com/package/eslint-plugin-jest) +Muutetaan tiedoston eslint.config.js sisältöä seuraavasti: ```js -npm install --save-dev eslint-plugin-jest -``` +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' -Luodaan tiedosto .eslintrc.js ja kopioidaan sinne seuraava sisältö: - -```js -module.exports = { - "env": { - "browser": true, - "es6": true, - "jest/globals": true - }, - "extends": [ - "eslint:recommended", - "plugin:react/recommended" - ], - "parserOptions": { - "ecmaFeatures": { - "jsx": true - }, - "ecmaVersion": 2018, - "sourceType": "module" - }, - "plugins": [ - "react", "jest" - ], - "rules": { - "indent": [ - "error", - 2 - ], - "linebreak-style": [ - "error", - "unix" - ], - "quotes": [ - "error", - "single" - ], - "semi": [ - "error", - "never" - ], - "eqeqeq": "error", - "no-trailing-spaces": "error", - "object-curly-spacing": [ - "error", "always" - ], - "arrow-spacing": [ - "error", { "before": true, "after": true } +export default [ + { ignores: ['dist'] }, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module' + } + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh + }, + rules: { + ...js.configs.recommended.rules, + ...reactHooks.configs.recommended.rules, + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true } + // highlight-start ], - "no-console": 0, - "react/prop-types": 0 + indent: ['error', 2], + 'linebreak-style': ['error', 'unix'], + quotes: ['error', 'single'], + semi: ['error', 'never'], + eqeqeq: 'error', + 'no-trailing-spaces': 'error', + 'object-curly-spacing': ['error', 'always'], + 'arrow-spacing': ['error', { before: true, after: true }], + 'no-console': 'off' + //highlight-end + } } -} -``` - -Tehdään projektin juureen tiedosto [.eslintignore](https://eslint.org/docs/user-guide/configuring#ignoring-files-and-directories) ja sille seuraava sisältö - -```bash -node_modules -build +] ``` -Näin ainoastaan sovelluksessa oleva itse kirjoitettu koodi huomioidaan linttauksessa. - -Tehdään lintausta varten npm-skripti: +HUOM: Jos käytät Visual Studio Codea yhdessä ESLint-laajennuksen kanssa, saatat joutua muokkaamaan VS Coden asetuksia, jotta linttaus toimii oikein. Jos näet virheen Failed to load plugin react: Cannot find module 'eslint-plugin-react', tarvitaan lisäkonfiguraatiota. Seuraavan rivin lisääminen settings.json-tiedostoon voi auttaa: ```js -{ - // ... - { - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject", - "server": "json-server -p3001 db.json", - "eslint": "eslint ." // highlight-line - }, - // ... -} +"eslint.workingDirectories": [{ "mode": "auto" }] ``` -Komponentti _Togglable_ aiheuttaa ikävän näköisen varoituksen Component definition is missing display name: +Katso lisätietoja [täältä](https://github.com/microsoft/vscode-eslint/issues/880#issuecomment-578052807). -![](../../images/5/25ea.png) +Tuttuun tapaan voit suorittaa linttauksen joko komentoriviltä komennolla -Komponentin "nimettöämyys" käy ilmi myös react-devtoolsilla: - -![](../../images/5/25ea.png) - -Korjaus on onneksi hyvin helppo tehdä - -```js -import React, { useState, useImperativeHandle } from 'react' -import PropTypes from 'prop-types' - -const Togglable = React.forwardRef((props, ref) => { - // ... -}) - -Togglable.displayName = 'Togglable' // highlight-line - -export default Togglable +```bash +npm run lint ``` -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part2-notes/tree/part5-7), branchissa part5-7. +tai editorin Eslint-pluginia hyväksikäyttäen. + +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-7), branchissa part5-7.
    -### Tehtävät 5.11.-5.12. - -#### 5.11: blogilistan frontend, step11 - -Määrittele joillekin sovelluksesi komponenteille PropTypet. +### Tehtävä 5.12. #### 5.12: blogilistan frontend, step12 -Ota projektiin käyttöön ESlint. Määrittele haluamasi kaltainen konfiguraatio. Korjaa kaikki lint-virheet. - -Create-react-app on asentanut projektille eslintin valmiiksi, joten ei tarvita muuta kun sopiva konfiguraatio tiedostoon .eslintrc.js. +Ota projektiin käyttöön ESLint. Määrittele haluamasi kaltainen konfiguraatio. Korjaa kaikki lint-virheet. -**HUOM:** älä suorita komentoa _eslint --init_. Se asentaa uuden version eslintistä joka on epäsopiva create-react-app:in konfiguraatioiden kanssa! +Vite on asentanut projektille ESLintin valmiiksi, joten ei tarvita muuta kun sopiva konfiguraatio tiedostoon eslint.config.js.
    diff --git a/src/content/5/fi/osa5c.md b/src/content/5/fi/osa5c.md index a5d824a26d5..6bad58e7ef4 100644 --- a/src/content/5/fi/osa5c.md +++ b/src/content/5/fi/osa5c.md @@ -9,18 +9,67 @@ lang: fi Reactilla tehtyjen frontendien testaamiseen on monia tapoja. Aloitetaan niihin tutustuminen nyt. -Testit tehdään samaan tapaan kuin edellisessä osassa eli Facebookin [Jest](http://jestjs.io/)-kirjastolla. Jest onkin valmiiksi konfiguroitu create-react-app:illa luotuihin projekteihin. +Kurssilla käytettiin aiemmin React-komponenttien testaamiseen Facebookin kehittämää [Jest](http://jestjs.io/)-kirjastoa. Käytämme kurssilla nyt Viten kehittäjien uuden generaation testikirjastoa [Vitestiä](https://vitest.dev/). Konfigurointia lukuunottamatta kirjastot tarjoavat saman ohjelmointirajapinnan, joten testauskoodissa ei käytännössä ole mitään eroa. -Tarvitsemme Jestin lisäksi testaamiseen apukirjaston, jonka avulla React-komponentteja voidaan renderöidä testejä varten. +Aloitetaan asentamalla Vitest sekä Web-selainta simuloiva [jsdom](https://github.com/jsdom/jsdom)-kirjasto: -Tähän tarkoitukseen ehdottomasti paras vaihtoehto on [react-testing-library](https://github.com/testing-library/react-testing-library). Jestin ilmaisuvoimaa kannattaa myös laajentaa kirjastolla [jest-dom](https://github.com/testing-library/jest-dom). +``` +npm install --save-dev vitest jsdom +``` + +Tarvitsemme Vitestin lisäksi testaamiseen apukirjaston, jonka avulla React-komponentteja voidaan renderöidä testejä varten. -Asennetaan kirjastot komennolla: +Tähän tarkoitukseen ehdottomasti paras vaihtoehto on [React Testing Library](https://github.com/testing-library/react-testing-library). Testien ilmaisuvoimaa kannattaa laajentaa myös kirjastolla [jest-dom](https://github.com/testing-library/jest-dom). + +Asennetaan tarvittavat kirjastot: ```js npm install --save-dev @testing-library/react @testing-library/jest-dom ``` +Ennen kuin pääsemme tekemään ensimmäistä testiä, tarvitsemme hieman konfiguraatioita. + +Lisätään tiedostoon package.json skripti testien suorittamiselle: + +```js +{ + "scripts": { + // ... + "test": "vitest run" + } + // ... +} +``` + +Tehdään projektin juureen tiedosto _testSetup.js_ ja sille sisältö + +```js +import { afterEach } from 'vitest' +import { cleanup } from '@testing-library/react' +import '@testing-library/jest-dom/vitest' + +afterEach(() => { + cleanup() +}) +``` + +Nyt jokaisen testin jälkeen suoritetaan toimenpide joka nollaa selainta simuloivan jsdomin. + +Laajennetaan tiedostoa _vite.config.js_ seuraavasti + +```js +export default defineConfig({ + // ... + test: { + environment: 'jsdom', + globals: true, + setupFiles: './testSetup.js', + } +}) +``` + +Määrittelyn _globals: true_ ansiosta testien käyttämiä avainsanoja kuten _describe_, _test_ ja _expect_ ei ole tarvetta importata testeissä. + Testataan aluksi muistiinpanon renderöivää komponenttia: ```js @@ -38,19 +87,16 @@ const Note = ({ note, toggleImportance }) => { } ``` -Huomaa, että muistiinpanon sisältävällä li-elementillä on [CSS](https://reactjs.org/docs/dom-elements.html#classname)-luokka note, pääsemme sen avulla muistiinpanoon käsiksi testistä. - +Huomaa, että muistiinpanon sisältävällä li-elementillä on [CSS](https://react.dev/learn#adding-styles)-luokka note. Pääsemme sen avulla halutessamme muistiinpanoon käsiksi testistä. Emme kuitenkaan ensisijaisesti käytä CSS-luokkia testauksessa. ### Komponentin renderöinti testiä varten -Tehdään testi tiedostoon src/components/Note.test.js, eli samaan hakemistoon, missä komponentti itsekin sijaitsee. +Tehdään testi tiedostoon src/components/Note.test.jsx eli samaan hakemistoon, jossa komponentti itsekin sijaitsee. Ensimmäinen testi varmistaa, että komponentti renderöi muistiinpanon sisällön: ```js -import React from 'react' -import '@testing-library/jest-dom/extend-expect' -import { render } from '@testing-library/react' +import { render, screen } from '@testing-library/react' import Note from './Note' test('renders content', () => { @@ -59,128 +105,230 @@ test('renders content', () => { important: true } - const component = render( - - ) + render() - expect(component.container).toHaveTextContent( - 'Component testing is done with react-testing-library' - ) + const element = screen.getByText('Component testing is done with react-testing-library') + expect(element).toBeDefined() }) ``` -Alun konfiguroinnin jälkeen testi renderöi komponentin metodin react-testing-library-kirjaston tarjoaman [render](https://testing-library.com/docs/react-testing-library/api#render) avulla: +Alun konfiguroinnin jälkeen testi renderöi komponentin React Testing Library ‑kirjaston tarjoaman funktion [render](https://testing-library.com/docs/react-testing-library/api#render) avulla: ```js -const component = render( - -) +render() ``` Normaalisti React-komponentit renderöityvät DOM:iin. Nyt kuitenkin renderöimme komponentteja testeille sopivaan muotoon laittamatta niitä DOM:iin. -_render_ palauttaa olion, jolla on useita [kenttiä](https://testing-library.com/docs/react-testing-library/api#render-result). Yksi kentistä on container, se sisältää koko komponentin renderöimän HTML:n. - -Ekspektaatiossa varmistamme, että komponenttiin on renderöitynyt oikea teksti, eli muistiinpanon sisältö: +Testin renderöimään näkymään päästään käsiksi olion [screen](https://testing-library.com/docs/queries/about#screen) kautta. Haetaan screenistä metodin [getByText](https://testing-library.com/docs/queries/bytext) avulla elementtiä, jossa on muistiinpanon sisältö ja varmistetaan että elementti on olemassa: ```js -expect(component.container).toHaveTextContent( - 'Component testing is done with react-testing-library' -) + const element = screen.getByText('Component testing is done with react-testing-library') + expect(element).toBeDefined() ``` -### Testien suorittaminen - -Create-react-app:issa on konfiguroitu testit oletusarvoisesti suoritettavaksi ns. watch-moodissa, eli jos suoritat testit komennolla _npm test_, jää konsoli odottamaan koodissa tapahtuvia muutoksia. Muutosten jälkeen testit suoritetaan automaattisesti ja Jest alkaa taas odottamaan uusia muutoksia koodiin. +Elementin olemassaolo tarkastetaan Vitestin [expect](https://vitest.dev/api/expect.html#expect) komennon avulla. Expect muodostaa parametristaan väittämän jonka paikkansapitävyyttä voidaan testata erilaisten ehtofunktioiden avulla. Nyt käytössä oli [toBeDefined](https://vitest.dev/api/expect.html#tobedefined) joka siis testaa, onko expectin parametrina oleva _element_ olemassa. -Jos haluat ajaa testit "normaalisti", se onnistuu komennolla +Suoritetaan testi: ```js -CI=true npm test +$ npm test + +> notes-frontend@0.0.0 test +> vitest run + + + RUN v3.2.3 /home/vejolkko/repot/fullstack-examples/notes-frontend + + ✓ src/components/Note.test.jsx (1 test) 19ms + ✓ renders content 18ms + + Test Files 1 passed (1) + Tests 1 passed (1) + Start at 14:31:54 + Duration 874ms (transform 51ms, setup 169ms, collect 19ms, tests 19ms, environment 454ms, prepare 87ms) ``` -**HUOM:** konsoli saattaa herjata virhettä, jos sinulla ei ole asennettuna watchmania. Watchman on Facebookin kehittämä tiedoston muutoksia tarkkaileva ohjelma. Ohjelma nopeuttaa testien ajoa ja ainakin osx sierrasta ylöspäin jatkuva testien vahtiminen aiheuttaa käyttäjillä virheilmoituksia. Näistä ilmoituksista pääsee eroon asentamalla Watchmanin. +Kuten olettaa saattaa, testi menee läpi. + +Eslint valittaa testeissä olevista avainsanoista _test_ ja _expect_. Ongelmasta päästään eroon lisäämällä tiedostoon eslint.config.js seuraava määrittely: + +```js +// ... -Ohjeet ohjelman asentamiseen eri käyttöjärjestelmille löydät Watchmanin sivulta: -https://facebook.github.io/watchman/ +export default [ + // ... + // highlight-start + { + files: ['**/*.test.{js,jsx}'], + languageOptions: { + globals: { + ...globals.vitest + } + } + } + // highlight-end +] +``` + +Näin ESLintille kerrotaan, että Vitestin avainsanat ovat testitiedostoissa globaalisti saatavilla. ### Testien sijainti -Reactissa on (ainakin) [kaksi erilaista](https://medium.com/@JeffLombardJr/organizing-tests-in-jest-17fc431ff850) konventiota testien sijoittamiseen. Sijoitimme testit ehkä vallitsevan tavan mukaan, eli samaan hakemistoon missä testattava komponentti sijaitsee. +Reactissa on (ainakin) [kaksi erilaista](https://medium.com/@JeffLombardJr/organizing-tests-in-jest-17fc431ff850) konventiota testien sijoittamiseen. Sijoitimme testit ehkä vallitsevan tavan mukaan samaan hakemistoon testattavan komponentin kanssa. -Toinen tapa olisi sijoittaa testit "normaaliin" tapaan omaan erilliseen hakemistoon. Valitaanpa kumpi tahansa tapa, on varmaa että se on jonkun mielestä täysin väärä. +Toinen tapa olisi sijoittaa testit "normaaliin" tapaan omaan erilliseen hakemistoon. Valitaanpa kumpi tapa tahansa, on varmaa että se on jonkun mielestä täysin väärä. -Itse en pidä siitä, että testit ja normaali koodi ovat samassa hakemistossa. Noudatamme kuitenkin nyt tätä tapaa, sillä se on oletusarvo create-react-app:illa konfiguroiduissa sovelluksissa. +Itse en pidä siitä, että testit ja normaali koodi ovat samassa hakemistossa. Noudatamme kuitenkin nyt tätä tapaa, sillä se on yleisin käytäntö pienissä projekteissa. ### Sisällön etsiminen testattavasta komponentista -react-testing-library-kirjasto tarjoaa runsaasti tapoja, miten voimme tutkia testattavan komponentin sisältöä. Laajennetaan testiämme hiukan: +React Testing Library ‑kirjasto tarjoaa runsaasti tapoja testattavan komponentin sisällön tutkimiseen. Tutustuimme jo aiemmin komentoon _getByText_. Itse asiassa testimme viimeisellä rivillä oleva expect on turha ```js +import { render, screen } from '@testing-library/react' +import Note from './Note' + test('renders content', () => { const note = { content: 'Component testing is done with react-testing-library', important: true } - const component = render( - - ) + render() - // tapa 1 - expect(component.container).toHaveTextContent( - 'Component testing is done with react-testing-library' - ) + const element = screen.getByText('Component testing is done with react-testing-library') - // tapa 2 - const element = component.getByText( - 'Component testing is done with react-testing-library' + expect(element).toBeDefined() // highlight-line +}) +``` + +Testi ei mene läpi, jos _getByText_ ei löydä halutun tekstin sisältävää elementtiä. + +Komento _getByText_ etsii oletusarvoisesti elementtiä, joka sisältää ainoastaan parametrina annetun tekstin eikä mitään muuta. Oletetaan että komponentti renderöisi samaan HTML-elementtiin tekstiä seuraavasti: + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' : 'make important' + + return ( +
  • + Your awesome note: {note.content} // highlight-line + +
  • ) +} + +export default Note +``` + +Nyt testissä käyttämämme _getByText_ ei löydä elementtiä: + +```js +test('renders content', () => { + const note = { + content: 'Does not work anymore :(', + important: true + } + + render() + + const element = screen.getByText('Does not work anymore :(') + expect(element).toBeDefined() +}) +``` - // tapa 3 - const div = component.container.querySelector('.note') - expect(div).toHaveTextContent( - 'Component testing is done with react-testing-library' - ) +Jos halutaan etsiä komponenttia joka sisältää tekstin, voidaan joko lisätä komennolle ekstraoptio: + +```js +const element = screen.getByText( + 'Does not work anymore :(', { exact: false } +) +``` + +tai käyttää komentoa _findByText_: + +```js +const element = await screen.findByText('Does not work anymore :(') +``` + +On tärkeä huomata, että toisin kuin muut _ByText_-komennoista, _findByText_ palauttaa promisen! + +On myös jotain tilanteita, missä komennon muoto _queryByText_ on käyttökelpoinen. Komento palauttaa elementin mutta ei aiheuta poikkeusta jos etsittävää elementtiä ei löydy. + +Komentoa voidaan hyödyntää esim. varmistamaan, että jokin asia ei renderöidy: + +```js +test('does not render this', () => { + const note = { + content: 'This is a reminder', + important: true + } + + render() + + const element = screen.queryByText('do not want this thing to be rendered') + expect(element).toBeNull() }) ``` -Ensimmäinen tapa eli metodi toHaveTextContent siis etsii tiettyä tekstiä koko komponentin renderöimästä HTML:stä. toHaveTextContent on eräs monista [jest-dom](https://github.com/testing-library/jest-dom#tohavetextcontent)-kirjaston tarjoamista "matcher"-metodeista. +Muitakin tapoja on, esim. [getByTestId](https://testing-library.com/docs/queries/bytestid/), joka etsii elementtejä erikseen testejä varten luotujen id-kenttien perusteella. + +Jos haluamme etsiä testattavia komponentteja [CSS-selektorien](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors) avulla, se onnistuu renderin palauttaman [container](https://testing-library.com/docs/react-testing-library/api/#container)-olion metodilla [querySelector](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector): + +```js +import { render, screen } from '@testing-library/react' +import Note from './Note' + +test('renders content', () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } -Toisena käytimme render-metodin palauttamaan olioon liittyvää [getByText](https://testing-library.com/docs/dom-testing-library/api-queries#bytext)-metodia, joka palauttaa sen elementin, jolla on parametrina määritelty teksti. Jos elementtiä ei ole, tapahtuu poikkeus. Eli mitään ekspektaatiota ei välttämättä edes tarvittaisi. + const { container } = render() // highlight-line -Kolmas tapa on etsiä komponentin sisältä tietty elementti metodilla [querySelector](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector), joka saa parametrikseen [CSS-selektorin](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors). +// highlight-start + const div = container.querySelector('.note') + expect(div).toHaveTextContent( + 'Component testing is done with react-testing-library' + ) + // highlight-end +}) +``` -Kaksi viimeistä tapaa siis hakevat metodien getByText ja querySelector avulla renderöidystä komponentista jonkin ehdon täyttävän elementin. Vastaavalla periaatteella toimivia "query"-metodeja, on tarjolla [lukuisia](https://testing-library.com/docs/dom-testing-library/api-queries). +On kuitenkin suositeltavaa etsiä elementtejä lähtökohtaisesti muilla tavoin kuin _container_-oliota ja CSS-selektoreja käyttäen. CSS-määreitä voidaan usein muuttaa vaikuttamatta sovelluksen toiminnallisuuteen, eikä käyttäjä ole niistä tietoinen. On parempi etsiä elementtejä käyttäjälle havaittavien ominaisuuksien perusteella, esimerkiksi _getByText_-metodia käyttäen. Tällä tavoin testit simuloivat paremmin komponentin todellista olemusta ja sitä, miten käyttäjä löytäisi elementin ruudulta. ### Testien debuggaaminen -Testejä tehdessä törmäämme tyypillisesti erittäin moniin ongelmiin. +Testejä tehdessä törmäämme tyypillisesti moniin ongelmiin. -Renderin palauttaman olion metodilla [debug](https://testing-library.com/docs/react-testing-library/api#debug) voimme tulostaa komponentin tuottaman HTML:n konsoliin, eli kun muutamme testiä seuraavasti: +Olion _screen_ metodilla [debug](https://testing-library.com/docs/queries/about/#screendebug) voimme tulostaa komponentin tuottaman HTML:n konsoliin. Eli kun muutamme testiä seuraavasti: ```js +import { render, screen } from '@testing-library/react' +import Note from './Note' + test('renders content', () => { const note = { content: 'Component testing is done with react-testing-library', important: true } - const component = render( - - ) + render() - component.debug() // highlight-line + screen.debug() // highlight-line // ... + }) ``` konsoliin tulostuu komponentin generoima HTML: ```js -console.log node_modules/@testing-library/react/dist/index.js:90
  • ``` -On myös mahdollista etsiä komponentista pienempi osa, ja tulostaa sen HTML-koodi, tällöin tarvitsemme metodia _prettyDOM_, joka löytyy react-testing-library:n mukana tulevasta kirjastosta @testing-library/dom: +On myös mahdollista etsiä komponentista pienempi osa, ja tulostaa sen HTML-koodi: ```js -import React from 'react' -import '@testing-library/jest-dom/extend-expect' -import { render } from '@testing-library/react' -import { prettyDOM } from '@testing-library/dom' // highlight-line +import { render, screen } from '@testing-library/react' import Note from './Note' test('renders content', () => { @@ -210,19 +355,19 @@ test('renders content', () => { important: true } - const component = render( - - ) - const li = component.container.querySelector('li') - - console.log(prettyDOM(li)) // highlight-line + render() + + const element = screen.getByText('Component testing is done with react-testing-library') + + screen.debug(element) // highlight-line + + expect(element).toBeDefined() }) ``` -Eli haimme selektorin avulla komponentin sisältä li-elementin ja tulostimme sen HTML:n konsoliin: +Haimme nyt halutun tekstin sisältävän elementin sisällön tulostettavaksi: ```js -console.log src/components/Note.test.js:21
  • @@ -235,14 +380,19 @@ console.log src/components/Note.test.js:21 ### Nappien painelu testeissä -Sisällön näyttämisen lisäksi toinen Note-komponenttien vastuulla oleva asia on huolehtia siitä, että painettaessa noten yhteydessä olevaa nappia, tulee propsina välitettyä tapahtumankäsittelijäfunktiota _toggleImportance_ kutsua. +Sisällön näyttämisen lisäksi toinen Note-komponenttien vastuulla oleva asia on huolehtia siitä, että propsina välitettyä tapahtumankäsittelijäfunktiota _toggleImportance_ kutsutaan kun noten yhteydessä olevaa nappia painetaan. + +Asennetaan testiä varten apukirjasto [user-event](https://testing-library.com/docs/user-event/intro/): + +``` +npm install --save-dev @testing-library/user-event +``` Testaus onnistuu seuraavasti: ```js -import React from 'react' -import { render, fireEvent } from '@testing-library/react' // highlight-line -import { prettyDOM } from '@testing-library/dom' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' // highlight-line import Note from './Note' // ... @@ -253,199 +403,161 @@ test('clicking the button calls event handler once', async () => { important: true } - const mockHandler = jest.fn() + const mockHandler = vi.fn() - const component = render( + render( ) - const button = component.getByText('make not important') - fireEvent.click(button) + const user = userEvent.setup() + const button = screen.getByText('make not important') + await user.click(button) expect(mockHandler.mock.calls).toHaveLength(1) }) ``` -Testissä on muutama mielenkiintoinen seikka. Tapahtumankäsittelijäksi annetaan Jestin avulla määritelty [mock](https://facebook.github.io/jest/docs/en/mock-functions.html)-funktio: +Testissä on muutama mielenkiintoinen seikka. Tapahtumankäsittelijäksi annetaan Vitestin avulla määritelty [mock](https://vitest.dev/api/mock)-funktio: + +```js +const mockHandler = vi.fn() +``` + +Jotta renderöidyn komponentin kanssa voi vuorovaikuttaa tapahtumien avulla, tulee ensin aloittaa uusi sessio. Tämä onnistuu userEvent-olion [setup](https://testing-library.com/docs/user-event/setup/)-metodin avulla: ```js -const mockHandler = jest.fn() +const user = userEvent.setup() ``` Testi hakee renderöidystä komponentista napin tekstin perusteella ja klikkaa sitä: ```js -const button = getByText('make not important') -fireEvent.click(button) +const button = screen.getByText('make not important') +await user.click(button) ``` -Klikkaaminen tapahtuu metodin [fireEvent](https://testing-library.com/docs/api-events#fireevent) avulla. +Klikkaaminen tapahtuu userEvent-olion metodin [click](https://testing-library.com/docs/user-event/convenience/#click) avulla. -Testin ekspektaatio varmistaa, että mock-funktiota on kutsuttu täsmälleen kerran: +Testin ekspektaatio varmistaa [toHaveLength](https://vitest.dev/api/expect.html#tohavelength) matcherin avulla, että mock-funktiota on kutsuttu täsmälleen kerran: ```js expect(mockHandler.mock.calls).toHaveLength(1) ``` -[Mockoliot ja -funktiot](https://en.wikipedia.org/wiki/Mock_object) ovat testauksessa yleisesti käytettyjä valekomponentteja, joiden avulla korvataan testattavien komponenttien riippuvuuksia, eli niiden tarvitsemia muita komponentteja. Mockit mahdollistavat mm. kovakoodattujen syötteiden palauttamisen sekä niiden metodikutsujen lukumäärän sekä parametrien testauksen aikaisen tarkkailun. +Mock-funktion kutsut tallennetaan mockin sisällä olevaan listaan [mock.calls](https://vitest.dev/api/mock#mock-calls). -Esimerkissämme mock-funktio sopi tarkoitukseen erinomaisesti, sillä sen avulla on helppo varmistaa, että metodia on kutsuttu täsmälleen kerran. - -### Komponentin Togglable testit - -Tehdään komponentille Togglable muutama testi. Lisätään komponentin lapset renderöivään div-elementtiin CSS-luokka togglableContent: +[Mock-oliot ja ‑funktiot](https://en.wikipedia.org/wiki/Mock_object) ovat testauksessa yleisesti käytettyjä valekomponentteja, joiden avulla korvataan testattavien komponenttien riippuvuuksia eli niiden tarvitsemia muita komponentteja. Mockit mahdollistavat mm. kovakoodattujen syötteiden palauttamisen ja metodikutsujen lukumäärän ja parametrien tarkkailun testauksen aikana. -```js -const Togglable = React.forwardRef((props, ref) => { - // ... +Esimerkissämme mock-funktio sopi tarkoitukseen erinomaisesti, sillä sen avulla on helppo varmistaa, että metodia on kutsuttu täsmälleen kerran. - return ( -
    -
    - -
    -
    // highlight-line - {props.children} - -
    -
    - ) -}) -``` +### Komponentin Togglable testit -Testit ovat seuraavassa +Tehdään komponentille Togglable muutama testi. Testit ovat seuraavassa: ```js -import React from 'react' -import '@testing-library/jest-dom/extend-expect' -import { render, fireEvent } from '@testing-library/react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import Togglable from './Togglable' describe('', () => { - let component - beforeEach(() => { - component = render( + render( -
    +
    togglable content
    ) }) test('renders its children', () => { - expect( - component.container.querySelector('.testDiv') - ).toBeDefined() + screen.getByText('togglable content') }) test('at start the children are not displayed', () => { - const div = component.container.querySelector('.togglableContent') - - expect(div).toHaveStyle('display: none') + const element = screen.getByText('togglable content') + expect(element).not.toBeVisible() }) - test('after clicking the button, children are displayed', () => { - const button = component.getByText('show...') - fireEvent.click(button) + test('after clicking the button, children are displayed', async () => { + const user = userEvent.setup() + const button = screen.getByText('show...') + await user.click(button) - const div = component.container.querySelector('.togglableContent') - expect(div).not.toHaveStyle('display: none') + const element = screen.getByText('togglable content') + expect(element).toBeVisible() }) - -}) ``` -Ennen jokaista testiä suoritettava _beforeEach_ renderöi Togglable-komponentin muuttujaan _component_. - -Ensimmäinen testi tarkastaa, että Togglable renderöi sen lapsikomponentin `
    `. +Ennen jokaista testiä suoritettava _beforeEach_ renderöi Togglable-komponentin. -Loput testit varmistavat metodia [toHaveStyle](https://www.npmjs.com/package/jest-dom#tohavestyle) käyttäen, että Togglablen sisältämä lapsikomponentti on alussa näkymättömissä, eli sen sisältävään div-elementtiin liittyy tyyli `{ display: 'none' }`, ja että nappia painettaessa komponentti näkyy, eli näkymättömäksi tekevää tyyliä ei enää ole. - -Nappi etsitään jälleen nappiin liittyvän tekstin perusteella. Nappi oltaisiin voitu etsiä myös CSS-selektorin avulla +Ensimmäinen testi tarkastaa, että Togglable renderöi sen lapsikomponentin ```js -const button = component.container.querySelector('button') +
    + togglable content +
    ``` -Komponentissa on kaksi nappia, mutta koska [querySelector](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector) palauttaa ensimmäisen löytyvän napin, löytyy napeista oikea. +Loput testit varmistavat metodia _toBeVisible_ käyttäen, että Togglablen sisältämä lapsikomponentti on alussa näkymättömissä, eli että sen sisältävään div-elementtiin liittyy tyyli _{ display: 'none' }_, ja että nappia painettaessa komponentti näkyy käyttäjälle, eli näkymättömäksi tekevää tyyliä ei enää ole. -Lisätään vielä mukaan testi, joka varmistaa että auki togglattu sisältö saadaan piilotettua painamalla komponentin toisena olevaa nappia +Lisätään vielä mukaan testi, joka varmistaa että auki togglattu sisältö saadaan piilotettua painamalla komponentin nappia cancel: ```js -test('toggled content can be closed', () => { - const button = component.container.querySelector('button') - fireEvent.click(button) - - const closeButton = component.container.querySelector( - 'button:nth-child(2)' - ) - fireEvent.click(closeButton) +describe('', () => { - const div = component.container.querySelector('.togglableContent') - expect(div).toHaveStyle('display: none') -}) -``` + // ... -eli määrittelimme selektorin, joka palauttaa toisena olevan napin `button:nth-child(2)`. Testeissä ei kuitenkaan ole viisasta olla riippuvainen komponentin nappien järjestyksestä, joten parempi onkin hakea napit niiden tekstin perusteella: + test('toggled content can be closed', async () => { + const user = userEvent.setup() -```js -test('toggled content can be closed', () => { - const button = component.getByText('show...') - fireEvent.click(button) + const button = screen.getByText('show...') + await user.click(button) - const closeButton = component.getByText('cancel') - fireEvent.click(closeButton) + const closeButton = screen.getByText('cancel') + await user.click(closeButton) - const div = component.container.querySelector('.togglableContent') - expect(div).toHaveStyle('display: none') + const element = screen.getByText('togglable content') + expect(element).not.toBeVisible() + }) }) ``` -Käyttämämme _getByText_ on vain yksi monista [queryistä](https://testing-library.com/docs/api-queries#queries), joita react-testing-library tarjoaa. - ### Lomakkeiden testaus -Käytimme jo edellisissä testeissä [fireEvent](https://testing-library.com/docs/api-events#fireevent)-funktiota nappien klikkaamiseen: +Käytimme jo edellisissä testeissä [user-event](https://testing-library.com/docs/user-event/intro)-kirjaston _click_-metodia nappien klikkaamiseen: ```js -const button = component.getByText('show...') -fireEvent.click(button) +const button = screen.getByText('show...') +await user.click(button) ``` -Käytännössä siis loimme fireEventin avulla tapahtuman click nappia vastaavalle komponentille. Voimme myös simuloida lomakkeisiin kirjoittamista fireEventin avulla. +Käytännössä siis loimme metodin avulla click-tapahtuman metodin argumenttina annetulle komponentille. Voimme simuloida myös lomakkeelle kirjoittamista userEvent-olion avulla. -Tehdään testi komponentille NoteForm. Lomakkeen koodi näyttää seuraavalta +Tehdään testi komponentille NoteForm. Lomakkeen koodi näyttää seuraavalta: ```js -import React, { useState } from 'react' +import { useState } from 'react' const NoteForm = ({ createNote }) => { const [newNote, setNewNote] = useState('') - const handleChange = (event) => { - setNewNote(event.target.value) - } - - const addNote = (event) => { + const addNote = event => { event.preventDefault() createNote({ content: newNote, - important: Math.random() > 0.5, + important: true }) setNewNote('') } return ( -
    +

    Create a new note

    setNewNote(event.target.value)} />
    @@ -461,112 +573,289 @@ Lomakkeen toimintaperiaatteena on kutsua sille propsina välitettyä funktiota _ Testi on seuraavassa: ```js -import React from 'react' -import { render, fireEvent } from '@testing-library/react' -import '@testing-library/jest-dom/extend-expect' +import { render, screen } from '@testing-library/react' import NoteForm from './NoteForm' +import userEvent from '@testing-library/user-event' -test(' updates parent state and calls onSubmit', () => { - const createNote = jest.fn() +test(' updates parent state and calls onSubmit', async () => { + const user = userEvent.setup() + const createNote = vi.fn() - const component = render( - - ) + render() - const input = component.container.querySelector('input') - const form = component.container.querySelector('form') + const input = screen.getByRole('textbox') + const sendButton = screen.getByText('save') - fireEvent.change(input, { - target: { value: 'testing of forms could be easier' } - }) - fireEvent.submit(form) + await user.type(input, 'testing a form...') + await user.click(sendButton) expect(createNote.mock.calls).toHaveLength(1) - expect(createNote.mock.calls[0][0].content).toBe('testing of forms could be easier' ) + expect(createNote.mock.calls[0][0].content).toBe('testing a form...') }) ``` -Syötekenttään input kirjoittamista simuloidaan tekemällä syötekenttään tapahtuma change ja määrittelemällä sopiva olio, joka määrittelee syötekenttään 'kirjoitetun' sisällön. +Syötekenttä etsitään metodin [getByRole](https://testing-library.com/docs/queries/byrole) avulla. Syötekenttään kirjoitetaan metodin [type](https://testing-library.com/docs/user-event/utility#type) avulla. Testin ensimmäinen ekspektaatio varmistaa, että lomakkeen lähetys on aikaansaanut tapahtumankäsittelijän _createNote_ kutsumisen. Toinen ekspektaatio tarkistaa, että tapahtumankäsittelijää kutsutaan oikealla parametrilla, eli että luoduksi tulee samansisältöinen muistiinpano kuin lomakkeelle kirjoitetaan. -Lomake lähetetään simuloimalla tapahtuma submit lomakkeelle. +Kannattaa huomata, että vanha kunnon _console.log_ toimii testeissä normaaliin tapaan. Jos esim. halutaan takastella miltä mock-olion tallettamat kutsut näyttävät, voidaan tehdä seuraavasti -Testin ensimmäinen ekspektaatio varmistaa, että lomakkeen lähetys on aikaansaanut tapahtumankäsittelijän _createNote_ kutsumisen. Toinen ekspektaatio tarkistaa, että tapahtumankäsittelijää kutsutaan oikealla parametrilla, eli että luoduksi tulee saman sisältöinen muistiinpano kuin lomakkeelle kirjoitetaan. +```js +test(' updates parent state and calls onSubmit', async () => { + const user = userEvent.setup() + const createNote = vi.fn() -### Testauskattavuus + render() -[Testauskattavuus](https://github.com/facebookincubator/create-react-app/blob/ed5c48c81b2139b4414810e1efe917e04c96ee8d/packages/react-scripts/template/README.md#coverage-reporting) saadaan helposti selville suorittamalla testit komennolla + const input = screen.getByRole('textbox') + const sendButton = screen.getByText('save') + + await user.type(input, 'testing a form...') + await user.click(sendButton) + + console.log(createNote.mock.calls) // highlight-line +}) +``` + +Testien suorituksen sekaan tulostuu + +``` +[ [ { content: 'testing a form...', important: true } ] ] +``` + +### Lisää elementtien etsimisestä + +Oletetaan että lomakkeella olisi useita syötekenttiä: ```js -CI=true npm test -- --coverage +const NoteForm = ({ createNote }) => { + // ... + + return ( +
    +

    Create a new note

    + +
    + setNewNote(event.target.value)} + /> + // highlight-start + + // highlight-end + +
    +
    + ) +} ``` -![](../../images/5/18ea.png) +Nyt testissä käytetty syötekentän etsimistapa: -Melko primitiivinen HTML-muotoinen raportti generoituu hakemistoon coverage/lcov-report. HTML-muotoinen raportti kertoo mm. yksittäisen komponenttien testaamattomat koodirivit: +```js +const input = screen.getByRole('textbox') +``` -![](../../images/5/19ea.png) +aiheuttaisi virheen: -Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part2-notes/tree/part5-8), branchissa part5-8. +![Konsoli kertoo virheestä TestingLibraryElementError: Found multiple elements with the role "textbox"](../../images/5/40.png) -
    +Virheilmoitus ehdottaa käytettäväksi metodia getAllByRole (jos tilanne ylipäätään on se mitä halutaan). Testi korjautuisi seuraavasti: -
    +```js +const inputs = screen.getAllByRole('textbox') -### Tehtävät 5.13.-5.16. +await user.type(inputs[0], 'testing a form...') +``` -#### 5.13: blogilistan testit, step1 +Metodi getAllByRole palauttaa taulukon, ja oikea tekstikenttä on taulukossa ensimmäisenä. Testi on kuitenkin hieman epäilyttävä, sillä se luottaa tekstikenttien järjestykseen. -Tee testi, joka varmistaa että blogin näyttävä komponentti renderöi blogin titlen, authorin mutta ei renderöi oletusarvoisesti urlia eikä likejen määrää. +Jos syötekentälle olisi määritelty label, voisi kyseisen syötekentän etsiä sen avulla käyttäen metodia _getByLabelText_. Jos siis lisäisimme syötekentälle labelin: -#### 5.14: blogilistan testit, step1 +```js + // ... + // highlight-line + // ... +``` -Tee testi, joka varmistaa että myös url ja likejen määrä näytetään kun blogin kaikki tiedot näyttävää nappia on painettu. +Testi löytäisi syötekentän seuraavasti: -#### 5.15: blogilistan testit, step2 +```js +test(' updates parent state and calls onSubmit', () => { + const createNote = vi.fn() -Tee testi, joka varmistaa, että jos komponentin like-nappia painetaan kahdesti, komponentin propsina saamaa tapahtumankäsittelijäfunktiota kutsutaan kaksi kertaa. + render() -#### 5.16*: blogilistan testit, step3 + const input = screen.getByLabelText('content') // highlight-line + const sendButton = screen.getByText('save') -Tee uuden blogin luomisesta huolehtivalle lomakkelle testi, joka varmistaa, että lomake kutsuu propseina saamaansa takaisinkutsufunktiota oikeilla tiedoilla siinä vaiheessa kun blogi luodaan. + userEvent.type(input, 'testing a form...' ) + userEvent.click(sendButton) -Lisää komponenttiin tarvittaessa testausta helpottavia CSS-luokkia tai id:itä. + expect(createNote.mock.calls).toHaveLength(1) + expect(createNote.mock.calls[0][0].content).toBe('testing a form...' ) +}) +``` -Jos esim. määrittelet input-elementille id:n 'author': +Syötekentille määritellään usein placeholder-teksti, joka ohjaa käyttäjää kirjoittamaan syötekenttään oikean arvon. Lisätään placeholder lomakkeellemme: ```js - {}} -/> +const NoteForm = ({ createNote }) => { + // ... + + return ( +
    +

    Create a new note

    + +
    + setNewNote(event.target.value)} + placeholder='write note content here' // highlight-line + /> + + +
    +
    + ) +} +``` + +Nyt oikean syötekentän etsiminen onnistuu metodin [getByPlaceholderText](https://testing-library.com/docs/queries/byplaceholdertext) avulla: + +```js +test(' updates parent state and calls onSubmit', () => { + const createNote = vi.fn() + + render() + + const input = screen.getByPlaceholderText('write note content here') // highlight-line + const sendButton = screen.getByText('save') + + userEvent.type(input, 'testing a form...' ) + userEvent.click(sendButton) + + expect(createNote.mock.calls).toHaveLength(1) + expect(createNote.mock.calls[0][0].content).toBe('testing a form...' ) +}) +``` + +Joskus oikean elementin löytäminen voi olla vaikeaa edellä kuvattuja metodeja käyttäen. Tällöin vaihtoehtona on aiemmin [tässä luvussa](/osa5/react_sovellusten_testaaminen#sisallon-etsiminen-testattavasta-komponentista) esitellyn _render_-metodin palauttaman olion _container_-kentän metodi querySelector, joka mahdollistaa komponenttien etsimisen mielivaltaisten CSS-selektorien avulla. + +Jos esim. määrittelisimme syötekentälle yksilöivän attribuutin _id_: + +```js +const NoteForm = ({ createNote }) => { + // ... + + return ( +
    +

    Create a new note

    + +
    + setNewNote(event.target.value)} + id='note-input' // highlight-line + /> + + +
    +
    + ) +} ``` -saat haettua kentän testissä seuraavasti +Testi löytäisi elementin seuraavasti: ```js -const author = component.container.querySelector('#author') +const { container } = render() + +const input = container.querySelector('#note-input') ``` +Jätämme koodiin placeholderiin perustuvan ratkaisun. + +### Testauskattavuus + +[Testauskattavuus](https://vitest.dev/guide/coverage.html#coverage) saadaan helposti selville suorittamalla testit komennolla + +```js +npm test -- --coverage +``` + +Kun suoritat ensimmäistä kertaa komennon, kysyy Vitest haluatko asentaa tarvittavan apukirjaston _@vitest/coverage-v8_. Asenna se, ja suorita komento uudelleen: + +![Konsoliin tulostuu taulukko joka näyttää kunkin tiedoston testien kattavuusraportin sekä mahdolliset testien kattamattomat rivit](../../images/5/18new.png) + +HTML-muotoinen raportti generoituu hakemistoon coverage. HTML-muotoinen raportti kertoo mm. yksittäisten komponentin testaamattomat koodirivit: + +![Selaimeen renderöityy näkymä tiedostoista jossa värein merkattu ne rivit joita testit eivät kattaneet](../../images/5/19newer.png) + +Lisätään vielä hakemisto coverage/ tiedostoon .gitignore, jotta hakemiston sisältö jää versionhallinnan ulkopuolelle: + +```js +//... + +coverage/ +``` + +Sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-8), branchissa part5-8. + +
    + +
    + +### Tehtävät 5.13.-5.16. + +#### 5.13: blogilistan testit, step1 + +Tee testi, joka varmistaa että blogin näyttävä komponentti renderöi blogin titlen ja authorin mutta ei renderöi oletusarvoisesti urlia eikä likejen määrää. Mikäli toteutit tehtävän 5.7, niin pelkkä titlen renderöinnin testaus riittää. + +#### 5.14: blogilistan testit, step2 + +Tee testi, joka varmistaa että myös url, likejen määrä ja käyttäjä näytetään, kun blogin kaikki tiedot näyttävää nappia on painettu. + +#### 5.15: blogilistan testit, step3 + +Tee testi, joka varmistaa, että jos komponentin like-nappia painetaan kahdesti, komponentin propsina saamaa tapahtumankäsittelijäfunktiota kutsutaan kaksi kertaa. + +#### 5.16: blogilistan testit, step4 + +Tee uuden blogin luomisesta huolehtivalle lomakkeelle testi, joka varmistaa, että lomake kutsuu propsina saamaansa takaisinkutsufunktiota oikeilla tiedoilla siinä vaiheessa kun blogi luodaan. +
    ### Frontendin integraatiotestaus -Suoritimme edellisessä osassa backendille integraatiotestejä, jotka testasivat backendin tarjoaman API:n läpi backendia ja tietokantaa. Backendin testauksessa tehtiin tietoinen päätös olla kirjoittamatta yksikkötestejä sillä backendin koodi on melko suoraviivaista ja ongelmat tulevatkin esiin todennäköisemmin juuri monimutkaisemmissa skenaarioissa, joita integraatiotestit testaavat hyvin. +Suoritimme edellisessä osassa backendille integraatiotestejä, jotka testasivat backendin tarjoaman API:n läpi backendia ja tietokantaa. Backendin testauksessa tehtiin tietoinen päätös olla kirjoittamatta yksikkötestejä, sillä backendin koodi on melko suoraviivaista ja ongelmat tulevatkin esiin todennäköisemmin juuri monimutkaisemmissa skenaarioissa, joita integraatiotestit testaavat hyvin. Toistaiseksi kaikki frontendiin tekemämme testit ovat olleet yksittäisten komponenttien oikeellisuutta valvovia yksikkötestejä. Yksikkötestaus on toki välillä hyödyllistä, mutta kattavinkaan yksikkötestaus ei riitä antamaan riittävää luotettavuutta sille, että järjestelmä toimii kokonaisuudessaan. -Voisimme tehdä myös frontendille useiden komponenttien yhteistoiminnallisuutta testaavia integraatiotestejä, mutta se on oleellisesti yksikkötestausta hankalampaa, sillä itegraatiotesteissä jouduttaisiin ottamaan kantaa mm. palvelimelta haettavan datan mockaamiseen. Päätämmekin keskittyä koko sovellusta testaavien end to end -testien tekemiseen, jonka parissa jatkamme tämän osan viimeisessä jaksossa. +Voisimme tehdä myös frontendille useiden komponenttien yhteistoiminnallisuutta testaavia integraatiotestejä, mutta se on oleellisesti yksikkötestausta hankalampaa, sillä integraatiotesteissä jouduttaisiin ottamaan kantaa mm. palvelimelta haettavan datan mockaamiseen. Päätämmekin keskittyä koko sovellusta testaavien end to end ‑testien tekemiseen, jonka parissa jatkamme tämän osan seuraavassa luvussa. ### Snapshot-testaus -Jest tarjoaa "perinteisen" testaustavan lisäksi aivan uudenlaisen tavan testaukseen, ns. [snapshot](https://facebook.github.io/jest/docs/en/snapshot-testing.html)-testauksen. Mielenkiintoista snapshot-testauksessa on se, että sovelluskehittäjän ei tarvitse itse määritellä ollenkaan testejä, snapshot-testauksen käyttöönotto riittää. +Vitest tarjoaa "perinteisen" testaustavan lisäksi aivan uudenlaisen tavan testaukseen, ns. [snapshot](https://vitest.dev/guide/snapshot)-testauksen. Mielenkiintoista snapshot-testauksessa on se, että sovelluskehittäjän ei tarvitse itse määritellä ollenkaan testejä, snapshot-testauksen käyttöönotto riittää. -Periaatteena on verrata komponenttien määrittelemää HTML:ää aina koodin muutoksen jälkeen siihen, minkälaisen HTML:n komponentit määrittelivät ennen muutosta. +Periaatteena on verrata aina koodin muutoksen jälkeen komponenttien määrittelemää HTML:ää siihen HTML:ään, jonka komponentit määrittelivät ennen muutosta. -Jos snapshot-testi huomaa muutoksen komponenttien määrittelemässä HTML:ssä, voi kyseessä joko olla haluttu muutos tai vahingossa aiheutettu "bugi". Snapshot-testi huomauttaa sovelluskehittäjälle, jos komponentin määrittelemä HTML muuttuu. Sovelluskehittäjä kertoo muutosten yhteydessä, oliko muutos haluttu. Jos muutos tuli yllätyksenä, eli kyseessä oli bugi, sovelluskehittäjä huomaa sen snapshot-testauksen ansiosta nopeasti. +Jos snapshot-testi huomaa muutoksen komponenttien määrittelemässä HTML:ssä, voi kyseessä olla joko haluttu muutos tai vahingossa aiheutettu "bugi". Snapshot-testi huomauttaa sovelluskehittäjälle, jos komponentin määrittelemä HTML muuttuu. Sovelluskehittäjä kertoo muutosten yhteydessä, oliko muutos haluttu. Jos muutos tuli yllätyksenä eli kyseessä oli bugi, sovelluskehittäjä huomaa sen snapshot-testauksen ansiosta nopeasti. Emme kuitenkaan käytä tällä kurssilla snapshot-testausta. diff --git a/src/content/5/fi/osa5d.md b/src/content/5/fi/osa5d.md index c129a221b62..42113dd3efa 100644 --- a/src/content/5/fi/osa5d.md +++ b/src/content/5/fi/osa5d.md @@ -9,7 +9,7 @@ lang: fi Olemme tehneet backendille sitä apin tasolla kokonaisuutena testaavia integraatiotestejä ja frontendille yksittäisiä komponentteja testaavia yksikkötestejä. -Katsotaan nyt erästä tapaa tehdä [järjestelmää kokonaisuutena](https://en.wikipedia.org/wiki/System_testing) tutkivia End to End (E2E) -testejä. +Katsotaan nyt erästä tapaa tehdä [järjestelmää kokonaisuutena](https://en.wikipedia.org/wiki/System_testing) tutkivia End to End (E2E) ‑testejä. Web-sovellusten E2E-testaus tapahtuu käyttäen selainta jonkin kirjaston avulla. Ratkaisuja on tarjolla useita, esimerkiksi [Selenium](http://www.seleniumhq.org/), joka mahdollistaa testien automatisoinnin lähes millä tahansa selaimella. Toinen vaihtoehto on käyttää ns. [headless browseria](https://en.wikipedia.org/wiki/Headless_browser) eli selainta, jolla ei ole ollenkaan graafista käyttöliittymää. Esim. Chromea on mahdollista suorittaa Headless-moodissa. @@ -19,40 +19,120 @@ E2E-testeihin liittyy myös ikäviä puolia. Niiden konfigurointi on haastavampa Ongelmana on usein myös se, että käyttöliittymän kautta tehtävät testit saattavat olla epäluotettavia eli englanniksi [flaky](https://hackernoon.com/flaky-tests-a-war-that-never-ends-9aa32fdef359), osa testeistä menee välillä läpi ja välillä ei, vaikka koodissa ei muuttuisi mikään. +Tämän hetken kaksi ehkä helppokäyttöisintä kirjastoa End to End -testaukseen ovat [Cypress](https://www.cypress.io/) ja [Playwright](https://playwright.dev/). -### Cypress +Sivun [npmtrends.com](https://npmtrends.com/cypress-vs-playwright) statistiikasta näemme, että Playwright on ohittanut Cypressin latausmäärissä vuoden 2024 aikana ja sen suosio jatkaa edelleen kasvuaan. +![cypress vs playwright in npm trends](../../images/5/cvsp.png) -[Cypress](https://www.cypress.io/)-niminen E2E-testaukseen soveltuva kirjasto on kasvattanut nopeasti suosiotaan viimeisen reilun vuoden aikana. Cypress on poikkeuksellisen helppokäyttöinen, kaikenlaisen säätämisen ja tunkkaamisen määrä esim. Seleniumin käyttöön verrattuna on lähes olematon. Cypressin toimintaperiaate poikkeaa radikaalisti useimmista E2E-testaukseen sopivista kirjastoista, sillä Cypress-testit ajetaan kokonaisuudessaan selaimen sisällä. Muissa lähestymistavoissa testit suoritetaan Node-prosessissa, joka on yhteydessä selaimeen ohjelmointirajapintojen kautta. +Tällä kurssilla on jo vuosia käytetty Cypressiä. Nyt mukana on uutena myös Playwright. Saat itse valita suoritatko kurssin E2E-testausta käsittelevän osan Cypressillä vai Playwrightillä. Molempien kirjastojen toimintaperiaatteet ovat hyvin samankaltaisia, joten kovin suurta merkitystä valinnallasi ei ole. Playwright on kuitenkin nyt kurssin ensisijaisesti suosittelema E2E-kirjasto. +Jos valintasi on Playwright, jatka eteenpäin. Jos päädyt käyttämään Cypressiä, mene [tänne](/osa5/end_to_end_testaus_cypress). -Tehdään tämän osan lopuksi muutamia end to end -testejä muistiinpanosovellukselle. +### Playwright -Aloitetaan asentamalla Cypress frontendin kehitysaikaiseksi riippuvuudeksi +[Playwright](https://playwright.dev/) on siis End to end -testien uusi tulokas, jonka suosio lähti vuoden 2023 loppupuolella räjähdysmäiseen nousuun. Playwright on käytön helppoudessa suurin piirtein Cypressin tasolla. Toimintaperiaatteeltaan kirjastot poikkeavat hieman toisistaan. Cypressin toimintaperiaate poikkeaa radikaalisti useimmista E2E-testaukseen sopivista kirjastoista, sillä Cypress-testit ajetaan kokonaisuudessaan selaimen sisällä. Playwrightin testit taas suoritetaan Node-prosessissa, joka on yhteydessä selaimeen ohjelmointirajapintojen kautta. + +Kirjastojen vertailuista on kirjoitettu monia blogeja, esim. [tämä](https://www.lambdatest.com/blog/cypress-vs-playwright/) ja [tämä](https://www.browserstack.com/guide/playwright-vs-cypress). + +On vaikea sanoa kumpi kirjastoista on parempi. Eräs Playwrightin etu on sen selaintuki, Playwright tukee Chromea, Firefoxia ja Webkit-pohjaisia selaimia kuten Safaria. Nykyisin Cypress sisältää tuen kaikkiin näihin selaimiin, Webkit-tuki on tosin vasta kokeellinen ja ei tue kaikkia Cypressin ominaisuuksia. Oma preferenssini kallisuu kirjoitushetkellä (1.3.2024) hieman Playwrightin puolelle. + +Tutustutaan nyt Playwrightin käyttöön. + +### Testien alustaminen + +Toisin kuin React-frontille tehdyt yksikkötestit tai backendin testit, nyt tehtävien End to End -testien ei tarvitse sijaita samassa npm-projektissa missä koodi on. Tehdään E2E-testeille kokonaan oma projekti komennolla _npm init_. Asennetaan sitten Playwright suorittamalla uuden projektin hakemistossa komento ```js -npm install --save-dev cypress +npm init playwright@latest ``` -ja määritellään npm-skripti käynnistämistä varten. +Asennusskripti kysyy muutamaa kysymystä, vastataan niihin seuraavasti: + +![vastataan: javascript, tests, false, true](../../images/5/play0.png) +Määritellään npm-skripti testien suorittamista sekä testiraportteja varten tiedostoon _package.json_: ```js { // ... "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject", - "server": "json-server -p3001 db.json", - "cypress:open": "cypress open" // highlight-line + "test": "playwright test", + "test:report": "playwright show-report" }, // ... } ``` -Toisin kuin esim. frontendin yksikkötestit, Cypress-testit voidaan sijoittaa joko frontendin tai backendin repositorioon, tai vaikka kokonaan omaan repositorioonsa. +Asennuksen yhteydessä konsoliin tulostui + +``` +And check out the following files: + - ./tests/example.spec.js - Example end-to-end test + - ./tests-examples/demo-todo-app.spec.js - Demo Todo App end-to-end tests + - ./playwright.config.js - Playwright Test configuration +``` + +eli asennus loi projektiin valmiiksi muutaman esimerkkitestin. + +Suoritetaan testit: + +```bash +$ npm test + +> notes-e2e@1.0.0 test +> playwright test + + +Running 6 tests using 5 workers + 6 passed (3.9s) + +To open last HTML report run: + + npx playwright show-report +``` + +Testit menevät läpi. Tarkempi testiraportti voidaa avata joko tulostuksen ehdottamalla komennolla, tai äsken määrittelemällämme npm-skriptillä: + +``` +npm run test:report +``` + +Testit voidaan myös suorittaa graafisen UI:n kautta komennolla + +``` +npm run test -- --ui +``` + +Esimerkkitestit tiedostossa _tests/example.spec.js_ näyttävät seuraavanlaisilta: + +```js +// @ts-check +import { test, expect } from '@playwright/test'; + +test('has title', async ({ page }) => { + await page.goto('https://playwright.dev/'); // highlight-line + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Playwright/); +}); + +test('get started link', async ({ page }) => { + await page.goto('https://playwright.dev/'); -Cypress-testit olettavat että testattava järjestelmä on käynnissä kun testit suoritetaan, eli toisin kuin esim. backendin integraatiotestit, Cypress-testit eivät käynnistä testattavaa järjestelmää testauksen yhteydessä. + // Click the get started link. + await page.getByRole('link', { name: 'Get started' }).click(); + + // Expects page to have a heading with the name of Installation. + await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); +}); +``` + +Testifunktion ensimmäinen rivi kertoo, että testit testaavat osoitteessa https://playwright.dev/ olevaa sivua. + +### Oman koodin testaaminen + +Poistetaan nyt esimerkkitestit ja aloitetaan oman sovelluksemme testaaminen. + +Playwright-testit olettavat että testattava järjestelmä on käynnissä kun testit suoritetaan, eli toisin kuin esim. backendin integraatiotestit, Playwright-testit eivät käynnistä testattavaa järjestelmää testauksen yhteydessä. Tehdään backendille npm-skripti, jonka avulla se saadaan käynnistettyä testausmoodissa, eli siten, että NODE\_ENV saa arvon test. @@ -61,87 +141,82 @@ Tehdään backendille npm-skripti, jonka avulla se saadaan käynnistetty // ... "scripts": { "start": "cross-env NODE_ENV=production node index.js", - "dev": "cross-env NODE_ENV=development nodemon index.js", - "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend", - "deploy": "git push heroku master", - "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy", - "logs:prod": "heroku logs --tail", + "dev": "cross-env NODE_ENV=development node --watch index.js", + "test": "cross-env NODE_ENV=test node --test", "lint": "eslint .", - "test": "cross-env NODE_ENV=test jest --verbose --runInBand", - "start:test": "cross-env NODE_ENV=test node index.js" // highlight-line + // ... + "start:test": "cross-env NODE_ENV=test node --watch index.js" // highlight-line }, // ... } ``` -Kun backend ja frontend ovat käynnissä, voidaan käynnistää Cypress komennolla +Käynnistetään frontend ja backend, ja luodaan sovellukselle ensimmäinen testi tiedostoon tests/note\_app.spec.js: ```js -npm run cypress:open -``` +const { test, expect } = require('@playwright/test') -Ensimmäisen käynnistyksen yhteydessä sovellukselle syntyy hakemisto cypress, jonka alihakemistoon integrations on tarkoitus sijoittaa testit. Cypress luo valmiiksi joukon esimerkkitestejä, poistetaan ne ja luodaan ensimmäinen oma testi tiedostoon note\_app.spec.js: +test('front page can be opened', async ({ page }) => { + await page.goto('http://localhost:5173') -```js -describe('Note ', function() { - it('front page can be opened', function() { - cy.visit('http://localhost:3000') - cy.contains('Notes') - cy.contains('Note app, Department of Computer Science, University of Helsinki 2020') - }) + const locator = page.getByText('Notes') + await expect(locator).toBeVisible() + await expect(page.getByText('Note app, Department of Computer Science, University of Helsinki 2024')).toBeVisible() }) ``` -Testin suoritus käynnistetään avautuneesta ikkunasta: +Ensin testi avaa sovelluksen metodilla [page.goto](https://playwright.dev/docs/writing-tests#navigation). +Tämän jälkeen testi etsii metodilla [page.getByText](https://playwright.dev/docs/api/class-page#page-get-by-text) [lokaattorin](https://playwright.dev/docs/api/class-locator) joka vastaa elementtiä, missä esiintyy teksti Notes. -![](../../images/5/40ea.png) +Metodilla [toBeVisible](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-be-visible) varmistetaan, että lokaattoria vastaava elementti on renderöitynä näkyville. -Testin suoritus avaa selaimen ja näyttää miten sovellus käyttäytyy testin edetessä: +Toinen tarkistus tehdään ilman apumuuttujan käyttöä. -![](../../images/5/32ae.png) +Testi ei mene läpi, sillä testiin päätynyt vanha vuosiluku. Playwright avaa testiraportin selaimeen ja siitä käy selväksi, että Playwright on itseasiassa suorittanut testit kolmella eri selaimella Chromella, yhden Firefoxilla sekä Webkitillä eli esim. Safarin käyttämällä selainmoottorilla: -Testi näyttää rakenteeltaan melko tutulta. describe-lohkoja käytetään samaan tapaan kuin Jestissä ryhmittelemään yksittäisiä testitapauksia, jotka on määritelty it-metodin avulla. Nämä osat Cypress on lainannut sisäisesti käyttämältään [Mocha](https://mochajs.org/)-testikirjastolta. +![](../../images/5/play2.png) -[cy.visit](https://docs.cypress.io/api/commands/visit.html) ja [cy.contains](https://docs.cypress.io/api/commands/contains.html) taas ovat Cypressin komentoja, joiden merkitys on aika ilmeinen. [cy.visit](https://docs.cypress.io/api/commands/visit.html) avaa testin käyttämään selaimeen parametrina määritellyn osoitteen ja [cy.contains](https://docs.cypress.io/api/commands/contains.html) etsii sivun sisältä parametrina annetun tekstin. +Klikkaamalla jonkin selaimen raporttia näemme tarkemman virheilmoituksen: -Olisimme voineet määritellä testin myös käyttäen nuolifunktioita +![](../../images/5/play3a.png) + +Isossa kuvassa on tietysti oikein hyvä asia että testaus tapahtuu kaikilla kolmella yleisesti käytetyllä selainmoottorilla, mutta tämä on hidasta, ja testejä kehittäessä kannattaa ehkä suorittaa pääosin vain yhdellä selaimella. Käytettävän selainmoottorin määrittely onnistuu komentoriviparametrilla: ```js -describe('Note app', () => { // highlight-line - it('front page can be opened', () => { // highlight-line - cy.visit('http://localhost:3000') - cy.contains('Notes') - cy.contains('Note app, Department of Computer Science, University of Helsinki 2020') - }) -}) +npm test -- --project chromium ``` -Mochan dokumentaatio kuitenkin [suosittelee](https://mochajs.org/#arrow-functions) että nuolifunktioita ei käytetä, ne saattavat aiheuttaa ongelmia joissain tilanteissa. - -Jos komento cy.contains ei löydä sivulta etsimäänsä tekstiä, testi ei mene läpi. Eli jos laajennamme testiä seuraavasti +Korjataan nyt testiin oikea vuosiluku ja lisätään testeihin _describe_-lohko: ```js -describe('Note app', function() { - it('front page can be opened', function() { - cy.visit('http://localhost:3000') - cy.contains('Notes') - cy.contains('Note app, Department of Computer Science, University of Helsinki 2020') - }) +const { test, describe, expect } = require('@playwright/test') -// highlight-start - it('front page contains random text', function() { - cy.visit('http://localhost:3000') - cy.contains('wtf is this app?') +describe('Note app', () => { + test('front page can be opened', async ({ page }) => { + await page.goto('http://localhost:5173') + + const locator = page.getByText('Notes') + await expect(locator).toBeVisible() + await expect(page.getByText('Note app, Department of Computer Science, University of Helsinki 2025')).toBeVisible() }) -// highlight-end }) ``` -havaitsee Cypress ongelman +Ennen kuin mennään eteenpäin, rikotaan testit vielä kertaalleen. Huomaamme, että testien suoritus on melko nopeaa kun testit menevät läpi, mutta paljon hitaampaa jos testit eivät mene läpi. Syynä tälle on se, että Playwrightin toimintaperiaatteena on odottaa etsittyjä elementtejä kunnes [ne ovat renderöityjä ja toimintaan valmiita](https://playwright.dev/docs/actionability). Jos elementtiä ei löydy, seurauksena on _TimeoutError_ ja testi ei mene läpi. Playwright odottaa elementtejä oletusarvoisesti 5 tai 30 sekunnin ajan testauksessa käytetyistä funktioista [riippuen](https://playwright.dev/docs/test-timeouts#introduction). -![](../../images/5/33ea.png) +Testejä kehitettäessä voi olla viisaampaa pienentää odotettavaa aikaa muutamaan sekuntiin. [Dokumentaation](https://playwright.dev/docs/test-timeouts) mukaan tämä onnistuu muuttamalla tiedostoa _playwright.config.js_ seuraavasti: -Poistetaan virheeseen johtanut testi koodista. +```js +export default defineConfig({ + // ... + timeout: 3000, // highlight-line + fullyParallel: false, // highlight-line + workers: 1, // highlight-line + // ... +}) +``` + +Teimme tiedostoon kaksi muutakin muutosta, joilla määrittelimme, että kaikki testit [suoritetaan yksi kerrallaan](https://playwright.dev/docs/test-parallel). Oletusarvoisella konfiguraatiolla suoritus tapahtuu rinnakkain, ja koska testimme käyttävät yhteistä tietokantaa, rinnakkainen suoritus aiheuttaa ongelmia. ### Lomakkeelle kirjoittaminen @@ -150,162 +225,186 @@ Laajennetaan testejä siten, että testi yrittää kirjautua sovellukseen. Olete Aloitetaan kirjautumislomakkeen avaamisella. ```js -describe('Note app', function() { +describe('Note app', () => { // ... - it('login form can be opened', function() { - cy.visit('http://localhost:3000') - cy.contains('login').click() + test('user can log in', async ({ page }) => { + await page.goto('http://localhost:5173') + + await page.getByRole('button', { name: 'login' }).click() }) }) ``` -Testi hakee ensin napin sen tekstin perusteella ja klikkaa nappia komennolla [cy.click](https://docs.cypress.io/api/commands/click.html#Syntax). +Testi hakee ensin funktion [getByRole](https://playwright.dev/docs/api/class-page#page-get-by-role) avulla napin sen tekstin perusteella. Funktio palauttaa Button-elementtiä vastaavan [Locatorin](https://playwright.dev/docs/api/class-locator). Napin painaminen suoritetaan Locatorin metodilla [click](https://playwright.dev/docs/api/class-locator#locator-click). + +Testejä kehitettäessä kannattaa käyttää Playwrightin [UI-moodia](https://playwright.dev/docs/test-ui-mode), eli käyttöliittymällistä versiota. Käynnistetään testit UI-moodissa seuraavasti: + +``` +npm test -- --ui +``` + +Näemme nyt että testi löytää napin -Koska molemmat testit aloittavat samalla tavalla, eli avaamalla sivun http://localhost:3000, kannattaa yhteinen osa eristää ennen jokaista testiä suoritettavaan beforeEach-lohkoon: +![](../../images/5/play4.png) + +Klikkauksen jälkeen lomake tulee näkyviin + +![](../../images/5/play5.png) + +Kun lomake on avattu, testin tulisi etsiä siitä tekstikentät ja kirjoittaa niihin käyttäjätunnus sekä salasana. Tehdään ensimmäinen yritys funktiota [page.getByRole](https://playwright.dev/docs/api/class-page#page-get-by-role) käyttäen: ```js -describe('Note app', function() { - // highlight-start - beforeEach(function() { - cy.visit('http://localhost:3000') - }) - // highlight-end +describe('Note app', () => { + // ... - it('front page can be opened', function() { - cy.contains('Notes') - cy.contains('Note app, Department of Computer Science, University of Helsinki 2020') - }) + test('user can log in', async ({ page }) => { + await page.goto('http://localhost:5173') - it('login form can be opened', function() { - cy.contains('login').click() + await page.getByRole('button', { name: 'login' }).click() + await page.getByRole('textbox').fill('mluukkai') }) }) ``` -Ilmoittautumislomake sisältää kaksi input-kenttää, joihin testin tulisi kirjoittaa. +Seurauksena on virheilmoitus: -Komento [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax) mahdollistaa elementtien etsimisen CSS-selektorien avulla. +```bash +Error: locator.fill: Error: strict mode violation: getByRole('textbox') resolved to 2 elements: + 1) aka locator('div').filter({ hasText: /^username$/ }).getByRole('textbox') + 2) aka locator('input[type="password"]') +``` -Voimme hakea lomakkeen ensimmäisen ja viimeisen input-kentän ja kirjoittaa niihin komennolla [cy.type](https://docs.cypress.io/api/commands/type.html#Syntax) seuraavasti: +Ongelmana on nyt se, että _getByRole_ löytää kaksi tekstikenttää, ja metodin [fill](https://playwright.dev/docs/api/class-locator#locator-fill) kutsuminen ei onnistu, sillä se olettaa että löydettyjä tekstikenttiä on vain yksi. Eräs tapa kiertää ongelma on käyttää metodeja [first](https://playwright.dev/docs/api/class-locator#locator-first) ja [last](https://playwright.dev/docs/api/class-locator#locator-last): ```js -it('user can login', function () { - cy.contains('login').click() - cy.get('input:first').type('mluukkai') - cy.get('input:last').type('salainen') -}) +describe('Note app', () => { + // ... + + test('user can log in', async ({ page }) => { + await page.goto('http://localhost:5173') + + await page.getByRole('button', { name: 'login' }).click() + await page.getByRole('textbox').first().fill('mluukkai') + await page.getByRole('textbox').last().fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + }) +}) ``` -Testi toimii mutta on kuitenkin sikäli ongelmallinen, että jos sovellukseen tulee jossain vaiheessa lisää input-kenttiä, testi saattaa hajota, sillä se luottaa tarvitsemiensa kenttien olevan sivulla ensimmäisenä ja viimeisenä. +Kirjoitettuaan tekstikenttiin, testi painaa nappia _login_ ja tarkastaa, että sovellus renderöi kirjaantuneen käyttäjän tiedot ruudulle. -Parempi ratkaisu on määritellä kentille yksilöivät id-attribuutit ja hakea kentät testeissä niiden perusteella. Eli laajennetaan kirjautumislomaketta seuraavasti +Jos tekstikenttiä olisi enemmän kuin kaksi, ei metodien _first_ ja _last_ käyttö riittäisi. Eräs mahdollisuus olisi käyttää metodia [all](https://playwright.dev/docs/api/class-locator#locator-all), joka muuttaa löydetyt locatorit taulukoksi, jota on mahdollista indeksoida: ```js -const LoginForm = ({ ... }) => { - return ( -
    -

    Login

    -
    -
    - username - -
    -
    - password - -
    - -
    -
    - ) -} -``` +describe('Note app', () => { + // ... + test('user can log in', async ({ page }) => { + await page.goto('http://localhost:5173') -Myös lomakkeen napille on lisätty id, jonka perusteella se voidaan hakea testissä. + await page.getByRole('button', { name: 'login' }).click() + const textboxes = await page.getByRole('textbox').all() -Testi muuttuu muotoon + await textboxes[0].fill('mluukkai') + await textboxes[1].fill('salainen') -```js -describe('Note app', function() { - // .. - it('user can log in', function() { - cy.contains('login').click() - cy.get('#username').type('mluukkai') // highlight-line - cy.get('#password').type('salainen') // highlight-line - cy.get('#login-button').click() // highlight-line - - cy.contains('Matti Luukkainen logged in') // highlight-line - }) + // await page.getByRole('textbox').last().fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + }) }) ``` -Viimeinen rivi varmistaa, että kirjautuminen on onnistunut. - -Huomaa, että CSS:n [id-selektori](https://developer.mozilla.org/en-US/docs/Web/CSS/ID_selectors) on risuaita, eli jos koodista etsitään elementtiä, jolla on id username on sitä vastaava CSS-selektori #username. +Sekä tämä että edellinen versio testistä toimivat. Molemmat ovat kuitenkin sikäli ongelmallisia, että jos kirjaantumislomaketta muutetaan, testit saattavat hajota, sillä ne luottavat tarvitsemiensa kenttien olevan sivulla tietyssä järjestyksessä. -### Muutama huomio +Jos elementtiä on vaikea löytää testeissä, sille voi määritellä erillisen test-id-attribuutin ja etsiä elementin testeissä sen avulla käyttäen metodia [getByTestId](https://playwright.dev/docs/api/class-page#page-get-by-test-id). -Testissä klikataan ensin kirjaantumislomakkeen avaavaa nappia seuraavasti +Käytetään nyt kuitenkin hyödyksi kirjautumislomakkeen olemassa olevia elementtejä. Kirjautumislomakkeen syötekentille on määritelty yksilölliset labelit: ```js -cy.contains('login').click() +// ... +
    +
    + // highlight-line +
    +
    + // highlight-line +
    + +
    +// ... ``` -Kun lomake on täytetty, lähetetään lomake klikkaamalla nappia +Syötekentät voi ja kannattaa etsiä testeissä labelien avulla käyttäen metodia [getByLabel](https://playwright.dev/docs/api/class-page#page-get-by-label): ```js -cy.get('#login-button').click() -``` +describe('Note app', () => { + // ... -Molemmissa napeissa on sama teksti login, mutta kyseessä on kaksi erillistä nappia. Molemmat napit ovat itse asiassa koko ajan sovelluksen DOM:issa, mutta niistä vain yksi kerrallaan on näkyvissä, sillä toiselle on lisätty tyylimääre display: none. + test('user can log in', async ({ page }) => { + await page.goto('http://localhost:5173') -Jos haemme nappia tekstin perusteella, palauttaa komento [cy.contains](https://docs.cypress.io/api/commands/contains.html#Syntax) aina napeista ensimmäisen, eli lomakkeen avaavan napin. Näin tapahtuu siis vaikka nappi ei olisikaan näkyvillä. Tämän takia lomakkeen lähettävään nappiin on lisätty id login-button, jonka perusteella testi pääsee nappiin käsiksi. + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') // highlight-line + await page.getByLabel('password').fill('salainen') // highlight-line + + await page.getByRole('button', { name: 'login' }).click() + + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + }) +}) +``` -Huomaamme, että testeissä käytetty muuttuja _cy_ aiheuttaa ikävän ESlint-virheen +Elementtien etsimisessä on järkevää pyrkiä hyödyntämään käyttöliittymän käyttäjälle näkyvää sisältöä, koska näin simuloidaan parhaiten sitä, miten käyttäjä oikeasti löytää halutun syötekentän navigoidessaan sovelluksessa. -![](../../images/5/30ea.png) +Huomaa, että testin läpimeno tässä vaiheessa edellyttää, että backendin ympäristön test tietokannassa on käyttäjä, jonka username on mluukkai ja salasana salainen. Luo käyttäjä tarvittaessa! -Siitä päästään eroon asentamalla [eslint-plugin-cypress](https://github.com/cypress-io/eslint-plugin-cypress) kehitysaikaiseksi riippuvuudeksi +### Testien alustus + +Koska molemmat testit aloittavat samalla tavalla, eli avaamalla sivun http://localhost:5173, kannattaa yhteinen osa eristää ennen jokaista testiä suoritettavaan beforeEach-lohkoon: ```js -npm install eslint-plugin-cypress --save-dev -``` +const { test, describe, expect, beforeEach } = require('@playwright/test') -ja laajentamalla tiedostossa .eslintrc.js olevaa konfiguraatiota seuraavasti: +describe('Note app', () => { + // highlight-start + beforeEach(async ({ page }) => { + await page.goto('http://localhost:5173') + }) + // highlight-end + + test('front page can be opened', async ({ page }) => { + const locator = page.getByText('Notes') + await expect(locator).toBeVisible() + await expect(page.getByText('Note app, Department of Computer Science, University of Helsinki 2025')).toBeVisible() + }) + + test('user can log in', async ({ page }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + }) +}) -```js -module.exports = { - "env": { - "browser": true, - "es6": true, - "jest/globals": true, - "cypress/globals": true // highlight-line - }, - "extends": [ - // ... - ], - "parserOptions": { - // ... - }, - "plugins": [ - "react", "jest", "cypress" // highlight-line - ], - "rules": { - // ... - } -} ``` ### Muistiinpanojen luomisen testaus @@ -313,29 +412,28 @@ module.exports = { Luodaan seuraavaksi testi, joka lisää sovellukseen uuden muistiinpanon: ```js -describe('Note app', function() { - // .. - // highlight-start - describe('when logged in', function() { - beforeEach(function() { - cy.contains('login').click() - cy.get('input:first').type('mluukkai') - cy.get('input:last').type('salainen') - cy.get('#login-button').click() - }) - // highlight-end +const { test, describe, expect, beforeEach } = require('@playwright/test') - // highlight-start - it('a new note can be created', function() { - cy.contains('new note').click() - cy.get('input').type('a note created by cypress') - cy.contains('save').click() +describe('Note app', () => { + // ... - cy.contains('a note created by cypress') + describe('when logged in', () => { + beforeEach(async ({ page }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() }) - }) - // highlight-end + + test('a new note can be created', async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('a note created by playwright') + await page.getByRole('button', { name: 'save' }).click() + await expect(page.getByText('a note created by playwright')).toBeVisible() + }) + }) }) + ``` Testi on määritelty omana describe-lohkonaan. Muistiinpanon luominen edellyttää että käyttäjä on kirjaantuneena, ja kirjautuminen hoidetaan beforeEach-lohkossa. @@ -343,46 +441,55 @@ Testi on määritelty omana describe-lohkonaan. Muistiinpanon luominen ed Testi luottaa siihen, että uutta muistiinpanoa luotaessa sivulla on ainoastaan yksi input-kenttä, eli se hakee kentän seuraavasti ```js -cy.get('input') +page.getByRole('textbox') ``` -Jos kenttiä olisi useampia, testi hajoaisi +Jos kenttiä olisi useampia, testi hajoaisi. Tämän takia voisi olla parempi lisätä lomakkeen kentälle test-id ja hakea kenttä testissä id:n perusteella. + +**Huom:** testi ei mene läpi kuin ensimmäisellä kerralla suoritettaessa. Syynä tälle on se, että ekspektaatio -![](../../images/5/31ea.png) +```js +await expect(page.getByText('a note created by playwright')).toBeVisible() +``` -Tämän takia olisi jälleen parempi lisätä lomakkeen kentälle id ja hakea kenttä testissä id:n perusteella. +aiheuttaa ongelmia siinä vaiheessa kun sovellukseen luodaan sama muistiinpano useammin kuin kertaalleen. Ongelmasta päästään eroon seuraavassa luvussa. Testien rakenne näyttää seuraavalta: ```js -describe('Note app', function() { - // ... +const { test, describe, expect, beforeEach } = require('@playwright/test') - it('user can log in', function() { - cy.contains('login').click() - cy.get('#username').type('mluukkai') - cy.get('#password').type('salainen') - cy.get('#login-button').click() +describe('Note app', () => { + // .... - cy.contains('Matti Luukkainen logged in') + test('user can log in', async ({ page }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() }) - describe('when logged in', function() { - beforeEach(function() { - cy.contains('login').click() - cy.get('input:first').type('mluukkai') - cy.get('input:last').type('salainen') - cy.get('#login-button').click() + describe('when logged in', () => { + beforeEach(async ({ page }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() }) - it('a new note can be created', function() { - // ... + test('a new note can be created', async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('a note created by playwright') + await page.getByRole('button', { name: 'save' }).click() + await expect(page.getByText('a note created by playwright')).toBeVisible() }) - }) + }) }) + ``` -Cypress suorittaa testit siinä järjestyksessä, missä ne ovat testikoodissa. Eli ensin suoritetaan testi user can log in, missä käyttäjä kirjautuu sovellukseen, ja tämän jälkeen suoritetaan testi a new note can be created, jonka beforeEach-lohkossa myös suoritetaan kirjautuminen. Miksi näin tehdään, eikö käyttäjä jo ole kirjaantuneena aiemman testin ansiosta? Ei, sillä jokaisen testin suoritus alkaa selaimen kannalta "nollatilanteesta", kaikki edellisten testien selaimen tilaan tekemät muutokset nollaantuvat. +Koska olemme estäneet testien rinnakkaisen suorittamisen, Playwright suorittaa testit siinä järjestyksessä, missä ne ovat testikoodissa. Eli ensin suoritetaan testi user can log in, missä käyttäjä kirjautuu sovellukseen, ja tämän jälkeen suoritetaan testi a new note can be created, jonka beforeEach-lohkossa myös suoritetaan kirjautuminen. Miksi näin tehdään, eikö käyttäjä jo ole kirjaantuneena aiemman testin ansiosta? Ei, sillä jokaisen testin suoritus alkaa selaimen kannalta "nollatilanteesta", kaikki edellisten testien selaimen tilaan tekemät muutokset nollaantuvat. ### Tietokannan tilan kontrollointi @@ -390,7 +497,7 @@ Jos testatessa on tarvetta muokata palvelimen tietokantaa, muuttuu tilanne heti Kuten yksikkö- integraatiotesteissä, on myös E2E-testeissä paras ratkaisu nollata tietokanta ja mahdollisesti alustaa se sopivasti aina ennen testien suorittamista. E2E-testauksessa lisähaasteen tuo se, että testeistä ei ole mahdollista päästä suoraan käsiksi tietokantaan. -Ratkaistaan ongelma luomalla backendiin testejä varten API-endpoint, jonka avulla testit voivat tarvittaessa nollata kannan. Tehdään testejä varten oma router +Ratkaistaan ongelma luomalla backendiin testejä varten API-endpoint, jonka avulla testit voivat tarvittaessa nollata kannan. Tehdään testejä varten oma router tiedostoon controllers/testing.js: ```js const router = require('express').Router() @@ -429,127 +536,106 @@ app.use(middleware.errorHandler) module.exports = app ``` -eli lisäyksen jälkeen HTTP POST -operaatio backendin endpointiin /api/testing/reset tyhjentää tietokannan. +eli lisäyksen jälkeen HTTP POST ‑operaatio backendin endpointiin /api/testing/reset tyhjentää tietokannan. -Backendin testejä varten muokattu koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part5-1), branchissä part5-1. +Backendin testejä varten muokattu koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part5-1), branchissä part5-1. Muutetaan nyt testien beforeEach-alustuslohkoa siten, että se nollaa palvelimen tietokannan aina ennen testien suorittamista. -Tällä hetkellä sovelluksen käyttöliittymän kautta ei ole mahdollista luoda käyttäjiä, luodaankin testien alustuksessa testikäyttäjä suoraan backendiin. +Tällä hetkellä sovelluksen käyttöliittymän kautta ei ole mahdollista luoda käyttäjiä, luodaankin testien alustuksessa testikäyttäjä suoraan backendiin: ```js -describe('Note app', function() { - beforeEach(function() { - // highlight-start - cy.request('POST', 'http://localhost:3001/api/testing/reset') - const user = { - name: 'Matti Luukkainen', - username: 'mluukkai', - password: 'salainen' - } - cy.request('POST', 'http://localhost:3001/api/users/', user) - // highlight-end - cy.visit('http://localhost:3000') +describe('Note app', () => { + beforeEach(async ({ page, request }) => { + await request.post('http://localhost:3001/api/testing/reset') + await request.post('http://localhost:3001/api/users', { + data: { + name: 'Matti Luukkainen', + username: 'mluukkai', + password: 'salainen' + } + }) + + await page.goto('http://localhost:5173') }) - it('front page can be opened', function() { + test('front page can be opened', () => { // ... }) - it('user can login', function() { + test('user can login', () => { // ... }) - describe('when logged in', function() { + describe('when logged in', () => { // ... }) }) ``` -Testi tekee alustuksen aikana HTTP-pyyntöjä backendiin komennolla [cy.request](https://docs.cypress.io/api/commands/request.html). +Testi tekee alustuksen aikana HTTP-pyyntöjä backendiin parametrin _request_ metodilla [post](https://playwright.dev/docs/api/class-apirequestcontext#api-request-context-post). -Toisin kuin aiemmin, nyt testaus alkaa nyt myös backendin suhteen aina hallitusti samasta tilanteesta, eli tietokannassa on yksi käyttäjä ja ei yhtään muistiinpanoa. +Toisin kuin aiemmin, nyt testaus alkaa myös backendin suhteen aina hallitusti samasta tilanteesta, eli tietokannassa on yksi käyttäjä ja ei yhtään muistiinpanoa. -Tehdään vielä testi, joka tarkastaa että muistiinpanojen tärkeyttä voi muuttaa. Muutetaan ensin sovelluksen frontendia siten, että uusi muistiinpano on oletusarvoisesti epätärkeä, eli kenttä important saa arvon false: +Tehdään vielä testi, joka tarkastaa että muistiinpanojen tärkeyttä voi muuttaa. -```js -const NoteForm = ({ createNote }) => { - // ... +Testin tekemiseen on muutamiakin erilaisia lähestymistapoja. - const addNote = (event) => { - event.preventDefault() - createNote({ - content: newNote, - important: false // highlight-line - }) - - setNewNote('') - } - // ... -} -``` - -On useita eri tapoja testata asia. Seuraavassa etsitään ensin muistiinpano ja klikataan sen nappia make important. Tämän jälkeen tarkistetaan että muistiinpano sisältää napin make not important. +Seuraavassa etsitään ensin muistiinpano ja klikataan sen nappia make not important. Tämän jälkeen tarkistetaan että muistiinpano sisältää napin make important. ```js -describe('Note app', function() { +describe('Note app', () => { // ... - describe('when logged in', function() { + describe('when logged in', () => { // ... - describe('and a note exists', function () { - beforeEach(function () { - cy.contains('new note').click() - cy.get('input').type('another note cypress') - cy.contains('save').click() + describe('and a note exists', () => { + beforeEach(async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('another note by playwright') + await page.getByRole('button', { name: 'save' }).click() }) - - it('it can be made important', function () { - cy.contains('another note cypress') - .contains('make important') - .click() - - cy.contains('another note cypress') - .contains('make not important') + + test('importance can be changed', async ({ page }) => { + await page.getByRole('button', { name: 'make not important' }).click() + await expect(page.getByText('make important')).toBeVisible() }) }) }) }) ``` -Ensimmäinen komento etsii ensin komponentin, missä on teksti another note cypress ja sen sisältä painikkeen make important ja klikkaa sitä. +Ensimmäinen komento etsii ensin komponentin, missä on teksti another note by playwright ja sen sisältä painikkeen make not important ja klikkaa sitä. -Toinen komento varmistaa, että saman napin teksti on vaihtunut muotoon make not important. +Toinen komento varmistaa, että saman napin teksti on vaihtunut muotoon make important. -Testit ja frontendin tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part2-notes/tree/part5-9), branchissa part5-9. +Testien tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/notes-e2e/tree/part5-1), branchissa part5-1. ### Epäonnistuneen kirjautumisen testi Tehdään nyt testi joka varmistaa, että kirjautumisyritys epäonnistuu jos salasana on väärä. -Cypress suorittaa oletusarvoisesti aina kaikki testit, ja testien määrän kasvaessa se alkaa olla aikaavievää. Uutta testiä kehitellessä tai rikkinäistä testiä debugatessa voidaan määritellä testi komennon it sijaan komennolla it.only, jolloin Cypress suorittaa ainoastaan sen testin. Kun testi on valmiina, voidaan only poistaa. - Testin ensimmäinen versio näyttää seuraavalta: ```js -describe('Note app', function() { +describe('Note app', () => { // ... - it.only('login fails with wrong password', function() { - cy.contains('login').click() - cy.get('#username').type('mluukkai') - cy.get('#password').type('wrong') - cy.get('#login-button').click() + test('login fails with wrong password', async ({ page }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('wrong') + await page.getByRole('button', { name: 'login' }).click() - cy.contains('wrong credentials') + await expect(page.getByText('wrong credentials')).toBeVisible() }) // ... -)} +}) ``` -Testi siis varmistaa komennon [cy.contains](https://docs.cypress.io/api/commands/contains.html#Syntax) avulla, että sovellus tulostaa virheilmoituksen. +Testi siis varmistaa metodin [page.getByText](https://playwright.dev/docs/api/class-page#page-get-by-text) avulla, että sovellus tulostaa virheilmoituksen. Sovellus renderöi virheilmoituksen CSS-luokan error sisältävään elementtiin: @@ -571,197 +657,189 @@ Voisimmekin tarkentaa testiä varmistamaan, että virheilmoitus tulostuu nimenom ```js -it('login fails with wrong password', function() { +test('login fails with wrong password', async ({ page }) => { // ... - cy.get('.error').contains('wrong credentials') // highlight-line + const errorDiv = page.locator('.error') + await expect(errorDiv).toContainText('wrong credentials') }) ``` -Eli ensin etsitään komennolla [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax) CSS-luokan error sisältävä komponentti ja sen jälkeen varmistetaan että virheilmoitus löytyy sen sisältä. Huomaa, että [luokan CSS-selektori](https://developer.mozilla.org/en-US/docs/Web/CSS/Class_selectors) alkaa pisteellä, eli luokan error selektori on .error. +Testi siis etsii metodilla [page.locator](https://playwright.dev/docs/api/class-page#page-locator) CSS-luokan error sisältävän komponentin ja tallentaa sen muuttujaan. Komponenttiin liittyvän tekstin oikeellisuus voidaan varmistaa ekspektaatiolla [toContainText](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-contain-text). Huomaa, että [luokan CSS-selektori](https://developer.mozilla.org/en-US/docs/Web/CSS/Class_selectors) alkaa pisteellä, eli luokan error selektori on .error. -Voisimme tehdä saman myös käyttäen [should](https://docs.cypress.io/api/commands/should.html)-syntaksia: +Ekspekaatiolla [toHaveCSS](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-css) on mahdollista testata sovelluksen CSS-tyylejä. Voimme esim. varmistaa, että virheilmoituksen väri on punainen, ja että sen ympärillä on border: ```js -it('login fails with wrong password', function() { +test('login fails with wrong password', async ({ page }) => { // ... - cy.get('.error').should('contain', 'wrong credentials') // highlight-line + const errorDiv = page.locator('.error') + await expect(errorDiv).toContainText('wrong credentials') + await expect(errorDiv).toHaveCSS('border-style', 'solid') + await expect(errorDiv).toHaveCSS('color', 'rgb(255, 0, 0)') }) ``` -Shouldin käyttö on jonkin verran "hankalampaa" kuin komennon contains, mutta se mahdollistaa huomattavasti monipuolisemmat testit kuin pelkän tekstisisällön perusteella toimiva contains. +Värit on määriteltävä Playwrightille [rgb](https://rgbcolorcode.com/color/red)-koodeina. -Lista yleisimmistä shouldin kanssa käytettävistä assertioista on [täällä](https://docs.cypress.io/guides/references/assertions.html#Common-Assertions). - -Voimme esim. varmistaa, että virheilmoituksen väri on punainen, ja että sen ympärillä on border: +Viimeistellään testi vielä siten, että se varmistaa myös, että sovellus **ei renderöi** onnistunutta kirjautumista kuvaavaa tekstiä 'Matti Luukkainen logged in': ```js -it('login fails with wrong password', function() { - // ... - - cy.get('.error').should('contain', 'wrong credentials') - cy.get('.error').should('have.css', 'color', 'rgb(255, 0, 0)') - cy.get('.error').should('have.css', 'border-style', 'solid') +test('login fails with wrong password', async ({ page }) =>{ + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('wrong') + await page.getByRole('button', { name: 'login' }).click() + + const errorDiv = page.locator('.error') + await expect(errorDiv).toContainText('wrong credentials') + await expect(errorDiv).toHaveCSS('border-style', 'solid') + await expect(errorDiv).toHaveCSS('color', 'rgb(255, 0, 0)') + + await expect(page.getByText('Matti Luukkainen logged in')).not.toBeVisible() // highlight-line }) ``` -Värit on määriteltävä Cypressille [rgb](https://rgbcolorcode.com/color/red)-koodeina. +### Testien suorittaminen yksitellen -Koska kaikki tarkastukset kohdistuvat samaan komennolla [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax) haettuun elementtiin, ne voidaan ketjuttaa komennon [and](https://docs.cypress.io/api/commands/and.html) avulla: +Playwright suorittaa oletusarvoisesti aina kaikki testit, ja testien määrän kasvaessa se alkaa olla aikaavievää. Uutta testiä kehitellessä tai rikkinäistä testiä debugatessa voidaan määritellä testi komennon test sijaan komennolla test.only, jolloin Playwright suorittaa ainoastaan sen testin: ```js -it('login fails with wrong password', function() { - // ... +describe(() => { + // this is the only test executed! + test.only('login fails with wrong password', async ({ page }) => { // highlight-line + // ... + }) - cy.get('.error') - .should('contain', 'wrong credentials') - .and('have.css', 'color', 'rgb(255, 0, 0)') - .and('have.css', 'border-style', 'solid') -}) -``` -Viimeistellään testi vielä siten, että se varmistaa myös, että sovellus ei renderöi onnistuneesta kirjautumista kuvaavaa tekstiä 'Matti Luukkainen logged in': + // this test is skipped... + test('user can login with correct credentials', async ({ page }) => { + // ... + }) -```js -it.only('login fails with wrong password', function() { - cy.contains('login').click() - cy.get('#username').type('mluukkai') - cy.get('#password').type('wrong') - cy.get('#login-button').click() - - cy.get('.error') - .should('contain', 'wrong credentials') - .and('have.css', 'color', 'rgb(255, 0, 0)') - .and('have.css', 'border-style', 'solid') - - cy.get('html').should('not.contain', 'Matti Luukkainen logged in') // highlight-line + // ... }) ``` -Komentoa should käytetään aina ketjutettuna komennon get (tai muun vastaavan ketjutettavissa olevan komennon) perään. Testissä käytetty cy.get('html') tarkoittaa käytännössä koko sovelluksen näkyvillä olevaa sisältöä. +Kun testi on valmiina, voidaan only poistaa. -### Operaatioiden tekeminen käyttöliittymän "ohi" +Toinen vaihtoehto suorittaa yksittäinen testi, on käyttää komentoriviparametria: + +``` +npm test -- -g "login fails with wrong password" +``` + +### Testien apufunktiot Sovelluksemme testit näyttävät tällä hetkellä seuraavalta: ```js -describe('Note app', function() { - it('user can login', function() { - cy.contains('login').click() - cy.get('#username').type('mluukkai') - cy.get('#password').type('salainen') - cy.get('#login-button').click() +const { test, describe, expect, beforeEach } = require('@playwright/test') - cy.contains('Matti Luukkainen logged in') +describe('Note app', () => { + // ... + + test('user can login with correct credentials', async ({ page }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() }) - it.only('login fails with wrong password', function() { + test('login fails with wrong password', async ({ page }) =>{ // ... }) - describe('when logged in', function() { - beforeEach(function() { - cy.contains('login').click() - cy.get('input:first').type('mluukkai') - cy.get('input:last').type('salainen') - cy.get('#login-button').click() + describe('when logged in', () => { + beforeEach(async ({ page, request }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() }) - it('a new note can be created', function() { - // ... + test('a new note can be created', async ({ page }) => { + // ... }) - - }) + + // ... + }) }) -``` -Ensin siis testataan kirjautumistoimintoa. Tämän jälkeen omassa describe-lohkossa on joukko testejä, jotka olettavat että käyttäjä on kirjaantuneena, kirjaantuminen hoidetaan alustuksen tekevän beforeEach-lohkon sisällä. +``` -Kuten aiemmin jo todettiin, jokainen testi suoritetaan alkutilasta, eli vaikka testi on koodissa alempana, se ei aloita samasta tilasta mihin ylempänä koodissa olevat testit ovat jääneet! +Ensin siis testataan kirjautumistoimintoa. Tämän jälkeen omassa _describe_-lohkossa on joukko testejä, jotka olettavat että käyttäjä on kirjaantuneena, kirjaantuminen hoidetaan alustuksen tekevän _beforeEach_-lohkon sisällä. -Cypressin dokumentaatio neuvoo meitä seuraavasti: [Fully test the login flow – but only once!](https://docs.cypress.io/guides/getting-started/testing-your-app.html#Logging-in). Eli sen sijaan että tekisimme beforeEach-lohkossa kirjaantumisen lomaketta käyttäen, suosittelee Cypress että kirjaantuminen tehdään [UI:n ohi](https://docs.cypress.io/guides/getting-started/testing-your-app.html#Bypassing-your-UI), tekemällä suoraan backendiin kirjaantumista vastaava HTTP-operaatio. Syynä tälle on se, että suoraan backendiin tehtynä kirjautuminen on huomattavasti nopeampi kuin lomakkeen täyttämällä. +Kuten aiemmin jo todettiin, jokainen testi suoritetaan alkutilasta (missä tietokanta tyhjennetään ja sinne luodaan yksi käyttäjä) alkaen, eli vaikka testi on koodissa alempana, se ei aloita samasta tilasta mihin ylempänä koodissa olevat testit ovat jääneet! -Tilanteemme on hieman monimutkaisempi kuin Cypressin dokumentaation esimerkissä, sillä kirjautumisen yhteydessä sovelluksemme tallettaa kirjautuneen käyttäjän tiedot localStorageen. Sekin toki onnistuu. Koodi on seuraavassa +Myös testeissä kannattaa pyrkiä toisteettomaan koodiin. Eristetään kirjautumisen hoitava koodi apufunktioksi, joka sijoitetaan esim. tiedostoon _tests/helper.js_: ```js -describe('when logged in', function() { - beforeEach(function() { - // highlight-start - cy.request('POST', 'http://localhost:3001/api/login', { - username: 'mluukkai', password: 'salainen' - }).then(response => { - localStorage.setItem('loggedNoteappUser', JSON.stringify(response.body)) - cy.visit('http://localhost:3000') - }) - // highlight-end - }) - - it('a new note can be created', function() { - // ... - }) +const loginWith = async (page, username, password) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill(username) + await page.getByLabel('password').fill(password) + await page.getByRole('button', { name: 'login' }).click() +} - // ... -}) +export { loginWith } ``` -Komennon [cy.request](https://docs.cypress.io/api/commands/request.html) tulokseen päästään käsiksi _then_-metodin avulla sillä sisäiseltä toteutukseltaan cy.request kuten muutkin Cypressin komennot ovat [eräänlaisia promiseja](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Commands-Are-Promises). Käsittelijäfunktio tallettaa kirjautuneen käyttäjän tiedot localStorageen ja lataa sivun uudelleen. Tämän jälkeen käyttäjä on kirjautuneena sovellukseen samalla tavalla kuin jos kirjautuminen olisi tapahtunut kirjautumislomakkeen täyttämällä. +Testit yksinkertaistuvat ja selkeytyvät: -Jos ja kun sovellukselle kirjoitetaan lisää testejä, joudutaan kirjautumisen hoitavaa koodia soveltamaan useassa paikassa. Koodi kannattaakin eristää itse määritellyksi [komennoksi](https://docs.cypress.io/api/cypress-api/custom-commands.html). +```js +const { test, describe, expect, beforeEach } = require('@playwright/test') +const { loginWith } = require('./helper') // highlight-line -Komennot määritellään tiedostoon cypress/support/commands.js. Kirjautumisen tekevä komento näyttää seuraavalta: +describe('Note app', () => { + // ... -```js -Cypress.Commands.add('login', ({ username, password }) => { - cy.request('POST', 'http://localhost:3001/api/login', { - username, password - }).then(({ body }) => { - localStorage.setItem('loggedNoteappUser', JSON.stringify(body)) - cy.visit('http://localhost:3000') + test('user can log in', async ({ page }) => { + await loginWith(page, 'mluukkai', 'salainen') // highlight-line + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() }) -}) -``` -Komennon käyttö on helppoa, testi yksinkertaisuu ja selkeytyy: + test('login fails with wrong password', async ({ page }) => { + await loginWith(page, 'mluukkai', 'wrong') // highlight-line -```js -describe('when logged in', function() { - beforeEach(function() { - // highlight-start - cy.login({ username: 'mluukkai', password: 'salainen' }) - // highlight-end + const errorDiv = page.locator('.error') + // ... }) - it('a new note can be created', function() { + describe('when logged in', () => { + beforeEach(async ({ page }) => { + await loginWith(page, 'mluukkai', 'salainen') // highlight-line + }) + // ... }) - - // ... }) ``` -Sama koskee oikeastaan myös uuden muistiinpanon luomista. Sitä varten on olemassa testi, joka luo muistiinpanon lomakkeen avulla. Myös muistiinpanon tärkeyden muuttamista testaavan testin beforeEach-alustuslohkossa luodaan muistiinpano lomakkeen avulla: +Playwright tarjoaa myös [ratkaisun](https://playwright.dev/docs/auth) missä kirjaantuminen suoritetaan kertaalleen ennen testejä, ja jokainen testi aloittaa tilanteesta missä sovellukseen ollaan jo kirjaantuneena. Jotta voisimme hyödyntää tätä tapaa, tulisi sovelluksen testidatan alustaminen tehdä hienojakoisemmin kuin nyt. Nykyisessä ratkaisussahan tietokanta nollataan ennen jokaista testiä, ja tämän takia kirjaantuminen ennen testejä on mahdotonta. Jotta voisimme käyttää Playwrightin tarjoamaa ennen testejä tehtävää kirjautumista, tulisi käyttäjä alustaa vain kertaalleen ennen testejä. Pitäydymme yksinkertaisuuden vuoksi nykyisessä ratkaisussamme. + +Vastaava toistuva koodi koskee oikeastaan myös uuden muistiinpanon luomista. Sitä varten on olemassa testi, joka luo muistiinpanon lomakkeen avulla. Myös muistiinpanon tärkeyden muuttamista testaavan testin beforeEach-alustuslohkossa luodaan muistiinpano lomakkeen avulla: ```js describe('Note app', function() { // ... - describe('when logged in', function() { - it('a new note can be created', function() { - cy.contains('new note').click() - cy.get('input').type('a note created by cypress') - cy.contains('save').click() - - cy.contains('a note created by cypress') + describe('when logged in', () => { + test('a new note can be created', async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('a note created by playwright') + await page.getByRole('button', { name: 'save' }).click() + await expect(page.getByText('a note created by playwright')).toBeVisible() }) - - describe('and a note exists', function () { - beforeEach(function () { - cy.contains('new note').click() - cy.get('input').type('another note cypress') - cy.contains('save').click() + + describe('and a note exists', () => { + beforeEach(async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('another note by playwright') + await page.getByRole('button', { name: 'save' }).click() }) - - it('it can be made important', function () { + + test('it can be made important', async ({ page }) => { // ... }) }) @@ -769,111 +847,150 @@ describe('Note app', function() { }) ``` -Eristetään myös muistiinpanon lisääminen omaksi komennoksi, joka tekee lisäämisen suoraan HTTP POST:lla: +Eristetään myös muistiinpanon lisääminen omaksi apufunktioksi. Tiedosto _tests/helper.js_ laajenee seuraavasti: ```js -Cypress.Commands.add('createNote', ({ content, important }) => { - cy.request({ - url: 'http://localhost:3001/api/notes', - method: 'POST', - body: { content, important }, - headers: { - 'Authorization': `bearer ${JSON.parse(localStorage.getItem('loggedNoteappUser')).token}` - } - }) +const loginWith = async (page, username, password) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill(username) + await page.getByLabel('password').fill(password) + await page.getByRole('button', { name: 'login' }).click() +} - cy.visit('http://localhost:3000') -}) -``` +// highlight-start +const createNote = async (page, content) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill(content) + await page.getByRole('button', { name: 'save' }).click() +} +// highlight-end -Komennon suoritus edellyttää, että käyttäjä on kirjaantuneena sovelluksessa ja käyttäjän tiedot talletettuna sovelluksen localStorageen. +export { loginWith, createNote } // highlight-line +``` -Testin alustuslohko yksinkertaistuu seuraavasti: +Testit yksinkertaistuvat seuraavasti: ```js -describe('Note app', function() { +const { test, describe, expect, beforeEach } = require('@playwright/test') +const { createNote, loginWith } = require('./helper') // highlight-line + +describe('Note app', () => { // ... - describe('when logged in', function() { - it('a new note can be created', function() { - // ... + describe('when logged in', () => { + beforeEach(async ({ page }) => { + await loginWith(page, 'mluukkai', 'salainen') }) - describe('and a note exists', function () { - beforeEach(function () { - // highlight-start - cy.createNote({ - content: 'another note cypress', - important: false - }) - // highlight-end - }) + test('a new note can be created', async ({ page }) => { + await createNote(page, 'a note created by playwright') // highlight-line + await expect(page.getByText('a note created by playwright')).toBeVisible() + }) - it('it can be made important', function () { - // ... + describe('and a note exists', () => { + beforeEach(async ({ page }) => { + await createNote(page, 'another note by playwright') // highlight-line + }) + + test('importance can be changed', async ({ page }) => { + await page.getByRole('button', { name: 'make not important' }).click() + await expect(page.getByText('make important')).toBeVisible() }) }) }) }) ``` -Testit ja frontendin koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part2-notes/tree/part5-10), branchissa part5-10. - -### Muistiinpanon tärkeyden muutos - -Tarkastellaan vielä aiemmin tekemäämme testiä, joka varmistaa että muistiinpanon tärkeyttä on mahdollista muuttaa. Muutetaan testin alustuslohkoa siten, että se luo yhden sijaan kolme muistiinpanoa: +Testeissämme on vielä eräs ikävä piirre. Sovelluksen frontendin osoite http://localhost:5173 sekä backendin osoite http://localhost:3001 on kovakoodattuna testeihin. Näistä oikeastaan backendin osoite on turha, sillä frontendin Vite-konfiguraatioon on määritelty proxy, joka forwardoi kaikki osoitteeseen http://localhost:5173/api menevät frontendin tekemät pyynnöt backendiin: ```js -describe('when logged in', function() { - describe('and several notes exist', function () { - beforeEach(function () { - // highlight-start - cy.createNote({ content: 'first note', important: false }) - cy.createNote({ content: 'second note', important: false }) - cy.createNote({ content: 'third note', important: false }) - // highlight-end - }) +export default defineConfig({ + server: { + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + } + }, + // ... +}) +``` - it('one of those can be made important', function () { - cy.contains('second note') - .contains('make important') - .click() +Voimme siis korvata testeissä kaikki osoitteet _http://localhost:3001/api/..._ osoitteella _http://localhost:5173/api/..._ - cy.contains('second note') - .contains('make not important') - }) - }) +Voimme nyt määrittellä sovellukselle _baseUrl_:in testien konfiguraatiotiedostoon playwright.config.js: + +```js +export default defineConfig({ + // ... + use: { + baseURL: 'http://localhost:5173', + // ... + }, + // ... }) ``` -Miten komento [cy.contains](https://docs.cypress.io/api/commands/contains.html) tarkalleen ottaen toimii? +Kaikki testeissä olevat sovelluksen urlia käyttävät komennot esim. -Kun klikkaamme komentoa _cy.contains('second note')_ Cypressin [test runnerista](https://docs.cypress.io/guides/core-concepts/test-runner.htm) nähdään, että komento löytää elementin, jonka sisällä on teksti second note: +```js +await page.goto('http://localhost:5173') +await page.post('http://localhost:5173/api/tests/reset') +``` -![](../../images/5/34ea.png) +voidaan nyt muuttaa muotoon +```js +await page.goto('/') +await page.post('/api/tests/reset') +``` -Klikkaamalla seuraavaa riviä _.contains('make important')_, nähdään että löydetään nimenomaan -second note:a vastaava tärkeyden muutoksen tekevä nappi: +Testien tämänhetkinen koodi on [GitHubissa](https://github.com/fullstack-hy2020/notes-e2e/tree/part5-2), branchissa part5-2. -![](../../images/5/35ea.png) +### Muistiinpanon tärkeyden muutos revisited -Peräkkäin ketjutettuna toisena oleva contains-komento siis jatkaa hakua ensimmäisen komennon löytämän komponentin sisältä. +Tarkastellaan vielä aiemmin tekemäämme testiä, joka varmistaa että muistiinpanon tärkeyttä on mahdollista muuttaa. -Jos emme ketjuttaisi komentoja, eli olisimme kirjoittaneet +Muutetaan testin alustuslohkoa siten, että se luo yhden sijaan kaksi muistiinpanoa: ```js -cy.contains('second note') -cy.contains('make important').click() +describe('when logged in', () => { + // ... + describe('and several notes exists', () => { // highlight-line + beforeEach(async ({ page }) => { + // highlight-start + await createNote(page, 'first note', true) + await createNote(page, 'second note', true) + // highlight-end + }) + + test('one of those can be made nonimportant', async ({ page }) => { + const otherNoteElement = page.getByText('first note') + + await otherNoteElement + .getByRole('button', { name: 'make not important' }).click() + await expect(otherNoteElement.getByText('make important')).toBeVisible() + }) + }) +}) ``` -tulos olisi ollut aivan erilainen, toinen rivi painaisi väärän muistiinpanon nappia: +Testi etsii ensin metodin _getByRole_ avulla ensimmäisenä luotua muistiinpanoa vastaavan elementin ja tallettaa sen muuttujaan. Tämän jälkeen elementin sisältä etsitään nappi, missä on teksti _make not important_ ja painetaan nappia. Lopuksi testi varmistaa että napin tekstiksi on muuttunut _make important_. -![](../../images/5/36ea.png) +Testi olisi voitu kirjoittaa myös ilman apumuuttujaa: -Testejä tehdessä kannattaa siis ehdottomasti varmistaa test runnerista, että testit etsivät niitä elementtejä, joita niiden on tarkoitus tutkia! +```js +test('one of those can be made nonimportant', async ({ page }) => { + page.getByText('first note') + .getByRole('button', { name: 'make not important' }).click() -Muutetaan komponenttia _Note_ siten, että muistiinpanon teksti renderöitään span-komponentin sisälle + await expect(page.getByText('first note').getByText('make important')) + .toBeVisible() +}) +``` + +Muutetaan komponenttia _Note_ siten, että muistiinpanon teksti renderöidään _span_-elementin sisälle ```js const Note = ({ note, toggleImportance }) => { @@ -889,199 +1006,306 @@ const Note = ({ note, toggleImportance }) => { } ``` -Testit hajoavat! Kuten test runner paljastaa, komento _cy.contains('second note')_ palauttaakin nyt ainoastaan tekstin sisältävän komponentin, ja nappi on sen ulkopuolella: - -![](../../images/5/37ea.png) +Testit hajoavat! Syynä ongelmalle on se, komento _page.getByText('second note')_ palauttaakin nyt ainoastaan tekstin sisältävän _span_-elementin, ja nappi on sen ulkopuolella. Eräs tapa korjata ongelma on seuraavassa: ```js -it('other of those can be made important', function () { - cy.contains('second note').parent().find('button').click() - cy.contains('second note').parent().find('button') - .should('contain', 'make not important') +test('one of those can be made nonimportant', async ({ page }) => { + const otherNoteText = page.getByText('first note') // highlight-line + const otherNoteElement = otherNoteText.locator('..') // highlight-line + + await otherNoteElement.getByRole('button', { name: 'make not important' }).click() + await expect(otherNoteElement.getByText('make important')).toBeVisible() }) ``` -Ensimmäisellä rivillä etsitään komennon [parent](https://docs.cypress.io/api/commands/parent.htm) tekstin second note sisältävän elementin vanhemman alla oleva nappi ja painetaan sitä. Toinen rivi varmistaa, että napin teksti muuttuu. +Ensimmäinen rivi etsii nyt ensimmäisenä luotuun muistiinpanoon liittyvän tekstin sisältävän _span_-elementin. Toisella rivillä käytetään funktiota _locator_ ja annetaan parametriksi _.._, joka hakee elementin vanhempielementin. Funktio locator on hyvin joustava, ja hyödynnämme tässä sitä että funktio hyväksyy [parametrikseen](https://playwright.dev/docs/locators#locate-by-css-or-xpath) CSS-selektorien lisäksi myös [XPath](https://developer.mozilla.org/en-US/docs/Web/XPath)-muotoisen selektorin. Sama olisi mahdollista ilmaista myös CSS:n avulla, mutta tässä tapauksessa XPath tarjoaa yksinkertaisimman tavan elementin vanhemman etsimiseen. -Huomaa, että napin etsimiseen käytetään komentoa [find](https://docs.cypress.io/api/commands/find.html#Syntax). Komento [cy.get](https://docs.cypress.io/api/commands/get.html) ei sovellu tähän tilanteeseen, sillä se etsii elementtejä aina koko sivulta ja palauttaisi nyt kaikki sovelluksen viisi nappia. - -Testissä on ikävästi copypastea, rivien alku eli napin etsivä koodi on sama. -Tälläisissä tilanteissa on mahdollista hyödyntää komentoa [as](https://docs.cypress.io/api/commands/as.html): +Testi voidaan toki kirjoittaa myös ainoastaan yhtä apumuuttujaa käyttäen: ```js -it.only('other of those can be made important', function () { - cy.contains('second note').parent().find('button').as('theButton') - cy.get('@theButton').click() - cy.get('@theButton').should('contain', 'make not important') +test('one of those can be made nonimportant', async ({ page }) => { + const secondNoteElement = page.getByText('second note').locator('..') + await secondNoteElement.getByRole('button', { name: 'make not important' }).click() + await expect(secondNoteElement.getByText('make important')).toBeVisible() }) ``` -Nyt ensimmäinen rivi etsii oikean napin, ja tallentaa sen komennon as avulla nimellä theButton. Seuraavat rivit pääsevät nimettyyn elementtiin käsiksi komennolla cy.get('@theButton'). +Muutetaan testiä vielä siten, että muistiinpanoja luodaankin kolme, ja tärkeyttä vaihdetaan toisena luodulta muistiinpanolta: -### Testien suoritus ja debuggaaminen +```js +describe('when logged in', () => { + beforeEach(async ({ page }) => { + await loginWith(page, 'mluukkai', 'salainen') + }) -Vielä osan lopuksi muutamia huomioita Cypressin toimintaperiaatteesta sekä testien debuggaamisesta. + test('a new note can be created', async ({ page }) => { + await createNote(page, 'a note created by playwright', true) + await expect(page.getByText('a note created by playwright')).toBeVisible() + }) -Cypressissä testien kirjoitusasu antaa vaikutelman, että testit ovat normaalia javascript-koodia, ja että voisimme esim. yrittää seuraavaa: + describe('and several notes exists', () => { + beforeEach(async ({ page }) => { + await createNote(page, 'first note', true) + await createNote(page, 'second note', true) + await createNote(page, 'third note', true) // highlight-line + }) -```js -const button = cy.contains('login') -button.click() -debugger() -cy.contains('logout').click() + test('one of those can be made nonimportant', async ({ page }) => { + const otherNoteText = page.getByText('second note') // highlight-line + const otherNoteElement = otherNoteText.locator('..') + + await otherNoteElement.getByRole('button', { name: 'make not important' }).click() + await expect(otherNoteElement.getByText('make important')).toBeVisible() + }) + }) +}) ``` -Näin kirjoitettu koodi ei kuitenkaan toimi. Kun Cypress suorittaa testin, se lisää jokaisen _cy_-komennon suoritusjonoon. Kun testimetodin koodi on suoritettu loppuun, suorittaa Cypress yksi kerrallaan suoritusjonoon lisätyt _cy_-komennot. +Jostain syystä testi alkaa toimia epäluotettavasti, se menee välillä läpi ja välillä ei. On aika kääriä hihat ja opetella debuggaamaan testejä. -Cypressin komennot palauttavat aina _undefined_, eli yllä olevassa koodissa komento _button.click()_ aiheuttaisi virheen ja yritys käynnistää debuggeri ei pysäyttäisi koodia Cypress-komentojen suorituksen välissä, vaan jo ennen kuin yhtään Cypress-komentoa olisi suoritettu. +### Testien kehittäminen ja debuggaaminen -Cypress-komennot ovat promisen kaltaisia, joten jos niiden palauttamia arvoja halutaan käsitellä, se tulee tehdä komennon [then](https://docs.cypress.io/api/commands/then.html) avulla. Esim. seuraava testi tulostaisi sovelluksen kaikkien nappien lukumäärän ja klikkaisi napeista ensimmäistä: +Jos/kun testit eivät mene läpi ja herää epäilys, että vika on koodin sijaan testeissä, kannattaa testejä suorittaa [debug](https://playwright.dev/docs/debug#run-in-debug-mode-1)-moodissa. + +Seuraava komento suorittaa ongelmallisen testin debug-moodissa: + +``` +npm test -- -g'one of those can be made nonimportant' --debug +``` + +Playwright-inspector näyttää testien etenemisen askel askeleelta. Yläreunan nuoli-piste-painike vie testejä yhden askeleen eteenpäin. Lokaattorien löytämät elementit sekä selaimen kanssa käyty interaktio visualisoituvat selaimeen: + +![](../../images/5/play6a.png) + +Oletusarvoisesti debugatessa askelletaan testi läpi komento komennolta. Jos on kyse monimutkaisesta testistä, voi olla melko vaivalloista askeltaa testissä kiinnostavaan kohtaan asti. Liialta askellukselta voidaan välttyä lisäämällä juuri kiinnostavaa kohtaa ennen komento _await page.pause()_: ```js -it('then example', function() { - cy.get('button').then( buttons => { - console.log('number of buttons', buttons.length) - cy.wrap(buttons[0]).click() +describe('Note app', () => { + beforeEach(async ({ page, request }) => { + // ... + } + + describe('when logged in', () => { + beforeEach(async ({ page }) => { + // ... + }) + + describe('and several notes exists', () => { + beforeEach(async ({ page }) => { + await createNote(page, 'first note') + await createNote(page, 'second note') + await createNote(page, 'third note') + }) + + test('one of those can be made unimportant', async ({ page }) => { + await page.pause() // highlight-line + const otherNoteText = page.getByText('second note') + const otherNoteElement = otherNoteText.locator('..') + + await otherNoteElement.getByRole('button', { name: 'make not important' }).click() + await expect(otherNoteElement.getByText('make important')).toBeVisible() + }) + }) }) }) ``` -Myös testien suorituksen pysäyttäminen debuggeriin on [mahdollista](https://docs.cypress.io/api/commands/debug.html). Debuggeri käynnistyy vain jos Cypress test runnerin developer konsoli on auki. +Nyt testissä voidaan siirtyä kiinnostavaan kohtaan yhdellä askelella, painamalla inspectorissa vihreää nuolisymbolia. + +Kun suoritamme nyt testin ja hyppäämme suorituksessa komenon _page.pause()_ kohdalle, havaitsemme mielenkiintoisen seikan: + +![](../../images/5/play6b.png) -Developer konsoli on monin tavoin hyödyllinen testejä debugatessa. Network-tabilla näkyvät testattavan sovelluksen tekemät HTTP-pyynnöt, ja console-välilehti kertoo testin komentoihin liittyviä tietoja: +Näyttää siltä, että selain ei renderöi kaikkia lohkossa _beforeEach_ luotuja muistiinpanoja. Mistä on kyse? -![](../../images/5/38ea.png) +Syynä ongelmaan on se, että kun testi luo yhden muistiinpanon, se aloittaa seuraavan luomisen jo ennen kuin palvelin on vastannut, ja lisätty muistiinpano renderöidään ruudulle. Tämä taas saattaa aiheuttaa sen, että jotain muistiinpanoja katoaa (kuvassa näin kävi toisena luodulle muistiinpanolle), sillä selain uudelleenrenderöidään palvelimen vastatessa perustuen siihen muistiinpanojen tilaan mikä kyseisen lisäysoperaation alussa oli. -Olemme toistaiseksi suorittaneet Cypress-testejä ainoastaan graafisen test runnerin kautta. Testit on luonnollisesti mahdollista suorittaa myös [komentoriviltä](https://docs.cypress.io/guides/guides/command-line.html). Lisätään vielä sovellukselle npm-skripti tätä tarkoitusta varten +Ongelma korjaantuu "hidastamalla" lisäysoperaatioita siten, että lisäyksen jälkeen odotetaan komennolla [waitFor](https://playwright.dev/docs/api/class-locator#locator-wait-for), että lisätty muistinpano ehditään renderöidä: ```js - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject", - "server": "json-server -p3001 --watch db.json", - "cypress:open": "cypress open", - "test:e2e": "cypress run" // highlight-line - }, +const createNote = async (page, content) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill(content) + await page.getByRole('button', { name: 'save' }).click() + await page.getByText(content).waitFor() // highlight-line +} +``` + +Debuggausmoodin sijaan tai rinnalla voi testien suorittaminen UI-moodissa olla hyödyllistä. Tämä tapahtuu seuraavasti: + +``` +npm run test -- --ui ``` -Nyt siis voimme suorittaa Cypress-testit komentoriviltä komennolla npm run test:e2e +Lähes samaan tapaan kuin UI-moodi, toimii Playwrightin [Trace Viewer](https://playwright.dev/docs/trace-viewer-intro). Ideana siinä on se että testeistä tallennetaan "visuaalinen jälki", jota voidaan tarkastella tarvittaessa testien suorituksen jälkeen. Trace tallennetaan suorittamalla testit seuraavasti: -![](../../images/5/39ea.png) +``` +npm run test -- --trace on +``` + +Tracen pääsee tarvittaessa katsomaan komennolla -Huomaa, että testien suorituksesta tallentuu video hakemistoon cypress/videos/, hakemisto lienee syytä gitignoroida. +``` +npx playwright show-report +``` -Testien ja frontendin koodin lopullinen versio on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/part2-notes/tree/part5-11), branchissa part5-11. +tai määrittelemällämme npm-skriptillä _npm run test:report_ + +Trace näyttää käytännössä samalta kuin testien suoritus UI-moodissa. + +UI-moodi sekä Trace viewer tarjoavat myös mahdollisuuden avustettuun lokaattorien etsimiseen. Tämä tapahtuu painamalla alapalkin vasemmanpuoleista tuplaympyrää, ja sen jälkeen klikkaamalla haluttua käyttöliittymäelementtiä. Playwright näyttää elementin lokaattorin: + +![](../../images/5/play8.png) + +Playwright ehdottaa siis kolmannen muistiinpanon lokaattoriksi seuraavaa + +```js +page.locator('li').filter({ hasText: 'third note' }).getByRole('button') +``` + +Metodia [page.locator](https://playwright.dev/docs/api/class-page#page-locator) kutsutaan parametrilla _li_ eli etsitään sivulta kaikki li-elementit, joita sivulla on yhteensä kolme. Tämän jälkeen rajaudutaan metodia [locator.filter](https://playwright.dev/docs/api/class-locator#locator-filter) käyttäen siihen li-elementtiin, joka sisältää tekstin third notemake not important ja otetaan sen sisällä oleva button-elementti metodia [locator.getByRole](https://playwright.dev/docs/api/class-locator#locator-get-by-role) käyttäen. + +Playwrightin generoima lokaattori poikkeaa jossain määrin testien käyttämästä lokaattorista, joka oli + +```js +page.getByText('first note').locator('..').getByRole('button', { name: 'make not important' }) +``` + +Lienee makuasia kumpi lokaattoreista on parempi. + +Playwright sisältää myös [testigeneraattorin](https://playwright.dev/docs/codegen-intro), jonka avulla on mahdollista "nauhoittaa" käyttöliittymän kautta klikkailemalla testien käyttämiä lokaattoreita. Testigeneraattori käynnistyy komennolla + +``` +npx playwright codegen http://localhost:5173/ +``` + +_Record_-tilan päälläollessa testigeneraattori "tallentaa" käyttäjän interaktion Playwright inspectoriin, mistä lokaattorit ja actionit on mahdollista kopioida testeihin: + +![](../../images/5/play9.png) + +Komentorivin sijaan Playwrightiä voi käyttää myös [VS Code](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright)-pluginin kautta. Plugin tarjoaa monia käteviä ominaisuuksia, mm. breakpointien käytön testejä debugatessa. + +Ongelmatilanteiden välttämiseksi ja ymmärryksen lisäämiseksi kannattaa ehdottomasti selailla Playwrightin laadukasta [dokumentaatiota](https://playwright.dev/docs/intro). Seuraavassa on listattu tärkeimmät: +- [lokaattoreista](https://playwright.dev/docs/locators) kertova osa antaa hyviä vihjeitä testattavien elementtien etsimiseen +- osa [actions](https://playwright.dev/docs/input) kertoo miten selaimen kanssa käytävää vuorovaikutusta on mahdollista simuloida testeissä +- [assertioista](https://playwright.dev/docs/test-assertions) kertova osa demonstroi mitä erilaisia testauksessa käytettäviä ekspektaatioita Playwright tarjoaa + +Tarkemmat detaljit löytyvät [API](https://playwright.dev/docs/api/class-playwright)-kuvauksesta, erityisen hyödyllisiä ovat testattavan sovelluksen selainikkunaa vastaavan luokan [Page](https://playwright.dev/docs/api/class-page) kuvaus, sekä testeissä etsittyjä elementtejä vastaavan luokan [Locator](https://playwright.dev/docs/api/class-locator)-kuvaus. + +Testien lopullinen versio on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/notes-e2e/tree/part5-3), branchissa part5-3. + +Frontendin lopullinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-9), branchissa part5-9.
    -### Tehtävät 5.17.-5.22. +### Tehtävät 5.17.-5.23. -Tehdään osan lopuksi muutamia E2E-testejä blogisovellukseen. Ylläolevan materiaalin pitäisi riittää ainakin suurimmaksi osaksi tehtävien tekemiseen. Cypressin [dokumentaatiota](https://docs.cypress.io/guides/overview/why-cypress.html#In-a-nutshell) kannattaa ehdottomasti myös lueskella, kyseessä on ehkä paras dokumentaatio, mitä olen koskaan open source -projektissa nähnyt. +Tehdään osan lopuksi muutamia E2E-testejä blogisovellukseen. Yllä olevan materiaalin pitäisi riittää suurimman osan tehtävien tekemiseen. Playwrightin [dokumentaatiota](https://playwright.dev/docs/intro) ja [API-kuvausta](https://playwright.dev/docs/api/class-playwright) kannattaa kuitenkin ehdottomasti lukea, ainakin edellisen luvun lopussa mainitut osat. -Erityisesti kannattaa lukea luku [Introduction to Cypress](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Cypress-Can-Be-Simple-Sometimes), joka toteaa +#### 5.17: blogilistan end to end ‑testit, step1 -> This is the single most important guide for understanding how to test with Cypress. Read it. Understand it. +Tee uusi npm-projekti testejä varten ja konfiguroi sinne Playwright. -#### 5.17: blogilistan end to end -testit, step1 - -Konfiguroi Cypress projektiisi. Tee testi, joka varmistaa, että sovellus näyttää oletusarvoisesati kirjautumislomakkeen. +Tee testi, joka varmistaa, että sovellus näyttää oletusarvoisesti kirjautumislomakkeen. Testin rungon tulee olla seuraavanlainen ```js -describe('Blog app', function() { - beforeEach(function() { - cy.request('POST', 'http://localhost:3001/api/testing/reset') - cy.visit('http://localhost:3000') +const { test, expect, beforeEach, describe } = require('@playwright/test') + +describe('Blog app', () => { + beforeEach(async ({ page, request }) => { + await request.post('http://localhost:3003/api/testing/reset') + await request.post('http://localhost:3003/api/users', { + data: { + name: 'Matti Luukkainen', + username: 'mluukkai', + password: 'salainen' + } + }) + + await page.goto('http://localhost:5173') }) - it('Login from is shown', function() { + test('Login form is shown', async ({ page }) => { // ... }) }) + ``` -Testin beforeEach-alustuslohkon tulee nollata tietokannan tilanne esim. -[materiaalissa](/osa5/end_to_end_testaus#tietokannan-tilan-kontrollointi) näytetyllä tavalla. +Testin beforeEach-alustuslohkon tulee nollata tietokannan tilanne esim. [materiaalissa](/osa5/end_to_end_testaus_playwright#testien-alustus) näytetyllä tavalla. -#### 5.18: blogilistan end to end -testit, step2 +#### 5.18: blogilistan end to end ‑testit, step2 -Tee testit kirjautumiselle, testaa sekä onnistunut että epäonnistunut kirjautuminen. -Luo testejä varten käyttäjä beforeEach-lohkossa. +Tee testit kirjautumiselle. Testaa sekä onnistunut että epäonnistunut kirjautuminen. Luo testejä varten käyttäjä beforeEach-lohkossa. Testien runko laajenee seuraavasti ```js -describe('Blog app', function() { - beforeEach(function() { - cy.request('POST', 'http://localhost:3001/api/testing/reset') - // create here a user to backend - cy.visit('http://localhost:3000') +const { test, expect, beforeEach, describe } = require('@playwright/test') + +describe('Note app', () => { + beforeEach(async ({ page, request }) => { + // ... }) - it('Login from is shown', function() { + test('Login form is shown', async ({ page }) => { // ... }) - describe('Login',function() { - it('succeeds with correct credentials', function() { + describe('Login', () => { + test('succeeds with correct credentials', async ({ page }) => { // ... }) - it('fails with wrong credentials', function() { + test('fails with wrong credentials', async ({ page }) => { // ... }) }) }) ``` -Vapaaehtoinen bonustehtävä: varmista, että epäonnistuneeseen kirjautumiseen liittyvä notifikaatio näytetään punaisella. - -#### 5.19: blogilistan end to end -testit, step3 +#### 5.19: blogilistan end to end ‑testit, step3 -Tee testi, joka varmistaa, että kirjaantunut käyttäjä pystyy luomaan blogin. Testin runko voi näyttää seuraavalta +Tee testi, joka varmistaa, että kirjautunut käyttäjä pystyy luomaan blogin. Testin runko voi näyttää seuraavalta ```js -describe('Blog app', function() { - // ... - - describe.only('When logged in', function() { - beforeEach(function() { - // log in user here - }) - - it('A blog can be created', function() { - // ... - }) +describe('When logged in', () => { + beforeEach(async ({ page }) => { + // ... }) + test('a new blog can be created', async ({ page }) => { + // ... + }) }) ``` Testin tulee varmistaa, että luotu blogi tulee näkyville blogien listalle. -#### 5.20: blogilistan end to end -testit, step4 +#### 5.20: blogilistan end to end ‑testit, step4 Tee testi, joka varmistaa, että blogia voi likettää. -#### 5.21: blogilistan end to end -testit, step5 +#### 5.21: blogilistan end to end ‑testit, step5 + +Tee testi, joka varmistaa, että blogin lisännyt käyttäjä voi poistaa blogin. Jos käytät poisto-operaation yhteydessä _window.confirm_-dialogia, saatat joutua hieman etsimään miten dialogin käyttö tapahtuu Playwright-testeistä käsin. -Tee testi, joka varmistaa, että blogin lisännyt käyttäjä voi poistaa blogin. +#### 5.22: blogilistan end to end ‑testit, step6 -Vapaaehtoinen bonustehtävä: varmista myös että poisto ei onnistu muilta kuin blogin lisänneeltä käyttäjältä. +Tee testi, joka varmistaa, että vain blogin lisännyt käyttäjä näkee blogin poistonapin. -#### 5.22: blogilistan end to end -testit, step6 +#### 5.23: blogilistan end to end ‑testit, step7 Tee testi, joka varmistaa, että blogit järjestetään likejen mukaiseen järjestykseen, eniten likejä saanut blogi ensin. -Tämä tehtävä saattaa olla hieman edeltäviä haastavampi. Eräs ratkaisutapa on etsiä kaikki blogit ja tarkastella tulosta [then](https://docs.cypress.io/api/commands/then.html#DOM-element)-komennon takaisinkutsufunktiossa. +Tämä tehtävä on edellisiä huomattavasti haastavampi. -Tämä oli osan viimeinen tehtävä ja on aika pushata koodi githubiin sekä merkata tehdyt tehtävät [palautussovellukseen](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). +Tämä oli osan viimeinen tehtävä ja on aika pushata koodi GitHubiin sekä merkata tehdyt tehtävät [palautussovellukseen](https://studies.cs.helsinki.fi/stats/courses/fullstackopen).
    diff --git a/src/content/5/fi/osa5e.md b/src/content/5/fi/osa5e.md new file mode 100644 index 00000000000..a1d4408c687 --- /dev/null +++ b/src/content/5/fi/osa5e.md @@ -0,0 +1,1126 @@ +--- +mainImage: ../../../images/part-5.svg +part: 5 +letter: e +lang: fi +--- + +
    + +[Cypress](https://www.cypress.io/) on siis ollut edellisten vuosien ajan suosituin E2E-testauskirjasto, jonka rinnalle Playwright on kovaa vauhtia nousemassa. Tällä kurssilla on jo vuosia käytetty Cypressiä. Nyt mukana on uutena myös Playwright. Saat itse valita suoritatko kurssin E2E-testausta käsittelevän osan Cypressillä vai Playwrightillä. Molempien kirjastojen toimintaperiaatteet ovat hyvin samankaltaisia, joten kovin suurta merkitystä valinnallasi ei ole. Playwright on kuitenkin nyt kurssin ensisijaisesti suosittelema E2E-kirjasto. + +Jos valintasi on Cypress, jatka eteenpäin. Jos päädyt käyttämään Playwrightia, mene [tänne](/osa5/end_to_end_testaus_playwright). + +### Cypress + +Toisin kuin React-frontendille tehdyt yksikkötestit tai backendin testit, nyt tehtävien End to End -testien ei tarvitse sijaita samassa npm-projektissa missä koodi on. Tehdään E2E-testeille kokonaan oma projekti komennolla _npm init_. + +Asennetaan sitten Cypress suorittamalla uuden projektin kehitysaikaiseksi riippuvuudeksi + +```js +npm install --save-dev cypress +``` + +ja määritellään npm-skripti käynnistämistä varten, ja tehdään myös pieni muutos sovelluksen käynnistävään skriptiin: + +```js +{ + // ... + "scripts": { + "cypress:open": "cypress open" // highlight-line + }, + // ... +} +``` + +Cypress-testit olettavat että testattava järjestelmä on käynnissä kun testit suoritetaan, eli toisin kuin esim. backendin integraatiotestit, Cypress-testit eivät käynnistä testattavaa järjestelmää testauksen yhteydessä. + +Tehdään backendille npm-skripti, jonka avulla se saadaan käynnistettyä testausmoodissa, eli siten, että NODE\_ENV saa arvon test. + +```js +{ + // ... + "scripts": { + "start": "cross-env NODE_ENV=production node index.js", + "dev": "cross-env NODE_ENV=development node --watch index.js", + "test": "cross-env NODE_ENV=test node --test", + "lint": "eslint .", + // ... + "start:test": "cross-env NODE_ENV=test node --watch index.js" // highlight-line + }, + // ... +} +``` + +Kun backend ja frontend ovat käynnissä, voidaan käynnistää Cypress komennolla + +```js +npm run cypress:open +``` + +Cypress kysyy minkä tyyppisiä testejä haluamme tehdä. Valitaan "E2E Testing": + +![](../../images/5/51new.png) + +Valitaan sopiva selain (esim. Chrome) ja "Create new spec": + +![](../../images/5/52new.png) + +Annetaan testille nimeksi note\_app.cy.js ja sijoitetaan se ehdotettuun hakemistoon cypress/e2e: + +![](../../images/5/53new.png) + +Voisimme tehdä testejä Cypressin kautta, mutta käytetään kuitenkin VS Codea testien editointiin: + +![](../../images/5/54new.png) + +Suljetaan Cypressin testin editointinäkymä. + +Muutetaan testin sisältö seuraavanlaiseksi + +```js +describe('Note app', function() { + it('front page can be opened', function() { + cy.visit('http://localhost:5173') + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2025') + }) +}) +``` + +Testin suoritus käynnistetään klikkaamalla testin nimeä Cypressin näkymästä: + +![](../../images/5/55new.png) + +Testin suoritus näyttää, miten sovellus käyttäytyy testin edetessä: + +![Selain renderöi näkymän, jossa vasemmalla testit ja niiden askeleet ja oikealla testin alla oleva sovellus.](../../images/5/56new.png) + +Testi näyttää rakenteeltaan melko tutulta. describe-lohkoja käytetään samaan tapaan kuin Vitestissä ryhmittelemään yksittäisiä testitapauksia, jotka on määritelty it-metodin avulla. Nämä osat Cypress on lainannut sisäisesti käyttämältään [Mocha](https://mochajs.org/)-testikirjastolta. + +[cy.visit](https://docs.cypress.io/api/commands/visit.html) ja [cy.contains](https://docs.cypress.io/api/commands/contains.html) taas ovat Cypressin komentoja, joiden merkitys on aika ilmeinen. [cy.visit](https://docs.cypress.io/api/commands/visit.html) avaa testin käyttämään selaimeen parametrina määritellyn osoitteen ja [cy.contains](https://docs.cypress.io/api/commands/contains.html) etsii sivun sisältä parametrina annetun tekstin. + +Olisimme voineet määritellä testin myös käyttäen nuolifunktioita + +```js +describe('Note app', () => { // highlight-line + it('front page can be opened', () => { // highlight-line + cy.visit('http://localhost:5173') + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2025') + }) +}) +``` + +Mochan dokumentaatio kuitenkin [suosittelee](https://mochajs.org/#arrow-functions), että nuolifunktioita ei käytetä, sillä ne saattavat aiheuttaa ongelmia joissain tilanteissa. + +HUOM: tässä materiaalissa suoritetaan Cypress-testejä pääasiassa graafisen test runnerin kautta. Testit on luonnollisesti mahdollista suorittaa myös [komentoriviltä](https://docs.cypress.io/guides/guides/command-line.html) komennolla cypress run, joka kannattaa halutessa lisätä npm-skriptiksi. + +Jos komento cy.contains ei löydä sivulta etsimäänsä tekstiä, testi ei mene läpi. Eli jos laajennamme testiä seuraavasti + +```js +describe('Note app', function() { + it('front page can be opened', function() { + cy.visit('http://localhost:5173') + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2025') + }) + +// highlight-start + it('front page contains random text', function() { + cy.visit('http://localhost:5173') + cy.contains('wtf is this app?') + }) +// highlight-end +}) +``` + +havaitsee Cypress ongelman + +![Vasemmalla oleva testin suoritusta kuvaava näkymä paljastaa, että "contains"-askel epäonnistuu ja aiheuttaa virheen AssertionError, timed out... Expected to find content 'wtf is this app?' but never did.](../../images/5/57new.png) + +Poistetaan virheeseen johtanut testi koodista. + +### Lomakkeelle kirjoittaminen + +Laajennetaan testejä siten, että testi yrittää kirjautua sovellukseen. Oletetaan että backendin tietokantaan on tallennettu käyttäjä, jonka käyttäjätunnus on mluukkai ja salasana salainen. + +Aloitetaan kirjautumislomakkeen avaamisella. + +```js +describe('Note app', function() { + // ... + + it('user can login', function() { + cy.visit('http://localhost:5173') + cy.contains('button', 'login').click() + }) +}) +``` + +Testi etsii ensin _button_-tyyppisen elementin, jossa on haluttu teksti, ja klikkaa nappia komennolla [cy.click](https://docs.cypress.io/api/commands/click.html#Syntax). + +Koska molemmat testit aloittavat samalla tavalla, eli avaamalla sivun http://localhost:5173, kannattaa yhteinen osa eristää ennen jokaista testiä suoritettavaan beforeEach-lohkoon: + +```js +describe('Note app', function() { + // highlight-start + beforeEach(function() { + cy.visit('http://localhost:5173') + }) + // highlight-end + + it('front page can be opened', function() { + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2025') + }) + + it('user can login', function() { + cy.contains('button', 'login').click() + }) +}) +``` + +Ilmoittautumislomake sisältää kaksi input-kenttää, joihin testin tulisi kirjoittaa. + +Komento [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax) mahdollistaa elementtien etsimisen CSS-selektorien avulla. + +Voimme hakea lomakkeen ensimmäisen ja viimeisen input-kentän ja kirjoittaa niihin komennolla [cy.type](https://docs.cypress.io/api/commands/type.html#Syntax) seuraavasti: + +```js +it('user can login', function () { + cy.contains('button', 'login').click() + cy.get('input:first').type('mluukkai') + cy.get('input:last').type('salainen') +}) +``` + +Testi toimii, mutta on kuitenkin sikäli ongelmallinen, että jos sovellukseen tulee jossain vaiheessa lisää input-kenttiä, testi saattaa hajota, sillä se luottaa tarvitsemiensa kenttien olevan sivulla ensimmäisenä ja viimeisenä. + +Käytetään nyt hyödyksi kirjautumislomakkeen olemassa olevia elementtejä. Kirjautumislomakkeen syötekentille on määritelty yksilölliset labelit: + +```js +// ... +
    +
    + // highlight-line +
    +
    + // highlight-line +
    + +
    +// ... +``` + +Syötekentät voi ja kannattaa etsiä testeissä labelien avulla:: + +```js +describe('Note app', function () { + // ... + + it('user can login', function () { + cy.contains('button', 'login').click() + cy.contains('label', 'username').type('mluukkai') // highlight-line + cy.contains('label', 'password').type('salainen') // highlight-line + }) +}) +``` + +Elementtien etsimisessä on järkevää pyrkiä hyödyntämään käyttöliittymän käyttäjälle näkyvää sisältöä, koska näin simuloidaan parhaiten sitä, miten käyttäjä oikeasti löytää halutun syötekentän navigoidessaan sovelluksessa. + +Kun käyttäjätunnus ja salasana on syötetty lomakkeelle, tulisi seuraavaksi painaa login-painiketta. Se aiheuttaa kuitenkin hieman päänvaivaa, sillä sivulla on nyt oikeastaan kaksi login-nimistä painiketta. Käyttämämme Togglable-komponentti nimittäin sisältää samannimisen painikkeen, joka on piilotettu antamalla sille näkyvyysmääre _style="display: none"_, kun kirjautumislomake on näkyvillä. + +Jotta saamme testissä painettua varmasti oikeaa painiketta, määritellään kirjautumislomakkeen login-painikkeelle yksilöllinen id-attribuutti: + +```js +const LoginForm = ({ ... }) => { + return ( +
    +

    Login

    +
    + // + + +
    +
    + ) +} +``` + +Testi muuttuu muotoon + +```js +describe('Note app', function() { + // .. + it('user can login', function () { + cy.contains('button', 'login').click() + cy.contains('label', 'username').type('mluukkai') + cy.contains('label', 'password').type('salainen') + cy.get('#login-button').click() // highlight-line + + cy.contains('Matti Luukkainen logged in') // highlight-line + }) +}) +``` + +Viimeinen rivi varmistaa, että kirjautuminen on onnistunut. + +Huomaa, että CSS:n [id-selektori](https://developer.mozilla.org/en-US/docs/Web/CSS/ID_selectors) on risuaita, eli jos koodista etsitään elementtiä, jolla on id login-button on sitä vastaava CSS-selektori #login-button. + +Huomaa, että testin läpimeno tässä vaiheessa edellyttää, että backendin ympäristön test tietokannassa on käyttäjä, jonka username on mluukkai ja salasana salainen. Luo käyttäjä tarvittaessa! + +### Muistiinpanojen luomisen testaus + +Luodaan seuraavaksi testi, joka lisää sovellukseen uuden muistiinpanon: + +```js +describe('Note app', function() { + // .. + // highlight-start + describe('when logged in', function() { + beforeEach(function() { + cy.contains('button', 'login').click() + cy.contains('label', 'username').type('mluukkai') + cy.contains('label', 'password').type('salainen') + cy.get('#login-button').click() + }) + // highlight-end + + // highlight-start + it('a new note can be created', function() { + cy.contains('new note').click() + cy.get('input').type('a note created by cypress') + cy.contains('save').click() + + cy.contains('a note created by cypress') + }) + }) + // highlight-end +}) +``` + +Testi on määritelty omana describe-lohkonaan. Muistiinpanon luominen edellyttää että käyttäjä on kirjaantuneena, ja kirjautuminen hoidetaan beforeEach-lohkossa. + +Testi luottaa siihen, että uutta muistiinpanoa luotaessa sivulla on ainoastaan yksi input-kenttä, eli se hakee kentän seuraavasti + +```js +cy.get('input') +``` + +Jos kenttiä olisi useampia, testi hajoaisi + +![Aiheutuu virhe cy.type() cam only be called on a single element. Your subject contained 2 elements.](../../images/5/31x.png) + +Tämän takia olisi jälleen parempi lisätä lomakkeen kentälle id ja hakea kenttä testissä id:n perusteella. Pitäydytään nyt kuitenkin yksinkertaisimmassa ratkaisussa. + +Testien rakenne näyttää seuraavalta: + +```js +describe('Note app', function() { + // ... + + it('user can login', function() { + cy.contains('button', 'login').click() + cy.contains('label', 'username').type('mluukkai') + cy.contains('label', 'password').type('salainen') + cy.get('#login-button').click() + + cy.contains('Matti Luukkainen logged in') + }) + + describe('when logged in', function() { + beforeEach(function() { + cy.contains('button', 'login').click() + cy.contains('label', 'username').type('mluukkai') + cy.contains('label', 'password').type('salainen') + cy.get('#login-button').click() + }) + + it('a new note can be created', function() { + // ... + }) + }) +}) +``` + +Cypress suorittaa testit siinä järjestyksessä, missä ne ovat testikoodissa. Eli ensin suoritetaan testi user can login, missä käyttäjä kirjautuu sovellukseen, ja tämän jälkeen suoritetaan testi a new note can be created, jonka beforeEach-lohkossa myös suoritetaan kirjautuminen. Miksi näin tehdään, eikö käyttäjä jo ole kirjaantuneena aiemman testin ansiosta? Ei, sillä jokaisen testin suoritus alkaa selaimen kannalta "nollatilanteesta", kaikki edellisten testien selaimen tilaan tekemät muutokset nollaantuvat. + +### Tietokannan tilan kontrollointi + +Jos testatessa on tarvetta muokata palvelimen tietokantaa, muuttuu tilanne heti haastavammaksi. Ideaalitilanteessa testauksen tulee aina lähteä liikkeelle palvelimen tietokannan suhteen samasta alkutilanteesta, jotta testeistä saadaan luotettavia ja helposti toistettavia. + +Kuten yksikkö- ja integraatiotesteissä, on myös E2E-testeissä paras ratkaisu nollata tietokanta ja mahdollisesti alustaa se sopivasti aina ennen testien suorittamista. E2E-testauksessa lisähaasteen tuo se, että testeistä ei ole mahdollista päästä suoraan käsiksi tietokantaan. + +Ratkaistaan ongelma luomalla backendiin testejä varten API-endpoint, jonka avulla testit voivat tarvittaessa nollata kannan. Tehdään testejä varten oma router + +```js +const router = require('express').Router() +const Note = require('../models/note') +const User = require('../models/user') + +router.post('/reset', async (request, response) => { + await Note.deleteMany({}) + await User.deleteMany({}) + + response.status(204).end() +}) + +module.exports = router +``` + +ja lisätään se backendiin ainoastaan jos sovellusta suoritetaan test-moodissa: + +```js +// ... + +app.use('/api/login', loginRouter) +app.use('/api/users', usersRouter) +app.use('/api/notes', notesRouter) + +// highlight-start +if (process.env.NODE_ENV === 'test') { + const testingRouter = require('./controllers/testing') + app.use('/api/testing', testingRouter) +} +// highlight-end + +app.use(middleware.unknownEndpoint) +app.use(middleware.errorHandler) + +module.exports = app +``` + +eli lisäyksen jälkeen HTTP POST ‑operaatio backendin endpointiin /api/testing/reset tyhjentää tietokannan. + +Backendin testejä varten muokattu koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part5-1), branchissä part5-1. + +Muutetaan nyt testien beforeEach-alustuslohkoa siten, että se nollaa palvelimen tietokannan aina ennen testien suorittamista. + +Tällä hetkellä sovelluksen käyttöliittymän kautta ei ole mahdollista luoda käyttäjiä, joten testien alustuksessa luodaan testikäyttäjä suoraan backendiin. + +```js +describe('Note app', function() { + beforeEach(function() { + // highlight-start + cy.request('POST', 'http://localhost:3001/api/testing/reset') + const user = { + name: 'Matti Luukkainen', + username: 'mluukkai', + password: 'salainen' + } + cy.request('POST', 'http://localhost:3001/api/users/', user) + // highlight-end + cy.visit('http://localhost:5173') + }) + + it('front page can be opened', function() { + // ... + }) + + it('user can login', function() { + // ... + }) + + describe('when logged in', function() { + // ... + }) +}) +``` + +Testi tekee alustuksen aikana HTTP-pyyntöjä backendiin komennolla [cy.request](https://docs.cypress.io/api/commands/request.html). + +Toisin kuin aiemmin, nyt testaus alkaa nyt myös backendin suhteen aina hallitusti samasta tilanteesta, eli tietokannassa on yksi käyttäjä ja ei yhtään muistiinpanoa. + +Tehdään vielä testi, joka tarkastaa että muistiinpanojen tärkeyttä voi muuttaa. Muutimme sovellusta hieman aiemmin jo siten, että important saa aluksi arvon true: + +```js +const NoteForm = ({ createNote }) => { + // ... + + const addNote = (event) => { + event.preventDefault() + createNote({ + content: newNote, + important: true // highlight-line + }) + + setNewNote('') + } + // ... +} +``` + +On useita eri tapoja testata asia. Seuraavassa etsitään ensin muistiinpano ja klikataan sen nappia make not important. Tämän jälkeen tarkistetaan että muistiinpano sisältää napin make important. + +```js +describe('Note app', function() { + // ... + + describe('when logged in', function() { + // ... + + describe('and a note exists', function () { + beforeEach(function () { + cy.contains('new note').click() + cy.get('input').type('another note cypress') + cy.contains('save').click() + }) + + it('it can be made not important', function () { + cy.contains('another note cypress') + .contains('button', 'make not important') + .click() + + cy.contains('another note cypress') + .contains('button', 'make important') + }) + }) + }) +}) +``` + +Ensimmäinen komento tekee monta eri asiaa. Ensin etsitään elementti, missä on teksti another note cypress. Sitten löydetyn elementin sisältä etsitään painike make not important ja klikataan sitä. + +Toinen komento varmistaa, että saman napin teksti on vaihtunut muotoon make important. + +### Epäonnistuneen kirjautumisen testi + +Tehdään nyt testi joka varmistaa, että kirjautumisyritys epäonnistuu jos salasana on väärä. + +Cypress suorittaa oletusarvoisesti aina kaikki testit, ja testien määrän kasvaessa se alkaa olla aikaavievää. Uutta testiä kehitellessä tai rikkinäistä testiä debugatessa voidaan määritellä testi komennon it sijaan komennolla it.only, jolloin Cypress suorittaa ainoastaan sen testin. Kun testi on valmiina, voidaan only poistaa. + +Testin ensimmäinen versio näyttää seuraavalta: + +```js +describe('Note app', function() { + // ... + + it.only('login fails with wrong password', function() { + cy.contains('button', 'login').click() + cy.contains('label', 'username').type('mluukkai') + cy.contains('label', 'password').type('wrong') + cy.get('#login-button').click() + + cy.contains('wrong credentials') + }) + + // ... +)} +``` + +Testi siis varmistaa komennon [cy.contains](https://docs.cypress.io/api/commands/contains.html#Syntax) avulla, että sovellus tulostaa virheilmoituksen. + +Sovellus renderöi virheilmoituksen CSS-luokan error sisältävään elementtiin: + +```js +const Notification = ({ message }) => { + if (message === null) { + return null + } + + return ( +
    // highlight-line + {message} +
    + ) +} +``` + +Voisimmekin tarkentaa testiä varmistamaan, että virheilmoitus tulostuu nimenomaan oikeaan paikkaan, eli CSS-luokan error sisältävään elementtiin: + + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error').contains('wrong credentials') // highlight-line +}) +``` + +Eli ensin etsitään komennolla [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax) CSS-luokan error sisältävä komponentti ja sen jälkeen varmistetaan että virheilmoitus löytyy sen sisältä. Huomaa, että [luokan CSS-selektori](https://developer.mozilla.org/en-US/docs/Web/CSS/Class_selectors) alkaa pisteellä, eli luokan error selektori on .error. + +Voisimme tehdä saman myös käyttäen [should](https://docs.cypress.io/api/commands/should.html)-syntaksia: + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error').should('contain', 'wrong credentials') // highlight-line +}) +``` + +Shouldin käyttö on jonkin verran "hankalampaa" kuin komennon contains, mutta se mahdollistaa huomattavasti monipuolisemmat testit kuin pelkän tekstisisällön perusteella toimiva contains. + +Lista yleisimmistä shouldin kanssa käytettävistä assertioista on [täällä](https://docs.cypress.io/guides/references/assertions.html#Common-Assertions). + +Voimme esim. varmistaa, että virheilmoituksen väri on punainen, ja että sen ympärillä on border: + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error').should('contain', 'wrong credentials') + cy.get('.error').should('have.css', 'color', 'rgb(255, 0, 0)') + cy.get('.error').should('have.css', 'border-style', 'solid') +}) +``` + +Värit on määriteltävä Cypressille [rgb](https://rgbcolorcode.com/color/red)-koodeina. + +Koska kaikki tarkastukset kohdistuvat samaan komennolla [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax) haettuun elementtiin, ne voidaan ketjuttaa komennon [and](https://docs.cypress.io/api/commands/and.html) avulla: + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error') + .should('contain', 'wrong credentials') + .and('have.css', 'color', 'rgb(255, 0, 0)') + .and('have.css', 'border-style', 'solid') +}) +``` +Viimeistellään testi vielä siten, että se varmistaa myös, että sovellus ei renderöi onnistunutta kirjautumista kuvaavaa tekstiä 'Matti Luukkainen logged in': + +```js +it('login fails with wrong password', function() { + cy.contains('button', 'login').click() + cy.contains('label', 'username').type('mluukkai') + cy.contains('label', 'password').type('wrong') + cy.get('#login-button').click() + + cy.get('.error') + .should('contain', 'wrong credentials') + .and('have.css', 'color', 'rgb(255, 0, 0)') + .and('have.css', 'border-style', 'solid') + + cy.get('html').should('not.contain', 'Matti Luukkainen logged in') // highlight-line +}) +``` + +Komentoa should käytetään useimmiten ketjutettuna komennon get (tai muun vastaavan ketjutettavissa olevan komennon) perään. Testissä käytetty cy.get('html') tarkoittaa käytännössä koko sovelluksen näkyvillä olevaa sisältöä. + +Saman asian olisi myös voinut tarkastaa ketjuttamalla komennon contains perään komento should hieman toisenlaisella parametrilla: + +```js +cy.contains('Matti Luukkainen logged in').should('not.exist') +``` + +### Operaatioiden tekeminen käyttöliittymän "ohi" + +Sovelluksemme testit näyttävät tällä hetkellä seuraavalta: + +```js +describe('Note app', function() { + it('user can login', function() { + cy.contains('button', 'login').click() + cy.contains('label', 'username').type('mluukkai') + cy.contains('label', 'password').type('salainen') + cy.get('#login-button').click() + + cy.contains('Matti Luukkainen logged in') + }) + + it('login fails with wrong password', function() { + // ... + }) + + describe('when logged in', function() { + beforeEach(function() { + cy.contains('button', 'login').click() + cy.contains('label', 'username').type('mluukkai') + cy.contains('label', 'password').type('salainen') + cy.get('#login-button').click() + }) + + it('a new note can be created', function() { + // ... + }) + + }) +}) +``` + +Ensin siis testataan kirjautumistoimintoa. Tämän jälkeen omassa describe-lohkossa on joukko testejä, jotka olettavat että käyttäjä on kirjaantuneena, kirjaantuminen hoidetaan alustuksen tekevän beforeEach-lohkon sisällä. + +Kuten aiemmin jo todettiin, jokainen testi suoritetaan alkutilasta, eli vaikka testi on koodissa alempana, se ei aloita samasta tilasta mihin ylempänä koodissa olevat testit ovat jääneet! + +Cypressin dokumentaatio neuvoo meitä seuraavasti: [Fully test the login flow – but only once](https://docs.cypress.io/guides/getting-started/testing-your-app.html#Logging-in). Eli sen sijaan että tekisimme beforeEach-lohkossa kirjaantumisen lomaketta käyttäen, suosittelee Cypress että kirjaantuminen tehdään [UI:n ohi](https://docs.cypress.io/guides/getting-started/testing-your-app.html#Bypassing-your-UI), tekemällä suoraan backendiin kirjaantumista vastaava HTTP-operaatio. Syynä tälle on se, että suoraan backendiin tehtynä kirjautuminen on huomattavasti nopeampi kuin lomakkeen täyttämällä. + +Tilanteemme on hieman monimutkaisempi kuin Cypressin dokumentaation esimerkissä, sillä kirjautumisen yhteydessä sovelluksemme tallettaa kirjautuneen käyttäjän tiedot localStorageen. Sekin toki onnistuu. Koodi on seuraavassa + +```js +describe('when logged in', function() { + beforeEach(function() { + // highlight-start + cy.request('POST', 'http://localhost:3001/api/login', { + username: 'mluukkai', password: 'salainen' + }).then(response => { + localStorage.setItem('loggedNoteappUser', JSON.stringify(response.body)) + cy.visit('http://localhost:5173') + }) + // highlight-end + }) + + it('a new note can be created', function() { + // ... + }) + + // ... +}) +``` + +Komennon [cy.request](https://docs.cypress.io/api/commands/request.html) tulokseen päästään käsiksi _then_-metodin avulla sillä sisäiseltä toteutukseltaan cy.request kuten muutkin Cypressin komennot ovat [eräänlaisia promiseja](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Commands-Are-Promises). Käsittelijäfunktio tallettaa kirjautuneen käyttäjän tiedot localStorageen ja lataa sivun uudelleen. Tämän jälkeen käyttäjä on kirjautuneena sovellukseen samalla tavalla kuin jos kirjautuminen olisi tapahtunut kirjautumislomakkeen täyttämällä. + +Jos ja kun sovellukselle kirjoitetaan lisää testejä, joudutaan kirjautumisen hoitavaa koodia soveltamaan useassa paikassa. Koodi kannattaakin eristää itse määritellyksi [komennoksi](https://docs.cypress.io/api/cypress-api/custom-commands.html). + +Komennot määritellään tiedostoon cypress/support/commands.js. Kirjautumisen tekevä komento näyttää seuraavalta: + +```js +Cypress.Commands.add('login', ({ username, password }) => { + cy.request('POST', 'http://localhost:3001/api/login', { + username, password + }).then(({ body }) => { + localStorage.setItem('loggedNoteappUser', JSON.stringify(body)) + cy.visit('http://localhost:5173') + }) +}) +``` + +Komennon käyttö on helppoa, testi yksinkertaistuu ja selkeytyy: + +```js +describe('when logged in', function() { + beforeEach(function() { + // highlight-start + cy.login({ username: 'mluukkai', password: 'salainen' }) + // highlight-end + }) + + it('a new note can be created', function() { + // ... + }) + + // ... +}) +``` + +Sama koskee oikeastaan myös uuden muistiinpanon luomista. Sitä varten on olemassa testi, joka luo muistiinpanon lomakkeen avulla. Myös muistiinpanon tärkeyden muuttamista testaavan testin beforeEach-alustuslohkossa luodaan muistiinpano lomakkeen avulla: + +```js +describe('Note app', function() { + // ... + + describe('when logged in', function() { + beforeEach(function () { + cy.login({ username: 'mluukkai', password: 'salainen' }) + }) + + it('a new note can be created', function() { + cy.contains('new note').click() + cy.get('input').type('a note created by cypress') + cy.contains('save').click() + + cy.contains('a note created by cypress') + }) + + describe('and a note exists', function () { + beforeEach(function () { + // highlight-start + cy.contains('new note').click() + cy.get('input').type('another note cypress') + cy.contains('save').click() + // highlight-end + }) + + it('it can be made important', function () { + // ... + }) + }) + }) +}) +``` + +Eristetään myös muistiinpanon lisääminen omaksi komennoksi, joka tekee lisäämisen suoraan HTTP POST:lla: + +```js +Cypress.Commands.add('createNote', ({ content, important }) => { + cy.request({ + url: 'http://localhost:3001/api/notes', + method: 'POST', + body: { content, important }, + headers: { + 'Authorization': `Bearer ${JSON.parse(localStorage.getItem('loggedNoteappUser')).token}` + } + }) + + cy.visit('http://localhost:5173') +}) +``` + +Komennon suoritus edellyttää, että käyttäjä on kirjaantuneena sovelluksessa ja käyttäjän tiedot talletettuna sovelluksen localStorageen. + +Testin alustuslohko yksinkertaistuu seuraavasti: + +```js +describe('Note app', function() { + // ... + + describe('when logged in', function() { + // ... + + describe('and a note exists', function () { + beforeEach(function () { + // highlight-start + cy.createNote({ + content: 'another note cypress', + important: true + }) + // highlight-end + }) + + it('it can be made important', function () { + // ... + }) + }) + }) +}) +``` + +Testeissämme on vielä eräs ikävä piirre. Sovelluksen frontendin osoite http://localhost:5173 sekä backendin osoite http://localhost:3001 on kovakoodattuna testeihin. Näistä oikeastaan backendin osoite on turha, sillä frontendin Vite-konfiguraatioon on määritelty proxy, joka forwardoi kaikki osoitteeseen http://localhost:5173/api menevät frontendin tekemät pyynnöt backendiin: + +```js +export default defineConfig({ + server: { + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + } + }, + // ... +}) +``` + +Voimme siis korvata testeissä kaikki osoitteet _http://localhost:3001/api/..._ osoitteella _http://localhost:5173/api/..._ + +Määritellään sovellukselle baseUrl Cypressin valmiiksi generoimaan [konfiguraatiotiedostoon](https://docs.cypress.io/guides/references/configuration) cypress.config.js: + +```js +const { defineConfig } = require("cypress") + +module.exports = defineConfig({ + e2e: { + setupNodeEvents(on, config) { + }, + baseUrl: 'http://localhost:5173' // highlight-line + }, +}) +``` + +Kaikki testeissä ja command.js-tiedostossa olevat sovelluksen osoitetta käyttävät komennot + +```js +cy.visit('http://localhost:5173') +``` + +voidaan nyt muuttaa muotoon + +```js +cy.visit('') +``` + +### Muistiinpanon tärkeyden muutos + +Tarkastellaan vielä aiemmin tekemäämme testiä, joka varmistaa että muistiinpanon tärkeyttä on mahdollista muuttaa. Muutetaan testin alustuslohkoa siten, että se luo yhden sijaan kolme muistiinpanoa. Testit muuttuvat seuraavasti: + +```js +describe('when logged in', function () { + beforeEach(function () { + cy.login({ username: 'mluukkai', password: 'salainen' }) + }) + + it('a new note can be created', function () { + cy.contains('new note').click() + cy.get('input').type('a note created by cypress') + cy.contains('save').click() + cy.contains('a note created by cypress') + }) + describe('and several notes exist', function () { // highlight-line + beforeEach(function () { + cy.createNote({ content: 'first note', important: true }) // highlight-line + cy.createNote({ content: 'second note', important: true }) // highlight-line + cy.createNote({ content: 'third note', important: true }) // highlight-line + }) + + it('one of those can be made non important', function () { // highlight-line + cy.contains('second note') // highlight-line + .contains('button', 'make not important') + .click() + + cy.contains('second note') // highlight-line + .contains('button', 'make important') + }) + }) +}) +``` + +Miten komento [cy.contains](https://docs.cypress.io/api/commands/contains.html) tarkalleen ottaen toimii? + +Kun klikkaamme komentoa _-contains 'second note'_, Cypressin [test runnerista](https://docs.cypress.io/guides/core-concepts/test-runner.html) nähdään, että komento löytää elementin, jonka sisällä on teksti second note: + +![Klikatessa vasemmalla olevasta testisteppien listasta komentoa, renderöityy oikealle sovelluksen sen hetkinen tila, missä löydetty elementti on merkattuna korostettuna.](../../images/5/34eb.png) + +Klikkaamalla seuraavaa riviä _-contains 'button, make not important'_, nähdään että löydetään nimenomaan +second note:a vastaava tärkeyden muutoksen tekevä nappi: + +![Klikatessa vasemmalla olevasta testisteppien listasta komentoa, korostuu oikealle valintaa vastaava nappi](../../images/5/35a.png) + +Peräkkäin ketjutettuna toisena oleva contains-komento siis jatkaa hakua ensimmäisen komennon löytämän komponentin sisältä. + +Jos emme ketjuttaisi komentoja, eli olisimme kirjoittaneet + +```js +cy.contains('second note') +cy.contains('button', 'make not important').click() +``` + +tulos olisi ollut aivan erilainen. Toinen rivi painaisi tässä tapauksessa väärän muistiinpanon nappia: + +![Renderöityy virhe AssertionError: Timed out retrying after 4000ms: Expected to find content 'make not important'.](../../images/5/36.png) + +Testejä tehdessä kannattaa siis ehdottomasti varmistaa test runnerista, että testit etsivät niitä elementtejä, joita niiden on tarkoitus tutkia! + +Muutetaan komponenttia _Note_ siten, että muistiinpanon teksti renderöitään span-komponentin sisälle + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' : 'make important' + + return ( +
  • + {note.content} // highlight-line + +
  • + ) +} +``` + +Testit hajoavat! Kuten test runner paljastaa, komento _cy.contains('second note')_ palauttaakin nyt ainoastaan tekstin sisältävän komponentin, ja nappi on sen ulkopuolella: + +![Oikealle puolelle havainnollistuu, että fokus osuu napin sijaan pelkkään tekstiin](../../images/5/37.png) + +Eräs tapa korjata ongelma on seuraavassa: + +```js +it('one of those can be made non important', function () { + cy.contains('second note').parent().find('button').click() + cy.contains('second note') + .parent() + .find('button') + .should('contain', 'make important') +}) +``` + +Ensimmäisellä rivillä etsitään komennon [parent](https://docs.cypress.io/api/commands/parent.htm) avulla tekstin second note sisältävän elementin vanhemman alla oleva nappi ja painetaan sitä. Toinen rivi varmistaa, että napin teksti muuttuu. + +Huomaa, että napin etsimiseen käytetään komentoa [find](https://docs.cypress.io/api/commands/find.html#Syntax). Komento [cy.get](https://docs.cypress.io/api/commands/get.html) ei sovellu tähän tilanteeseen, sillä se etsii elementtejä aina koko sivulta ja palauttaisi nyt kaikki sovelluksen viisi nappia. + +Testissä on ikävästi copypastea, sillä rivien alku eli napin etsivä koodi on sama. +Tälläisissä tilanteissa on mahdollista hyödyntää komentoa [as](https://docs.cypress.io/api/commands/as.html): + +```js +it('one of those can be made non important', function () { + cy.contains('second note').parent().find('button').as('theButton') + cy.get('@theButton').click() + cy.get('@theButton').should('contain', 'make important') +}) +``` + +Nyt ensimmäinen rivi etsii oikean napin, ja tallentaa sen komennon as avulla nimellä theButton. Seuraavat rivit pääsevät nimettyyn elementtiin käsiksi komennolla _cy.get('@theButton')_. + +### Testien suoritus ja debuggaaminen + +Vielä osan lopuksi muutamia huomioita Cypressin toimintaperiaatteesta sekä testien debuggaamisesta. + +Cypressissä testien kirjoitusasu antaa vaikutelman, että testit ovat normaalia JavaScript-koodia, ja että voisimme esim. yrittää seuraavaa: + +```js +const button = cy.contains('button', 'login') +button.click() +debugger +cy.contains('logout').click() +``` + +Näin kirjoitettu koodi ei kuitenkaan toimi. Kun Cypress suorittaa testin, se lisää jokaisen _cy_-komennon suoritusjonoon. Kun testimetodin koodi on suoritettu loppuun, suorittaa Cypress yksi kerrallaan suoritusjonoon lisätyt _cy_-komennot. + +Cypressin komennot palauttavat aina _undefined_, eli yllä olevassa koodissa komento _button.click()_ aiheuttaisi virheen ja yritys käynnistää debuggeri ei pysäyttäisi koodia Cypress-komentojen suorituksen välissä, vaan jo ennen kuin yhtään Cypress-komentoa olisi suoritettu. + +Cypress-komennot ovat promisen kaltaisia, joten jos niiden palauttamia arvoja halutaan käsitellä, se tulee tehdä komennon [then](https://docs.cypress.io/api/commands/then.html) avulla. Esim. seuraava testi tulostaisi sovelluksen kaikkien nappien lukumäärän ja klikkaisi napeista ensimmäistä: + +```js +it('then example', function() { + cy.get('button').then( buttons => { + console.log('number of buttons', buttons.length) + cy.wrap(buttons[0]).click() + }) +}) +``` + +Myös testien suorituksen pysäyttäminen debuggeriin on [mahdollista](https://docs.cypress.io/api/commands/debug.html). Debuggeri käynnistyy vain jos Cypress test runnerin developer-konsoli on auki. + +Developer-konsoli on monin tavoin hyödyllinen testejä debugatessa. Network-tabilla näkyvät testattavan sovelluksen tekemät HTTP-pyynnöt, ja console-välilehti kertoo testin komentoihin liittyviä tietoja: + +![Console-välilehti havainnollistaa testien löytämiä elementtejä.](../../images/5/38.png) + +Olemme toistaiseksi suorittaneet Cypress-testejä ainoastaan graafisen test runnerin kautta. Testit on luonnollisesti mahdollista suorittaa myös [komentoriviltä](https://docs.cypress.io/guides/guides/command-line.html). Lisätään vielä projektiin npm-skripti tätä tarkoitusta varten + +```js + "scripts": { + "cypress:open": "cypress open", + "test:e2e": "cypress run" // highlight-line + }, +``` + +Nyt siis voimme suorittaa Cypress-testit komentoriviltä komennolla npm run test:e2e + +![Komennon suoritus tulostaa konsoliin tekstuaalisen raportin joka kertoo 5 läpimenneestä testistä.](../../images/5/39.png) + +Cypressissä on mahdollista tallentaa myös video testien suorituksesta. Videon tallentaminen voi olla erityisen hyödyllistä esimerkiksi debugatessa tai CI/CD-putkessa, sillä videosta voi jälkeenpäin tarkastaa helposti, mitä selaimessa tapahtui ennen virhettä. Ominaisuus on oletuksena pois päältä, ohjeet sen käyttöönottoon löytyvät Cypressin [dokumentaatiosta](https://docs.cypress.io/guides/guides/screenshots-and-videos#Videos). + +Testien koodin lopullinen versio on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/notes-e2e-cypress/). + +Frontendin lopullinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-9), branchissa part5-9. + +
    + +
    + +### Tehtävät 5.17.-5.23. + +Tehdään osan lopuksi muutamia E2E-testejä blogisovellukseen. Yllä olevan materiaalin pitäisi riittää ainakin suurimmaksi osaksi tehtävien tekemiseen. Cypressin [dokumentaatiota](https://docs.cypress.io/guides/overview/why-cypress.html#In-a-nutshell) kannattaa ehdottomasti myös lueskella. Kyseessä on ehkä paras dokumentaatio, mitä olen koskaan open source ‑projektissa nähnyt. + +Erityisesti kannattaa lukea luku [Introduction to Cypress](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Cypress-Can-Be-Simple-Sometimes), joka toteaa + +> This is the single most important guide for understanding how to test with Cypress. Read it. Understand it. + +#### 5.17: blogilistan end to end ‑testit, step1 + +Konfiguroi Cypress projektiisi. Tee testi, joka varmistaa, että sovellus näyttää oletusarvoisesti kirjautumislomakkeen. + +Testin rungon tulee olla seuraavanlainen + +```js +describe('Blog app', function() { + beforeEach(function() { + cy.request('POST', 'http://localhost:3003/api/testing/reset') + cy.visit('http://localhost:5173') + }) + + it('Login form is shown', function() { + // ... + }) +}) +``` + +Testin beforeEach-alustuslohkon tulee nollata tietokannan tilanne esim. [materiaalissa](/osa5/end_to_end_testaus#tietokannan-tilan-kontrollointi) näytetyllä tavalla. + +#### 5.18: blogilistan end to end ‑testit, step2 + +Tee testit kirjautumiselle ja testaa sekä onnistunut että epäonnistunut kirjautuminen. Luo testejä varten käyttäjä beforeEach-lohkossa. + +Testien runko laajenee seuraavasti + +```js +describe('Blog app', function() { + beforeEach(function() { + cy.request('POST', 'http://localhost:3003/api/testing/reset') + // create here a user to backend + cy.visit('http://localhost:5173') + }) + + it('Login form is shown', function() { + // ... + }) + + describe('Login',function() { + it('succeeds with correct credentials', function() { + // ... + }) + + it('fails with wrong credentials', function() { + // ... + }) + }) +}) +``` + +#### 5.19: blogilistan end to end ‑testit, step3 + +Tee testi, joka varmistaa, että kirjautunut käyttäjä pystyy luomaan blogin. Testin runko voi näyttää seuraavalta + +```js +describe('Blog app', function() { + // ... + + describe('When logged in', function() { + beforeEach(function() { + // login user here + }) + + it('A blog can be created', function() { + // ... + }) + }) + +}) +``` + +Testin tulee varmistaa, että luotu blogi tulee näkyville blogien listalle. + +#### 5.20: blogilistan end to end ‑testit, step4 + +Tee testi, joka varmistaa, että blogia voi likettää. + +#### 5.21: blogilistan end to end ‑testit, step5 + +Tee testi, joka varmistaa, että blogin lisännyt käyttäjä voi poistaa blogin. + +#### 5.22: blogilistan end to end ‑testit, step6 + +Tee testi, joka varmistaa, että vain blogin lisännyt käyttäjä näkee blogin poistonapin. + +#### 5.23: blogilistan end to end ‑testit, step6 + +Tee testi, joka varmistaa, että blogit järjestetään likejen mukaiseen järjestykseen, eniten likejä saanut blogi ensin. + +Tämä tehtävä on edellisiä huomattavasti haastavampi. Eräs ratkaisutapa on lisätä tietty luokka elementille, joka sisältää blogin sisällön ja käyttää [eq](https://docs.cypress.io/api/commands/eq#Syntax)-metodia tietyssä indeksissä olevan elementin hakemiseen: + +```js +cy.get('.blog').eq(0).should('contain', 'The title with the most likes') +cy.get('.blog').eq(1).should('contain', 'The title with the second most likes') +``` + +Saatat törmätä tässä tehtävässä ongelmaan jos klikkaat monta kertaa peräkkäin like-nappia. Saattaa olla, että näin tehdessä liketykset tehdään samalle oliolle, eli Cypress ei "ehdi" välissä päivittää sovelluksen tilaa. Eräs tapa korjata ongelma on odottaa jokaisen klikkauksen jälkeen likejen lukumäärä päivittymistä ja tehdä uusi liketys vasta tämän jälkeen. + +Tämä oli osan viimeinen tehtävä ja on aika pushata koodi GitHubiin sekä merkata tehdyt tehtävät [palautussovellukseen](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    diff --git a/src/content/5/fr/part5.md b/src/content/5/fr/part5.md new file mode 100644 index 00000000000..007da8fd2ee --- /dev/null +++ b/src/content/5/fr/part5.md @@ -0,0 +1,14 @@ +--- +mainImage: ../../../images/part-5.svg +part: 5 +lang: fr +--- + +
    Partie mise à jour le 17 août 2023 +- Create React App remplacé par Vite + +
    \ No newline at end of file diff --git a/src/content/5/fr/part5a.md b/src/content/5/fr/part5a.md new file mode 100644 index 00000000000..b2e29ade412 --- /dev/null +++ b/src/content/5/fr/part5a.md @@ -0,0 +1,642 @@ +--- +mainImage: ../../../images/part-5.svg +part: 5 +letter: a +lang: fr +--- + +
    + +Dans les deux dernières parties, nous nous sommes principalement concentrés sur le backend. Le frontend que nous avons développé dans la [partie 2](/fr/part2) ne prend pas encore en charge la gestion des utilisateurs que nous avons mise en oeuvre dans le backend de la partie 4. + +Pour le moment, le frontend affiche les notes existantes et permet aux utilisateurs de changer l'état d'une note de important à non important et vice versa. Il n'est plus possible d'ajouter de nouvelles notes en raison des changements apportés au backend dans la partie 4: le backend attend désormais qu'un jeton vérifiant l'identité d'un utilisateur soit envoyé avec la nouvelle note. + +Nous allons maintenant mettre en oeuvre une partie de la fonctionnalité de gestion des utilisateurs requise dans le frontend. Commençons par la connexion des utilisateurs. Tout au long de cette partie, nous supposerons que de nouveaux utilisateurs ne seront pas ajoutés depuis le frontend. + +### Gestion de la connexion + +Un formulaire de connexion a maintenant été ajouté en haut de la page: + +![navigateur affichant la connexion utilisateur pour les notes](../../images/5/1new.png) + +Le code du composant App se présente désormais comme suit: + +```js +const App = () => { + const [notes, setNotes] = useState([]) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + const [errorMessage, setErrorMessage] = useState(null) + // highlight-start + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') +// highlight-end + + useEffect(() => { + noteService + .getAll().then(initialNotes => { + setNotes(initialNotes) + }) + }, []) + + // ... + +// highlight-start + const handleLogin = (event) => { + event.preventDefault() + console.log('logging in with', username, password) + } + // highlight-end + + return ( +
    +

    Notes

    + + + + // highlight-start +
    +
    + username + setUsername(target.value)} + /> +
    +
    + password + setPassword(target.value)} + /> +
    + +
    + // highlight-end + + // ... +
    + ) +} + +export default App +``` + +Le code de l'application actuelle peut être trouvé sur [Github](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-1), branche part5-1. Si vous clonez le repo, n'oubliez pas d'exécuter _npm install_ avant d'essayer d'exécuter le frontend. + +Le frontend n'affichera aucune note s'il n'est pas connecté au backend. Vous pouvez démarrer le backend avec _npm run dev_ dans son dossier de la Partie 4. Cela exécutera le backend sur le port 3001. Pendant que cela est actif, dans une fenêtre de terminal séparée, vous pouvez démarrer le frontend avec _npm start_, et maintenant vous pouvez voir les notes qui sont sauvegardées dans votre base de données MongoDB de la Partie 4. + +Gardez cela à l'esprit à partir de maintenant. + +Le formulaire de connexion est géré de la même manière que nous avons géré les formulaires dans la [partie 2](/fr/part2/formulaires). L'état de l'application a des champs pour username (nom d'utilisateur) et password (mot de passe) pour stocker les données du formulaire. Les champs du formulaire ont des gestionnaires d'événements, qui synchronisent les changements dans le champ avec l'état du composant App. Les gestionnaires d'événements sont simples : un objet leur est donné en paramètre, et ils déstructurent le champ target de l'objet et sauvegardent sa valeur dans l'état. + +```js +({ target }) => setUsername(target.value) +``` + +La méthode _handleLogin_, qui est responsable de la gestion des données du formulaire, reste à implémenter. + +La connexion se fait en envoyant une requête HTTP POST à l'adresse du serveur api/login. Séparons le code responsable de cette requête dans son propre module, dans le fichier services/login.js. + +Nous utiliserons la syntaxe async/await au lieu des promesses pour la requête HTTP: + +```js +import axios from 'axios' +const baseUrl = '/api/login' + +const login = async credentials => { + const response = await axios.post(baseUrl, credentials) + return response.data +} + +export default { login } +``` + +La méthode pour gérer la connexion peut être implémentée comme suit: + +```js +import loginService from './services/login' // highlight-line + +const App = () => { + // ... + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') +// highlight-start + const [user, setUser] = useState(null) +// highlight-end + + // highlight-start + const handleLogin = async (event) => { + event.preventDefault() + + try { + const user = await loginService.login({ + username, password, + }) + + setUser(user) + setUsername('') + setPassword('') + } catch (exception) { + setErrorMessage('Wrong credentials') + setTimeout(() => { + setErrorMessage(null) + }, 5000) + } + // highlight-end + } + + // ... +} +``` + +Si la connexion est réussie, les champs du formulaire sont vidés et la réponse du serveur (incluant un jeton et les détails de l'utilisateur) est sauvegardée dans le champ user de l'état de l'application. + +Si la connexion échoue ou si l'exécution de la fonction _loginService.login_ résulte en une erreur, l'utilisateur est notifié. + +L'utilisateur n'est pas informé d'une connexion réussie de quelque manière que ce soit. Modifions l'application pour afficher le formulaire de connexion uniquement si l'utilisateur n'est pas connecté, donc quand _user === null_. Le formulaire pour ajouter de nouvelles notes est montré uniquement si l'utilisateur est connecté, donc user contient les détails de l'utilisateur. + +Ajoutons deux fonctions d'aide au composant App pour générer les formulaires: + +```js +const App = () => { + // ... + + const loginForm = () => ( +
    +
    + username + setUsername(target.value)} + /> +
    +
    + password + setPassword(target.value)} + /> +
    + +
    + ) + + const noteForm = () => ( +
    + + +
    + ) + + return ( + // ... + ) +} +``` + +et les rendre conditionnellement: + +```js +const App = () => { + // ... + + const loginForm = () => ( + // ... + ) + + const noteForm = () => ( + // ... + ) + + return ( +
    +

    Notes

    + + + + {user === null && loginForm()} // highlight-line + {user !== null && noteForm()} // highlight-line + +
    + +
    +
      + {notesToShow.map((note, i) => + toggleImportanceOf(note.id)} + /> + )} +
    + +
    +
    + ) +} +``` + +Une astuce légèrement inhabituelle mais fréquemment utilisée en [React](https://react.dev/learn/conditional-rendering#logical-and-operator-) est utilisée pour le rendu conditionnel des formulaires: + +```js +{ + user === null && loginForm() +} +``` + +Si la première instruction est évaluée à faux ou est considérée comme [falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy), la seconde instruction (générant le formulaire) n'est pas du tout exécutée. + +Nous pouvons rendre cela encore plus direct en utilisant l'[opérateur conditionnel](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator): + +```js +return ( +
    +

    Notes

    + + + + {user === null ? + loginForm() : + noteForm() + } + +

    Notes

    + + // ... + +
    +) +``` + +Si _user === null_ est considéré comme [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy), _loginForm()_ est exécuté. Sinon, c'est _noteForm()_ qui l'est. + +Faisons une dernière modification. Si l'utilisateur est connecté, son nom est affiché à l'écran: + + +```js +return ( +
    +

    Notes

    + + + + {!user && loginForm()} + {user &&
    +

    {user.name} logged in

    + {noteForm()} +
    + } + +

    Notes

    + + // ... + +
    +) +``` + +La solution n'est pas parfaite, mais nous allons la laisser telle quelle pour l'instant. + +Notre composant principal App est actuellement bien trop volumineux. Les changements que nous avons effectués maintenant sont un signe clair que les formulaires devraient être refactorisés dans leurs propres composants. Cependant, nous laisserons cela pour un exercice optionnel. + +Le code actuel de l'application peut être trouvé sur [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-2), branche part5-2. + +### Création de nouvelles notes + +Le jeton renvoyé avec une connexion réussie est sauvegardé dans l'état de l'application - le champ token de l'utilisateur: + +```js +const handleLogin = async (event) => { + event.preventDefault() + try { + const user = await loginService.login({ + username, password, + }) + + setUser(user) // highlight-line + setUsername('') + setPassword('') + } catch (exception) { + // ... + } +} +``` + +Réparons la création de nouvelles notes pour qu'elle fonctionne avec le backend. Cela signifie ajouter le jeton de l'utilisateur connecté à l'en-tête d'autorisation de la requête HTTP. + +Le module noteService change ainsi: + +```js +import axios from 'axios' +const baseUrl = '/api/notes' + +let token = null // highlight-line + +// highlight-start +const setToken = newToken => { + token = `Bearer ${newToken}` +} +// highlight-end + +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} + +// highlight-start +const create = async newObject => { + const config = { + headers: { Authorization: token }, + } +// highlight-end + + const response = await axios.post(baseUrl, newObject, config) // highlight-line + return response.data +} + +const update = (id, newObject) => { + const request = axios.put(`${ baseUrl }/${id}`, newObject) + return request.then(response => response.data) +} + +export default { getAll, create, update, setToken } // highlight-line +``` + +Le module noteService contient une variable privée _token_. Sa valeur peut être modifiée avec une fonction _setToken_, qui est exportée par le module. _create_, maintenant avec la syntaxe async/await, définit le jeton dans l'en-tête Authorization. L'en-tête est donné à axios comme le troisième paramètre de la méthode post. + +Le gestionnaire d'événements responsable de la connexion doit être modifié pour appeler la méthode noteService.setToken(user.token) lors d'une connexion réussie: + +```js +const handleLogin = async (event) => { + event.preventDefault() + try { + const user = await loginService.login({ + username, password, + }) + + noteService.setToken(user.token) // highlight-line + setUser(user) + setUsername('') + setPassword('') + } catch (exception) { + // ... + } +} +``` + +Et maintenant, l'ajout de nouvelles notes fonctionne à nouveau! + +### Sauvegarder le jeton dans le stockage local du navigateur + +Notre application a un petit défaut : si le navigateur est actualisé (par exemple, en appuyant sur F5), les informations de connexion de l'utilisateur disparaissent. + +Ce problème est facilement résolu en sauvegardant les détails de connexion dans le [stockage local](https://developer.mozilla.org/en-US/docs/Web/API/Storage). Le stockage local est une base de données [clé-valeur](https://en.wikipedia.org/wiki/Key-value_database) dans le navigateur. + +Il est très facile à utiliser. Une valeur correspondant à une certaine clé est sauvegardée dans la base de données avec la méthode [setItem](https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem). Par exemple: + +```js +window.localStorage.setItem('name', 'juha tauriainen') +``` + +sauvegarde la chaîne donnée comme deuxième paramètre comme la valeur de la clé name. + +La valeur d'une clé peut être trouvée avec la méthode [getItem](https://developer.mozilla.org/en-US/docs/Web/API/Storage/getItem): + +```js +window.localStorage.getItem('name') +``` + +et [removeItem](https://developer.mozilla.org/en-US/docs/Web/API/Storage/removeItem) supprime une clé. + +Les valeurs dans le stockage local sont persistées même lorsque la page est réaffichée. Le stockage est spécifique à l'[origine](https://developer.mozilla.org/en-US/docs/Glossary/Origin), donc chaque application web a son propre stockage. + +Étendons notre application de sorte qu'elle sauvegarde les détails d'un utilisateur connecté dans le stockage local. + +Les valeurs sauvegardées dans le stockage sont des [DOMstrings](https://docs.w3cub.com/dom/domstring), donc nous ne pouvons pas sauvegarder un objet JavaScript tel quel. L'objet doit d'abord être converti en JSON, avec la méthode _JSON.stringify_. De manière correspondante, lorsqu'un objet JSON est lu depuis le stockage local, il doit être reconverti en JavaScript avec _JSON.parse_. + +Les modifications apportées à la méthode de connexion sont les suivantes: + +```js + const handleLogin = async (event) => { + event.preventDefault() + try { + const user = await loginService.login({ + username, password, + }) + + // highlight-start + window.localStorage.setItem( + 'loggedNoteappUser', JSON.stringify(user) + ) + // highlight-end + noteService.setToken(user.token) + setUser(user) + setUsername('') + setPassword('') + } catch (exception) { + // ... + } + } +``` + +Les détails d'un utilisateur connecté sont maintenant sauvegardés dans le stockage local, et ils peuvent être consultés dans la console (en tapant _window.localStorage_ dans la console) : + +![navigateur montrant quelqu'un connecté aux notes](../../images/5/3e.png) + +Vous pouvez également inspecter le stockage local en utilisant les outils de développement. Sur Chrome, allez à l'onglet Application et sélectionnez Stockage local (plus de détails [ici](https://developers.google.com/web/tools/chrome-devtools/storage/localstorage))). Sur Firefox, allez à l'onglet Stockage et sélectionnez Stockage local (détails [ici](https://developer.mozilla.org/en-US/docs/Tools/Storage_Inspector)). + +Nous devons encore modifier notre application pour que, lorsque nous entrons sur la page, l'application vérifie si les détails d'un utilisateur connecté peuvent déjà être trouvés dans le stockage local. Si c'est le cas, les détails sont sauvegardés dans l'état de l'application et dans noteService. + +La bonne manière de faire cela est avec un [hook d'effet](https://react.dev/reference/react/useEffect): un mécanisme que nous avons rencontré pour la première fois dans la [partie 2](/fr/part2/obtenir_des_donnees_du_serveur#hooks-deffet), et utilisé pour récupérer des notes depuis le serveur. + +Nous pouvons avoir plusieurs hooks d'effet, créons-en donc un second pour gérer le premier chargement de la page: + +```js +const App = () => { + const [notes, setNotes] = useState([]) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + const [errorMessage, setErrorMessage] = useState(null) + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [user, setUser] = useState(null) + + useEffect(() => { + noteService + .getAll().then(initialNotes => { + setNotes(initialNotes) + }) + }, []) + + // highlight-start + useEffect(() => { + const loggedUserJSON = window.localStorage.getItem('loggedNoteappUser') + if (loggedUserJSON) { + const user = JSON.parse(loggedUserJSON) + setUser(user) + noteService.setToken(user.token) + } + }, []) + // highlight-end + + // ... +} +``` + +Le tableau vide comme paramètre de l'effet garantit que l'effet est exécuté uniquement lorsque le composant est rendu [pour la première fois](https://react.dev/reference/react/useEffect#parameters). + +Maintenant, un utilisateur reste connecté à l'application indéfiniment. Nous devrions probablement ajouter une fonctionnalité de déconnexion qui supprime les détails de connexion du stockage local. Nous le laisserons cependant comme exercice. + +Il est possible de déconnecter un utilisateur en utilisant la console, et cela suffit pour le moment. +Vous pouvez vous déconnecter avec la commande: + +```js +window.localStorage.removeItem('loggedNoteappUser') +``` + +ou avec la commande qui vide complètement le localstorage: + +```js +window.localStorage.clear() +``` + + +Le code actuel de l'application peut être trouvé sur [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-3), branche part5-3. + +
    + +
    + +### Exercices 5.1.-5.4. + +Nous allons maintenant créer un frontend pour le backend de liste de blogs que nous avons créé dans la dernière partie. Vous pouvez utiliser [cette application](https://github.com/fullstack-hy2020/bloglist-frontend) sur GitHub comme base pour votre solution. Vous devez connecter votre backend avec un proxy comme montré dans la [partie 3](/fr/part3/deployer_votre_application_sur_internet#proxy). + +Il suffit de soumettre votre solution terminée. Vous pouvez faire un commit après chaque exercice, mais cela n'est pas nécessaire. + +Les premiers exercices révisent tout ce que nous avons appris sur React jusqu'à présent. Ils peuvent être difficiles, surtout si votre backend est incomplet. +Il pourrait être préférable d'utiliser le backend que nous avons marqué comme réponse pour la partie 4. + +Pendant que vous faites les exercices, rappelez-vous toutes les méthodes de débogage dont nous avons parlé, en gardant particulièrement un oeil sur la console. + +**Attention:** Si vous remarquez que vous mélangez les fonctions _async/await_ et les commandes _then_, il est à 99,9 % certain que vous faites quelque chose de mal. Utilisez l'un ou l'autre, jamais les deux. + +#### 5.1 : Blog List Frontend, étape 1 + +Clonez l'application depuis [GitHub](https://github.com/fullstack-hy2020/bloglist-frontend) avec la commande: + +```bash +git clone https://github.com/fullstack-hy2020/bloglist-frontend +``` + +supprimez la configuration git de l'application clonée + +```bash +cd bloglist-frontend // go to cloned repository +rm -rf .git +``` + +L'application est démarrée de la manière habituelle, mais vous devez d'abord installer ses dépendances: + +```bash +npm install +npm run dev +``` + +Implémentez la fonctionnalité de connexion au frontend. Le jeton renvoyé lors d'une connexion réussie est sauvegardé dans l'état user de l'application. + +Si un utilisateur n'est pas connecté, seul le formulaire de connexion est visible. + +![navigateur montrant uniquement le formulaire de connexion visible](../../images/5/4e.png) + +Si l'utilisateur est connecté, le nom de l'utilisateur et une liste de blogs sont affichés. + +![navigateur montrant les notes et qui est connecté](../../images/5/5e.png) + +Les détails de l'utilisateur connecté n'ont pas encore à être sauvegardés dans le stockage local. + +**NB** Vous pouvez implémenter le rendu conditionnel du formulaire de connexion comme ceci par exemple: + +```js + if (user === null) { + return ( +
    +

    Log in to application

    +
    + //... +
    +
    + ) + } + + return ( +
    +

    blogs

    + {blogs.map(blog => + + )} +
    + ) +} +``` + +#### 5.2 : Blog List Frontend, étape 2 + +Rendez la connexion 'permanente' en utilisant le stockage local. Implémentez également un moyen de se déconnecter. + +![navigateur montrant le bouton de déconnexion après la connexion](../../images/5/6e.png) + +Assurez-vous que le navigateur ne se souvient pas des détails de l'utilisateur après la déconnexion. + +#### 5.3 : Blog List Frontend, étape 3 + +Étendez votre application pour permettre à un utilisateur connecté d'ajouter de nouveaux blogs : + +![navigateur montrant le formulaire de nouveau blog](../../images/5/7e.png) + +#### 5.4 : Blog List Frontend, étape 4 + +Implémentez des notifications qui informent l'utilisateur des opérations réussies et échouées en haut de la page. Par exemple, lorsqu'un nouveau blog est ajouté, la notification suivante peut être affichée: + +![navigateur montrant une opération réussie](../../images/5/8e.png) + +Une tentative de connexion échouée peut afficher la notification suivante: + +![navigateur montrant une tentative de connexion échouée](../../images/5/9e.png) + +Les notifications doivent être visibles quelques secondes. Il n'est pas obligatoire d'ajouter des couleurs. + +
    + +
    + +### Remarque sur l'utilisation du stockage local + +À la [fin](/fr/part4/jeton_dauthentification#problemes-de-lauthentification-basee-sur-des-jetons) de la dernière partie, nous avons mentionné que le défi de l'authentification basée sur les jetons est de savoir comment faire face à la situation où l'accès à l'API du détenteur du jeton doit être révoqué. + +Il existe deux solutions à ce problème. La première consiste à limiter la période de validité d'un jeton. Cela oblige l'utilisateur à se reconnecter à l'application une fois le jeton expiré. L'autre approche consiste à sauvegarder les informations de validité de chaque jeton dans la base de données du backend. Cette solution est souvent appelée une session côté serveur. + +Peu importe comment la validité des jetons est vérifiée et assurée, sauvegarder un jeton dans le stockage local peut contenir un risque de sécurité si l'application a une vulnérabilité de sécurité qui permet des attaques [Cross Site Scripting (XSS)](https://owasp.org/www-community/attacks/xss/). Une attaque XSS est possible si l'application permettait à un utilisateur d'injecter un code JavaScript arbitraire (par exemple, en utilisant un formulaire) que l'application exécuterait ensuite. Lors de l'utilisation sensée de React, cela ne devrait pas être possible puisque [React assainit](https://legacy.reactjs.org/docs/introducing-jsx.html#jsx-prevents-injection-attacks) tout le texte qu'il rend, ce qui signifie qu'il n'exécute pas le contenu rendu en tant que JavaScript. + +Si l'on veut jouer la sécurité, la meilleure option est de ne pas stocker un jeton dans le stockage local. Cela pourrait être une option dans des situations où la fuite d'un jeton pourrait avoir des conséquences tragiques. + +Il a été suggéré que l'identité d'un utilisateur connecté devrait être sauvegardée sous forme de [cookies httpOnly](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies), de sorte que le code JavaScript ne puisse avoir aucun accès au jeton. L'inconvénient de cette solution est qu'elle rendrait la mise en oeuvre d'applications SPA un peu plus complexe. Il serait nécessaire au moins de mettre en oeuvre une page séparée pour la connexion. + +Cependant, il est bon de noter que même l'utilisation de cookies httpOnly ne garantit rien. Il a même été suggéré que les cookies httpOnly [ne sont pas plus sûrs que](https://academind.com/tutorials/localstorage-vs-cookies-xss/) l'utilisation du stockage local. + +Donc, quelle que soit la solution utilisée, la chose la plus importante est de [minimiser le risque](https://cheatsheetseries.owasp.org/cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.html) d'attaques XSS dans son ensemble. + +
    \ No newline at end of file diff --git a/src/content/5/fr/part5b.md b/src/content/5/fr/part5b.md new file mode 100644 index 00000000000..359bedcb62b --- /dev/null +++ b/src/content/5/fr/part5b.md @@ -0,0 +1,826 @@ +--- +mainImage: ../../../images/part-5.svg +part: 5 +letter: b +lang: fr +--- + +
    + +### Afficher le formulaire de connexion uniquement lorsque c'est approprié + +Modifions l'application pour que le formulaire de connexion ne soit pas affiché par défaut: + +![navigateur montrant le bouton de connexion par défaut](../../images/5/10e.png) + +Le formulaire de connexion apparaît lorsque l'utilisateur appuie sur le bouton login: + +![utilisateur sur l'écran de connexion sur le point d'appuyer sur annuler](../../images/5/11e.png) + +L'utilisateur peut fermer le formulaire de connexion en cliquant sur le bouton cancel. + +Commençons par extraire le formulaire de connexion dans son propre composant: + +```js +const LoginForm = ({ + handleSubmit, + handleUsernameChange, + handlePasswordChange, + username, + password + }) => { + return ( +
    +

    Login

    + +
    +
    + username + +
    +
    + password + +
    + +
    +
    + ) +} + +export default LoginForm +``` + +L'état et toutes les fonctions qui s'y rapportent sont définis à l'extérieur du composant et sont passés au composant sous forme de props. + +Remarquez que les props sont assignées à des variables par le biais de la décomposition, ce qui signifie qu'au lieu d'écrire: + +```js +const LoginForm = (props) => { + return ( +
    +

    Login

    +
    +
    + username + +
    + // ... + +
    +
    + ) +} +``` + +où les propriétés de l'objet _props_ sont accessibles par exemple via _props.handleSubmit_, les propriétés sont directement assignées à leurs propres variables. + +Une manière rapide de mettre en oeuvre la fonctionnalité consiste à modifier la fonction _loginForm_ du composant App de cette façon: + +```js +const App = () => { + const [loginVisible, setLoginVisible] = useState(false) // highlight-line + + // ... + + const loginForm = () => { + const hideWhenVisible = { display: loginVisible ? 'none' : '' } + const showWhenVisible = { display: loginVisible ? '' : 'none' } + + return ( +
    +
    + +
    +
    + setUsername(target.value)} + handlePasswordChange={({ target }) => setPassword(target.value)} + handleSubmit={handleLogin} + /> + +
    +
    + ) + } + + // ... +} +``` + +L'état du composant App contient maintenant le booléen loginVisible, qui définit si le formulaire de connexion doit être montré à l'utilisateur ou non. + +La valeur de _loginVisible_ est basculée avec deux boutons. Les deux boutons ont leurs gestionnaires d'événements définis directement dans le composant: + +```js + + + +``` + +La visibilité du composant est définie en donnant au composant une règle de style [en ligne](/fr/part2/styliser_vos_applications_react#styles-en-ligne), où la valeur de la propriété [display](https://developer.mozilla.org/en-US/docs/Web/CSS/display) est none si nous ne voulons pas que le composant soit affiché: + +```js +const hideWhenVisible = { display: loginVisible ? 'none' : '' } +const showWhenVisible = { display: loginVisible ? '' : 'none' } + +
    + // button +
    + +
    + // button +
    +``` + +Nous utilisons une fois de plus l'opérateur ternaire "point d'interrogation". Si _loginVisible_ est true, alors la règle CSS du composant sera: + +```css +display: 'none'; +``` + +Si _loginVisible_ est false, alors display ne recevra aucune valeur liée à la visibilité du composant. + +### Les enfants des composants, alias props.children + +Le code lié à la gestion de la visibilité du formulaire de connexion pourrait être considéré comme sa propre entité logique, et pour cette raison, il serait bon de l'extraire du composant App pour le placer dans un composant séparé. + +Notre objectif est de mettre en oeuvre un nouveau composant Togglable qui peut être utilisé de la manière suivante: + +```js + + setUsername(target.value)} + handlePasswordChange={({ target }) => setPassword(target.value)} + handleSubmit={handleLogin} + /> + +``` + +La manière dont le composant est utilisé est légèrement différente de nos composants précédents. Le composant a des balises d'ouverture et de fermeture qui entourent un composant LoginForm. Dans la terminologie React, LoginForm est un composant enfant de Togglable. + +Nous pouvons ajouter tous les éléments React que nous voulons entre les balises d'ouverture et de fermeture de Togglable, comme ceci par exemple: + +```js + +

    this line is at start hidden

    +

    also this is hidden

    +
    +``` + +Le code pour le composant Togglable est montré ci-dessous: + +```js +import { useState } from 'react' + +const Togglable = (props) => { + const [visible, setVisible] = useState(false) + + const hideWhenVisible = { display: visible ? 'none' : '' } + const showWhenVisible = { display: visible ? '' : 'none' } + + const toggleVisibility = () => { + setVisible(!visible) + } + + return ( +
    +
    + +
    +
    + {props.children} + +
    +
    + ) +} + +export default Togglable +``` + +La partie nouvelle et intéressante du code est [props.children](https://react.dev/learn/passing-props-to-a-component#passing-jsx-as-children), qui est utilisée pour référencer les composants enfants du composant. Les composants enfants sont les éléments React que nous définissons entre les balises d'ouverture et de fermeture d'un composant. + +Cette fois, les enfants sont rendus dans le code utilisé pour le rendu du composant lui-même: + +```js +
    + {props.children} + +
    +``` + +Contrairement aux props "normales" que nous avons vues précédemment, children est automatiquement ajouté par React et existe toujours. Si un composant est défini avec une balise de fermeture automatique _/>_, comme ceci: + +```js + toggleImportanceOf(note.id)} +/> +``` + +Alors, props.children est un tableau vide. + +Le composant Togglable est réutilisable et nous pouvons l'utiliser pour ajouter une fonctionnalité de basculement de visibilité similaire au formulaire utilisé pour créer de nouvelles notes. + +Avant cela, extrayons le formulaire de création de notes dans un composant: + +```js +const NoteForm = ({ onSubmit, handleChange, value}) => { + return ( +
    +

    Create a new note

    + +
    + + +
    +
    + ) +} +``` + +Ensuite, définissons le composant de formulaire à l'intérieur d'un composant Togglable: + +```js + + + +``` + +Vous pouvez trouver le code de notre application actuelle dans son intégralité dans la branche part5-4 [de ce dépôt GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-4). + +### État des formulaires + +L'état de l'application est actuellement dans le composant _App_. + +La documentation de React dit ce qui [suit](https://react.dev/learn/sharing-state-between-components) sur l'endroit où placer l'état: + +Parfois, vous voulez que l'état de deux composants change toujours ensemble. Pour ce faire, retirez l'état de ces deux composants, déplacez-le vers leur parent commun le plus proche, puis transmettez-le à ces composants via les props. Cela est connu sous le nom de remontée d'état, et c’est l’une des choses les plus courantes que vous ferez en écrivant du code React. + +Si nous réfléchissons à l'état des formulaires, donc par exemple au contenu d'une nouvelle note avant qu'elle n'ait été créée, le composant _App_ n'en a besoin pour rien. +Nous pourrions tout aussi bien déplacer l'état des formulaires vers les composants correspondants. + +Le composant pour une note change comme suit: + +```js +import { useState } from 'react' + +const NoteForm = ({ createNote }) => { + const [newNote, setNewNote] = useState('') + + const addNote = (event) => { + event.preventDefault() + createNote({ + content: newNote, + important: true + }) + + setNewNote('') + } + + return ( +
    +

    Create a new note

    + +
    + setNewNote(event.target.value)} + /> + +
    +
    + ) +} + +export default NoteForm +``` + +**NOTE** En même temps, nous avons changé le comportement de l'application de sorte que les nouvelles notes soient importantes par défaut, c'est-à-dire que le champ important reçoit la valeur true. + +L'attribut d'état newNote et le gestionnaire d'événements responsable de sa modification ont été déplacés du composant _App_ au composant responsable du formulaire de note. + +Il ne reste qu'une seule prop, la fonction _createNote_, que le formulaire appelle lorsqu'une nouvelle note est créée. + +Le composant _App_ devient plus simple maintenant que nous nous sommes débarrassés de l'état newNote et de son gestionnaire d'événements. +La fonction _addNote_ pour créer de nouvelles notes reçoit une nouvelle note en paramètre, et la fonction est la seule prop que nous envoyons au formulaire: + +```js +const App = () => { + // ... + const addNote = (noteObject) => { // highlight-line + noteService + .create(noteObject) + .then(returnedNote => { + setNotes(notes.concat(returnedNote)) + }) + } + // ... + const noteForm = () => ( + + + + ) + + // ... +} +``` + +Nous pourrions faire de même pour le formulaire de connexion, mais nous laisserons cela pour un exercice optionnel. + +Le code de l'application peut être trouvé sur [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-5), +branche part5-5. + +### Références aux composants avec ref + +Notre mise en oeuvre actuelle est assez bonne; il y a un aspect qui pourrait être amélioré. + +Après la création d'une nouvelle note, il serait logique de masquer le formulaire de la nouvelle note. Actuellement, le formulaire reste visible. Il y a un léger problème pour masquer le formulaire. La visibilité est contrôlée avec la variable visible à l'intérieur du composant Togglable. Comment pouvons-nous y accéder de l'extérieur du composant ? + +Il existe de nombreuses manières d'implémenter la fermeture du formulaire depuis le composant parent, mais utilisons le mécanisme de [ref](https://react.dev/learn/referencing-values-with-refs) de React, qui offre une référence au composant. + +Faisons les changements suivants au composant App: + +```js +import { useState, useEffect, useRef } from 'react' // highlight-line + +const App = () => { + // ... + const noteFormRef = useRef() // highlight-line + + const noteForm = () => ( + // highlight-line + + + ) + + // ... +} +``` + +Le hook [useRef](https://react.dev/reference/react/useRef) est utilisé pour créer une référence noteFormRef, qui est attribuée au composant Togglable contenant le formulaire de création de note. La variable noteFormRef agit comme une référence au composant. Ce hook garantit que la même référence (ref) est conservée tout au long des rendus du composant. + +Nous apportons également les changements suivants au composant Togglable: + +```js +import { useState, forwardRef, useImperativeHandle } from 'react' // highlight-line + +const Togglable = forwardRef((props, refs) => { // highlight-line + const [visible, setVisible] = useState(false) + + const hideWhenVisible = { display: visible ? 'none' : '' } + const showWhenVisible = { display: visible ? '' : 'none' } + + const toggleVisibility = () => { + setVisible(!visible) + } + +// highlight-start + useImperativeHandle(refs, () => { + return { + toggleVisibility + } + }) +// highlight-end + + return ( +
    +
    + +
    +
    + {props.children} + +
    +
    + ) +}) // highlight-line + +export default Togglable +``` + +La fonction qui crée le composant est encapsulée à l'intérieur d'un appel de fonction [forwardRef](https://react.dev/reference/react/forwardRef). De cette manière, le composant peut accéder à la référence qui lui est attribuée. + +Le composant utilise le hook [useImperativeHandle](https://react.dev/reference/react/useImperativeHandle) pour rendre sa fonction toggleVisibility disponible à l'extérieur du composant. + +Nous pouvons maintenant masquer le formulaire en appelant noteFormRef.current.toggleVisibility() après qu'une nouvelle note a été créée: + +```js +const App = () => { + // ... + const addNote = (noteObject) => { + noteFormRef.current.toggleVisibility() // highlight-line + noteService + .create(noteObject) + .then(returnedNote => { + setNotes(notes.concat(returnedNote)) + }) + } + // ... +} +``` + +Pour résumer, la fonction [useImperativeHandle](https://react.dev/reference/react/useImperativeHandle) est un hook de React, qui est utilisé pour définir des fonctions dans un composant, qui peuvent être invoquées de l'extérieur du composant. + +Cette astuce fonctionne pour changer l'état d'un composant, mais elle semble un peu désagréable. Nous aurions pu accomplir la même fonctionnalité avec un code légèrement plus propre en utilisant les composants basés sur les classes du "vieux React". Nous examinerons ces composants de classe pendant la partie 7 du matériel du cours. Jusqu'à présent, c'est la seule situation où l'utilisation des hooks de React mène à un code qui n'est pas plus propre qu'avec les composants de classe. + +Il existe également [d'autres cas d'utilisation](https://react.dev/learn/manipulating-the-dom-with-refs) pour les refs que l'accès aux composants React. + +Vous pouvez trouver le code de notre application actuelle dans son intégralité dans la branche part5-6 de [ce dépôt GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-6). + +### Un point sur les composants + +Lorsque nous définissons un composant en React: + +```js +const Togglable = () => ... + // ... +} +``` + +And use it like this: + +```js +
    + + first + + + + second + + + + third + +
    +``` + +Nous créons trois instances distinctes du composant qui ont toutes leur état séparé: + +![navigateur de trois composants togglable](../../images/5/12e.png) + +L'attribut ref est utilisé pour assigner une référence à chacun des composants dans les variables togglable1, togglable2 et togglable3. + +### Le serment du développeur full stack mis à jour + +Le nombre de parties mobiles augmente. En même temps, la probabilité de se retrouver dans une situation où nous cherchons un bug au mauvais endroit augmente. Nous devons donc être encore plus systématiques. + +Nous devrions donc étendre une fois de plus notre serment: + +Le développement full stack est extrêmement difficile, c'est pourquoi j'utiliserai tous les moyens possibles pour le rendre plus facile + +- J'aurai ma console de développeur de navigateur ouverte tout le temps +- J'utiliserai l'onglet réseau des outils de développement du navigateur pour m'assurer que le frontend et le backend communiquent comme je le souhaite +- Je garderai constamment un oeil sur l'état du serveur pour m'assurer que les données envoyées par le frontend y sont sauvegardées comme je le souhaite +- Je garderai un oeil sur la base de données: le backend y sauvegarde-t-il les données dans le bon format +- Je progresse par petites étapes +- lorsque je suspecte qu'il y a un bug dans le frontend, je m'assure que le backend fonctionne à coup sûr +- lorsque je suspecte qu'il y a un bug dans le backend, je m'assure que le frontend fonctionne à coup sûr +- J'écrirai beaucoup de _console.log_ pour m'assurer que je comprends comment le code et les tests se comportent et pour aider à localiser les problèmes +- Si mon code ne fonctionne pas, je n'écrirai pas plus de code. Au lieu de cela, je commence à supprimer le code jusqu'à ce qu'il fonctionne ou je reviens à un état où tout fonctionnait encore +- Si un test ne passe pas, je m'assure que la fonctionnalité testée fonctionne à coup sûr dans l'application +- Lorsque je demande de l'aide sur le canal Discord du cours ou ailleurs, je formule correctement mes questions, voir [ici](https://fullstackopen.com/en/part0/general_info#how-to-get-help-in-discord) comment demander de l'aide + +
    + +
    + +### Exercices 5.5.-5.11. + +#### 5.5 Blog list frontend, étape 5 + +Changez le formulaire de création de billets de blog de sorte qu'il ne soit affiché que lorsque cela est approprié. Utilisez une fonctionnalité similaire à celle montrée [plus tôt dans cette partie du matériel du cours](/en/part5/props_children_and_proptypes#displaying-the-login-form-only-when-appropriate). Si vous le souhaitez, vous pouvez utiliser le composant Togglable défini dans la partie 5. + +Par défaut, le formulaire n'est pas visible + +![navigateur montrant le bouton nouvelle note sans formulaire](../../images/5/13ae.png) + +Il se déploie lorsque le bouton créer un nouveau blog est cliqué + +![navigateur montrant le formulaire avec créer nouveau](../../images/5/13be.png) + +Le formulaire se ferme lorsqu'un nouveau blog est créé. + +#### 5.6 Blog list frontend, étape 6 + +Séparez le formulaire de création d'un nouveau blog dans son propre composant (si ce n'est pas déjà fait), et déplacez tous les états nécessaires à la création d'un nouveau blog dans ce composant. + +Le composant doit fonctionner comme le composant NoteForm du [matériel](/en/part5/props_children_and_proptypes) de cette partie. + +#### 5.7 Blog list frontend, étape 7 + +Ajoutons un bouton à chaque blog, qui contrôle si tous les détails sur le blog sont montrés ou non. + +Les détails complets du blog s'ouvrent lorsque le bouton est cliqué. + +![navigateur montrant les détails complets d'un blog avec les autres ayant juste des boutons de vue](../../images/5/13ea.png) + +Et les détails sont cachés lorsque le bouton est cliqué à nouveau. + +À ce stade, le bouton like n'a pas besoin de faire quoi que ce soit. + +L'application montrée dans l'image a un peu de CSS supplémentaire pour améliorer son apparence. + +Il est facile d'ajouter des styles à l'application comme montré dans la partie 2 en utilisant des styles [en ligne](/fr/part2/styliser_vos_applications_react#styles-en-ligne): + +```js +const Blog = ({ blog }) => { + const blogStyle = { + paddingTop: 10, + paddingLeft: 2, + border: 'solid', + borderWidth: 1, + marginBottom: 5 + } + + return ( +
    // highlight-line +
    + {blog.title} {blog.author} +
    + // ... +
    +)} +``` + +**NB:** même si la fonctionnalité mise en oeuvre dans cette partie est presque identique à la fonctionnalité fournie par le composant Togglable, le composant ne peut pas être utilisé directement pour obtenir le comportement souhaité. La solution la plus simple sera d'ajouter un état au billet de blog qui contrôle la forme affichée du billet de blog. + +#### 5.8 : Blog list frontend, étape 8 + +Nous remarquons que quelque chose ne va pas. Lorsqu'un nouveau blog est créé dans l'application, le nom de l'utilisateur qui a ajouté le blog n'est pas affiché dans les détails du blog: + +![navigateur montrant le nom manquant sous le bouton like](../../images/5/59new.png) + +Lorsque le navigateur est rechargé, les informations de la personne sont affichées. Ceci n'est pas acceptable, trouvez où se trouve le problème et apportez la correction nécessaire. + +#### 5.9 : Blog list frontend, étape 9 + +Mettez en oeuvre la fonctionnalité pour le bouton like. Les likes sont augmentés en faisant une requête HTTP _PUT_ à l'adresse unique du billet de blog dans le backend. + +Puisque l'opération du backend remplace l'ensemble du billet de blog, vous devrez envoyer tous ses champs dans le corps de la requête. Si vous vouliez ajouter un like au billet de blog suivant: + +```js +{ + _id: "5a43fde2cbd20b12a2c34e91", + user: { + _id: "5a43e6b6c37f3d065eaaa581", + username: "mluukkai", + name: "Matti Luukkainen" + }, + likes: 0, + author: "Joel Spolsky", + title: "The Joel Test: 12 Steps to Better Code", + url: "https://www.joelonsoftware.com/2000/08/09/the-joel-test-12-steps-to-better-code/" +}, +``` + +Vous devriez faire une requête HTTP PUT à l'adresse /api/blogs/5a43fde2cbd20b12a2c34e91 avec les données de requête suivantes: + +```js +{ + user: "5a43e6b6c37f3d065eaaa581", + likes: 1, + author: "Joel Spolsky", + title: "The Joel Test: 12 Steps to Better Code", + url: "https://www.joelonsoftware.com/2000/08/09/the-joel-test-12-steps-to-better-code/" +} +``` + +Le backend doit également être mis à jour pour gérer la référence utilisateur. + +**Un dernier avertissement:** si vous remarquez que vous utilisez async/await et la méthode _then_ dans le même code, il est presque certain que vous faites quelque chose de mal. Tenez-vous en à l'utilisation de l'un ou de l'autre, et n'utilisez jamais les deux en même temps "juste au cas où". + +#### 5.10 : Blog list frontend, étape 10 + +Modifiez l'application pour lister les posts de blog par nombre de likes. Le tri des posts de blog peut être réalisé avec la méthode [sort](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) du tableau. + +#### 5.11 : Blog list frontend, étape 11 + +Ajoutez un nouveau bouton pour supprimer les posts de blog. Implémentez également la logique de suppression des posts de blog dans le frontend. + +Votre application pourrait ressembler à cela: + +![navigateur de confirmation de suppression de blog](../../images/5/14ea.png) + +La boîte de dialogue de confirmation pour la suppression d'un post de blog est facile à implémenter avec la fonction [window.confirm](https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm). + +Affichez le bouton de suppression d'un post de blog uniquement si le post de blog a été ajouté par l'utilisateur. + +
    + +
    + +### PropTypes + +Le composant Togglable suppose qu'on lui donne le texte pour le bouton via la prop buttonLabel. Si nous oublions de le définir pour le composant: + +```js + buttonLabel forgotten... +``` + +L'application fonctionne, mais le navigateur affiche un bouton qui n'a pas de texte d'étiquette. + +Nous aimerions imposer que lorsque le composant Togglable est utilisé, la prop de texte d'étiquette du bouton doit se voir attribuer une valeur. + +Les props attendues et requises d'un composant peuvent être définies avec le package [prop-types](https://github.com/facebook/prop-types). Installons le package: + +```shell +npm install prop-types +``` + +Nous pouvons définir la prop buttonLabel comme une prop obligatoire ou required de type chaîne de caractères comme montré ci-dessous: + +```js +import PropTypes from 'prop-types' + +const Togglable = React.forwardRef((props, ref) => { + // .. +}) + +Togglable.propTypes = { + buttonLabel: PropTypes.string.isRequired +} +``` + +La console affichera le message d'erreur suivant si la prop est laissée indéfinie: + +![erreur de console indiquant que buttonLabel est indéfini](../../images/5/15.png) + +L'application fonctionne toujours et rien ne nous oblige à définir des props malgré les définitions de PropTypes. Cela dit, il est extrêmement peu professionnel de laisser n'importe quel message d'erreur en rouge dans la console du navigateur. + +Définissons également les PropTypes pour le composant LoginForm: + +```js +import PropTypes from 'prop-types' + +const LoginForm = ({ + handleSubmit, + handleUsernameChange, + handlePasswordChange, + username, + password + }) => { + // ... + } + +LoginForm.propTypes = { + handleSubmit: PropTypes.func.isRequired, + handleUsernameChange: PropTypes.func.isRequired, + handlePasswordChange: PropTypes.func.isRequired, + username: PropTypes.string.isRequired, + password: PropTypes.string.isRequired +} +``` + +Si le type d'une prop passée est incorrect, par exemple, si nous essayons de définir la prop handleSubmit comme une chaîne de caractères, cela entraînera l'avertissement suivant: + +![erreur de console disant que handleSubmit attendait une fonction](../../images/5/16.png) + +### ESlint + +Dans la partie 3, nous avons configuré l'outil de style de code [ESlint](/fr/part3/validation_et_es_lint#lint) pour le backend. Prenons ESlint en main pour l'utiliser également dans le frontend. + +Vite a installé ESlint dans le projet par défaut, il ne nous reste donc plus qu'à définir notre configuration souhaitée dans le fichier .eslintrc.cjs. + +Ensuite, nous commencerons à tester le frontend et afin d'éviter des erreurs de linter indésirables et non pertinentes, nous installerons le package [eslint-plugin-jest](https://www.npmjs.com/package/eslint-plugin-jest): + +```bash +npm install --save-dev eslint-plugin-jest +``` + +Créons un fichier .eslintrc.cjs avec le contenu suivant: + +```js +module.exports = { + root: true, + env: { + browser: true, + es2020: true, + "jest/globals": true + }, + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + settings: { react: { version: '18.2' } }, + plugins: ['react-refresh', 'jest'], + rules: { + "indent": [ + "error", + 2 + ], + "linebreak-style": [ + "error", + "unix" + ], + "quotes": [ + "error", + "single" + ], + "semi": [ + "error", + "never" + ], + "eqeqeq": "error", + "no-trailing-spaces": "error", + "object-curly-spacing": [ + "error", "always" + ], + "arrow-spacing": [ + "error", { "before": true, "after": true } + ], + "no-console": 0, + "react/react-in-jsx-scope": "off", + "react/prop-types": 0, + "no-unused-vars": 0 + }, +} +``` + +NOTE: Si vous utilisez Visual Studio Code avec le plugin ESLint, vous pourriez avoir besoin d'ajouter un paramètre de workspace pour qu'il fonctionne. Si vous voyez l'erreur ```Failed to load plugin react: Cannot find module 'eslint-plugin-react'```, une configuration supplémentaire est nécessaire. Ajouter la ligne ```"eslint.workingDirectories": [{ "mode": "auto" }]``` au fichier settings.json dans l'espace de travail semble fonctionner. Voir [ici](https://github.com/microsoft/vscode-eslint/issues/880#issuecomment-578052807) pour plus d'informations. + +Créons un fichier [.eslintignore](https://eslint.org/docs/user-guide/configuring#ignoring-files-and-directories) avec le contenu suivant à la racine du dépôt: + +```bash +node_modules +dist +.eslintrc.cjs +``` + +Maintenant, les répertoires dist et node_modules seront ignorés lors du linting. + +Comme d'habitude, vous pouvez effectuer le linting soit depuis la ligne de commande avec la commande + +```bash +npm run Lint +``` + +ou en utilisant le plugin Eslint de l'éditeur. + +Le composant _Togglable_ provoque un avertissement désagréable La définition du composant manque d'un nom d'affichage: + +![vscode montrant une erreur de définition de composant](../../images/5/25x.png) + +Les react-devtools révèlent également que le composant n'a pas de nom: + +1[react devtools montrant forwardRef comme anonyme](../../images/5/26ea.png) + +Heureusement, cela est facile à corriger + +```js +import { useState, useImperativeHandle } from 'react' +import PropTypes from 'prop-types' + +const Togglable = React.forwardRef((props, ref) => { + // ... +}) + +Togglable.displayName = 'Togglable' // highlight-line + +export default Togglable +``` + +Vous pouvez trouver le code de notre application actuelle dans son intégralité dans la branche part5-7 de [ce dépôt GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-7). + +
    + +
    + +### exercice 5.12. + +#### 5.12 : Blog list frontend, étape 12 + +Définissez PropTypes pour l'un des composants de votre application et ajoutez ESlint au projet. Définissez la configuration selon vos préférences. Corrigez toutes les erreurs du linter. + +Vite a installé ESlint dans le projet par défaut, il ne vous reste donc plus qu'à définir votre configuration souhaitée dans le fichier .eslintrc.cjs. + +
    \ No newline at end of file diff --git a/src/content/5/fr/part5c.md b/src/content/5/fr/part5c.md new file mode 100644 index 00000000000..b68b74ca5d7 --- /dev/null +++ b/src/content/5/fr/part5c.md @@ -0,0 +1,797 @@ +--- +mainImage: ../../../images/part-5.svg +part: 5 +letter: c +lang: fr +--- + +
    + +Il existe de nombreuses manières différentes de tester les applications React. Examinons-les ensuite. + +Les tests seront implémentés avec la même bibliothèque de test [Jest](http://jestjs.io/) développée par Facebook qui a été utilisée dans la partie précédente. + +En plus de Jest, nous avons également besoin d'une autre bibliothèque de test qui nous aidera à rendre les composants pour les besoins des tests. L'option actuelle la meilleure pour cela est [react-testing-library](https://github.com/testing-library/react-testing-library) qui a connu une croissance rapide en popularité récemment. + +Installons les bibliothèques avec la commande: + +```js +npm install --save-dev @testing-library/react @testing-library/jest-dom jest jest-environment-jsdom @babel/preset-env @babel/preset-react +``` + +Le fichier package.json devrait être étendu comme suit: + +```js +{ + "scripts": { + // ... + "test": "jest" + } + // ... + "jest": { + "testEnvironment": "jsdom" + } +} +``` + +Nous avons également besoin du fichier .babelrc avec le contenu suivant: + +```js +{ + "presets": [ + "@babel/preset-env", + ["@babel/preset-react", { "runtime": "automatic" }] + ] +} +``` + +Écrivons d'abord des tests pour le composant qui est responsable de l'affichage d'une note: + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' + : 'make important' + + return ( +
  • // highlight-line + {note.content} + +
  • + ) +} +``` + +Remarquez que l'élément li possède la classe [CSS](https://react.dev/learn#adding-styles) nommée note, qui pourrait être utilisée pour accéder au composant dans nos tests. + +### Rendre le composant pour les tests + +Nous écrirons notre test dans le fichier src/components/Note.test.js, qui se trouve dans le même répertoire que le composant lui-même. + +Le premier test vérifie que le composant rend le contenu de la note: + +```js +import React from 'react' +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' +import Note from './Note' + +test('renders content', () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + render() + + const element = screen.getByText('Component testing is done with react-testing-library') + expect(element).toBeDefined() +}) +``` + +Après la configuration initiale, le test rend le composant avec la fonction [render](https://testing-library.com/docs/react-testing-library/api#render) fournie par la react-testing-library: + +```js +render() +``` + +Normalement, les composants React sont rendus dans le DOM. La méthode render que nous avons utilisée rend les composants dans un format adapté aux tests sans les rendre dans le DOM. + +Nous pouvons utiliser l'objet [screen](https://testing-library.com/docs/queries/about#screen) pour accéder au composant rendu. Nous utilisons la méthode [getByText](https://testing-library.com/docs/queries/bytext) de screen pour rechercher un élément qui contient le contenu de la note et nous assurer qu'il existe: + +```js + const element = screen.getByText('Component testing is done with react-testing-library') + expect(element).toBeDefined() +``` + +Exécutez le test avec la commande _npm test_: + +```js +$ npm test + +> notes-frontend@0.0.0 test +> jest + + PASS src/components/Note.test.js + ✓ renders content (15 ms) + +Test Suites: 1 passed, 1 total +Tests: 1 passed, 1 total +Snapshots: 0 total +Time: 1.152 s +``` + +Comme prévu, le test passe. + +**NB:** la console peut émettre un avertissement si vous n'avez pas installé Watchman. Watchman est une application développée par Facebook qui surveille les modifications apportées aux fichiers. Le programme accélère l'exécution des tests et au moins à partir de macOS Sierra, l'exécution des tests en mode watch émet quelques avertissements dans la console, qui peuvent être supprimés en installant Watchman. + +Les instructions pour installer Watchman sur différents systèmes d'exploitation peuvent être trouvées sur le site officiel de Watchman: + +### Emplacement du fichier de test + +En React, il existe (au moins) [deux conventions différentes](https://medium.com/@JeffLombardJr/organizing-tests-in-jest-17fc431ff850) pour l'emplacement du fichier de test. Nous avons créé nos fichiers de test selon la norme actuelle en les plaçant dans le même répertoire que le composant testé. + +L'autre convention consiste à stocker les fichiers de test "normalement" dans un répertoire _test_ séparé. Quelle que soit la convention que nous choisissons, il est presque garanti qu'elle sera considérée comme incorrecte selon l'opinion de quelqu'un. + +Je n'aime pas cette manière de stocker les tests et le code de l'application dans le même répertoire. La raison pour laquelle nous choisissons de suivre cette convention est qu'elle est configurée par défaut dans les applications créées par Vite ou create-react-app. + +### Rechercher du contenu dans un composant + +Le paquet react-testing-library offre de nombreuses manières différentes d'investiguer le contenu du composant testé. En réalité, le _expect_ dans notre test n'est pas nécessaire du tout + +```js +import React from 'react' +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' +import Note from './Note' + +test('renders content', () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + render() + + const element = screen.getByText('Component testing is done with react-testing-library') + + expect(element).toBeDefined() // highlight-line +}) +``` + +Le test échoue si _getByText_ ne trouve pas l'élément qu'il recherche. + +Nous pourrions également utiliser les [sélecteurs CSS](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors) pour trouver les éléments rendus en utilisant la méthode [querySelector](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector) de l'objet [container](https://testing-library.com/docs/react-testing-library/api/#container-1) qui est l'un des champs retournés par le rendu: + +```js +import React from 'react' +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' +import Note from './Note' + +test('renders content', () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + const { container } = render() // highlight-line + +// highlight-start + const div = container.querySelector('.note') + expect(div).toHaveTextContent( + 'Component testing is done with react-testing-library' + ) + // highlight-end +}) +``` + +**NB:** Une manière plus cohérente de sélectionner des éléments consiste à utiliser un [attribut data](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*) qui est spécifiquement défini à des fins de test. En utilisant _react-testing-library_, nous pouvons tirer parti de la méthode [getByTestId](https://testing-library.com/docs/queries/bytestid/) pour sélectionner des éléments avec un attribut _data-testid_ spécifié. + +### Déboguer les tests + +Nous rencontrons généralement de nombreux types de problèmes différents lors de la rédaction de nos tests. + +L'objet _screen_ a la méthode [debug](https://testing-library.com/docs/dom-testing-library/api-debugging#screendebug) qui peut être utilisée pour imprimer le HTML d'un composant dans le terminal. Si nous modifions le test comme suit: + +```js +import React from 'react' +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' +import Note from './Note' + +test('renders content', () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + render() + + screen.debug() // highlight-line + + // ... + +}) +``` + +le HTML est imprimé dans la console: + +```js +console.log + +
    +
  • + Component testing is done with react-testing-library + +
  • +
    + +``` + +Il est également possible d'utiliser la même méthode pour imprimer un élément souhaité dans la console: + + +```js +import React from 'react' +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' +import Note from './Note' + +test('renders content', () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + render() + + const element = screen.getByText('Component testing is done with react-testing-library') + + screen.debug(element) // highlight-line + + expect(element).toBeDefined() +}) +``` + +Maintenant, le HTML de l'élément souhaité est imprimé: + +```js +
  • + Component testing is done with react-testing-library + +
  • +``` + +### Cliquer sur des boutons dans les tests + +En plus d'afficher du contenu, le composant Note s'assure également que lorsque le bouton associé à la note est pressé, la fonction gestionnaire d'événement _toggleImportance_ est appelée. + +Installons une bibliothèque [user-event](https://testing-library.com/docs/user-event/intro) qui facilite un peu la simulation des entrées utilisateur: + +```bash +npm install --save-dev @testing-library/user-event +``` + +Tester cette fonctionnalité peut être réalisé de cette manière: + +```js +import React from 'react' +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' // highlight-line +import Note from './Note' + +// ... + +test('clicking the button calls event handler once', async () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + const mockHandler = jest.fn() // highlight-line + + render( + // highlight-line + ) + + const user = userEvent.setup() // highlight-line + const button = screen.getByText('make not important') // highlight-line + await user.click(button) // highlight-line + + expect(mockHandler.mock.calls).toHaveLength(1) // highlight-line +}) +``` + +Il y a quelques points intéressants liés à ce test. Le gestionnaire d'événement est une fonction [mock](https://facebook.github.io/jest/docs/en/mock-functions.html) définie avec Jest: + +```js +const mockHandler = jest.fn() +``` + +Une [session](https://testing-library.com/docs/user-event/setup/) est démarrée pour interagir avec le composant rendu: + +```js +const user = userEvent.setup() +``` + +Le test trouve le bouton en se basant sur le texte du composant rendu et clique sur l'élément: + +```js +const button = screen.getByText('make not important') +await user.click(button) +``` + +Le clic est effectué avec la méthode [click](https://testing-library.com/docs/user-event/convenience/#click) de la bibliothèque userEvent. + +L'attente du test vérifie que la fonction mock a été appelée exactement une fois. + +```js +expect(mockHandler.mock.calls).toHaveLength(1) +``` + +Les [objets et fonctions mock](https://en.wikipedia.org/wiki/Mock_object) sont couramment utilisés comme composants bouchons dans les tests, servant à remplacer les dépendances des composants testés. Les mocks permettent de retourner des réponses codées en dur, et de vérifier le nombre de fois que les fonctions mock sont appelées et avec quels paramètres. + +Dans notre exemple, la fonction mock est un choix parfait puisqu'elle peut être facilement utilisée pour vérifier que la méthode est appelée exactement une fois. + +### Tests pour le composant Togglable + +Écrivons quelques tests pour le composant Togglable. Ajoutons la classe CSS togglableContent à la div qui retourne les composants enfants. + +```js +const Togglable = forwardRef((props, ref) => { + // ... + + return ( +
    +
    + +
    +
    // highlight-line + {props.children} + +
    +
    + ) +}) +``` + +Les tests sont présentés ci-dessous: + +```js +import React from 'react' +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Togglable from './Togglable' + +describe('', () => { + let container + + beforeEach(() => { + container = render( + +
    + togglable content +
    +
    + ).container + }) + + test('renders its children', async () => { + await screen.findAllByText('togglable content') + }) + + test('at start the children are not displayed', () => { + const div = container.querySelector('.togglableContent') + expect(div).toHaveStyle('display: none') + }) + + test('after clicking the button, children are displayed', async () => { + const user = userEvent.setup() + const button = screen.getByText('show...') + await user.click(button) + + const div = container.querySelector('.togglableContent') + expect(div).not.toHaveStyle('display: none') + }) +}) +``` + +La fonction _beforeEach_ est appelée avant chaque test, ce qui permet ensuite de rendre le composant Togglable et de sauvegarder le champ _container_ de la valeur retournée. + +Le premier test vérifie que le composant Togglable rend son composant enfant + +```js +
    + togglable content +
    +``` + +Les tests restants utilisent la méthode [toHaveStyle](https://www.npmjs.com/package/@testing-library/jest-dom#tohavestyle) pour vérifier que le composant enfant du composant Togglable n'est pas visible initialement, en vérifiant que le style de l'élément div contient _{ display: 'none' }_. Un autre test vérifie que lorsque le bouton est pressé, le composant est visible, signifiant que le style pour cacher le composant n'est plus attribué au composant. + +Ajoutons également un test qui peut être utilisé pour vérifier que le contenu visible peut être caché en cliquant sur le second bouton du composant: + +```js +describe('', () => { + + // ... + + test('toggled content can be closed', async () => { + const user = userEvent.setup() + const button = screen.getByText('show...') + await user.click(button) + + const closeButton = screen.getByText('cancel') + await user.click(closeButton) + + const div = container.querySelector('.togglableContent') + expect(div).toHaveStyle('display: none') + }) +}) +``` + +### Tester les formulaires + +Nous avons déjà utilisé la fonction click de [user-event](https://testing-library.com/docs/user-event/intro) dans nos tests précédents pour cliquer sur des boutons. + +```js +const user = userEvent.setup() +const button = screen.getByText('show...') +await user.click(button) +``` + +Nous pouvons également simuler la saisie de texte avec userEvent. + +Faisons un test pour le composant NoteForm. Le code du composant est le suivant. + +```js +import { useState } from 'react' + +const NoteForm = ({ createNote }) => { + const [newNote, setNewNote] = useState('') + + const handleChange = (event) => { + setNewNote(event.target.value) + } + + const addNote = (event) => { + event.preventDefault() + createNote({ + content: newNote, + important: Math.random() > 0.5, + }) + + setNewNote('') + } + + return ( +
    +

    Create a new note

    + +
    + + +
    +
    + ) +} + +export default NoteForm +``` + +Le formulaire fonctionne en appelant la fonction _createNote_ qu'il a reçue en props avec les détails de la nouvelle note. + +Le test est le suivant: + +```js +import React from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import NoteForm from './NoteForm' +import userEvent from '@testing-library/user-event' + +test(' updates parent state and calls onSubmit', async () => { + const createNote = jest.fn() + const user = userEvent.setup() + + render() + + const input = screen.getByRole('textbox') + const sendButton = screen.getByText('save') + + await user.type(input, 'testing a form...') + await user.click(sendButton) + + expect(createNote.mock.calls).toHaveLength(1) + expect(createNote.mock.calls[0][0].content).toBe('testing a form...') +}) +``` + +Les tests accèdent au champ de saisie en utilisant la fonction [getByRole](https://testing-library.com/docs/queries/byrole). + +La méthode [type](https://testing-library.com/docs/user-event/utility#type) de userEvent est utilisée pour écrire du texte dans le champ de saisie. + +La première attente du test assure que la soumission du formulaire appelle la méthode _createNote_. +La deuxième attente vérifie que le gestionnaire d'événement est appelé avec les bons paramètres - qu'une note avec le contenu correct est créée lorsque le formulaire est rempli. + +### À propos de la recherche des éléments + +Supposons que le formulaire ait deux champs de saisie + +```js +const NoteForm = ({ createNote }) => { + // ... + + return ( +
    +

    Create a new note

    + +
    + + // highlight-start + + // highlight-end + +
    +
    + ) +} +``` + +Maintenant, l'approche que notre test utilise pour trouver le champ de saisie + +```js +const input = screen.getByRole('textbox') +``` + +provoquerait une erreur: + +![erreur node indiquant deux éléments avec textbox puisque nous utilisons getByRole](../../images/5/40.png) + +Le message d'erreur suggère d'utiliser getAllByRole. Le test pourrait être corrigé comme suit: + +```js +const inputs = screen.getAllByRole('textbox') + +await user.type(inputs[0], 'testing a form...') +``` + +La méthode getAllByRole retourne maintenant un tableau et le bon champ de saisie est le premier élément de ce tableau. Cependant, cette approche est un peu suspecte car elle repose sur l'ordre des champs de saisie. + +Assez souvent, les champs de saisie ont un texte placeholder qui indique à l'utilisateur quel type de saisie est attendu. Ajoutons un placeholder à notre formulaire: + +```js +const NoteForm = ({ createNote }) => { + // ... + + return ( +
    +

    Create a new note

    + +
    + + + +
    +
    + ) +} +``` + +Maintenant, trouver le bon champ de saisie est facile avec la méthode [getByPlaceholderText](https://testing-library.com/docs/queries/byplaceholdertext): + +```js +test(' updates parent state and calls onSubmit', () => { + const createNote = jest.fn() + + render() + + const input = screen.getByPlaceholderText('write note content here') // highlight-line + const sendButton = screen.getByText('save') + + userEvent.type(input, 'testing a form...') + userEvent.click(sendButton) + + expect(createNote.mock.calls).toHaveLength(1) + expect(createNote.mock.calls[0][0].content).toBe('testing a form...') +}) +``` + +La manière la plus flexible de trouver des éléments dans les tests est la méthode querySelector de l'objet _container_, qui est retourné par _render_, comme mentionné [plus tôt dans cette partie](/en/part5/testing_react_apps#searching-for-content-in-a-component). Tout sélecteur CSS peut être utilisé avec cette méthode pour rechercher des éléments dans les tests. + +Considérez par exemple que nous définirions un _id_ unique au champ de saisie: + +```js +const NoteForm = ({ createNote }) => { + // ... + + return ( +
    +

    Create a new note

    + +
    + + + +
    +
    + ) +} +``` + +L'élément de saisie pourrait maintenant être trouvé dans le test comme suit: + +```js +const { container } = render() + +const input = container.querySelector('#note-input') +``` + +Cependant, nous allons nous en tenir à l'approche consistant à utiliser _getByPlaceholderText_ dans le test. + +Examinons quelques détails avant de continuer. Supposons qu'un composant rende du texte dans un élément HTML comme suit: + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' : 'make important' + + return ( +
  • + Your awesome note: {note.content} // highlight-line + +
  • + ) +} + +export default Note +``` + +la commande _getByText_ que le test utilise ne trouve pas l'élément + +```js +test('renders content', () => { + const note = { + content: 'Does not work anymore :(', + important: true + } + + render() + + const element = screen.getByText('Does not work anymore :(') + + expect(element).toBeDefined() +}) +``` + +La commande _getByText_ recherche un élément qui a le **même texte** qu'elle a comme paramètre, et rien de plus. Si nous voulons chercher un élément qui contient le texte, nous pourrions utiliser une option supplémentaire: + +```js +const element = screen.getByText( + 'Does not work anymore :(', { exact: false } +) +``` + +ou nous pourrions utiliser la commande _findByText_: + +```js +const element = await screen.findByText('Does not work anymore :(') +``` + +Il est important de noter que, contrairement aux autres commandes_ ByText_, _findByText_ renvoie une promesse! + +Il existe des situations où une autre forme de la commande _queryByText_ est utile. La commande renvoie l'élément mais ne provoque pas d'exception si l'élément n'est pas trouvé. + +Nous pourrions par exemple utiliser la commande pour nous assurer que quelque chose n'est pas rendu dans le composant: + +```js +test('does not render this', () => { + const note = { + content: 'This is a reminder', + important: true + } + + render() + + const element = screen.queryByText('do not want this thing to be rendered') + expect(element).toBeNull() +}) +``` + +### Couverture des tests + +Nous pouvons facilement découvrir la [couverture](https://jestjs.io/blog/2020/01/21/jest-25#v8-code-coverage) de nos tests en les exécutant avec la commande. + +```js +npm test -- --coverage --collectCoverageFrom='src/**/*.{jsx,js}' +``` + +![Le résultat dans le terminal de la couverture de test](../../images/5/18new.png) + +Un rapport HTML assez primitif sera généré dans le répertoire coverage/lcov-report. Le rapport nous indiquera les lignes de code non testées dans chaque composant : + +![rapport HTML de la couverture des tests](../../images/5/19new.png) + +Vous pouvez trouver le code de notre application actuelle dans son intégralité dans la branche part5-8 de ce [dépôt GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-8). + +
    + +
    + +### Exercices 5.13.-5.16. + +#### 5.13 : Tests de la liste des blogs, étape 1 + +Réalisez un test qui vérifie que le composant affichant un blog rend le titre et l'auteur du blog, mais ne rend pas par défaut son URL ou le nombre de likes. + +Ajoutez des classes CSS au composant pour faciliter le test si nécessaire. + +#### 5.14 : Tests de la liste des blogs, étape 2 + +Réalisez un test qui vérifie que l'URL du blog et le nombre de likes sont affichés lorsque le bouton contrôlant les détails affichés a été cliqué. + +#### 5.15 : Tests de la liste des blogs, étape 3 + +Réalisez un test qui assure que si le bouton like est cliqué deux fois, le gestionnaire d'événements que le composant a reçu en props est appelé deux fois. + +#### 5.16 : Tests de la liste des blogs, étape 4 + +Réalisez un test pour le formulaire de nouveau blog. Le test devrait vérifier que le formulaire appelle le gestionnaire d'événements qu'il a reçu en props avec les bons détails lorsqu'un nouveau blog est créé. + +
    + +
    + +### Tests d'intégration Frontend + +Dans la partie précédente du matériel de cours, nous avons écrit des tests d'intégration pour le backend qui testaient sa logique et se connectaient à la base de données à travers l'API fournie par le backend. Lors de l'écriture de ces tests, nous avons pris la décision consciente de ne pas écrire de tests unitaires, car le code pour ce backend est assez simple, et il est probable que les bugs dans notre application surviennent dans des scénarios plus compliqués que les tests unitaires ne le permettent. + +Jusqu'à présent, tous nos tests pour le frontend ont été des tests unitaires qui ont validé le bon fonctionnement des composants individuels. Les tests unitaires sont utiles parfois, mais même un ensemble complet de tests unitaires n'est pas suffisant pour valider que l'application fonctionne dans son ensemble. + +Nous pourrions également faire des tests d'intégration pour le frontend. Les tests d'intégration testent la collaboration de plusieurs composants. C'est considérablement plus difficile que les tests unitaires, car nous devrions par exemple simuler des données provenant du serveur. +Nous avons choisi de nous concentrer sur la réalisation de tests de bout en bout pour tester l'ensemble de l'application. Nous travaillerons sur les tests de bout en bout dans le dernier chapitre de cette partie. + +### Tests de snapshot + +Jest offre une alternative complètement différente aux tests "traditionnels" appelés tests de [snapshot](https://facebook.github.io/jest/docs/en/snapshot-testing.html). La particularité des tests de snapshot est que les développeurs n'ont pas besoin de définir eux-mêmes les tests, il est assez simple d'adopter les tests de snapshot. + +Le principe fondamental est de comparer le code HTML défini par le composant après qu'il a été changé au code HTML qui existait avant qu'il ne soit modifié. + +Si le snapshot remarque un changement dans le code HTML défini par le composant, alors soit c'est une nouvelle fonctionnalité, soit un "bug" causé par accident. Les tests de snapshot informent le développeur si le code HTML du composant change. Le développeur doit indiquer à Jest si le changement était souhaité ou non. Si le changement du code HTML est inattendu, cela implique fortement un bug, et le développeur peut prendre conscience de ces problèmes potentiels facilement grâce aux tests de snapshot. + +
    \ No newline at end of file diff --git a/src/content/5/fr/part5d.md b/src/content/5/fr/part5d.md new file mode 100644 index 00000000000..ed8fc2fac80 --- /dev/null +++ b/src/content/5/fr/part5d.md @@ -0,0 +1,1217 @@ +--- +mainImage: ../../../images/part-5.svg +part: 5 +letter: d +lang: fr +--- + +
    + +Jusqu'à présent, nous avons testé le backend dans son ensemble au niveau de l'API en utilisant des tests d'intégration et testé certains composants frontend en utilisant des tests unitaires. + +Ensuite, nous examinerons une manière de tester le [système dans son ensemble](https://en.wikipedia.org/wiki/System_testing) en utilisant des tests End to End (E2E). + +Nous pouvons effectuer des tests E2E d'une application web en utilisant un navigateur et une bibliothèque de tests. Il existe plusieurs bibliothèques disponibles. Un exemple est [Selenium](http://www.seleniumhq.org/), qui peut être utilisé avec presque tous les navigateurs. +Une autre option de navigateur est ce qu'on appelle les [navigateurs sans tête](https://en.wikipedia.org/wiki/Headless_browser), qui sont des navigateurs sans interface graphique utilisateur. +Par exemple, Chrome peut être utilisé en mode sans tête. + +Les tests E2E sont potentiellement la catégorie de tests la plus utile car ils testent le système via la même interface que celle utilisée par les vrais utilisateurs. + +Ils présentent toutefois certains inconvénients. Configurer des tests E2E est plus difficile que les tests unitaires ou d'intégration. Ils tendent également à être assez lents, et avec un grand système, leur temps d'exécution peut être de minutes ou même d'heures. Cela est mauvais pour le développement car pendant la codification, il est bénéfique de pouvoir exécuter des tests aussi souvent que possible en cas de [régressions](https://en.wikipedia.org/wiki/Regression_testing) de code. + +Les tests E2E peuvent également être [instables](https://hackernoon.com/flaky-tests-a-war-that-never-ends-9aa32fdef359). +Certains tests peuvent réussir une fois et échouer une autre, même si le code ne change pas du tout. + +### Cypress + +La bibliothèque E2E [Cypress](https://www.cypress.io/) est devenue populaire au cours de la dernière année. Cypress est exceptionnellement facile à utiliser et, comparé à Selenium, par exemple, il nécessite beaucoup moins de tracas et de maux de tête. +Son principe de fonctionnement est radicalement différent de celui de la plupart des bibliothèques de tests E2E parce que les tests Cypress sont exécutés entièrement dans le navigateur. +D'autres bibliothèques exécutent les tests dans un processus Node, qui est connecté au navigateur via une API. + +Faisons quelques tests de bout en bout pour notre application de notes. + +Nous commençons par installer Cypress dans le frontend en tant que dépendance de développement + +```js +npm install --save-dev cypress +``` + +et en ajoutant un script npm pour l'exécuter: + +```js +{ + // ... + "scripts": { + "dev": "vite --host", // highlight-line + "build": "vite build", + "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview", + "server": "json-server -p3001 --watch db.json", + "test": "jest", + "cypress:open": "cypress open" // highlight-line + }, + // ... +} +``` + +Nous avons également apporté un petit changement au script qui démarre l'application, sans ce changement Cypress ne peut pas accéder à l'appli. + +Contrairement aux tests unitaires du frontend, les tests Cypress peuvent se trouver dans le dépôt du frontend ou du backend, ou même dans leur propre dépôt séparé. + +Les tests nécessitent que le système testé soit en cours d'exécution. Contrairement à nos tests d'intégration backend, les tests Cypress ne démarrent pas le système lorsqu'ils sont exécutés. + +Ajoutons un script npm à backend qui le démarre en mode test, ou de manière à ce que NODE\_ENV soit test. + +```js +{ + // ... + "scripts": { + "start": "NODE_ENV=production node index.js", + "dev": "NODE_ENV=development nodemon index.js", + "build:ui": "rm -rf build && cd ../frontend/ && npm run build && cp -r build ../backend", + "deploy": "fly deploy", + "deploy:full": "npm run build:ui && npm run deploy", + "logs:prod": "fly logs", + "lint": "eslint .", + "test": "jest --verbose --runInBand", + "start:test": "NODE_ENV=test node index.js" // highlight-line + }, + // ... +} +``` + +**NB** Pour que Cypress fonctionne avec WSL2, il peut être nécessaire d'effectuer d'abord quelques configurations supplémentaires. Ces deux [liens](https://docs.cypress.io/guides/getting-started/installing-cypress#Windows-Subsystem-for-Linux) sont de bons points de [départ](https://nickymeuleman.netlify.app/blog/gui-on-wsl2-cypress). + +Lorsque le backend et le frontend sont en cours d'exécution, nous pouvons démarrer Cypress avec la commande + +```js +npm run cypress:open +``` + +Cypress demande quel type de tests nous effectuons. Choisissons "E2E Testing" (Tests E2E): + +![flèche cypress vers l'option de test e2e](../../images/5/51new.png) + +Ensuite, un navigateur est sélectionné (par exemple, Chrome) et nous cliquons sur "Create new spec" (Créer une nouvelle spécification): + +![créer une nouvelle spécification avec une flèche pointant vers celle-ci](../../images/5/52new.png) + +Créons le fichier de test cypress/e2e/note\_app.cy.js: + +![cypress avec le chemin cypress/e2e/note_app.cy.js](../../images/5/53new.png) + +Nous pourrions modifier les tests dans Cypress, mais utilisons plutôt VS Code: + +![vscode montrant les modifications du test et cypress montrant la spécification ajoutée](../../images/5/54new.png) + +Nous pouvons maintenant fermer la vue d'édition de Cypress. + +Changeons le contenu du test comme suit: + +```js +describe('Note app', function() { + it('front page can be opened', function() { + cy.visit('http://localhost:5173') + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2023') + }) +}) +``` + +Le test est exécuté en cliquant sur le test dans Cypress: + +L'exécution du test montre comment l'application se comporte pendant l'exécution du test: + +![cypress montrant l'automatisation du test de note](../../images/5/56new.png) + +La structure du test devrait vous sembler familière. Ils utilisent des blocs describe pour regrouper différents cas de test, tout comme Jest. Les cas de test ont été définis avec la méthode it. Cypress a emprunté ces parties à la bibliothèque de tests [Mocha](https://mochajs.org/) qu'il utilise en interne. + +[cy.visit](https://docs.cypress.io/api/commands/visit.html) et [cy.contains](https://docs.cypress.io/api/commands/contains.html) sont des commandes Cypress, et leur but est assez évident. +[cy.visit](https://docs.cypress.io/api/commands/visit.html) ouvre l'adresse web qui lui est donnée en paramètre dans le navigateur utilisé par le test. [cy.contains](https://docs.cypress.io/api/commands/contains.html) recherche la chaîne qu'il a reçue en paramètre sur la page. + +Nous aurions pu déclarer le test en utilisant une fonction fléchée + +```js +describe('Note app', () => { // highlight-line + it('front page can be opened', () => { // highlight-line + cy.visit('http://localhost:5173') + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2023') + }) +}) +``` + +Cependant, Mocha [recommande](https://mochajs.org/#arrow-functions) de ne pas utiliser de fonctions fléchées, car elles pourraient causer certains problèmes dans certaines situations. + +Si cy.contains ne trouve pas le texte qu'il recherche, le test ne passe pas. Donc, si nous étendons notre test ainsi + +```js +describe('Note app', function() { + it('front page can be opened', function() { + cy.visit('http://localhost:5173') + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2023') + }) + +// highlight-start + it('front page contains random text', function() { + cy.visit('http://localhost:5173') + cy.contains('wtf is this app?') + }) +// highlight-end +}) +``` + +le test échoue + +![cypress montrant l'échec s'attendant à trouver wtf mais non](../../images/5/57new.png) + +Supprimons le code qui échoue du test. + +La variable _cy_ que nos tests utilisent nous donne une vilaine erreur Eslint + +![capture d'écran vscode montrant cy n'est pas défini](../../images/5/58new.png) + +Nous pouvons nous en débarrasser en installant [eslint-plugin-cypress](https://github.com/cypress-io/eslint-plugin-cypress) en tant que dépendance de développement + +```js +npm install eslint-plugin-cypress --save-dev +``` + +et en changeant la configuration dans .eslintrc.cjs comme suit: + +```js +module.exports = { + "env": { + browser: true, + es2020: true, + "jest/globals": true, + "cypress/globals": true // highlight-line + }, + "extends": [ + // ... + ], + "parserOptions": { + // ... + }, + "plugins": [ + "react", "jest", "cypress" // highlight-line + ], + "rules": { + // ... + } +} +``` + +### Écrire dans un formulaire + +Étendons nos tests de manière à ce que le test essaie de se connecter à notre application. +Nous supposons que notre backend contient un utilisateur avec le nom d'utilisateur mluukkai et le mot de passe salainen. + +Le test commence par ouvrir le formulaire de connexion. + +```js +describe('Note app', function() { + // ... + + it('login form can be opened', function() { + cy.visit('http://localhost:5173') + cy.contains('log in').click() + }) +}) +``` + +Le test recherche d'abord le bouton de connexion par son texte et clique sur le bouton avec la commande [cy.click](https://docs.cypress.io/api/commands/click.html#Syntax). + +Comme nos deux tests commencent de la même manière, par l'ouverture de la page , nous devrions séparer la partie commune dans un bloc beforeEach exécuté avant chaque test: + +```js +describe('Note app', function() { + // highlight-start + beforeEach(function() { + cy.visit('http://localhost:5173') + }) + // highlight-end + + it('front page can be opened', function() { + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2023') + }) + + it('login form can be opened', function() { + cy.contains('log in').click() + }) +}) +``` + +Le champ de connexion contient deux champs input, dans lesquels le test devrait écrire. + +La commande [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax) permet de rechercher des éléments par sélecteurs CSS. + +Nous pouvons accéder au premier et au dernier champ de saisie sur la page, et y écrire avec la commande [cy.type](https://docs.cypress.io/api/commands/type.html#Syntax) comme suit: + +```js +it('user can login', function () { + cy.contains('log in').click() + cy.get('input:first').type('mluukkai') + cy.get('input:last').type('salainen') +}) +``` + +Le test fonctionne. Le problème est que si nous ajoutons plus tard d'autres champs de saisie, le test échouera car il s'attend à ce que les champs dont il a besoin soient le premier et le dernier sur la page. + +Il serait préférable de donner à nos entrées des ids uniques et de les utiliser pour les trouver. +Nous modifions notre formulaire de connexion comme suit: + +```js +const LoginForm = ({ ... }) => { + return ( +
    +

    Login

    +
    +
    + username + +
    +
    + password + +
    + +
    +
    + ) +} +``` + +Nous avons également ajouté un id à notre bouton de soumission afin de pouvoir y accéder dans nos tests. + +Le test devient: + +```js +describe('Note app', function() { + // .. + it('user can log in', function() { + cy.contains('log in').click() + cy.get('#username').type('mluukkai') // highlight-line + cy.get('#password').type('salainen') // highlight-line + cy.get('#login-button').click() // highlight-line + + cy.contains('Matti Luukkainen logged in') // highlight-line + }) +}) +``` + +La dernière ligne assure que la connexion a été réussie. + +Notez que le [sélecteur d'id](https://developer.mozilla.org/en-US/docs/Web/CSS/ID_selectors) CSS est #, donc si nous voulons rechercher un élément avec l'id username, le sélecteur CSS est #username. + +Veuillez noter que réussir le test à ce stade nécessite qu'il y ait un utilisateur dans la base de données de test de l'environnement backend dont le nom d'utilisateur est mluukkai et le mot de passe est salainen. Créez un utilisateur si nécessaire ! + +### Tester le formulaire de nouvelle note + +Ajoutons ensuite des méthodes de test pour tester la fonctionnalité "nouvelle note": + +```js +describe('Note app', function() { + // .. + // highlight-start + describe('when logged in', function() { + beforeEach(function() { + cy.contains('log in').click() + cy.get('input:first').type('mluukkai') + cy.get('input:last').type('salainen') + cy.get('#login-button').click() + }) + // highlight-end + + // highlight-start + it('a new note can be created', function() { + cy.contains('new note').click() + cy.get('input').type('a note created by cypress') + cy.contains('save').click() + + cy.contains('a note created by cypress') + }) + }) + // highlight-end +}) +``` + +Le test a été défini dans son propre bloc describe. +Seuls les utilisateurs connectés peuvent créer de nouvelles notes, donc nous avons ajouté la connexion à l'application dans un bloc beforeEach. + +Le test suppose que lors de la création d'une nouvelle note, la page contient un seul champ de saisie, donc il le recherche comme suit: + +```js +cy.get('input') +``` + +Si la page contenait plus d'entrées, le test échouerait + +![erreur cypress - cy.type ne peut être appelé que sur un seul élément](../../images/5/31x.png) + +À cause de ce problème, il serait à nouveau préférable de donner un id à l'entrée et de rechercher l'élément par son id. + +La structure des tests ressemble à ceci: + +```js +describe('Note app', function() { + // ... + + it('user can log in', function() { + cy.contains('log in').click() + cy.get('#username').type('mluukkai') + cy.get('#password').type('salainen') + cy.get('#login-button').click() + + cy.contains('Matti Luukkainen logged in') + }) + + describe('when logged in', function() { + beforeEach(function() { + cy.contains('log in').click() + cy.get('input:first').type('mluukkai') + cy.get('input:last').type('salainen') + cy.get('#login-button').click() + }) + + it('a new note can be created', function() { + // ... + }) + }) +}) +``` + +Cypress exécute les tests dans l'ordre où ils figurent dans le code. Ainsi, il exécute d'abord user can log in (l'utilisateur peut se connecter), où l'utilisateur se connecte. Ensuite, Cypress exécutera a new note can be created (une nouvelle note peut être créée) pour lequel un bloc beforeEach se connecte également. +Pourquoi faire cela ? L'utilisateur n'est-il pas déjà connecté après le premier test? +Non, car chaque test commence de zéro en ce qui concerne le navigateur. +Tous les changements dans l'état du navigateur sont réinitialisés après chaque test. + +### Contrôler l'état de la base de données + +Si les tests doivent pouvoir modifier la base de données du serveur, la situation devient immédiatement plus compliquée. Idéalement, la base de données du serveur devrait être la même chaque fois que nous exécutons les tests, pour que nos tests puissent être fiables et facilement répétables. + +Comme avec les tests unitaires et d'intégration, avec les tests E2E, il est préférable de vider la base de données et éventuellement de la formater avant l'exécution des tests. Le défi avec les tests E2E est qu'ils n'ont pas accès à la base de données. + +La solution est de créer des points d'API pour les tests backend. +Nous pouvons vider la base de données en utilisant ces points d'API. +Créons un nouveau routeur pour les tests à l'intérieur du dossier controllers, dans le fichier testing.js + +```js +const testingRouter = require('express').Router() +const Note = require('../models/note') +const User = require('../models/user') + +testingRouter.post('/reset', async (request, response) => { + await Note.deleteMany({}) + await User.deleteMany({}) + + response.status(204).end() +}) + +module.exports = testingRouter +``` + +et l'ajouter au backend uniquement si l'application est exécutée en mode test: + +```js +// ... + +app.use('/api/login', loginRouter) +app.use('/api/users', usersRouter) +app.use('/api/notes', notesRouter) + +// highlight-start +if (process.env.NODE_ENV === 'test') { + const testingRouter = require('./controllers/testing') + app.use('/api/testing', testingRouter) +} +// highlight-end + +app.use(middleware.unknownEndpoint) +app.use(middleware.errorHandler) + +module.exports = app +``` + +Après les modifications, une requête HTTP POST vers le point de terminaison /api/testing/reset vide la base de données. Assurez-vous que votre backend est exécuté en mode test en le démarrant avec cette commande (préalablement configurée dans le fichier package.json): + +```js + npm run start:test +``` + +Le code backend modifié peut être trouvé sur la branche [GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part5-1) part5-1. + +Ensuite, nous allons modifier le bloc beforeEach de sorte qu'il vide la base de données du serveur avant l'exécution des tests. + +Actuellement, il n'est pas possible d'ajouter de nouveaux utilisateurs via l'interface utilisateur du frontend, donc nous ajoutons un nouvel utilisateur au backend depuis le bloc beforeEach. + +```js +describe('Note app', function() { + beforeEach(function() { + // highlight-start + cy.request('POST', 'http://localhost:3001/api/testing/reset') + const user = { + name: 'Matti Luukkainen', + username: 'mluukkai', + password: 'salainen' + } + cy.request('POST', 'http://localhost:3001/api/users/', user) + // highlight-end + cy.visit('http://localhost:5173') + }) + + it('front page can be opened', function() { + // ... + }) + + it('user can login', function() { + // ... + }) + + describe('when logged in', function() { + // ... + }) +}) +``` + +Lors du formatage, le test effectue des requêtes HTTP vers le backend avec [cy.request](https://docs.cypress.io/api/commands/request.html). + +Contrairement à avant, maintenant, les tests commencent avec le backend dans le même état à chaque fois. Le backend contiendra un utilisateur et aucune note. + +Ajoutons un autre test pour vérifier que nous pouvons changer l'importance des notes. + +Il y a quelque temps, nous avons modifié le frontend de sorte qu'une nouvelle note soit importante par défaut, ou que le champ important soit true: + +```js +const NoteForm = ({ createNote }) => { + // ... + + const addNote = (event) => { + event.preventDefault() + createNote({ + content: newNote, + important: true // highlight-line + }) + + setNewNote('') + } + // ... +} +``` + +Il existe plusieurs manières de tester cela. Dans l'exemple suivant, nous recherchons d'abord une note et cliquons sur son bouton rendre non important. Ensuite, nous vérifions que la note contient maintenant un bouton rendre important. + +```js +describe('Note app', function() { + // ... + + describe('when logged in', function() { + // ... + + describe('and a note exists', function () { + beforeEach(function () { + cy.contains('new note').click() + cy.get('input').type('another note cypress') + cy.contains('save').click() + }) + + it('it can be made not important', function () { + cy.contains('another note cypress') + .contains('make not important') + .click() + + cy.contains('another note cypress') + .contains('make important') + }) + }) + }) +}) +``` + +La première commande recherche un composant contenant le texte another note cypress, puis un bouton rendre non important à l'intérieur. Elle clique ensuite sur le bouton. + +La deuxième commande vérifie que le texte sur le bouton a changé en rendre important. + +Les tests et le code frontend actuel peuvent être trouvés sur la branche [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-9) part5-9. + +### Test d'échec de connexion + +Faisons un test pour s'assurer qu'une tentative de connexion échoue si le mot de passe est incorrect. + +Par défaut, Cypress exécutera tous les tests chaque fois, et à mesure que le nombre de tests augmente, cela commence à devenir assez chronophage. +Lors du développement d'un nouveau test ou lors du débogage d'un test en échec, nous pouvons définir le test avec it.only au lieu de it, de sorte que Cypress n'exécute que le test requis. +Lorsque le test fonctionne, nous pouvons retirer .only. + +La première version de nos tests est la suivante: + +```js +describe('Note app', function() { + // ... + + it.only('login fails with wrong password', function() { + cy.contains('log in').click() + cy.get('#username').type('mluukkai') + cy.get('#password').type('wrong') + cy.get('#login-button').click() + + cy.contains('wrong credentials') + }) + + // ... +)} +``` + +Le test utilise [cy.contains](https://docs.cypress.io/api/commands/contains.html#Syntax) pour s'assurer que l'application affiche un message d'erreur. + +L'application rend le message d'erreur dans un composant avec la classe CSS error: + +```js +const Notification = ({ message }) => { + if (message === null) { + return null + } + + return ( +
    // highlight-line + {message} +
    + ) +} +``` + +Nous pourrions faire en sorte que le test s'assure que le message d'erreur est rendu dans le composant correct, c'est-à-dire, le composant avec la classe CSS error: + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error').contains('wrong credentials') // highlight-line +}) +``` + +D'abord, nous utilisons [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax) pour rechercher un composant avec la classe CSS error. Ensuite, nous vérifions que le message d'erreur peut être trouvé dans ce composant. +Notez que le [sélecteur de classe CSS](https://developer.mozilla.org/en-US/docs/Web/CSS/Class_selectors) commence par un point, donc le sélecteur pour la classe error est .error. + +Nous pourrions faire la même chose en utilisant la syntaxe [should](https://docs.cypress.io/api/commands/should.html): + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error').should('contain', 'wrong credentials') // highlight-line +}) +``` + +Utiliser should est un peu plus délicat que d'utiliser contains, mais cela permet des tests plus divers que contains, qui fonctionne uniquement sur la base du contenu textuel. + +Une liste des assertions les plus courantes qui peuvent être utilisées avec _should_ peut être trouvée [ici](https://docs.cypress.io/guides/references/assertions.html#Common-Assertions). + +Nous pouvons, par exemple, nous assurer que le message d'erreur est rouge et qu'il a une bordure: + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error').should('contain', 'wrong credentials') + cy.get('.error').should('have.css', 'color', 'rgb(255, 0, 0)') + cy.get('.error').should('have.css', 'border-style', 'solid') +}) +``` + +Cypress exige que les couleurs soient données en [rgb](https://rgbcolorcode.com/color/red). + +Puisque tous les tests concernent le même composant auquel nous avons accédé en utilisant [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax), nous pouvons les enchaîner en utilisant [and](https://docs.cypress.io/api/commands/and.html). + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error') + .should('contain', 'wrong credentials') + .and('have.css', 'color', 'rgb(255, 0, 0)') + .and('have.css', 'border-style', 'solid') +}) +``` + +Terminons le test de sorte qu'il vérifie également que l'application ne rend pas le message de succès 'Matti Luukkainen connecté': + +```js +it('login fails with wrong password', function() { + cy.contains('log in').click() + cy.get('#username').type('mluukkai') + cy.get('#password').type('wrong') + cy.get('#login-button').click() + + cy.get('.error') + .should('contain', 'wrong credentials') + .and('have.css', 'color', 'rgb(255, 0, 0)') + .and('have.css', 'border-style', 'solid') + + cy.get('html').should('not.contain', 'Matti Luukkainen logged in') // highlight-line +}) +``` + +La commande should est le plus souvent utilisée en la chaînant après la commande get (ou une autre commande similaire qui peut être enchaînée). Le cy.get('html') utilisé dans le test signifie pratiquement le contenu visible de toute l'application. + +Nous pourrions également vérifier la même chose en chaînant la commande contains avec la commande should avec un paramètre légèrement différent: + +```js +cy.contains('Matti Luukkainen logged in').should('not.exist') +``` + +**REMARQUE:** Certaines propriétés CSS se [comportent différemment sur Firefox](https://github.com/cypress-io/cypress/issues/9349). Si vous exécutez les tests avec Firefox: + +![running](https://user-images.githubusercontent.com/4255997/119015927-0bdff800-b9a2-11eb-9234-bb46d72c0368.png) + +alors les tests qui impliquent, par exemple, `border-style`, `border-radius` et `padding`, passeront dans Chrome ou Electron, mais échoueront dans Firefox : + +![borderstyle](https://user-images.githubusercontent.com/4255997/119016340-7b55e780-b9a2-11eb-82e0-bab0418244c0.png) + +### Contournement de l'UI + +Actuellement, nous avons les tests suivants: + +```js +describe('Note app', function() { + it('user can login', function() { + cy.contains('log in').click() + cy.get('#username').type('mluukkai') + cy.get('#password').type('salainen') + cy.get('#login-button').click() + + cy.contains('Matti Luukkainen logged in') + }) + + it('login fails with wrong password', function() { + // ... + }) + + describe('when logged in', function() { + beforeEach(function() { + cy.contains('log in').click() + cy.get('input:first').type('mluukkai') + cy.get('input:last').type('salainen') + cy.get('#login-button').click() + }) + + it('a new note can be created', function() { + // ... + }) + + }) +}) +``` + +D'abord, nous testons la connexion. Ensuite, dans leur propre bloc describe, nous avons un ensemble de tests, qui supposent que l'utilisateur est connecté. L'utilisateur est connecté dans le bloc beforeEach. + +Comme nous l'avons dit ci-dessus, chaque test commence à zéro! Les tests ne commencent pas à partir de l'état où les tests précédents se sont terminés. + +La documentation de Cypress nous donne le conseil suivant: [Tester complètement le flux de connexion – mais une seule fois](https://docs.cypress.io/guides/end-to-end-testing/testing-your-app#Fully-test-the-login-flow-but-only-once). +Donc, au lieu de connecter un utilisateur en utilisant le formulaire dans le bloc beforeEach, Cypress recommande de [contourner l'UI](https://docs.cypress.io/guides/getting-started/testing-your-app.html#Bypassing-your-UI) et de faire une requête HTTP au backend pour se connecter. La raison en est que se connecter avec une requête HTTP est beaucoup plus rapide que de remplir un formulaire. + +Notre situation est un peu plus compliquée que dans l'exemple de la documentation de Cypress car, lorsqu'un utilisateur se connecte, notre application sauvegarde ses détails dans le localStorage. +Cependant, Cypress peut également gérer cela. +Le code est le suivant + +```js +describe('when logged in', function() { + beforeEach(function() { + // highlight-start + cy.request('POST', 'http://localhost:3001/api/login', { + username: 'mluukkai', password: 'salainen' + }).then(response => { + localStorage.setItem('loggedNoteappUser', JSON.stringify(response.body)) + cy.visit('http://localhost:5173') + }) + // highlight-end + }) + + it('a new note can be created', function() { + // ... + }) + + // ... +}) +``` + +Nous pouvons accéder à la réponse d'une [cy.request](https://docs.cypress.io/api/commands/request.html) avec la méthode _then_. Sous le capot, cy.request, comme toutes les commandes Cypress, sont des [promesses](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Commands-Are-Promises). +La fonction de rappel sauvegarde les détails d'un utilisateur connecté dans le localStorage, et recharge la page. +Maintenant, il n'y a aucune différence avec un utilisateur se connectant avec le formulaire de connexion. + +Si et lorsque nous écrivons de nouveaux tests pour notre application, nous devons utiliser le code de connexion à plusieurs endroits. +Nous devrions en faire une [commande personnalisée](https://docs.cypress.io/api/cypress-api/custom-commands.html). + +Les commandes personnalisées sont déclarées dans cypress/support/commands.js. +Le code pour se connecter est le suivant: + +```js +Cypress.Commands.add('login', ({ username, password }) => { + cy.request('POST', 'http://localhost:3001/api/login', { + username, password + }).then(({ body }) => { + localStorage.setItem('loggedNoteappUser', JSON.stringify(body)) + cy.visit('http://localhost:5173') + }) +}) +``` + +Utiliser notre commande personnalisée est facile, et notre test devient plus clair: + +```js +describe('when logged in', function() { + beforeEach(function() { + // highlight-start + cy.login({ username: 'mluukkai', password: 'salainen' }) + // highlight-end + }) + + it('a new note can be created', function() { + // ... + }) + + // ... +}) +``` + +La même chose s'applique à la création d'une nouvelle note maintenant que nous y pensons. Nous avons un test qui crée une nouvelle note en utilisant le formulaire. Nous créons également une nouvelle note dans le bloc beforeEach du test testant le changement de l'importance d'une note: + +```js +describe('Note app', function() { + // ... + + describe('when logged in', function() { + it('a new note can be created', function() { + cy.contains('new note').click() + cy.get('input').type('a note created by cypress') + cy.contains('save').click() + + cy.contains('a note created by cypress') + }) + + describe('and a note exists', function () { + beforeEach(function () { + cy.contains('new note').click() + cy.get('input').type('another note cypress') + cy.contains('save').click() + }) + + it('it can be made important', function () { + // ... + }) + }) + }) +}) +``` + +Créons une nouvelle commande personnalisée pour créer une nouvelle note. La commande créera une nouvelle note avec une requête HTTP POST: + +```js +Cypress.Commands.add('createNote', ({ content, important }) => { + cy.request({ + url: 'http://localhost:3001/api/notes', + method: 'POST', + body: { content, important }, + headers: { + 'Authorization': `Bearer ${JSON.parse(localStorage.getItem('loggedNoteappUser')).token}` + } + }) + + cy.visit('http://localhost:5173') +}) +``` + +La commande suppose que l'utilisateur est connecté et que les détails de l'utilisateur sont sauvegardés dans le localStorage. + +Maintenant, le bloc de formatage devient: + +```js +describe('Note app', function() { + // ... + + describe('when logged in', function() { + it('a new note can be created', function() { + // ... + }) + + describe('and a note exists', function () { + beforeEach(function () { + // highlight-start + cy.createNote({ + content: 'another note cypress', + important: true + }) + // highlight-end + }) + + it('it can be made important', function () { + // ... + }) + }) + }) +}) +``` + +Il y a encore une caractéristique ennuyeuse dans nos tests. L'adresse de l'application est codée en dur à de nombreux endroits. + +Définissons l'baseUrl pour l'application dans le [fichier de configuration](https://docs.cypress.io/guides/references/configuration) pré-généré par Cypress cypress.config.js: + +```js +const { defineConfig } = require("cypress") + +module.exports = defineConfig({ + e2e: { + setupNodeEvents(on, config) { + }, + baseUrl: 'http://localhost:5173' // highlight-line + }, +}) +``` + +Toutes les commandes dans les tests utilisent l'adresse de l'application + +```js +cy.visit('http://localhost:5173' ) +``` + +peuvent être transformées en + +```js +cy.visit('') +``` + +L'adresse codée en dur du backend est encore dans les tests. La [documentation](https://docs.cypress.io/guides/guides/environment-variables) de Cypress recommande de définir les autres adresses utilisées par les tests comme variables d'environnement. + +Étendons le fichier de configuration cypress.config.js comme suit: + +```js +const { defineConfig } = require("cypress") + +module.exports = defineConfig({ + e2e: { + setupNodeEvents(on, config) { + }, + baseUrl: 'http://localhost:5173', + env: { + BACKEND: 'http://localhost:3001/api' // highlight-line + } + }, +}) +``` + +Remplaçons toutes les adresses du backend dans les tests de la manière suivante + +```js +describe('Note ', function() { + beforeEach(function() { + + cy.request('POST', `${Cypress.env('BACKEND')}/testing/reset`) // highlight-line + const user = { + name: 'Matti Luukkainen', + username: 'mluukkai', + password: 'secret' + } + cy.request('POST', `${Cypress.env('BACKEND')}/users`, user) // highlight-line + cy.visit('') + }) + // ... +}) +``` + +Les tests et le code frontend peuvent être trouvés sur la branche [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-10) part5-10. + +### Changer l'importance d'une note + +Enfin, examinons le test que nous avons réalisé pour changer l'importance d'une note. +D'abord, nous allons changer le bloc de formatage pour qu'il crée trois notes au lieu d'une: + +```js +describe('when logged in', function() { + describe('and several notes exist', function () { + beforeEach(function () { + // highlight-start + cy.login({ username: 'mluukkai', password: 'salainen' }) + cy.createNote({ content: 'first note', important: false }) + cy.createNote({ content: 'second note', important: false }) + cy.createNote({ content: 'third note', important: false }) + // highlight-end + }) + + it('one of those can be made important', function () { + cy.contains('second note') + .contains('make important') + .click() + + cy.contains('second note') + .contains('make not important') + }) + }) +}) +``` + +Comment la commande [cy.contains](https://docs.cypress.io/api/commands/contains.html) fonctionne-t-elle réellement ? + +Lorsque nous cliquons sur la commande _cy.contains('second note')_ dans le [Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner.html) de Cypress, nous voyons que la commande recherche l'élément contenant le texte second note: + +![cypress test runner cliquant sur testbody et second note](../../images/5/34new.png) + +En cliquant sur la ligne suivante _.contains('make important')_, nous voyons que le test utilise le bouton 'make important' correspondant à la second note: + +![cypress test runner cliquant sur make important](../../images/5/35new.png) + +Lorsqu'elles sont enchaînées, la seconde commande contains continue la recherche à partir du composant trouvé par la première commande. + +Si nous n'avions pas enchaîné les commandes, et avions écrit à la place: + +```js +cy.contains('second note') +cy.contains('make important').click() +``` + +le résultat aurait été totalement différent. La seconde ligne du test aurait cliqué sur le bouton d'une mauvaise note: + +![cypress montrant une erreur et essayant incorrectement de cliquer sur le premier bouton](../../images/5/36new.png) + +Lors de l'écriture des tests, vous devriez vérifier dans le test runner que les tests utilisent les bons composants! + +Changeons le composant _Note_ pour que le texte de la note soit rendu dans un span. + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' : 'make important' + + return ( +
  • + {note.content} // highlight-line + +
  • + ) +} +``` + +Nos tests échouent ! Comme le révèle le test runner, _cy.contains('second note')_ retourne maintenant le composant contenant le texte, et le bouton n'y est pas. + +![cypress montrant que le test est cassé en essayant de cliquer sur rendre important](../../images/5/37new.png) + +Une façon de corriger cela est la suivante: + +```js +it('one of those can be made important', function () { + cy.contains('second note').parent().find('button').click() + cy.contains('second note').parent().find('button') + .should('contain', 'make not important') +}) +``` + +Dans la première ligne, nous utilisons la commande [parent](https://docs.cypress.io/api/commands/parent.html) pour accéder à l'élément parent de l'élément contenant second note et trouver le bouton à l'intérieur de celui-ci. +Ensuite, nous cliquons sur le bouton et vérifions que le texte dessus change. + +Notez que nous utilisons la commande [find](https://docs.cypress.io/api/commands/find.html#Syntax) pour rechercher le bouton. Nous ne pouvons pas utiliser [cy.get](https://docs.cypress.io/api/commands/get.html) ici, car cela recherche toujours dans la totalité de la page et retournerait les 5 boutons sur la page. + +Malheureusement, nous avons maintenant un peu de copier-coller dans les tests, car le code pour rechercher le bon bouton est toujours le même. + +Dans ce genre de situations, il est possible d'utiliser la commande [as](https://docs.cypress.io/api/commands/as.html): + +```js +it('one of those can be made important', function () { + cy.contains('second note').parent().find('button').as('theButton') + cy.get('@theButton').click() + cy.get('@theButton').should('contain', 'make not important') +}) +``` + +Maintenant, la première ligne trouve le bon bouton et utilise as pour le sauvegarder sous le nom theButton. Les lignes suivantes peuvent utiliser l'élément nommé avec cy.get('@theButton'). + +### Exécution et débogage des tests + +Enfin, quelques notes sur le fonctionnement de Cypress et le débogage de vos tests. + +La forme des tests Cypress donne l'impression que les tests sont du code JavaScript normal, et nous pourrions par exemple essayer ceci: + +```js +const button = cy.contains('log in') +button.click() +debugger +cy.contains('logout').click() +``` + +Cela ne fonctionnera cependant pas. Lorsque Cypress exécute un test, il ajoute chaque commande _cy_ à une file d'exécution. +Quand le code de la méthode de test a été exécuté, Cypress exécutera chaque commande dans la file une par une. + +Les commandes Cypress retournent toujours _undefined_, donc _button.click()_ dans le code ci-dessus provoquerait une erreur. Une tentative de démarrer le débogueur ne stopperait pas le code entre l'exécution des commandes, mais avant que toute commande ait été exécutée. + +Les commandes Cypress sont comme des promesses, donc si nous voulons accéder à leurs valeurs de retour, nous devons le faire en utilisant la commande [then](https://docs.cypress.io/api/commands/then.html). +Par exemple, le test suivant imprimerait le nombre de boutons dans l'application et cliquerait sur le premier bouton: + +```js +it('then example', function() { + cy.get('button').then( buttons => { + console.log('number of buttons', buttons.length) + cy.wrap(buttons[0]).click() + }) +}) +``` + +Arrêter l'exécution du test avec le débogueur est [possible](https://docs.cypress.io/api/commands/debug.html). Le débogueur se lance uniquement si la console de développement du test runner de Cypress est ouverte. + +La console de développement est très utile pour déboguer vos tests. +Vous pouvez voir les requêtes HTTP effectuées par les tests dans l'onglet Réseau, et l'onglet Console vous montrera des informations sur vos tests: + +![console de développement lors de l'exécution de cypress](../../images/5/38new.png) + +Jusqu'à présent, nous avons exécuté nos tests Cypress en utilisant le test runner graphique. +Il est également possible de les exécuter [depuis la ligne de commande](https://docs.cypress.io/guides/guides/command-line.html). Il suffit d'ajouter un script npm pour cela: + +```js + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "server": "json-server -p3001 --watch db.json", + "cypress:open": "cypress open", + "test:e2e": "cypress run" // highlight-line + }, +``` + +Maintenant, nous pouvons exécuter nos tests depuis la ligne de commande avec la commande npm run test:e2e + +![sortie terminal de l'exécution des tests npm e2e montrant réussi](../../images/5/39new.png) + +Notez que des vidéos de l'exécution des tests seront sauvegardées dans cypress/videos/, vous devriez donc probablement ignorer ce répertoire avec git. Il est également possible de [désactiver](https://docs.cypress.io/guides/guides/screenshots-and-videos#Videos) la création de vidéos. + +Le frontend et le code de test peuvent être trouvés sur la branche [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-11) part5-11. + +
    + +
    + +### Exercices 5.17.-5.23. + +Dans les derniers exercices de cette partie, nous allons réaliser quelques tests E2E pour notre application de blogs. +Le matériel de cette partie devrait être suffisant pour compléter les exercices. +Vous **devez consulter la [documentation](https://docs.cypress.io/guides/overview/why-cypress.html#In-a-nutshell) de Cypress**. C'est probablement la meilleure documentation que j'ai jamais vue pour un projet open source. + +Je recommande particulièrement de lire [Introduction à Cypress](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Cypress-Can-Be-Simple-Sometimes), qui déclare + +>Ceci est le guide le plus important pour comprendre comment tester avec Cypress. Lisez-le. Comprenez-le. + +#### 5.17: tests de bout en bout de la liste des blogs, étape 1 + +Configurez Cypress pour votre projet. Réalisez un test pour vérifier que l'application affiche par défaut le formulaire de connexion. + +La structure du test doit être la suivante: + +```js +describe('Blog app', function() { + beforeEach(function() { + cy.request('POST', 'http://localhost:3003/api/testing/reset') + cy.visit('http://localhost:5173') + }) + + it('Login form is shown', function() { + // ... + }) +}) +``` + +Le bloc de formatage beforeEach doit vider la base de données en utilisant par exemple la méthode que nous avons utilisée dans le [matériel](/en/part5/end_to_end_testing#controlling-the-state-of-the-database). + +#### 5.18: tests de bout en bout de la liste des blogs, étape 2 + +Réalisez des tests pour la connexion. Testez à la fois les tentatives de connexion réussies et échouées. +Créez un nouvel utilisateur dans le bloc beforeEach pour les tests. + +La structure du test s'étend comme suit: + +```js +describe('Blog app', function() { + beforeEach(function() { + cy.request('POST', 'http://localhost:3001/api/testing/reset') + // create here a user to backend + cy.visit('http://localhost:5173') + }) + + it('Login form is shown', function() { + // ... + }) + + describe('Login',function() { + it('succeeds with correct credentials', function() { + // ... + }) + + it('fails with wrong credentials', function() { + // ... + }) + }) +}) +``` + +Exercice bonus facultatif: Vérifiez que la notification affichée lors d'une connexion infructueuse est affichée en rouge. + +#### 5.19: tests de bout en bout de la liste des blogs, étape 3 + +Réalisez un test qui vérifie qu'un utilisateur connecté peut créer un nouveau blog. +La structure du test pourrait être la suivante: + +```js +describe('Blog app', function() { + // ... + + describe('When logged in', function() { + beforeEach(function() { + // log in user here + }) + + it('A blog can be created', function() { + // ... + }) + }) + +}) +``` + +Le test doit s'assurer qu'un nouveau blog est ajouté à la liste de tous les blogs. + +#### 5.20: tests de bout en bout de la liste des blogs, étape 4 + +Réalisez un test qui confirme que les utilisateurs peuvent aimer un blog. + +#### 5.21: tests de bout en bout de la liste des blogs, étape 5 + +Réalisez un test pour s'assurer que l'utilisateur qui a créé un blog peut le supprimer. + +#### 5.22: tests de bout en bout de la liste des blogs, étape 6 + +Réalisez un test pour s'assurer que seul le créateur peut voir le bouton de suppression d'un blog, et pas les autres. + +#### 5.23: tests de bout en bout de la liste des blogs, étape 7 + +Réalisez un test qui vérifie que les blogs sont ordonnés selon les likes, avec le blog ayant le plus de likes en premier. + +Cet exercice est un peu plus compliqué que les précédents. Une solution est d'ajouter une certaine classe pour l'élément qui enveloppe le contenu du blog et d'utiliser la méthode [eq](https://docs.cypress.io/api/commands/eq#Syntax) pour obtenir l'élément du blog à un index spécifique: + +```js +cy.get('.blog').eq(0).should('contain', 'The title with the most likes') +cy.get('.blog').eq(1).should('contain', 'The title with the second most likes') +``` + +Notez que vous pourriez rencontrer des problèmes si vous cliquez plusieurs fois de suite sur un bouton de like. Il se peut que Cypress clique si rapidement qu'il n'ait pas le temps de mettre à jour l'état de l'application entre les clics. Un remède à cela est d'attendre que le nombre de likes se mette à jour entre tous les clics. + +C'était le dernier exercice de cette partie, et il est temps de pousser votre code sur GitHub et de marquer les exercices que vous avez complétés dans le [système de soumission des exercices](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    \ No newline at end of file diff --git a/src/content/5/ptbr/part5.md b/src/content/5/ptbr/part5.md new file mode 100644 index 00000000000..db24d0c81f6 --- /dev/null +++ b/src/content/5/ptbr/part5.md @@ -0,0 +1,14 @@ +--- +mainImage: ../../../images/part-5.svg +part: 5 +lang: ptbr +--- + +
    + +Nessa parte, nós retornamos ao frontend, analisando as diferentes possibilidades de testar o código em React. Também implementaremos uma autenticação baseada em token, que irá permitir que os usuários façam login na nossa aplicação. + +Parte atualizada em 25 de janeiro de 2023 +- Sem maiores atualizações + +
    diff --git a/src/content/5/ptbr/part5a.md b/src/content/5/ptbr/part5a.md new file mode 100644 index 00000000000..770b526c84e --- /dev/null +++ b/src/content/5/ptbr/part5a.md @@ -0,0 +1,670 @@ +--- +mainImage: ../../../images/part-5.svg +part: 5 +letter: a +lang: ptbr +--- + +
    + +Nas últimas duas partes, nós focamos principalmente no backend. O frontend que desenvolvemos na [parte 2](/ptbr/part2) ainda não suporta o gerenciamento de usuários que implementamos no backend na [parte 4](/ptbr/part4). + +No momento, o frontend mostra as notas existentes e permite que os usuários mudem o estado de uma nota de importante para não importante e vice-versa. Novas notas não podem mais ser adicionadas por causa das mudanças feitas no backend na parte 4: o backend agora espera que um token contendo a identidade de um usuário seja enviado com a nova nota. + +Agora nós iremos implementar uma parte da funcionalidade de gerenciamento de usuários necessária no frontend. Vamos começar com o login de usuário. Durante toda essa parte, nós iremos assumir que novos usuários não serão adicionados a partir do frontend. + +### Gerenciando o login + +Um formulário de login foi agora adicionado ao topo da página: + +![navegador mostrando login do usuário para as notas](../../images/5/1new.png) + +O código do componente App agora é o seguinte: + +```js +const App = () => { + const [notes, setNotes] = useState([]) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + const [errorMessage, setErrorMessage] = useState(null) + // highlight-start + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') +// highlight-end + + useEffect(() => { + noteService + .getAll().then(initialNotes => { + setNotes(initialNotes) + }) + }, []) + + // ... + +// highlight-start + const handleLogin = (event) => { + event.preventDefault() + console.log('logging in with', username, password) + } + // highlight-end + + return ( +
    +

    Notes

    + + + + // highlight-start +
    +
    + username + setUsername(target.value)} + /> +
    +
    + password + setPassword(target.value)} + /> +
    + +
    + // highlight-end + + // ... +
    + ) +} + +export default App +``` + +O código atual da aplicação pode ser encontrado no [Github](https://github.com/fullstack-hy2020/part2-notes/tree/part5-1), branch part5-1. Se você clonar o repositório, não se esqueça de rodar _npm install_ antes de tentar iniciar o frontend. + +O frontend não irá renderizar nenhuma nota se não estiver conectado ao backend. Você pode iniciar o backend através do comando _npm run dev_ em sua pasta da Parte 4. Isso iniciará o backend na porta 3001. Enquanto ele estiver ativo, você pode iniciar o frontend em uma janela de terminal separada com o comando _npm start_, e agora você pode ver as notas que foram salvas no seu banco de dados MongoDB da Parte 4. + +Mantenha isso em mente de agora em diante. + +O formulário de login é gerenciado da mesma forma que gerenciamos formulários na [parte 2](/ptbr/part2/forms). O estado do app tem campos de username e password para armazenar os dados do formulário. Os campos do formulário possuem gerenciadores de eventos, que sincronizam as alterações no campo e enviam o estado para o componente App . Os gerenciadores de eventos são simples: Um objeto é passado como parâmetro, e eles desestruturam o campo target do objeto e salvam seu valor no estado. + +```js +({ target }) => setUsername(target.value) +``` + +O método _handleLogin_, que responsável por lidar com os dados no formulário, ainda será implementado. + +O login é feito enviando uma requisição HTTP POST para o endereço do servidor api/login. Vamos separar o código responsável por essa requisição em seu próprio módulo, para o arquivo services/login.js. + +Nós usaremos a sintaxe async/await ao invés de promises para a requisição HTTP: + +```js +import axios from 'axios' +const baseUrl = '/api/login' + +const login = async credentials => { + const response = await axios.post(baseUrl, credentials) + return response.data +} + +export default { login } +``` + +Se você instalou o plugin eslint no VS Code, poderá ver agora o seguinte aviso: + +![aviso do vs code - atribua o objeto a uma variável antes de exportá-la como um módulo padrão](../../images/5/50new.png) + +Nós iremos retornar à configuração do eslint em breve. Você pode ignorar o erro por enquanto ou silenciá-lo adicionando a seguinte linha de código antes do aviso: + +```js +// eslint-disable-next-line import/no-anonymous-default-export +export default { login } +``` + +O método para gerenciar o login pode ser implementado da seguinte forma: + +```js +import loginService from './services/login' // highlight-line + +const App = () => { + // ... + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') +// highlight-start + const [user, setUser] = useState(null) +// highlight-end + + // highlight-start + const handleLogin = async (event) => { + event.preventDefault() + + try { + const user = await loginService.login({ + username, password, + }) + + setUser(user) + setUsername('') + setPassword('') + } catch (exception) { + setErrorMessage('Wrong credentials') + setTimeout(() => { + setErrorMessage(null) + }, 5000) + } + // highlight-end + } + + // ... +} +``` + +Se o login for um sucesso, os campos do formulário serão apagados e o e a resposta do servidor (incluindo um token e os detalhes do usuário) será salva no campo do usuário no estado (state) da aplicação. + +Se o login falhar ou a função _loginService.login_ resultar em erro, o usuário será notificado. + +O usuário não é notificado sobre um login bem-sucedido de nenhuma forma. Vamos modificar a aplicação para mostrar o formulário de login apenas se o usuário não estiver logado, ou seja, _user === null_. O formulário para adicionar novas notas será mostrado apenas se o usuário estiver logado, ou seja, se user contiver os detalhes do usuário. + +Vamos adicionar duas funções auxiliares ao componente App para gerar os formulários: + +```js +const App = () => { + // ... + + const loginForm = () => ( +
    +
    + username + setUsername(target.value)} + /> +
    +
    + password + setPassword(target.value)} + /> +
    + +
    + ) + + const noteForm = () => ( +
    + + +
    + ) + + return ( + // ... + ) +} +``` + +e condicionalmente renderizá-los: + +```js +const App = () => { + // ... + + const loginForm = () => ( + // ... + ) + + const noteForm = () => ( + // ... + ) + + return ( +
    +

    Notes

    + + + + {user === null && loginForm()} // highlight-line + {user !== null && noteForm()} // highlight-line + +
    + +
    +
      + {notesToShow.map((note, i) => + toggleImportanceOf(note.id)} + /> + )} +
    + +
    +
    + ) +} +``` + + +Um [truque](https://pt-br.reactjs.org/docs/conditional-rendering.html#inline-if-with-logical--operator) um pouco estranho, mas comumente usado no React, é usado para renderizar os formulários condicionalmente: + + + +```js +{ + user === null && loginForm() +} +``` + +Se a primeira declaração for avaliada como falsa ou for [falsy](https://developer.mozilla.org/pt-BR/docs/Glossary/Falsy), a segunda declaração (gerando o formulário) não será executada. + + + + + +Podemos tornar isso ainda mais simples usando o [operador condicional](https://developer.mozilla.org/pt-BR/docs/Web/JavaScript/Reference/Operators/Operador_Condicional): + +```js +return ( +
    +

    Notes

    + + + + {user === null ? + loginForm() : + noteForm() + } + +

    Notes

    + + // ... + +
    +) +``` + +Se _user === null_ for igual a um valor [truthy](https://developer.mozilla.org/pt-BR/docs/Glossary/Truthy), _loginForm()_ será executado. Caso contrário, _noteForm()_ será executado. + + +Vamos fazer mais uma modificação. Se o usuário estiver logado, seu nome é exibido na tela: + +```js +return ( +
    +

    Notes

    + + + + {!user && loginForm()} + {user &&
    +

    {user.name} logged in

    + {noteForm()} +
    + } + +

    Notes

    + + // ... + +
    +) +``` + +A solução não é perfeita, mas vamos deixar assim por enquanto. + + +Nosso componente principal App está muito grande no momento. As mudanças que fizemos agora são um sinal claro de que os formulários devem ser refatorados em seus próprios componentes. No entanto, deixaremos isso como um exercício opcional. + + + +O código atual da aplicação pode ser encontrado no [GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part5-2), branch part5-2. + +### Criando novas notas + +O token retornado com um login bem-sucedido é salvo no estado (state) da aplicação - o campo token do usuário: + +```js +const handleLogin = async (event) => { + event.preventDefault() + try { + const user = await loginService.login({ + username, password, + }) + + setUser(user) // highlight-line + setUsername('') + setPassword('') + } catch (exception) { + // ... + } +} +``` + +Vamos corrigir a criação de novas notas para que funcione com o backend. Isso significa adicionar o token do usuário logado ao cabeçalho Authorization da solicitação HTTP. + +O módulo noteService muda da seguinte forma: + +```js +import axios from 'axios' +const baseUrl = '/api/notes' + +let token = null // highlight-line + +// highlight-start +const setToken = newToken => { + token = `Bearer ${newToken}` +} +// highlight-end + +const getAll = () => { + const request = axios.get(baseUrl) + return request.then(response => response.data) +} + +const create = async newObject => { + // highlight-start + const config = { + headers: { Authorization: token }, + } +// highlight-end + + const response = await axios.post(baseUrl, newObject, config) // highlight-line + return response.data +} + +const update = (id, newObject) => { + const request = axios.put(`${ baseUrl }/${id}`, newObject) + return request.then(response => response.data) +} + +// eslint-disable-next-line import/no-anonymous-default-export +export default { getAll, create, update, setToken } // highlight-line +``` + +O módulo noteService contém uma variável privada token. Seu valor pode ser alterado com uma função setToken, que é exportada pelo módulo. A função create, agora com sintaxe async/await, define o token para o cabeçalho Authorization. O cabeçalho é fornecido ao axios como o terceiro parâmetro do método post. + +O gerenciador de eventos responsável pelo login deve ser alterado para chamar o método noteService.setToken(user.token) com um login bem-sucedido: + +```js +const handleLogin = async (event) => { + event.preventDefault() + try { + const user = await loginService.login({ + username, password, + }) + + noteService.setToken(user.token) // highlight-line + setUser(user) + setUsername('') + setPassword('') + } catch (exception) { + // ... + } +} +``` + +E agora, a adição de novas notas funciona novamente! + +### Salvando o token no local storage do navegador + +Nossa aplicação tem uma pequena falha: se o navegador for atualizado (por exemplo, pressionando F5), as informações de login do usuário desaparecem. + +Esse problema é facilmente resolvido salvando os detalhes de login no [local storage](https://developer.mozilla.org/pt-BR/docs/Web/API/Storage). O local storage é um banco de dados de [chave-valor](https://pt.wikipedia.org/wiki/Banco_de_dados_de_chave-valor) no navegador. + +Ele é muito fácil de usar. Um valor correspondente a uma determinada chave é salvo no banco de dados com o método [setItem](https://developer.mozilla.org/pt-BR/docs/Web/API/Storage/setItem). Por exemplo: + +```js +window.localStorage.setItem('name', 'juha tauriainen') +``` + + +salva a string dada como segundo parâmetro como o valor da chave name. + + +O valor de uma chave pode ser encontrado com o método [getItem](https://developer.mozilla.org/pt-BR/docs/Web/API/Storage/getItem): + +```js +window.localStorage.getItem('name') +``` + + +e o método [removeItem](https://developer.mozilla.org/pt-BR/docs/Web/API/Storage/removeItem) remove uma chave. + + +Valores no local storage são persistidos mesmo quando a página é re-renderizada. O armazenamento é específico para [origem](https://developer.mozilla.org/pt-BR/docs/Glossary/Origin) , então cada aplicação web tem seu próprio armazenamento. + +Vamos estender nossa aplicação para que ela salve os detalhes de um usuário logado no local storage. + +Valores salvos no armazenamento são [DOMstrings](https://docs.w3cub.com/dom/domstring), então não podemos salvar um objeto JavaScript da forma como ele é. O objeto deve ser convertido para JSON primeiro, com o método _JSON.stringify_. Da mesma forma, quando um objeto JSON é lido do local storage, ele deve ser convertido de volta para JavaScript com _JSON.parse_. + +As mudanças no método login são as seguintes: + +```js + const handleLogin = async (event) => { + event.preventDefault() + try { + const user = await loginService.login({ + username, password, + }) + + // highlight-start + window.localStorage.setItem( + 'loggedNoteappUser', JSON.stringify(user) + ) + // highlight-end + noteService.setToken(user.token) + setUser(user) + setUsername('') + setPassword('') + } catch (exception) { + // ... + } + } +``` + +Os detalhes de um usuário logado agora são salvos no local storage e podem ser visualizados no console (digitando _window.localStorage_ no console): + +![navegador mostrando alguém logado em notas](../../images/5/3e.png) + +Você também pode inspecionar o local storage usando as ferramentas de desenvolvedor. No Chrome, vá para a guia Aplicativo e selecione local storage (mais detalhes [aqui](https://developers.google.com/web/tools/chrome-devtools/storage/localstorage)). No Firefox, vá para a guia Storage e selecione Local Storage (detalhes [aqui](https://developer.mozilla.org/pt-BR/docs/Tools/Storage_Inspector)). + +Nós ainda temos que modificar nossa aplicação para que, quando entrarmos na página, a aplicação verifique se os detalhes de um usuário logado já podem ser encontrados no local storage. Se eles puderem, os detalhes são salvos no estado da aplicação e no noteService. + +O jeito certo de fazer isso é com um [effect hook](https://pt-br.reactjs.org/docs/hooks-effect.html): um mecanismo que conhecemos pela primeira vez na [parte 2](/ptbr/part2/obtendo_dados_do_servidor#effect-hooks), e usamos para buscar notas do servidor. + +Nós podemos ter vários hooks de efeitos, então vamos criar um para lidar com o primeiro carregamento da página: + +```js +const App = () => { + const [notes, setNotes] = useState([]) + const [newNote, setNewNote] = useState('') + const [showAll, setShowAll] = useState(true) + const [errorMessage, setErrorMessage] = useState(null) + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [user, setUser] = useState(null) + + useEffect(() => { + noteService + .getAll().then(initialNotes => { + setNotes(initialNotes) + }) + }, []) + + // highlight-start + useEffect(() => { + const loggedUserJSON = window.localStorage.getItem('loggedNoteappUser') + if (loggedUserJSON) { + const user = JSON.parse(loggedUserJSON) + setUser(user) + noteService.setToken(user.token) + } + }, []) + // highlight-end + + // ... +} +``` + +O array vazio como parâmetro do hook de efeito garante que o efeito seja executado apenas quando o componente for renderizado [pela primeira vez](https://pt-br.reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect). + + +Agora um usuário fica logado na aplicação para sempre. Provavelmente devemos adicionar uma funcionalidade de logout, que remove os detalhes de login do local storage. No entanto, deixaremos isso como um exercício. + +É possível deslogar um usuário usando o console, e isso é o suficiente por enquanto. +Você pode deslogar com o comando: + +```js +window.localStorage.removeItem('loggedNoteappUser') +``` + +ou com o comando que esvazia o localstorage completamente: + +```js +window.localStorage.clear() +``` + +O código da aplicação atual pode ser encontrado no [GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part5-3), branch part5-3. + +
    + +
    + +### Exercícios 5.1.-5.4. + +Nós agora criaremos um frontend para o backend do bloglist que criamos na última parte. Você pode usar [esta aplicação](https://github.com/fullstack-hy2020/bloglist-frontend) do GitHub como base para sua solução. A aplicação espera que seu backend esteja em execução na porta 3003. + +É suficiente enviar sua solução final. Você pode fazer um commit após cada exercício, mas isso não é necessário. + +Os primeiros exercícios revisam tudo o que aprendemos sobre React até agora. Eles podem ser desafiadores, especialmente se seu backend estiver incompleto. Pode ser melhor usar o backend que marcamos como resposta para a parte 4. + +Enquanto faz os exercícios, lembre-se de todos os métodos de depuração que discutimos, especialmente prestando atenção no console. + +**Aviso:** Se você perceber que está misturando os comandos das funções _async/await_ e _then_, é 99,9% certo que está fazendo algo errado. Use apenas um ou outro, nunca os dois. + +#### 5.1: frontend do Blog List Passo 1 + +Clone a aplicação do [GitHub](https://github.com/fullstack-hy2020/bloglist-frontend) com o comando: + +```bash +git clone https://github.com/fullstack-hy2020/bloglist-frontend +``` + +remova a configuração do git da aplicação clonada + +```bash +cd bloglist-frontend // vai para o repositório clonado +rm -rf .git +``` + +A aplicação é iniciada da maneira usual, mas você deve instalar suas dependências primeiro: + +```bash +npm install +npm start +``` + +Implemente a funcionalidade de login no frontend. O token retornado com um login bem-sucedido é salvo no estado da aplicação user. + +se um usuário não estiver logado, apenas o formulário de login pode ser visto. + +![navegador mostrando apenas o formulário de login visível](../../images/5/4e.png) + +Se um usuário estiver logado, o nome do usuário e uma lista de blogs são exibidos. + +![navegador mostrando notas e quem está logado](../../images/5/5e.png) + +Detalhes do usuário logado não precisam ser salvos no local storage ainda. + +**Obs.** Você pode implementar a renderização condicional do formulário de login da seguinte maneira, por exemplo: + +```js + if (user === null) { + return ( +
    +

    Log in to application

    +
    + //... +
    +
    + ) + } + + return ( +
    +

    blogs

    + {blogs.map(blog => + + )} +
    + ) +} +``` + +### 5.2: frontend do Blog List Passo 2 + + +Torne o login 'permanente' usando o local storage. Além disso, implemente uma maneira de deslogar. + +![navegador mostrando o botão de logout após o login](../../images/5/6e.png) + +Assegure-se de que o navegador não lembre os detalhes do usuário após o logout. + +#### 5.3: frontend do Blog List Passo 3 + +Expanda sua aplicação para permitir que um usuário logado adicione novos blogs: + +![navegador mostrando o formulário do novo blog](../../images/5/7e.png) + +#### 5.4: frontend do Blog List Passo 4 + +Implemente notificações que informem o usuário sobre operações bem-sucedidas e mal-sucedidas no topo da página. Por exemplo, quando um novo blog é adicionado, a seguinte notificação pode ser exibida: + +![navegador mostrando a operação bem-sucedida](../../images/5/8e.png) + +Um login mal-sucedido pode mostrar a seguinte notificação: + +![navegador mostrando a tentativa de login mal-sucedida](../../images/5/9e.png) + +As notificações devem ser visíveis por alguns segundos. Não é obrigatório adicionar cores. + +
    + +
    + +### Uma nota sobre o uso do loca storagel + + +No [fim](/ptbr/part4/autenticacao_por_token#problemas-da-autenticacao-baseada-em-token) da última parte, nós mencionamos que o desafio da autenticação baseada em token é como lidar com a situação em que o acesso da API do titular do token à API precisa ser revogado. + +Existem duas soluções para o problema. A primeira é limitar o período de validade de um token. Isso obriga o usuário a fazer login novamente no aplicativo assim que o token expirar. A outra abordagem é salvar as informações de validade de cada token no banco de dados do backend. Essa solução geralmente é chamada de server-side session. + +Não importa como a validade dos tokens é verificada e garantida, salvar um token no local storage pode conter um risco de segurança se o aplicativo tiver uma vulnerabilidade de segurança que permita ataques [Cross Site Scripting (XSS)](https://owasp.org/www-community/attacks/xss/). Um ataque XSS é possível se o aplicativo permitir que um usuário injete código JavaScript arbitrário (por exemplo, usando um formulário) que o aplicativo então execute. Ao usar o React com sensatez, não deve ser possível aplicar esse ataque, pois o [React sanitiza](https://pt-br.reactjs.org/docs/introducing-jsx.html#jsx-prevents-injection-attacks) todo o texto que ele renderiza, o que significa que não está executando o conteúdo renderizado como JavaScript. + +Se você quiser mais segurança, a melhor opção é não armazenar um token no local storage. Essa pode ser uma opção em situações em que vazar um token pode ter consequências trágicas. + +Foi sugerido que a identidade de um usuário logado deve ser salva como [cookies httpOnly](https://developer.mozilla.org/pt-BR/docs/Web/HTTP/Cookies#restrict_access_to_cookies), para que o código JavaScript não tenha acesso ao token. A desvantagem dessa solução é que tornaria a implementação de aplicativos SPA um pouco mais complexa. Seria necessário implementar pelo menos uma página separada para fazer login. + + +No entanto, é bom notar que mesmo o uso de cookies httpOnly não garante nada. Até mesmo foi sugerido que os cookies httpOnly [não são mais seguros do que](https://academind.com/tutorials/localstorage-vs-cookies-xss/) o uso do local storage. + +Então não importa a solução usada, o mais importante é [minimizar o risco](https://cheatsheetseries.owasp.org/cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.html) de ataques XSS. + +
    diff --git a/src/content/5/ptbr/part5b.md b/src/content/5/ptbr/part5b.md new file mode 100644 index 00000000000..12dd5fb927e --- /dev/null +++ b/src/content/5/ptbr/part5b.md @@ -0,0 +1,869 @@ +--- +mainImage: ../../../images/part-5.svg +part: 5 +letter: b +lang: ptbr +--- + +
    + +### Mostrando o formulário de login apenas quando apropriado + +Vamos modificar a aplicação para que o formulário de login não seja exibido por padrão: + +![navegador mostrando o botão de login por padrão](../../images/5/10e.png) + +O formulário de login aparece quando o usuário pressiona o botão login: + +![usuário na tela de login prestes a apertar o botão cancelar](../../images/5/11e.png) + +O usuário pode fechar o formulário de login clicando no botão cancelar. + +Vamos começar extraindo o formulário de login para um componente próprio: + +```js +const LoginForm = ({ + handleSubmit, + handleUsernameChange, + handlePasswordChange, + username, + password + }) => { + return ( +
    +

    Login

    + +
    +
    + username + +
    +
    + password + +
    + +
    +
    + ) +} + +export default LoginForm +``` + +O estado e todas as funções relacionadas a ele são definidos fora do componente e são passados para o componente por meio de props. + +Perceba que as props são atribuídas a variáveis ​​através de destructuring, o que significa que, em vez de escrever: + +```js +const LoginForm = (props) => { + return ( +
    +

    Login

    +
    +
    + username + +
    + // ... + +
    +
    + ) +} +``` + +Onde as propriedades do objeto _props_ são acessadas por meio de, por exemplo, _props.handleSubmit_, as propriedades são atribuídas diretamente às suas próprias variáveis. + +Uma forma rápida de implementar a funcionalidade é alterar a função _loginForm_ do componente App da seguinte maneira: + +```js +const App = () => { + const [loginVisible, setLoginVisible] = useState(false) // highlight-line + + // ... + + const loginForm = () => { + const hideWhenVisible = { display: loginVisible ? 'none' : '' } + const showWhenVisible = { display: loginVisible ? '' : 'none' } + + return ( +
    +
    + +
    +
    + setUsername(target.value)} + handlePasswordChange={({ target }) => setPassword(target.value)} + handleSubmit={handleLogin} + /> + +
    +
    + ) + } + + // ... +} +``` + +O estado do componente App agora contém o boolean loginVisible, que define se o formulário de login deve ser exibido ao usuário ou não. + +O valor de _loginVisible_ é alternado com dois botões. Ambos os botões têm seus gerenciadores de eventos definidos diretamente no componente: + +```js + + + +``` + +A visibilidade do componente é definida atribuindo uma regra de estilo [inline](/ptbr/part2/adicionando_estilos_a_aplicacao_react#estilos-inline), onde o valor da propriedade [display](https://developer.mozilla.org/pt-BR/docs/Web/CSS/display) é none se não quisermos que o componente seja exibido: + +```js +const hideWhenVisible = { display: loginVisible ? 'none' : '' } +const showWhenVisible = { display: loginVisible ? '' : 'none' } + +
    + // button +
    + +
    + // button +
    +``` + +Nós estamos usando novamente o operador ternário "ponto de interrogação". Se _loginVisible_ for true, então a regra CSS do componente será: + +```css +display: 'none'; +``` + +Se _loginVisible_ for false, então display não receberá nenhum valor relacionado à visibilidade do componente. + +### Os componentes filhos, conhecidos como props.children + +O código relacionado ao gerenciamento da visibilidade do formulário de login poderia ser considerado uma entidade lógica própria, e por esse motivo, seria bom extrai-lo do componente App para um componente separado. + +Nosso objetivo é implementar um novo componente Togglable que possa ser usado da seguinte maneira: + +```js + + setUsername(target.value)} + handlePasswordChange={({ target }) => setPassword(target.value)} + handleSubmit={handleLogin} + /> + +``` + +A maneira como o componente é usado é ligeiramente diferente dos nossos componentes anteriores. O componente tem tags de abertura e fechamento que cercam um componente LoginForm. Na terminologia React, LoginForm é um componente filho de Togglable. + +Nós podemos adicionar qualquer elemento React que quisermos entre as tags de abertura e fechamento de Togglable, como este, por exemplo: + +```js + +

    this line is at start hidden

    +

    also this is hidden

    +
    +``` + +O código do componente Togglable é mostrado abaixo: + +```js +import { useState } from 'react' + +const Togglable = (props) => { + const [visible, setVisible] = useState(false) + + const hideWhenVisible = { display: visible ? 'none' : '' } + const showWhenVisible = { display: visible ? '' : 'none' } + + const toggleVisibility = () => { + setVisible(!visible) + } + + return ( +
    +
    + +
    +
    + {props.children} + +
    +
    + ) +} + +export default Togglable +``` + +A nova e interessante parte do código é a [props.children](https://pt-br.reactjs.org/docs/glossary.html#propschildren), que é usada para referenciar os componentes filhos do componente. Os componentes filhos são os elementos React que definimos entre as tags de abertura e fechamento de um componente. + + +Dessa vez, os children são renderizados no código que é usado para renderizar o próprio componente: + +```js +
    + {props.children} + +
    +``` + +Diferente das props "normais" que vimos antes, children é adicionada automaticamente pelo React e sempre existe na aplicação. Se um componente é definido com uma tag de fechamento automático _/>_, como este: + +```js + toggleImportanceOf(note.id)} +/> +``` + +Então props.children é um array vazio. + +O componente Togglable é reutilizável e podemos usá-lo para adicionar funcionalidade semelhante de alternância de visibilidade ao formulário usado para criar novas anotações. + +Antes de fazermos isso, vamos extrair o formulário para criar notas em um componente: + +```js +const NoteForm = ({ onSubmit, handleChange, value}) => { + return ( +
    +

    Create a new note

    + +
    + + +
    +
    + ) +} +``` + +Depois vamos definir o componente do formulário dentro de um componente Togglable: + + +```js + + + +``` + +Você pode encontrar o código completo da nossa aplicação atual no branch part5-4 [deste repositório do GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part5-4). + +### Estado dos formulários + +O estado da aplicação atualmente está no componente _App_. + +A documentação do React diz o [seguinte](https://pt-br.reactjs.org/docs/lifting-state-up.html) sobre onde colocar o estado: + +Muitas vezes, vários componentes precisam refletir os mesmos dados em mudança. Recomendamos levantar o estado compartilhado até o ancestral comum mais próximo. + +Se pensarmos no estado dos formulários, como por exemplo o conteúdo de uma nova nota antes que ela tenha sido criada, o componente _App_ não precisa dele para nada. +Nós poderíamos simplesmente mover o estado dos formulários para os componentes correspondentes. + +O componente para uma nota muda da seguinte maneira: + +```js +import { useState } from 'react' + +const NoteForm = ({ createNote }) => { + const [newNote, setNewNote] = useState('') + + const addNote = (event) => { + event.preventDefault() + createNote({ + content: newNote, + important: true + }) + + setNewNote('') + } + + return ( +
    +

    Create a new note

    + +
    + setNewNote(event.target.value)} + /> + +
    +
    + ) +} + +export default NoteForm +``` + +**NOTA** Ao mesmo tempo, mudamos o comportamento da aplicação para que as novas notas sejam importantes por padrão, ou seja, o campo important recebe o valor true. + +O atributo de estado newNote e o gerenciador de eventos responsável por alterá-lo foram movidos do componente _App_ para o componente responsável pelo formulário de notas. + +Há apenas uma prop restante, a função _createNote_, que o formulário chama quando uma nova nota é criada. + +O componente _App_ fica mais simples agora que nós nos livramos do estado newNote e do seu gerenciador de eventos. +A função _addNote_ para criar novas notas recebe uma nova nota como parâmetro, e a função é a única prop que enviamos para o formulário: + +```js +const App = () => { + // ... + const addNote = (noteObject) => { // highlight-line + noteService + .create(noteObject) + .then(returnedNote => { + setNotes(notes.concat(returnedNote)) + }) + } + // ... + const noteForm = () => ( + + + + ) + + // ... +} +``` + +Nós poderíamos fazer o mesmo para o formulário de login, mas vamos deixar isso para um exercício opcional. + + +O código da aplicação pode ser encontrado no [GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part5-5), +branch part5-5. + +### Referências para componentes com ref + +Nossa implementação atual é muito boa; ela tem um aspecto que poderia ser melhorado. + +Depois que uma nova nota é criada, faria sentido esconder o formulário de nova nota. Atualmente, o formulário permanece visível. Há um pequeno problema em ocultar o formulário. A visibilidade é controlada com a variável visible dentro do componente Togglable. Como podemos acessá-la fora do componente? + +Há muitas maneiras de implementar o fechamento do formulário a partir do componente pai, mas vamos usar o mecanismo [ref](https://pt-br.reactjs.org/docs/refs-and-the-dom.html) + +Vamos fazer as seguintes alterações no componente App: + +```js +import { useState, useEffect, useRef } from 'react' // highlight-line + +const App = () => { + // ... + const noteFormRef = useRef() // highlight-line + + const noteForm = () => ( + // highlight-line + + + ) + + // ... +} +``` + +O hook [useRef](https://pt-br.reactjs.org/docs/hooks-reference.html#useref) é usado para criar uma referência noteFormRef, que é atribuída ao componente Togglable que contém o formulário de criação de notas. A variável noteFormRef atua como uma referência ao componente. Este hook garante a mesma referência (ref) que é mantida durante as re-renderizações do componente. + +Nós também fazemos as seguintes alterações no componente Togglable: + +```js +import { useState, forwardRef, useImperativeHandle } from 'react' // highlight-line + +const Togglable = forwardRef((props, refs) => { // highlight-line + const [visible, setVisible] = useState(false) + + const hideWhenVisible = { display: visible ? 'none' : '' } + const showWhenVisible = { display: visible ? '' : 'none' } + + const toggleVisibility = () => { + setVisible(!visible) + } + +// highlight-start + useImperativeHandle(refs, () => { + return { + toggleVisibility + } + }) +// highlight-end + + return ( +
    +
    + +
    +
    + {props.children} + +
    +
    + ) +}) // highlight-line + +export default Togglable +``` + +A função que cria o componente é envolvida dentro de uma chamada de função [forwardRef](https://pt-br.reactjs.org/docs/react-api.html#reactforwardref). Desta forma, o componente pode acessar a referência (ref) que é atribuída a ele. + +O componente usa o hook [useImperativeHandle](https://pt-br.reactjs.org/docs/hooks-reference.html#useref) para tornar a função toggleVisibility disponível fora do componente. + +Nós podemos agora ocultar o formulário chamando noteFormRef.current.toggleVisibility() após a criação de uma nova nota: + +```js +const App = () => { + // ... + const addNote = (noteObject) => { + noteFormRef.current.toggleVisibility() // highlight-line + noteService + .create(noteObject) + .then(returnedNote => { + setNotes(notes.concat(returnedNote)) + }) + } + // ... +} +``` + +Para recapitular, a função [useImperativeHandle](https://pt-br.reactjs.org/docs/hooks-reference.html#useimperativehandle) é um hook do React, que é usado para definir funções em um componente, que podem ser invocadas de fora do componente. + +Esse truque funciona para alterar o estado de um componente, mas parece um pouco desagradável. Poderíamos ter conseguido a mesma funcionalidade com um código um pouco mais limpo usando componentes baseados em classe do "antigo React". Vamos dar uma olhada nesses componentes de classe durante a parte 7 do material do curso. Até agora, esta é a única situação em que o uso de hooks do React leva a um código que não é mais limpo do que com componentes de classe. + +Existem também [outros casos de uso](https://pt-br.reactjs.org/docs/refs-and-the-dom.html) para referências além de acessar componentes React. + +Você pode encontrar o código para nossa aplicação atual em sua totalidade na branch part5-6 [deste repositório do GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part5-6). + +### Um ponto sobre componentes + +Quando definimos um componente no React: + +```js +const Togglable = () => ... + // ... +} +``` + +E o usamos assim: + +```js +
    + + first + + + + second + + + + third + +
    +``` + +Nós criamos três instâncias separadas do componente que todas têm seu próprio estado: + +![navegador com três componentes togglable](../../images/5/12e.png) + +O atributo ref é usado para atribuir uma referência a cada um dos componentes nas variáveis togglable1, togglable2 e togglable3. + +### O juramento atualizado do desenvolvedor full stack + +O número de partes móveis aumenta. Ao mesmo tempo, a probabilidade de acabar em uma situação em que estamos procurando um bug no lugar errado aumenta. Portanto, precisamos ser ainda mais sistemáticos. + +Então, devemos estender novamente nosso juramento: + +Desenvolvimento Full Stack é extremamente difícil, por isso usarei todos os meios possíveis para torná-lo mais fácil + +- Eu irei ter o meu console de desenvolvedor do navegador aberto o tempo todo +- Eu irei usar a aba "rede" das ferramentas de desenvolvedor do navegador para garantir que o frontend e o backend estejam se comunicando como eu espero +- Eu irei constantemente vigiar o estado do servidor para assegurar que os dados enviados lá pelo frontend sejam salvos como eu espero +- Eu irei ficar de olho no banco de dados: os dados salvos lá pelo backend estão no formato correto? +- Eu avanço com pequenos passos +- Quando eu suspeito que há um bug no frontend, eu me certifico de que o backend funciona corretamente +- Quando eu suspeito que há um bug no backend, eu me certifico de que o frontend funciona corretamente +- Eu irei escrever vários _console.log_ para me certificar de que eu entendo como o código e os testes se comportam e para ajudar a localizar problemas +- Se meu código não funciona, eu não escreverei mais código. Em vez disso, eu começo a apagar o código até que ele funcione ou apenas volte para um estado em que tudo ainda estava funcionando +- Se um teste não passa, eu me certifico de que a funcionalidade testada funciona com certeza na aplicação +- Quando eu peço ajuda no canal do Discord do curso ou em outro lugar, eu formulo minhas perguntas corretamente, veja [aqui](/ptbr/part0/informacoes_gerais#como-pedir-ajuda-no-discord) como pedir ajuda + +
    + +
    Togglable definido na parte 5. + +Por padrão o formulário não é visível + +![navegador mostrando botão de nova nota com nenhum formulário](../../images/5/13ae.png) + +Ele se expande quando o botão create new blog é clicado + +![navegador mostrando formulário com create new](../../images/5/13be.png) + +O formulário fecha quando um novo blog é criado. + +#### 5.6 Frontend da lista de blogs, passo 6 + +Separe o formulário para criar um novo blog em seu próprio componente (se você ainda não o fez) e mova todos os estados necessários para criar um novo blog para este componente. + +O componente deve funcionar como o componente NoteForm do [material](/ptbr/part5/props_children_e_proptypes) desta parte. + +#### 5.7 Frontend da lista de blogs, passo 7 + +Vamos adicionar um botão a cada blog, que controla se todos os detalhes sobre o blog são mostrados ou não. + +Detalhes completos do blog abrem quando o botão é clicado. + +![navegador mostrando detalhes completos de um blog com outros tendo apenas botões de visualização](../../images/5/13ea.png) + +E os detalhes são escondidos quando o botão é clicado novamente. + +Neste ponto, o botão like não precisa fazer nada. + +A aplicação mostrada na imagem tem um pouco de CSS adicional para melhorar sua aparência. + +É fácil adicionar estilos à aplicação como mostrado na parte 2 usando [estilos inline](/ptbr/part2/adicionando_estilos_a_aplicacao_react#estilos-inline): + +```js +const Blog = ({ blog }) => { + const blogStyle = { + paddingTop: 10, + paddingLeft: 2, + border: 'solid', + borderWidth: 1, + marginBottom: 5 + } + + return ( +
    // highlight-line +
    + {blog.title} {blog.author} +
    + // ... +
    +)} +``` + +**Obs.:** mesmo que a funcionalidade implementada nesta parte seja quase idêntica à funcionalidade fornecida pelo componente Togglable, o componente não pode ser usado diretamente para obter o comportamento desejado. A solução mais fácil será adicionar um estado ao post do blog que controla o formulário exibido do post do blog. + +#### 5.8: Frontend da lista de blogs, passo 8 + +Nós notamos que algo está errado. Quando um novo blog é criado na aplicação, o nome do usuário que adicionou o blog não é mostrado nos detalhes do blog: + +![navegador mostrando nome faltando abaixo do botão like](../../images/5/59new.png) + +Quando o navegador é recarregado, as informações da pessoa são exibidas. Isso não é aceitável, descubra onde está o problema e faça a correção necessária. + +#### 5.9: Frontend da lista de blogs, passo 9 + +Implemente a funcionalidade para o botão like. Os likes são incrementados fazendo uma requisição HTTP _PUT_ para o endereço único do post do blog no backend. + +Como a operação do backend substitui todo o post do blog, você terá que enviar todos os seus campos no corpo da requisição. Se você quisesse acicionar um like ao seguinte post do blog: + +```js +{ + _id: "5a43fde2cbd20b12a2c34e91", + user: { + _id: "5a43e6b6c37f3d065eaaa581", + username: "mluukkai", + name: "Matti Luukkainen" + }, + likes: 0, + author: "Joel Spolsky", + title: "The Joel Test: 12 Steps to Better Code", + url: "https://www.joelonsoftware.com/2000/08/09/the-joel-test-12-steps-to-better-code/" +}, +``` + +Você teria que fazer uma requisição HTTP PUT para o endereço /api/blogs/5a43fde2cbd20b12a2c34e91 com a seguinte requisição de dados: + +```js +{ + user: "5a43e6b6c37f3d065eaaa581", + likes: 1, + author: "Joel Spolsky", + title: "The Joel Test: 12 Steps to Better Code", + url: "https://www.joelonsoftware.com/2000/08/09/the-joel-test-12-steps-to-better-code/" +} +``` + +O backend também tem que ser atualizado para lidar com a referência do usuário. + +**Um último aviso:** se você notar que está usando async/await e o método _then_ no mesmo código, é quase certo que você está fazendo algo errado. Fique com um ou outro, e nunca use os dois ao mesmo tempo "só por precaução". + +#### 5.10: Frontend da lista de blogs, passo 10 + +Modifique a aplicação para listar os posts do blog pelo número de likes. Ordenar os posts do blog pode ser feito com o método [sort](https://developer.mozilla.org/pt-BR/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) do array. + +#### 5.11: Frontend da lista de blogs, passo 11 + +Adicione um novo botão para deletar posts do blog. Além disso, implemente a lógica para deletar posts do blog no frontend. + +Sua aplicação pode parecer algo como isso: + +![navegador mostrando confirmação de remoção de blog](../../images/5/14ea.png) + +O diálogo de confirmação para deletar um post do blog é fácil de implementar com a função [window.confirm](https://developer.mozilla.org/pt-BR/docs/Web/API/Window/confirm). + +Exiba o botão para deletar um post do blog apenas se o post do blog foi adicionado pelo usuário. + +
    + +
    + +### PropTypes + +O componente Togglable assume que ele recebe o texto para o botão via a prop buttonLabel. Se esquecermos de defini-lo para o componente: + +```js + buttonLabel forgotten... +``` + +A aplicação funciona, mas o navegador renderiza um botão que não tem texto de label. + +Nós gostaríamos de impor que quando o componente Togglable é usado, o texto do label do botão deve ter um valor atribuído a ele. + +As props esperadas e obrigatórias de um componente podem ser definidas com o pacote [prop-types](https://github.com/facebook/prop-types). Vamos instalar o pacote: + +```shell +npm install prop-types +``` + +Nós podemos definir a prop buttonLabel como uma prop do tipo string obrigatória como mostrado abaixo: + +```js +import PropTypes from 'prop-types' + +const Togglable = React.forwardRef((props, ref) => { + // .. +}) + +Togglable.propTypes = { + buttonLabel: PropTypes.string.isRequired +} +``` + +O console irá exibir a seguinte mensagem de erro se a prop for deixada indefinida: + +![erro no console dizendo que buttonLabel está indefinido](../../images/5/15.png) + +A aplicação ainda funciona e nada nos força a definir props apesar das definições de PropTypes. Tenha em mente que é extremamente não profissional deixar qualquer saída vermelha no console do navegador. + +Vamos também definir PropTypes para o componente LoginForm: + +```js +import PropTypes from 'prop-types' + +const LoginForm = ({ + handleSubmit, + handleUsernameChange, + handlePasswordChange, + username, + password + }) => { + // ... + } + +LoginForm.propTypes = { + handleSubmit: PropTypes.func.isRequired, + handleUsernameChange: PropTypes.func.isRequired, + handlePasswordChange: PropTypes.func.isRequired, + username: PropTypes.string.isRequired, + password: PropTypes.string.isRequired +} +``` + +Se o tipo de uma prop passada estiver errado, por exemplo, se tentarmos definir a prop handleSubmit como uma string, então isso resultará no seguinte aviso: + +![erro no console dizendo que handleSubmit esperava uma função](../../images/5/16.png) + +### ESlint + +Na parte 3 nós configuramos a ferramenta de estilo de código [ESlint](/ptbr/part3/validacao_e_eslint#lint) para o backend. Vamos usar o ESlint no frontend também. + +Create-react-app instalou o ESlint no projeto por padrão, então tudo que precisamos fazer é definir nossa configuração desejada no arquivo .eslintrc.js. + +*Obs.:* não execute o comando _eslint --init_. Ele irá instalar a última versão do ESlint que não é compatível com o arquivo de configuração criado pelo create-react-app! + +Depois, nós iremos começar a testar o frontend e para evitar erros de linter indesejados e irrelevantes nós iremos instalar o pacote [eslint-plugin-jest](https://www.npmjs.com/package/eslint-plugin-jest): + +```bash +npm install --save-dev eslint-plugin-jest +``` + +Vamos criar um arquivo .eslintrc.js com o seguinte conteúdo: + +```js +/* eslint-env node */ +module.exports = { + "env": { + "browser": true, + "es6": true, + "jest/globals": true + }, + "extends": [ + "eslint:recommended", + "plugin:react/recommended" + ], + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": 2018, + "sourceType": "module" + }, + "plugins": [ + "react", "jest" + ], + "rules": { + "indent": [ + "error", + 2 + ], + "linebreak-style": [ + "error", + "unix" + ], + "quotes": [ + "error", + "single" + ], + "semi": [ + "error", + "never" + ], + "eqeqeq": "error", + "no-trailing-spaces": "error", + "object-curly-spacing": [ + "error", "always" + ], + "arrow-spacing": [ + "error", { "before": true, "after": true } + ], + "no-console": 0, + "react/prop-types": 0, + "react/react-in-jsx-scope": "off" + }, + "settings": { + "react": { + "version": "detect" + } + } +} +``` + +NOTA: Se estiver usando o Visual Studio Code junto com o plugin ESLint, você pode precisar adicionar uma configuração de workspace para que ele funcione. Se você estiver vendo ```Failed to load plugin react: Cannot find module 'eslint-plugin-react'```, uma configuração adicional é necessária. Adicionando a linha ```"eslint.workingDirectories": [{ "mode": "auto" }]``` em settings.json no workspace parece funcionar. Veja [aqui](https://github.com/microsoft/vscode-eslint/issues/880#issuecomment-578052807) para mais informações. + +Vamos criar um arquivo [.eslintignore](https://eslint.org/docs/user-guide/configuring#ignoring-files-and-directories) com o seguinte conteúdo na raiz do repositório: + +```bash +node_modules +build +.eslintrc.js +``` + +Agora os diretórios build e node_modules serão ignorados quando o lint for executado. + +Vamos também criar um script npm para executar o lint: + +```js +{ + // ... + { + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "eslint": "eslint ." // highlight-line + }, + // ... +} +``` + +```js +{ + // ... + { + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "eslint": "eslint ." // highlight-line + }, + // ... +} +``` + +O componente _Togglable_ causa um aviso desagradável Component definition is missing display name: + +![vscode mostrando erro de definição de componente](../../images/5/25x.png) + +O react-devtools também revela que o componente não tem um nome: + +![react devtools mostrando forwardRef como anônimo](../../images/5/26ea.png) + +Felizmente, isso é fácil de consertar + +```js +import { useState, useImperativeHandle } from 'react' +import PropTypes from 'prop-types' + +const Togglable = React.forwardRef((props, ref) => { + // ... +}) + +Togglable.displayName = 'Togglable' // highlight-line + +export default Togglable +``` + +Você pode encontrar o código para nossa aplicação atual na sua totalidade na branch part5-7 [desse repositório do GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part5-7). + + + +Note que o create-react-app também tem uma [configuração padrão do ESLint](https://www.npmjs.com/package/eslint-config-react-app), que nós agora substituímos. [A documentação](https://create-react-app.dev/docs/setting-up-your-editor/#extending-or-replacing-the-default-eslint-config) menciona que é ok substituir o padrão, mas não nos encoraja a fazê-lo: Nós altamente recomendamos estender a configuração base, pois removê-la pode introduzir problemas difíceis de encontrar. + +
    + +
    + +### Exercício 5.12. + +#### 5.12: Frontend da lista de blogs, passo 12 + +Defina PropTypes para um dos componentes da sua aplicação e adicione o ESlint ao projeto. Defina a configuração de acordo com sua preferência. Corrija todos os erros do linter. + +Create-react-app instalou o ESlint no projeto por padrão, então tudo que precisamos fazer é definir nossa configuração desejada no arquivo .eslintrc.js. + +*Obs.:* não execute o comando _eslint --init_. Ele irá instalar a última versão do ESlint que não é compatível com o arquivo de configuração criado pelo create-react-app! + +
    diff --git a/src/content/5/ptbr/part5c.md b/src/content/5/ptbr/part5c.md new file mode 100644 index 00000000000..de1802fb516 --- /dev/null +++ b/src/content/5/ptbr/part5c.md @@ -0,0 +1,770 @@ +--- +mainImage: ../../../images/part-5.svg +part: 5 +letter: c +lang: ptbr + +--- + +
    + +Existem algumas formas de testar aplicações React. Vamos dar uma olhada em algumas delas a frente. + +Testes serão implementados com a mesma [Jest](http://jestjs.io/) biblioteca de teste desenvolvida pelo Facebook que foi usada na parte anterior. Jest é configurado por padrão para aplicações criadas com o create-react-app. + +Além do JEST, também precisamos de outra biblioteca de testes que nos ajude a renderizar componentes para fins de teste. A melhor opção atual para isso é [react-test-library](https://github.com/testing-library/react-testing-library) que sofreu um rápido crescimento de popularidade nos últimos tempos. + +Vamos instalar a biblioteca com o comando: + +```bash +npm install --save-dev @testing-library/react @testing-library/jest-dom +``` + +Também instalamos [jest-dom](https://testing-library.com/docs/ecosystem-jest-dom/), que fornece alguns métodos auxiliares relacionados a Jest. + +Vamos primeiro escrever testes para o componente responsável por renderizar uma nota: + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' + : 'make important' + + return ( +
  • // highlight-line + {note.content} + +
  • + ) +} +``` + +Observe que o elemento li possui o [CSS](https://reactjs.org/docs/dom-elements.html#classname) className note , que poderia ser usado para acessar o componente em nossos testes. + +### Renderizando o componente para testes + +Escreveremos nosso teste no arquivo src/components/note.test.js , que está no mesmo diretório que o próprio componente. + +O primeiro teste verifica que o componente renderiza o conteúdo da nota: + +```js +import React from 'react' +import '@testing-library/jest-dom/extend-expect' +import { render, screen } from '@testing-library/react' +import Note from './Note' + +test('renders content', () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + render() + + const element = screen.getByText('Component testing is done with react-testing-library') + expect(element).toBeDefined() +}) +``` + +Após a configuração inicial, o teste renderiza o componente com o [render](https://testing-library.com/docs/react-testing-library/api#render) que é uma função fornecida pela react-testing-library: + +```js +render() +``` + +Normalmente, os componentes React são renderizados no DOM . O método de renderização que usamos renderiza os componentes em um formato adequado para testes sem renderizá-los ao DOM. + +Podemos usar o objeto [screen](https://testing-library.com/docs/queries/about#screen) para acessar o componente renderizado. Usamos o método da Screen [getByText](https://testing-library.com/docs/queries/bytext) para procurar um elemento que tenha o conteúdo da nota e garantir que ele exista: + +```js + const element = screen.getByText('Component testing is done with react-testing-library') + expect(element).toBeDefined() +``` + +### Testes de execução + +Create-react-app configura os testes a serem executados no modo Watch por padrão, o que significa que o comando _npm test_ não terminará assim que os testes terminarem e, em vez disso, aguardará as alterações a serem feitas no código. Depois que novas alterações no código são salvas, os testes são executados automaticamente, depois disso Jest volta a aguardar que novas alterações sejam feitas. + +Se você deseja executar testes "normalmente", pode fazê-lo com o comando: + +```js +CI=true npm test +``` + +Para usuários do Windows (PowerShell) + +```js +$env:CI=$true; npm test +``` + +**Obs:** O console pode emitir um aviso se você não tiver instalado o Watchman. O Watchman é uma aplicação desenvolvida pelo Facebook que observa as alterações feitas nos arquivos. O programa acelera a execução dos testes e pelo menos a partir do MacOS Sierra, executando testes no modo watch emite alguns avisos no console, que podem ser removidos instalando o Watchman. + +As instruções para instalar o Watchman em diferentes sistemas operacionais podem ser encontradas no site oficial do Watchman: + +### Localização do arquivo de teste + +No React, existem (pelo menos) [duas convenções diferentes](https://medium.com/@jefflombardjr/organizing-tests-in-jest-17fc431ff850) para a localização do arquivo de teste. Criamos nossos arquivos de teste de acordo com o padrão atual, colocando -os no mesmo diretório que o componente que está sendo testado. + +A outra convenção é armazenar os arquivos de teste "normalmente" em um diretório _test_ separado. Qualquer que seja a convenção que escolhemos, é quase garantido que esteja errado de acordo com a opinião de alguém. + +Não gosto dessa maneira de armazenar testes e código de aplicações no mesmo diretório. O motivo pelo qual escolhemos seguir esta convenção é que ela é configurada por padrão em aplicações criados pelo Create-React-App. + +### Procurando conteúdo em um componente + +O pacote react-testing-library oferece muitas maneiras diferentes de investigar o conteúdo do componente que está sendo testado. Na realidade, o _expect_ em nosso teste não é necessário. + +```js +import React from 'react' +import '@testing-library/jest-dom/extend-expect' +import { render, screen } from '@testing-library/react' +import Note from './Note' + +test('renders content', () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + render() + + const element = screen.getByText('Component testing is done with react-testing-library') + + expect(element).toBeDefined() // highlight-line +}) +``` + +O teste falha se _getByText_ não encontrar o elemento que está procurando. + +Também poderíamos usar [CSS-selectors](https://developer.mozilla.org/pt-BR/docs/web/css/css_selectors) para encontrar elementos renderizados usando o método [queryselector](https: // desenvolvedor. mozilla.org/en-us/docs/web/api/document/queryselector) do objeto [container](https://testing-library.com/docs/react-testing-library/api/#container-1) que é um dos campos retornados pela renderização: + +```js +import React from 'react' +import '@testing-library/jest-dom/extend-expect' +import { render, screen } from '@testing-library/react' +import Note from './Note' + +test('renders content', () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + const { container } = render() // highlight-line + +// highlight-start + const div = container.querySelector('.note') + expect(div).toHaveTextContent( + 'Component testing is done with react-testing-library' + ) + // highlight-end +}) +``` + +Existem também outros métodos, por exemplo, [getByTestId](https://testing-library.com/docs/queries/bytestid/), que procuram elementos com base em id-attributes que são inseridos no código especificamente para fins de teste. + +### Testes de depuração + +Normalmente, encontramos muitos tipos diferentes de problemas ao escrever nossos testes. + +Objeto _screen_ possui método [debug](https://testing-library.com/docs/queries/about/#screendebug) que pode ser usado para imprimir o HTML de um componente para o terminal. Se alterarmos o teste da seguinte forma: + +```js +import React from 'react' +import '@testing-library/jest-dom/extend-expect' +import { render, screen } from '@testing-library/react' +import Note from './Note' + +test('renders content', () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + render() + + screen.debug() // highlight-line + + // ... + +}) +``` + +O HTML é impresso no console: + +```js +console.log + +
    +
  • + Component testing is done with react-testing-library + +
  • +
    + +``` + +Também é possível usar o mesmo método para imprimir um elemento procurado para consolar: + +```js +import React from 'react' +import '@testing-library/jest-dom/extend-expect' +import { render, screen } from '@testing-library/react' +import Note from './Note' + +test('renders content', () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + render() + + const element = screen.getByText('Component testing is done with react-testing-library') + + screen.debug(element) // highlight-line + + expect(element).toBeDefined() +}) +``` + +Agora o HTML do elemento procurado é impresso: + +```js +
  • + Component testing is done with react-testing-library + +
  • +``` + +### Botões de clique em testes + +Além de exibir conteúdo, o componente Nota também garante que, quando o botão associado à nota é pressionado, a função que manipula eventos (event handler) _toggleImportance_ é chamada. + +Vamos instalar uma biblioteca [user-event](https://testing-library.com/docs/user-event/intro) que facilita a simulação de entrada do usuário: + +```bash +npm install --save-dev @testing-library/user-event +``` + +Testando essa funcionalidade pode ser realizada assim: + +```js +import React from 'react' +import '@testing-library/jest-dom/extend-expect' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' // highlight-line +import Note from './Note' + +// ... + +test('clicking the button calls event handler once', async () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + const mockHandler = jest.fn() + + render( + + ) + + const user = userEvent.setup() + const button = screen.getByText('make not important') + await user.click(button) + + expect(mockHandler.mock.calls).toHaveLength(1) +}) +``` + +Existem algumas coisas interessantes relacionadas a este teste. O event handler é uma função [mock](https://jestjs.io/pt-BR/docs/mock-functions) definida com jest: + +```js +const mockHandler = jest.fn() +``` + +Uma [sessão](https://testing-library.com/docs/user-event/setup/) é iniciada para interagir com o componente renderizado: + +```js +const user = userEvent.setup() +``` + +O teste encontra o botão com base no texto do componente renderizado e clica no elemento: + +```js +const button = screen.getByText('make not important') +await user.click(button) +``` + +Clicar acontece com o método [click](https://testing-library.com/docs/user-event/convenience/#click) da biblioteca userevent-library. + +A expectativa do teste verifica que a função mock foi chamada exatamente uma vez. + +```js +expect(mockHandler.mock.calls).toHaveLength(1) +``` + +[Objetos e funções mock](https://pt.wikipedia.org/wiki/Objeto_mock) são componentes omumente usados nos testes para substituir as dependências dos componentes que estão sendo testados. Mocks possibilitam retornar respostas codificadas e verificar o número de vezes que as funções mocks são chamadas e com quais parâmetros. + +Em nosso exemplo, a função mock é uma escolha perfeita, desde que ela possa ser facilmente usada para verificar se o método é chamado exatamente uma vez. + +### Testes para o componente Togglable + +Vamos escrever alguns testes para o componente Togglable. Vamos adicionar o o nome de classe de css togglableContent ao DIV que retorna os componentes filhos. + +```js +const Togglable = forwardRef((props, ref) => { + // ... + + return ( +
    +
    + +
    +
    // highlight-line + {props.children} + +
    +
    + ) +}) +``` + +Os testes são mostrados abaixo: + +```js +import React from 'react' +import '@testing-library/jest-dom/extend-expect' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Togglable from './Togglable' + +describe('', () => { + let container + + beforeEach(() => { + container = render( + +
    + togglable content +
    +
    + ).container + }) + + test('renders its children', async () => { + await screen.findAllByText('togglable content') + }) + + test('at start the children are not displayed', () => { + const div = container.querySelector('.togglableContent') + expect(div).toHaveStyle('display: none') + }) + + test('after clicking the button, children are displayed', async () => { + const user = userEvent.setup() + const button = screen.getByText('show...') + await user.click(button) + + const div = container.querySelector('.togglableContent') + expect(div).not.toHaveStyle('display: none') + }) +}) +``` + +A função _beforeEach_ é chamada antes de cada teste, o que renderiza o componente Togglable e salva o campo _container_ do valor de retorno. + +O primeiro teste verifica que o componente Togglable renderiza seu componente filho + +```js +
    + conteúdo alternável +
    +``` + +Os testes restantes usam o método [toHaveStyle](https://www.npmjs.com/package/@testing-library/jest-dom#tohavestyle) para verificar se o componente filho do componente Togglable não é visível inicialmente, verificando se o estilo do elemento div contém _{ display: 'none' }_. Outro teste verifica que, quando o botão é pressionado, o componente é visível, o que significa que o estilo para ocultar o componente não é mais atribuído ao componente. + +Vamos também adicionar um teste que pode ser usado para verificar se o conteúdo visível pode ser oculto clicando no segundo botão do componente: + +```js +describe('', () => { + + // ... + + test('toggled content can be closed', async () => { + const user = userEvent.setup() + const button = screen.getByText('show...') + await user.click(button) + + const closeButton = screen.getByText('cancel') + await user.click(closeButton) + + const div = container.querySelector('.togglableContent') + expect(div).toHaveStyle('display: none') + }) +}) +``` + +### Testando os formulários + +Já usamos a função Click do [user-event](https://testing-library.com/docs/user-event/intro) em nossos testes anteriores para clicar em botões. + +```js +const user = userEvent.setup() +const button = screen.getByText('show...') +await user.click(button) +``` + +Também podemos simular a entrada de texto com userEvent. + +Vamos fazer um teste para o componente NoteForm. O código do componente é o seguinte. + +```js +import { useState } from 'react' + +const NoteForm = ({ createNote }) => { + const [newNote, setNewNote] = useState('') + + const handleChange = (event) => { + setNewNote(event.target.value) + } + + const addNote = (event) => { + event.preventDefault() + createNote({ + content: newNote, + important: Math.random() > 0.5, + }) + + setNewNote('') + } + + return ( +
    +

    Create a new note

    + +
    + + +
    +
    + ) +} + +export default NoteForm +``` + +O formulário funciona chamando a função _createNote_ que ele recebeu como adereços com os detalhes da nova nota. + +O teste é o seguinte: + +```js +import React from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom/extend-expect' +import NoteForm from './NoteForm' +import userEvent from '@testing-library/user-event' + +test(' updates parent state and calls onSubmit', async () => { + const createNote = jest.fn() + const user = userEvent.setup() + + render() + + const input = screen.getByRole('textbox') + const sendButton = screen.getByText('save') + + await user.type(input, 'testing a form...') + await user.click(sendButton) + + expect(createNote.mock.calls).toHaveLength(1) + expect(createNote.mock.calls[0][0].content).toBe('testing a form...') +}) +``` + +Os testes têm acesso ao campo de entrada usando a função [getByRole](https://testing-library.com/docs/queries/byrole). + +O método [type](https://testing-library.com/docs/user-event/utility#type) do userEvent é usado para escrever texto no campo de entrada. + +A primeira expectativa de teste garante que o envio do formulário chama o método _createNote_. +A segunda expectativa verifica, que o event handler é chamado com os parâmetros corretos - que uma nota com o conteúdo correta é criada quando o formulário é preenchido. + +### Sobre encontrar os elementos + +Vamos supor que o formulário tenha dois campos de entrada + +```js +const NoteForm = ({ createNote }) => { + // ... + + return ( +
    +

    Create a new note

    + +
    + + // highlight-start + + // highlight-end + +
    +
    + ) +} +``` + +Agora a abordagem que nosso teste usa para encontrar o campo de entrada + +```js +const input = screen.getByRole('textbox') +``` + +causaria um erro: + +![Erro do nó que mostra dois elementos com caixa de texto, já que usamos getByRole](../../images/5/40.png) + +A mensagem de erro sugere usar getAllByRole. O teste pode ser corrigido da seguinte maneira: + +```js +const inputs = screen.getAllByRole('textbox') + +await user.type(inputs[0], 'testing a form...') +``` + +Método getAllByRole agora retorna uma matriz e o campo de entrada correto é o primeiro elemento da matriz. No entanto, essa abordagem é um pouco suspeita, pois se baseia na ordem dos campos de entrada. + +Muitas vezes, os campos de entrada têm um placeholder que sugere o usuário que tipo de entrada é esperada. Vamos adicionar um espaço reservado ao nosso formulário: + +```js +const NoteForm = ({ createNote }) => { + // ... + + return ( +
    +

    Create a new note

    + +
    + + + +
    +
    + ) +} +``` + +Agora, encontrar o campo de entrada certo é fácil com o método [getByPlaceholderText](https://testing-library.com/docs/queries/byplaceholdertext): + +```js +test(' updates parent state and calls onSubmit', () => { + const createNote = jest.fn() + + render() + + const input = screen.getByPlaceholderText('write here note content') // highlight-line + const sendButton = screen.getByText('save') + + userEvent.type(input, 'testing a form...') + userEvent.click(sendButton) + + expect(createNote.mock.calls).toHaveLength(1) + expect(createNote.mock.calls[0][0].content).toBe('testing a form...') +}) +``` + +A maneira mais flexível de encontrar elementos nos testes é o método querySelector do objeto _container_, que é retornado por _render_, como foi mencionado [anteriormente nesta parte](/ptbr/part5/testando_aplicacoes_react#procurando-por-conteudo-em-um-componente). Qualquer seletor de CSS pode ser usado com esse método para pesquisar elementos nos testes. + +Considere por exemplo. que definiríamos um _id _id único para o campo de entrada: + +```js +const NoteForm = ({ createNote }) => { + // ... + + return ( +
    +

    Create a new note

    + +
    + + + +
    +
    + ) +} +``` + +O elemento input agora pode ser encontrado no teste da seguinte forma: + +```js +const { container } = render() + +const input = container.querySelector('#note-input') +``` + +No entanto, seguiremos a abordagem de usar _getByPlaceholderText_ no teste. + +Vejamos alguns detalhes antes de seguir em frente. Vamos supor que um componente renderia o texto para um elemento HTML da seguinte maneira: + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' : 'make important' + + return ( +
  • + Your awesome note: {note.content} // highlight-line + +
  • + ) +} + +export default Note +``` + +O comando _getByText_ que o teste usa faz não Encontre o elemento + +```js +test('renders content', () => { + const note = { + content: 'Does not work anymore :(', + important: true + } + + render() + + const element = screen.getByText('Does not work anymore :(') + + expect(element).toBeDefined() +}) +``` + +Command _getByText_ procura um elemento que tenha o **mesmo texto** que possui como parâmetro e nada mais. Se quisermos procurar um elemento que contém o texto, poderíamos usar uma opção extra: + +```js +const element = screen.getByText( + 'Does not work anymore :(', { exact: false } +) +``` + +ou poderíamos usar o comando _findByText_: + +```js +const element = await screen.findByText('Does not work anymore :(') +``` + +É importante notar que, diferentemente dos outros comandos _ByText_, _findByText_ retorna uma promessa! + +Existem situações em que mais uma forma do comando _queryByText_ é útil. O comando retorna o elemento, mas não causa uma exceção se o elemento não for encontrado. + +Nós poderíamos por exemplo. Use o comando para garantir que algo não seja renderizado ao componente: + +```js +test('does not render this', () => { + const note = { + content: 'This is a reminder', + important: true + } + + render() + + const element = screen.queryByText('do not want this thing to be rendered') + expect(element).toBeNull() +}) +``` + +### Cobertura de teste + +Podemos descobrir facilmente a [cobertura](https://github.com/facebookincubator/create-react-app/blob/ed5c48c81b2139b4414810e1efe917e04c96ee8d/packages/react-scripts/template/README.md#coverage-reporting) de nossos testes executando-os com o comando. + +```js +CI=true npm test -- --coverage +``` + +![saída do terminal da cobertura de teste](../../images/5/18ea.png) + +Um relatório HTML bastante primitivo será gerado para o diretório coverage/lcov-report. +O relatório nos dirá as linhas de código não testado em cada componente: + +![Relatório HTML da cobertura do teste](../../images/5/19ea.png) + +Você pode encontrar o código para nossa aplicação atual na íntegra em part5-8 deste [repositório do github](https://github.com/fullstack-hy2020/part2-notes/tree/parte5-8). +
    + +
    + +### Exercícios 5.13.-5.16. + +#### 5.13: Testes da lista de blogs, Etapa1 + +Faça um teste, que verifica se o componente que exibe um blog renderiza o título e o autor do blog, mas não renderiza sua URL ou número de curtidas por padrão. + +Adicione as classes CSS ao componente para ajudar o teste conforme necessário. + +#### 5.14: Testes da lista de blogs, Etapa2 + +Faça um teste, que verifica se a URL do blog e o número de curtidas são mostrados quando o botão que controla os detalhes mostrado foi clicado. + +#### 5.15: Testes da lista de blogs, Etapa3 + +Faça um teste, que garante que, se o botão como foi clicado duas vezes, o componente event handlerrecebido é chamado duas vezes. + +#### 5.16: Testes da lista de blogs, Etapa4 + +Faça um teste para o novo formulário do blog. O teste deve verificar se o formulário chama o event handler que recebeu como parâmetro com os detalhes certos quando um novo blog for criado. + +
    + +
    + +### Testes de integração de front -end + +Na parte anterior do material do curso, escrevemos testes de integração para o back-end que testou sua lógica e conectou o banco de dados através da API fornecida pelo back-end. Ao escrever esses testes, tomamos a decisão consciente de não escrever testes de unidade, pois o código para esse back-end é bastante simples, e é provável que os bugs em nossa aplicação ocorram em cenários mais complicados do que os testes de unidade adequados. + +Até agora, todos os nossos testes para o frontend foram testes de unidade que validaram o funcionamento correto de componentes individuais. Às vezes, o teste de unidade é útil, mas mesmo um conjunto abrangente de testes de unidade não é suficiente para validar que o aplicação funciona como um todo. + +Também poderíamos fazer testes de integração para o front-end. Testes de integração testa a colaboração de vários componentes. É consideravelmente mais difícil do que os testes de unidade, pois teríamos que, por exemplo, por exemplo mockar dados do servidor. +Optamos por nos concentrar em fazer testes de ponta a ponta para testar todo a aplicação. Trabalharemos nos testes de ponta a ponta no último capítulo desta parte. + +### Teste de Snapshot + +O JEST oferece uma alternativa completamente diferente aos chamados testes "tradicionais" [snapshot](https://jestjs.io/pt-BR/docs/snapshot-testing). A característica interessante dos snapshots é que os desenvolvedores não precisam definir nenhum teste, é simples o suficiente para adotar testes snapshot. + +O princípio fundamental é comparar o código HTML definido pelo componente depois de alterar para o código HTML que existia antes de ser alterado. + +Se o snapshot perceber alguma alteração no HTML definido pelo componente, será uma nova funcionalidade ou um "bug" causado por acidente. Os testes de snapshot notificam o desenvolvedor se o código HTML do componente mudar. O desenvolvedor deve dizer a JEST se a alteração foi desejada ou indesejada. Se a alteração no código HTML for inesperada, ela implicará fortemente um bug, e o desenvolvedor poderá tomar conhecimento desses problemas em potencial, graças facilmente aos testes de snapshot. + +
    diff --git a/src/content/5/ptbr/part5d.md b/src/content/5/ptbr/part5d.md new file mode 100644 index 00000000000..d9ac8499cde --- /dev/null +++ b/src/content/5/ptbr/part5d.md @@ -0,0 +1,1208 @@ +--- +mainImage: ../../../images/part-5.svg +part: 5 +letter: d +lang: ptbr +--- + +
    + +Até aqui, nós testamos o backend como um todo ao nível de API, usando testes de integração e testamos alguns componentes do frontend usando testes unitários. + +Agora, vamos ver uma forma de testar [o sistema como um todo](https://en.wikipedia.org/wiki/System_testing) usando testes End to End (E2E). + +Podemos realizar testes E2E de aplicações web utilizando um navegador e uma biblioteca de test. Existem muitas bibliotecas disponíveis. Um exemplo é [Selenium](http://www.seleniumhq.org/), que pode ser usado com quase todos os navegadores. + Outra opção são os chamados [headless browsers](https://en.wikipedia.org/wiki/Headless_browser), que são navegadores sem nenhuma interface grafica. O Chrome, por exemplo, pode ser utilizado no modo headless. + +Testes E2E são provavelmente a categoria mais útil de testes, pois permitem testar o sistema pela mesma interface de um usuário real. + +Eles também têm algumas desvantagens. Configurar testes E2E é mais desafiador do que testes unitários ou de integração. Além disso, eles tendem a ser bastante lentos e, em um sistema grande, o tempo de execução pode ser de minutos ou até mesmo horas. Isso é ruim para o desenvolvimento, porque durante a codificação é benéfico poder executar os testes o máximo possível, caso ocorram [regressões](https://en.wikipedia.org/wiki/Regression_testing). + +Os testes E2E também podem ser [instáveis](https://hackernoon.com/flaky-tests-a-war-that-never-ends-9aa32fdef359). +Alguns testes podem passar em uma ocasião e falhar em outra, mesmo que o código não seja alterado. + +### Cypress + +A biblioteca E2E [Cypress](https://www.cypress.io/) se tornou popular no último ano. O Cypress é extremamente fácil de usar e, quando comparado ao Selenium, por exemplo, dá muito menos problemas e dores de cabeça. +Seu princípio de funcionamento é radicalmente diferente da maioria das bibliotecas de teste E2E, pois os testes do Cypress são executados completamente dentro do navegador. +Outras bibliotecas executam os testes em um processo Node, que está conectado ao navegador por meio de uma API. + +Vamos fazer alguns testes de end-to-end para o nosso aplicativo de notas. + +Começamos instalando o Cypress no frontend como uma dependência de desenvolvimento + +```js +npm install --save-dev cypress +``` + +e adicionando um npm-script para executá-lo: + +```js +{ + // ... + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "cypress:open": "cypress open" // highlight-line + }, + // ... +} +``` + +Ao contrário dos testes unitários do frontend, os testes do Cypress podem estar no repositório do frontend ou do backend, ou até mesmo em seus próprios repositórios separados. + +Os testes exigem que o sistema testado esteja em execução. Ao contrário de nossos testes de integração do backend, os testes do Cypress não iniciam o sistema quando são executados. + +Vamos adicionar um script npm para o backend que o inicia no modo de teste, ou seja, para que NODE\_ENV seja test. + +```js +{ + // ... + "scripts": { + "start": "NODE_ENV=production node index.js", + "dev": "NODE_ENV=development nodemon index.js", + "build:ui": "rm -rf build && cd ../frontend/ && npm run build && cp -r build ../backend", + "deploy": "fly deploy", + "deploy:full": "npm run build:ui && npm run deploy", + "logs:prod": "fly logs", + "lint": "eslint .", + "test": "jest --verbose --runInBand", + "start:test": "NODE_ENV=test node index.js" // highlight-line + }, + // ... +} +``` + +**Obs.:** Para fazer o Cypress funcionar com o WSL2, talvez seja necessário fazer algumas configurações adicionais. Esses dois [links](https://docs.cypress.io/guides/getting-started/installing-cypress#Windows-Subsystem-for-Linux) são ótimos lugares para [começar](https://nickymeuleman.netlify.app/blog/gui-on-wsl2-cypress). + +Quando tanto o backend quanto o frontend estiverem em execução, podemos iniciar o Cypress com o comando + +```js +npm run cypress:open +``` + +O Cypress pergunta que tipo de testes estamos fazendo. Vamos responder "E2E Testing": + +![tela do cypress com seta apontando para a opção de testes e2e](../../images/5/51new.png) + +Em seguida, um navegador é selecionado (por exemplo, Chrome) e então clicamos em "Create new spec": + +![opção criar novo spec com uma seta indicativa](../../images/5/52new.png) + +Vamos criar o arquivo de teste cypress/e2e/note\_app.cy.js: + +![cypress com o caminho cypress/e2e/note_app.cy.js](../../images/5/53new.png) + +Poderíamos editar os testes no Cypress, mas vamos usar o VS Code: + +![vscode mostrando edições do teste e cypress mostrando o spec adicionado](../../images/5/54new.png) + +Agora podemos fechar a visualização de edição do Cypress. + +Vamos alterar o conteúdo do teste da seguinte maneira: + +```js +describe('Note app', function() { + it('front page can be opened', function() { + cy.visit('http://localhost:3000') + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2023') + }) +}) +``` + +O teste é executado clicando no teste no Cypress: + +Executando o teste, podemos ver como a aplicação se comporta durante a execução do teste: + +![cypress mostrando a automação do teste de notas](../../images/5/56new.png) + +A estrutura do teste deve parecer familiar. Eles usam blocos describe para agrupar diferentes casos de teste, assim como o Jest. Os casos de teste foram definidos com o método it. O Cypress pegou essas partes da biblioteca de testes [Mocha](https://mochajs.org/), que ele usa internamente. + +[cy.visit](https://docs.cypress.io/api/commands/visit.html) e [cy.contains](https://docs.cypress.io/api/commands/contains.html) são comandos do Cypress, e sua finalidade é bastante óbvia. +[cy.visit](https://docs.cypress.io/api/commands/visit.html) abre o endereço web fornecido a ele como parâmetro no navegador usado no teste. [cy.contains](https://docs.cypress.io/api/commands/contains.html) procura pela string que recebeu como parâmetro na página. + +Poderíamos ter declarado o teste usando uma arrow function + +```js +describe('Note app', () => { // highlight-line + it('front page can be opened', () => { // highlight-line + cy.visit('http://localhost:3000') + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2023') + }) +}) +``` + +No entanto, o Mocha [recomenda](https://mochajs.org/#arrow-functions) que as arrow functions não sejam usadas, pois podem causar alguns problemas em determinadas situações. + +Se cy.contains não encontrar o texto que está procurando, o teste não passa. Portanto, se expandirmos nosso teste da seguinte maneira + +```js +describe('Note app', function() { + it('front page can be opened', function() { + cy.visit('http://localhost:3000') + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2023') + }) + +// highlight-start + it('front page contains random text', function() { + cy.visit('http://localhost:3000') + cy.contains('wtf is this app?') + }) +// highlight-end +}) +``` + +o teste falha + +![cypress mostrando falha esperando encontrar wtf](../../images/5/57new.png) + +Vamos remover o código com falha do teste. + +A variável _cy_ que nossos testes usam nos dá um erro desagradável do Eslint + +![captura de tela do vscode mostrando que cy não está definido](../../images/5/58new.png) + +Podemos nos livrar disso instalando o [eslint-plugin-cypress](https://github.com/cypress-io/eslint-plugin-cypress) como uma dependência de desenvolvimento + +```js +npm install eslint-plugin-cypress --save-dev +``` + +e alterando a configuração em .eslintrc.js da seguinte forma: + +```js +module.exports = { + "env": { + "browser": true, + "es6": true, + "jest/globals": true, + "cypress/globals": true // highlight-line + }, + "extends": [ + // ... + ], + "parserOptions": { + // ... + }, + "plugins": [ + "react", "jest", "cypress" // highlight-line + ], + "rules": { + // ... + } +} +``` + +### Preenchendo um formulário + +Vamos expandir nosso código para que o teste tente fazer login em nossa aplicação. +Vamos supor que nosso backend contenha um usuário com o nome de usuário mluukkai e senha salainen. + +O teste começa abrindo o formulário de login. + +```js +describe('Note app', function() { + // ... + + it('login form can be opened', function() { + cy.visit('http://localhost:3000') + cy.contains('log in').click() + }) +}) +``` + +O teste procura primeiro o botão de login pelo seu texto e clica no botão com o comando [cy.click](https://docs.cypress.io/api/commands/click.html#Syntax). + +Ambos os nossos testes começam da mesma maneira, abrindo a página , então devemos separar a parte compartilhada em um bloco beforeEach que é executado antes de cada teste: + +```js +describe('Note app', function() { + // highlight-start + beforeEach(function() { + cy.visit('http://localhost:3000') + }) + // highlight-end + + it('front page can be opened', function() { + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2023') + }) + + it('login form can be opened', function() { + cy.contains('log in').click() + }) +}) +``` + +O campo de login contém dois campos input, nos quais o teste deve escrever. + +O comando [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax) permite buscar elementos por seletores CSS. + +Podemos acessar o primeiro e o último campo de entrada na página e escrever neles com o comando [cy.type](https://docs.cypress.io/api/commands/type.html#Syntax) assim: + +```js +it('user can login', function () { + cy.contains('log in').click() + cy.get('input:first').type('mluukkai') + cy.get('input:last').type('salainen') +}) +``` + +O teste funciona. O problema é que se adicionarmos mais campos de entrada posteriormente, o teste quebrará porque espera que os campos necessários sejam os primeiros e os últimos na página. + +Seria melhor atribuir identificadores únicos aos nossos inputs e usá-los para encontrá-los. +Alteramos nosso formulário de login da seguinte forma: + +```js +const LoginForm = ({ ... }) => { + return ( +
    +

    Login

    +
    +
    + username + +
    +
    + password + +
    + +
    +
    + ) +} +``` + +Também adicionamos um id ao nosso botão de envio para que possamos acessá-lo em nossos testes. + +O teste fica assim: + +```js +describe('Note app', function() { + // .. + it('user can log in', function() { + cy.contains('log in').click() + cy.get('#username').type('mluukkai') // highlight-line + cy.get('#password').type('salainen') // highlight-line + cy.get('#login-button').click() // highlight-line + + cy.contains('Matti Luukkainen logged in') // highlight-line + }) +}) +``` + +A última linha garante que o login foi bem-sucedido. + +Observe que o seletor CSS para o [id-selector](https://developer.mozilla.org/en-US/docs/Web/CSS/ID_selectors) é #, então, se quisermos buscar um elemento com o id username, o seletor CSS é #username. + +Note que passar no teste nesta etapa requer a existência de um usuário no banco de dados de teste do ambiente de backend, cujo nome de usuário é mluukkai e a senha é salainen. Crie um usuário, se necessário! + +### Testando o formulário de nova nota + +Agora vamos adicionar métodos de teste para testar a funcionalidade de "nova nota": + +```js +describe('Note app', function() { + // .. + // highlight-start + describe('when logged in', function() { + beforeEach(function() { + cy.contains('log in').click() + cy.get('input:first').type('mluukkai') + cy.get('input:last').type('salainen') + cy.get('#login-button').click() + }) + // highlight-end + + // highlight-start + it('a new note can be created', function() { + cy.contains('new note').click() + cy.get('input').type('a note created by cypress') + cy.contains('save').click() + + cy.contains('a note created by cypress') + }) + }) + // highlight-end +}) +``` + +O teste foi definido em seu próprio bloco describe. +Apenas usuários logados podem criar novas notas, então adicionamos o login à aplicação em um bloco beforeEach. + +O teste pressupõe que, ao criar uma nova nota, a página contém apenas um campo de entrada, então ele o busca da seguinte maneira: + +```js +cy.get('input') +``` + +Se a página contiver mais campos de entrada, o teste falhará. + +![erro do cypress - cy.type só pode ser chamado em um único elemento](../../images/5/31x.png) + +Devido a esse problema, seria melhor atribuir um id à entrada e pesquisar o elemento pelo seu id. + +A estrutura dos testes é a seguinte: + +```js +describe('Note app', function() { + // ... + + it('user can log in', function() { + cy.contains('log in').click() + cy.get('#username').type('mluukkai') + cy.get('#password').type('salainen') + cy.get('#login-button').click() + + cy.contains('Matti Luukkainen logged in') + }) + + describe('when logged in', function() { + beforeEach(function() { + cy.contains('log in').click() + cy.get('input:first').type('mluukkai') + cy.get('input:last').type('salainen') + cy.get('#login-button').click() + }) + + it('a new note can be created', function() { + // ... + }) + }) +}) +``` + +O Cypress executa os testes na ordem em que estão no código. Portanto, primeiro ele executa user can log in (usuário pode fazer login), onde o usuário faz login. Em seguida, o Cypress executará a new note can be created (uma nova nota pode ser criada), para o qual um bloco beforeEach também faz login. +Por que fazer isso? O usuário não está logado após o primeiro teste? +Não, porque cada teste começa do zero do ponto de vista do navegador. +Todas as alterações no estado do navegador são revertidas após cada teste. + +### Controlando o estado do banco de dados + +Se os testes precisarem ser capazes de modificar o banco de dados do servidor, a situação imediatamente se torna mais complicada. Idealmente, o banco de dados do servidor deve ser o mesmo sempre que executamos os testes, para que os testes possam ser reproduzíveis com confiabilidade e facilidade. + +Assim como nos testes de unidade e integração, nos testes E2E é melhor esvaziar o banco de dados e possivelmente formatá-lo antes de executar os testes. O desafio nos testes E2E é que eles não têm acesso ao banco de dados. + +A solução é criar endpoints de API para os testes do backend. +Podemos esvaziar o banco de dados usando esses endpoints. +Vamos criar um novo router para os testes: + +```js +const testingRouter = require('express').Router() +const Note = require('../models/note') +const User = require('../models/user') + +testingRouter.post('/reset', async (request, response) => { + await Note.deleteMany({}) + await User.deleteMany({}) + + response.status(204).end() +}) + +module.exports = testingRouter +``` + +e adicioná-lo ao backend somente se a aplicação estiver em modo de teste: + +```js +// ... + +app.use('/api/login', loginRouter) +app.use('/api/users', usersRouter) +app.use('/api/notes', notesRouter) + +// highlight-start +if (process.env.NODE_ENV === 'test') { + const testingRouter = require('./controllers/testing') + app.use('/api/testing', testingRouter) +} +// highlight-end + +app.use(middleware.unknownEndpoint) +app.use(middleware.errorHandler) + +module.exports = app +``` + +Após as alterações, uma solicitação HTTP POST para o endpoint /api/testing/reset esvazia o banco de dados. Verifique se o backend está sendo executado no modo de teste iniciando-o com o seguinte comando (previamente configurado no arquivo package.json): + +```js + npm run start:test +``` +O código backend modificado pode ser encontrado no [GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part5-1), na branch part5-1. + +Em seguida, vamos alterar o bloco beforeEach para que ele esvazie o banco de dados do servidor antes da execução dos testes. + +Atualmente, não é possível adicionar novos usuários por meio da interface do usuário do frontend, então adicionamos um novo usuário ao backend a partir do bloco beforeEach. + +```js +describe('Note app', function() { + beforeEach(function() { + // highlight-start + cy.request('POST', 'http://localhost:3001/api/testing/reset') + const user = { + name: 'Matti Luukkainen', + username: 'mluukkai', + password: 'salainen' + } + cy.request('POST', 'http://localhost:3001/api/users/', user) + // highlight-end + cy.visit('http://localhost:3000') + }) + + it('front page can be opened', function() { + // ... + }) + + it('user can login', function() { + // ... + }) + + describe('when logged in', function() { + // ... + }) +}) +``` + +Durante a formatação, o teste faz requisições HTTP para o backend com [cy.request](https://docs.cypress.io/api/commands/request.html). + +Ao contrário do passado, agora os testes começam com o backend no mesmo estado todas as vezes. O backend conterá um usuário e nenhuma nota. + +Vamos adicionar mais um teste para verificar se podemos alterar a importância das notas. + +Algum tempo atrás nós alteramos o frontend de forma que uma nova nota é adicionada como importante por padrão, o campo important é true: + +```js +const NoteForm = ({ createNote }) => { + // ... + + const addNote = (event) => { + event.preventDefault() + createNote({ + content: newNote, + important: true // highlight-line + }) + + setNewNote('') + } + // ... +} +``` + +Há várias maneiras de testar isso. No exemplo a seguir, primeiro procuramos por uma nota e clicamos no botão tornar não importante. Em seguida, verificamos se a nota agora contém um botão tornar importante. + +```js +describe('Note app', function() { + // ... + + describe('when logged in', function() { + // ... + + describe('and a note exists', function () { + beforeEach(function () { + cy.contains('new note').click() + cy.get('input').type('another note cypress') + cy.contains('save').click() + }) + + it('it can be made not important', function () { + cy.contains('another note cypress') + .contains('make not important') + .click() + + cy.contains('another note cypress') + .contains('make important') + }) + }) + }) +}) +``` + +O primeiro comando procura por um componente que contém o texto outra nota cypress, e depois por um botão tornar não importante dentro dele. Em seguida, ele clica no botão. + +O segundo comando verifica se o texto do botão foi alterado para tornar importante. + +Os testes e o código frontend atual podem ser encontrados no [GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part5-9), na branch part5-9. + +### Teste de falha de login + +Vamos fazer um teste para garantir que uma tentativa de login falhe se a senha estiver incorreta. + +O Cypress executará todos os testes a cada vez por padrão, e à medida que o número de testes aumenta, isso começa a consumir bastante tempo. Ao desenvolver um novo teste ou depurar um teste quebrado, podemos definir o teste com it.only em vez de it, para que o Cypress execute apenas o teste necessário. Quando o teste estiver funcionando, podemos remover o .only. + +A primeira versão dos nossos testes é a seguinte: + +```js +describe('Note app', function() { + // ... + + it.only('login fails with wrong password', function() { + cy.contains('log in').click() + cy.get('#username').type('mluukkai') + cy.get('#password').type('wrong') + cy.get('#login-button').click() + + cy.contains('wrong credentials') + }) + + // ... +)} +``` + +O teste usa [cy.contains](https://docs.cypress.io/api/commands/contains.html#Syntax) para garantir que o aplicativo exiba uma mensagem de erro. + +O aplicativo renderiza a mensagem de erro em um componente com a classe CSS error: + +```js +const Notification = ({ message }) => { + if (message === null) { + return null + } + + return ( +
    // highlight-line + {message} +
    + ) +} +``` + +Podemos fazer o teste garantir que a mensagem de erro seja renderizada no componente correto, ou seja, o componente com a classe CSS error: + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error').contains('wrong credentials') // highlight-line +}) +``` + +Primeiro, usamos [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax) para procurar um componente com a classe CSS error. Em seguida, verificamos se a mensagem de erro pode ser encontrada a partir desse componente. +Observe que o [seletor de classe CSS](https://developer.mozilla.org/en-US/docs/Web/CSS/Class_selectors) começa com um ponto, então o seletor para a classe error é .error. + +Podemos fazer o mesmo usando a sintaxe do [should](https://docs.cypress.io/api/commands/should.html): + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error').should('contain', 'wrong credentials') // highlight-line +}) +``` + +Usar o should é um pouco mais complicado do que usar contains, mas permite testes mais diversos do que contains, que funciona apenas com base no conteúdo de texto. + +Uma lista das asserções mais comuns que podem ser usadas com o _should_ pode ser encontrada [aqui](https://docs.cypress.io/guides/references/assertions.html#Common-Assertions). + +Por exemplo, podemos garantir que a mensagem de erro seja vermelha e tenha uma borda: + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error').should('contain', 'wrong credentials') + cy.get('.error').should('have.css', 'color', 'rgb(255, 0, 0)') + cy.get('.error').should('have.css', 'border-style', 'solid') +}) +``` + +O Cypress requer que as cores sejam fornecidas em formato [rgb](https://rgbcolorcode.com/color/red). + +Como todos os testes são para o mesmo componente que acessamos usando [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax), podemos encadeá-los usando o [and](https://docs.cypress.io/api/commands/and.html). + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error') + .should('contain', 'wrong credentials') + .and('have.css', 'color', 'rgb(255, 0, 0)') + .and('have.css', 'border-style', 'solid') +}) +``` + +Vamos concluir o teste para também verificar se o aplicativo não renderiza a mensagem de sucesso 'Matti Luukkainen logged in': + +```js +it('login fails with wrong password', function() { + cy.contains('log in').click() + cy.get('#username').type('mluukkai') + cy.get('#password').type('wrong') + cy.get('#login-button').click() + + cy.get('.error') + .should('contain', 'wrong credentials') + .and('have.css', 'color', 'rgb(255, 0, 0)') + .and('have.css', 'border-style', 'solid') + + cy.get('html').should('not.contain', 'Matti Luukkainen logged in') // highlight-line +}) +``` + +O comando should é mais frequentemente usado encadeando-o após o comando get (ou outro comando similar que pode ser encadeado). O cy.get('html') usado no teste significa praticamente o conteúdo visível de todo o aplicativo. + +Também podemos verificar o mesmo encadeando o comando contains com o comando should com um parâmetro ligeiramente diferente: + +```js +cy.contains('Matti Luukkainen logged in').should('not.exist') +``` + +**NOTA:** Algumas propriedades CSS [comportam-se de forma diferente no Firefox](https://github.com/cypress-io/cypress/issues/9349). Se você executar os testes com o Firefox: + + ![running](https://user-images.githubusercontent.com/4255997/119015927-0bdff800-b9a2-11eb-9234-bb46d72c0368.png) + + então os testes que envolvem, por exemplo, `border-style`, `border-radius` e `padding`, serão aprovados no Chrome ou Electron, mas falharão no Firefox: + + ![borderstyle](https://user-images.githubusercontent.com/4255997/119016340-7b55e780-b9a2-11eb-82e0-bab0418244c0.png) + +### Bypassando a interface de usuário + +Atualmente, temos os seguintes testes: + +```js +describe('Note app', function() { + it('user can login', function() { + cy.contains('log in').click() + cy.get('#username').type('mluukkai') + cy.get('#password').type('salainen') + cy.get('#login-button').click() + + cy.contains('Matti Luukkainen logged in') + }) + + it('login fails with wrong password', function() { + // ... + }) + + describe('when logged in', function() { + beforeEach(function() { + cy.contains('log in').click() + cy.get('input:first').type('mluukkai') + cy.get('input:last').type('salainen') + cy.get('#login-button').click() + }) + + it('a new note can be created', function() { + // ... + }) + + }) +}) +``` + +Primeiro, testamos o login. Em seguida, em seu próprio bloco "describe", temos um monte de testes que esperam que o usuário esteja logado. O usuário é logado no bloco beforeEach. + +Como mencionado acima, cada teste parte do zero! Os testes não iniciam a partir do estado em que os testes anteriores terminaram. + +A documentação do Cypress nos dá o seguinte conselho: [Teste completamente o fluxo de login - mas apenas uma vez!](https://docs.cypress.io/guides/end-to-end-testing/testing-your-app#Fully-test-the-login-flow-but-only-once). +Portanto, em vez de fazer login de um usuário usando o formulário no bloco beforeEach, o Cypress recomenda que nós façamos o [bypass da interface de usuário](https://docs.cypress.io/guides/getting-started/testing-your-app.html#Bypassing-your-UI) e uma requisição HTTP ao backend para fazer o login. A razão para isso é que fazer login com uma requisição HTTP é muito mais rápido do que preencher um formulário. + +Nossa situação é um pouco mais complicada do que no exemplo da documentação do Cypress porque, quando um usuário faz login, nossa aplicação salva seus detalhes no localStorage. +No entanto, o Cypress também pode lidar com isso. +O código é o seguinte: + +```js +describe('when logged in', function() { + beforeEach(function() { + // highlight-start + cy.request('POST', 'http://localhost:3001/api/login', { + username: 'mluukkai', password: 'salainen' + }).then(response => { + localStorage.setItem('loggedNoteappUser', JSON.stringify(response.body)) + cy.visit('http://localhost:3000') + }) + // highlight-end + }) + + it('a new note can be created', function() { + // ... + }) + + // ... +}) +``` + +Podemos acessar a resposta de uma [cy.request](https://docs.cypress.io/api/commands/request.html) com o método _then_. Por baixo dos panos, cy.request, assim como todos os comandos do Cypress, são [promessas](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Commands-Are-Promises). +A função de retorno de chamada salva os detalhes de um usuário logado no localStorage e recarrega a página. Agora não há diferença para um usuário fazer login usando o formulário de login. + +Se e quando escrevermos novos testes para nossa aplicação, teremos que usar o código de login em vários lugares. +Devemos transformá-lo em um [comando personalizado](https://docs.cypress.io/api/cypress-api/custom-commands.html). + +Comandos personalizados são declarados em cypress/support/commands.js. +O código para fazer login é o seguinte: + +```js +Cypress.Commands.add('login', ({ username, password }) => { + cy.request('POST', 'http://localhost:3001/api/login', { + username, password + }).then(({ body }) => { + localStorage.setItem('loggedNoteappUser', JSON.stringify(body)) + cy.visit('http://localhost:3000') + }) +}) +``` + +Usar nosso comando personalizado é fácil e nosso teste fica mais limpo: + +```js +describe('when logged in', function() { + beforeEach(function() { + // highlight-start + cy.login({ username: 'mluukkai', password: 'salainen' }) + // highlight-end + }) + + it('a new note can be created', function() { + // ... + }) + + // ... +}) +``` + +O mesmo se aplica à criação de uma nova anotação, agora que pensamos sobre isso. Temos um teste que cria uma nova anotação usando o formulário. Também criamos uma nova anotação no bloco beforeEach do teste que testa a alteração da importância de uma anotação." + + +```js +describe('Note app', function() { + // ... + + describe('when logged in', function() { + it('a new note can be created', function() { + cy.contains('new note').click() + cy.get('input').type('a note created by cypress') + cy.contains('save').click() + + cy.contains('a note created by cypress') + }) + + describe('and a note exists', function () { + beforeEach(function () { + cy.contains('new note').click() + cy.get('input').type('another note cypress') + cy.contains('save').click() + }) + + it('it can be made important', function () { + // ... + }) + }) + }) +}) +``` + +Vamos criar um novo comando personalizado para criar uma nova nota. O comando irá criar uma nova nota com uma requisição HTTP POST: + +```js +Cypress.Commands.add('createNote', ({ content, important }) => { + cy.request({ + url: 'http://localhost:3001/api/notes', + method: 'POST', + body: { content, important }, + headers: { + 'Authorization': `Bearer ${JSON.parse(localStorage.getItem('loggedNoteappUser')).token}` + } + }) + + cy.visit('http://localhost:3000') +}) +``` + +O comando espera que o usuário esteja logado e que os detalhes do usuário estejam salvos no localStorage. + +Agora o bloco de formatação fica assim: + +```js +describe('Note app', function() { + // ... + + describe('when logged in', function() { + it('a new note can be created', function() { + // ... + }) + + describe('and a note exists', function () { + beforeEach(function () { + // highlight-start + cy.createNote({ + content: 'another note cypress', + important: true + }) + // highlight-end + }) + + it('it can be made important', function () { + // ... + }) + }) + }) +}) +``` + +Existe mais uma característica irritante em nossos testes. O endereço da aplicação http:localhost:3000 está codificado em vários lugares. + +Vamos definir o baseUrl para a aplicação no [arquivo de configuração](https://docs.cypress.io/guides/references/configuration) cypress.config.js, que é pré-gerado do Cypress: + +```js +const { defineConfig } = require("cypress") + +module.exports = defineConfig({ + e2e: { + setupNodeEvents(on, config) { + }, + baseUrl: 'http://localhost:3000' // highlight-line + }, +}) +``` + +Todos os comandos nos testes utilizam o endereço da aplicação + +```js +cy.visit('http://localhost:3000' ) +``` + +podem ser transformados em + +```js +cy.visit('') +``` + +O endereço codificado da backend http://localhost:3001 ainda está nos testes. A [documentation](https://docs.cypress.io/guides/guides/environment-variables) do Cypress recomenda definir outros endereços utilizados pelos testes como variáveis de ambiente. + +Vamos expandir o arquivo de configuração cypress.config.js da seguinte maneira: + +```js +const { defineConfig } = require("cypress") + +module.exports = defineConfig({ + e2e: { + setupNodeEvents(on, config) { + }, + baseUrl: 'http://localhost:3000', + }, + env: { + BACKEND: 'http://localhost:3001/api' // highlight-line + } +}) +``` + +Vamos substituir todos os endereços do backend nos testes da seguinte maneira + +```js +describe('Note ', function() { + beforeEach(function() { + + cy.request('POST', `${Cypress.env('BACKEND')}/testing/reset`) // highlight-line + const user = { + name: 'Matti Luukkainen', + username: 'mluukkai', + password: 'secret' + } + cy.request('POST', `${Cypress.env('BACKEND')}/users`, user) // highlight-line + cy.visit('') + }) + // ... +}) +``` + +Os testes e o código frontend podem ser encontrados no [GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part5-10) na branch part5-10. + +### Alterando a importância de uma nota + +Por último, vamos dar uma olhada no teste que fizemos para alterar a importância de uma nota. +Primeiro, vamos alterar o bloco de formatação para criar três notas em vez de uma: + +```js +describe('when logged in', function() { + describe('and several notes exist', function () { + beforeEach(function () { + // highlight-start + cy.createNote({ content: 'first note', important: false }) + cy.createNote({ content: 'second note', important: false }) + cy.createNote({ content: 'third note', important: false }) + // highlight-end + }) + + it('one of those can be made important', function () { + cy.contains('second note') + .contains('make important') + .click() + + cy.contains('second note') + .contains('make not important') + }) + }) +}) +``` + +Como o comando [cy.contains](https://docs.cypress.io/api/commands/contains.html) realmente funciona? + +Quando clicamos no comando _cy.contains('segunda nota')_ no Cypress [Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner.html), vemos que o comando busca pelo elemento que contém o texto segunda nota: + +![cypress test clicando no corpo de teste e na segunda nota](../../images/5/34new.png) + +Ao clicar na linha seguinte _.contains('make important')_, vemos que o teste usa o botão 'make important' correspondente à segunda nota: + +![cypress test clicando em 'make important'](../../images/5/35new.png) + +Quando encadeado, o segundo comando contains continua a busca a partir do componente encontrado pelo primeiro comando. + +Se não tivéssemos encadeado os comandos e, em vez disso, escrevêssemos: + +```js +cy.contains('second note') +cy.contains('make important').click() +``` + +o resultado teria sido completamente diferente. A segunda linha do teste clicaria no botão de uma nota errada: + +![cypress mostrando erro e incorretamente tentando clicar no primeiro botão](../../images/5/36new.png) + +Ao codificar testes, você deve verificar no test runner se os testes usam os componentes corretos! + +Vamos alterar o componente _Note_ para que o texto da nota seja renderizado em um elemento span. + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' : 'make important' + + return ( +
  • + {note.content} // highlight-line + +
  • + ) +} +``` + +Nossos testes quebram! Como o test runner revela, _cy.contains('second note')_ agora retorna o componente que contém o texto, e o botão não está nele. + +![cypress monstrando que o teste quebrou tentando clicar em 'make important'](../../images/5/37new.png) + +Uma maneira de corrigir isso é a seguinte: + +```js +it('one of those can be made important', function () { + cy.contains('second note').parent().find('button').click() + cy.contains('second note').parent().find('button') + .should('contain', 'make not important') +}) +``` + +Na primeira linha, usamos o comando [parent](https://docs.cypress.io/api/commands/parent.html) para acessar o elemento pai do elemento que contém a segunda nota e encontrar o botão dentro dele. +Em seguida, clicamos no botão e verificamos se o texto nele muda. + +Observe que usamos o comando [find](https://docs.cypress.io/api/commands/find.html#Syntax) para pesquisar o botão. Não podemos usar [cy.get](https://docs.cypress.io/api/commands/get.html) aqui, porque ele sempre procura em toda a página e retornaria todos os 5 botões na página. + +Infelizmente, agora temos algum código duplicado nos testes, porque o código para procurar o botão correto é sempre o mesmo. + +Nesses tipos de situações, é possível usar o comando [as](https://docs.cypress.io/api/commands/as.html): + +```js +it('one of those can be made important', function () { + cy.contains('second note').parent().find('button').as('theButton') + cy.get('@theButton').click() + cy.get('@theButton').should('contain', 'make not important') +}) +``` + +Agora, a primeira linha encontra o botão correto e o salva como theButton usando o as. As linhas seguintes podem usar o elemento nomeado com cy.get('@theButton'). + +### Executando e depurando os testes + +Por fim, algumas observações sobre como o Cypress funciona e como depurar seus testes. + +A forma dos testes do Cypress dá a impressão de que os testes são código JavaScript normal, e poderíamos, por exemplo, tentar o seguinte: + +```js +const button = cy.contains('log in') +button.click() +debugger +cy.contains('logout').click() +``` + +No entanto, isso não funcionará. Quando o Cypress executa um teste, ele adiciona cada comando cy a uma fila de execução. +Quando o código do método de teste é executado, o Cypress executará cada comando na fila, um por um. + +Os comandos do Cypress sempre retornam undefined, portanto, button.click() no código acima causaria um erro. Uma tentativa de iniciar o depurador não interromperia o código entre a execução dos comandos, mas sim antes que qualquer comando tenha sido executado. + +Os comandos do Cypress são como promessas, então, se quisermos acessar seus valores de retorno, precisamos fazer isso usando o comando [then](https://docs.cypress.io/api/commands/then.html). +Por exemplo, o teste a seguir imprimiria o número de botões na aplicação e clicaria no primeiro botão: + +```js +it('then example', function() { + cy.get('button').then( buttons => { + console.log('number of buttons', buttons.length) + cy.wrap(buttons[0]).click() + }) +}) +``` + +Interromper a execução do teste com o depurador é [possível](https://docs.cypress.io/api/commands/debug.html). O depurador só é iniciado se o console do Cypress test runner estiver aberto. + +O console do desenvolvedor é extremamente útil ao depurar seus testes. +Você pode ver as solicitações HTTP feitas pelos testes na guia "Network" e a guia "Console" mostrará informações sobre seus testes: + +![console do desenvolvedor enquanto o cypress está sendo executado](../../images/5/38new.png) + +Até agora, executamos nossos testes Cypress usando o test runner gráfico. +Também é possível executá-los [a partir da linha de comando](https://docs.cypress.io/guides/guides/command-line.html). Só precisamos adicionar um script npm para isso: + +```js + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "server": "json-server -p3001 --watch db.json", + "cypress:open": "cypress open", + "test:e2e": "cypress run" // highlight-line + }, +``` + +Agora podemos executar nossos testes a partir da linha de comando com o comando npm run test:e2e + +![saída do terminal ao executar os testes e2e do npm mostrando a aprovação](../../images/5/39new.png) + +Observe que os vídeos da execução do teste serão salvos em cypress/videos/, portanto, você provavelmente deve ignorar esse diretório no git. Também é possível [desativar](https://docs.cypress.io/guides/guides/screenshots-and-videos#Videos) a criação de vídeos. + +O código frontend e de teste pode ser encontrado no [GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part5-11) na branch part5-11. + +
    + +
    + +### Exercícios 5.17-5.23. + +Nos últimos exercícios desta parte, faremos alguns testes E2E para nossa aplicação de blog. +O material desta parte deve ser suficiente para completar os exercícios. +Você **deve consultar a [documentação](https://docs.cypress.io/guides/overview/why-cypress.html#In-a-nutshell)** do Cypress. Provavelmente é a melhor documentação que já vi para um projeto de código aberto. + +Recomendo especialmente a leitura de [Introdução ao Cypress](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Cypress-Can-Be-Simple-Sometimes), que afirma: + +> Este é o guia mais importante para entender como testar com o Cypress. Leia. Entenda. + +#### 5.17: testes end-to-end do Blog List etapa 1 + +Configure o Cypress para o seu projeto. Crie um teste para verificar se a aplicação exibe o formulário de login por padrão. + +A estrutura do teste deve ser a seguinte: + +```js +describe('Blog app', function() { + beforeEach(function() { + cy.request('POST', 'http://localhost:3003/api/testing/reset') + cy.visit('http://localhost:3000') + }) + + it('Login form is shown', function() { + // ... + }) +}) +``` + +O bloco beforeEach deve esvaziar o banco de dados usando, por exemplo, o método que usamos no [material](/ptbr/part5/testes_end_to_end#controlando-o-estado-do-banco-de-dados). + +#### 5.18: testes end-to-end do Blog List etapa 2 + +Faça testes para fazer login. Teste tentativas de login bem-sucedidas e malsucedidas. +Crie um novo usuário no bloco beforeEach para os testes. + +A estrutura do teste se estende da seguinte forma: + +```js +describe('Aplicativo Blog', function() { + beforeEach(function() { + cy.request('POST', 'http://localhost:3003/api/testing/reset') + // crie aqui um usuário para o backend + cy.visit('http://localhost:3000') + }) + + it('Login form is shown', function() { + // ... + }) + + describe('Login',function() { + it('succeeds with correct credentials', function() { + // ... + }) + + it('fails with wrong credentials', function() { + // ... + }) + }) +}) +``` + +Exercício bônus opcional: Verifique se a notificação exibida após uma tentativa de login malsucedida é exibida em vermelho. + +#### 5.19: testes end-to-end do Blog List etapa 3 + +Crie um teste que verifique se um usuário logado pode criar um novo blog. +A estrutura do teste pode ser a seguinte: + +```js +describe('Blog app', function() { + // ... + + describe('When logged in', function() { + beforeEach(function() { + // faça login do usuário aqui + }) + + it('A blog can be created', function() { + // ... + }) + }) + +}) +``` + +"O teste deve garantir que um novo blog seja adicionado à lista de todos os blogs. + +#### 5.20: Testes end-to-end do Blog List Passo 4 + +Crie um teste que confirme que os usuários podem curtir um blog. + +#### 5.21: Testes end-to-end do Blog List Passo 5 + +Crie um teste para garantir que o usuário que criou um blog possa excluí-lo. + +#### 5.22: Testes end-to-end do Blog List Passo 6 + +Crie um teste para garantir que outros usuários, exceto o criador, não vejam o botão de exclusão. + +#### 5.23: Testes end-to-end do Blog List Passo 7 + +Crie um teste que verifique se os blogs estão ordenados de acordo com as curtidas, sendo o blog com mais curtidas o primeiro. + +Este exercício é um pouco mais complicado do que os anteriores. Uma solução é adicionar uma determinada classe para o elemento que envolve o conteúdo do blog e usar o método [eq](https://docs.cypress.io/api/commands/eq#Syntax) para obter o elemento do blog em um índice específico: + +```js +cy.get('.blog').eq(0).should('contain', 'The title with the most likes') +cy.get('.blog').eq(1).should('contain', 'The title with the second most likes') +``` + +Observe que você pode ter problemas se clicar no botão de curtir muitas vezes seguidas. Pode ser que o Cypress clique tão rapidamente que não tenha tempo para atualizar o estado do aplicativo entre os cliques. Uma solução para isso é aguardar a atualização do número de curtidas entre todos os cliques. + +Este foi o último exercício desta parte, é hora de enviar seu código para o GitHub e marcar os exercícios que você completou no [sistema de envio de exercícios](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    diff --git a/src/content/5/zh/part5.md b/src/content/5/zh/part5.md index f3d7e953b65..e7305dd21da 100644 --- a/src/content/5/zh/part5.md +++ b/src/content/5/zh/part5.md @@ -6,9 +6,19 @@ lang: zh
    + + 在这一部分,我们回到前端,首先看一下测试React代码的不同可能性。我们还将实现基于令牌的认证,这将使用户能够登录到我们的应用 - -在这一章节,我们重回前端,首先来看一下测试 React 代码的方法,这与后端有些不同。我们同样会实现基于 token 的认证功能,让我们的用户能够登录应用。 + +2025年8月21日更新 +- React 版本从 v18 更新到 v19。PropTypes 和 forwardRef 已被弃用。 +- 在登录表单字段中添加了一个 label 元素,并在后续测试中用于识别字段 +- .eslintrc.cjs 被替换为 eslint.config.js 文件 +- .eslintignore 被替换为 eslint.config.js 中的配置 +
    diff --git a/src/content/5/zh/part5a.md b/src/content/5/zh/part5a.md index db586e92924..c04aacb5db3 100644 --- a/src/content/5/zh/part5a.md +++ b/src/content/5/zh/part5a.md @@ -8,25 +8,25 @@ lang: zh
    - -在上两章节中,我们主要关注于后端,但前端目前还不支持我们在第四章节中实现的后端用户管理。 + + 在过去的两部分中,我们主要集中在后端,而我们在[第二章节](/en/part2)中开发的前端还不支持我们在第四章节中对后端实现的用户管理。 - + + 目前,前端显示现有的笔记,并允许用户将一个笔记的状态从重要改为不重要,反之亦然。由于第四章节中对后端所做的修改,新的笔记不能再被添加:后端现在期望一个验证用户身份的令牌与新的笔记一起被发送。 -目前前端能够展示已经存在的 Note,并且允许用户切换 Note 的重要程度。由于我们在第四章节的修改,新的 Note 不能再添加了:因为在新建 Note 前,后端现在需要 token 来验证用户。 + + 我们现在要在前端实现一部分所需的用户管理功能。让我们从用户登录开始。在这一部分中,我们将假设新用户不会从前端添加。 - +### Adding a Login Form -我们现在将实现前台的用户管理功能的一部分。首先从用户登录开始,在这一章节中,我们假设还不会从前端来添加用户。 - - - -登录表单已经添加到了页面顶端。添加 Note 的表单也已经移到了 Note 列表的顶部。 + + 现在,一个登录表格已经被添加到页面的顶部。添加新笔记的表格也被移到了笔记列表的底部。 ![](../../images/5/1e.png) - -App 组件的代码如下: + + + App组件的代码现在看起来如下。 ```js const App = () => { @@ -58,30 +58,30 @@ const App = () => { return (

    Notes

    - - -

    Login

    - + // highlight-start +

    Login

    - username +
    - password +
    @@ -95,29 +95,33 @@ const App = () => { export default App ``` - -当前的应用代码可以在[Github](https://Github.com/fullstack-hy2020/part2-notes/tree/part5-1) ,branchpart5-1 上找到。 + + 当前的应用代码可以在[Github](https://github.com/fullstack-hy2020/part2-notes/tree/part5-1)的分支part5-1上找到。如果你克隆了这个 repo,在尝试运行前端之前,别忘了运行_npm install_。 + + 如果前端没有连接到后端,它将不会显示任何注释。你可以在第四章节的文件夹中用_npm run dev_来启动后端。这将在3001端口运行后端。当它处于激活状态时,在一个单独的终端窗口中,你可以用_npm start_启动前端,现在你可以看到第四章节中保存在MongoDB数据库中的注释。 - - + + 从现在开始记住这一点。 -登录表单的处理方式与我们第二章所讲的处理方式相同。当前应用状态有usernamepassword 都存储在表单中。表单有事件处理逻辑,与App组件的状态保持同步。事件处理逻辑也很简单:将一个对象作为参数传递给它们,它们将target 字段从对象里解构出来,将它的值保存为状态 + +登录表单的处理方式与我们在[第二部分](/en/part2/forms)中处理表单的方式相同。应用的状态有usernamepassword字段来存储表单中的数据。表单字段有事件处理器,它们将字段中的变化同步到App组件的状态中。这些事件处理器很简单:给它们一个对象作为参数,它们从对象中解构出target字段,并将其值保存到状态中。 ```js ({ target }) => setUsername(target.value) ``` - -_handleLogin_ 方法负责发送表单,不做其他逻辑处理。 + + 负责处理表单中数据的_handleLogin_方法还没有被实现。 - + ### Adding Logic to the Login Form -通过api/login这个 HTTP POST 请求完成登录。让我们将它解耦到自己的 services/login.js 模块中 + + 登录是通过向服务器地址api/login发送一个HTTP POST请求来完成。让我们把负责这个请求的代码分离到自己的模块中,放到services/login.js文件中。 - - -我们会使用async/await 语法而不再使用 promises,代码如下: + + 我们将使用async/await语法而不是 promise 来处理HTTP请求。 ```js import axios from 'axios' @@ -131,11 +135,10 @@ const login = async credentials => { export default { login } ``` - -处理登录的方法可以按如下方式实现: +The method for handling the login can be implemented as follows: ```js -import loginService from './services/login' +import loginService from './services/login' // highlight-line const App = () => { // ... @@ -145,42 +148,43 @@ const App = () => { const [user, setUser] = useState(null) // highlight-end - const handleLogin = async (event) => { + // ... + + const handleLogin = async event => { // highlight-line event.preventDefault() + + // highlight-start try { - const user = await loginService.login({ - username, password, - }) - + const user = await loginService.login({ username, password }) setUser(user) setUsername('') setPassword('') - } catch (exception) { - setErrorMessage('Wrong credentials') + } catch { + setErrorMessage('wrong credentials') setTimeout(() => { setErrorMessage(null) }, 5000) } + // highlight-end } // ... } ``` - + + 如果登录成功,表单字段被清空,并且服务器响应(包括一个token和用户详细信息)被保存到应用状态的user字段。 -如果登录成功,表单 字段 被清空,并且服务器响应(包括 token 和用户信息)被存储到 -应用状态的user 字段 。 + +如果登录失败,或运行_loginService.login_函数导致错误,用户将被通知。 - -如果登录失败,或者执行 _loginService.login_ 产生了错误,则会通知用户。 +### Conditional Rendering of the Login Form - + + 用户不会以任何方式得到关于成功登录的通知。让我们修改应用,只有在用户没有登录的情况下才显示登录表单,所以当_user == null_。只有当用户登录时,才会显示添加新笔记的表单,所以用户包含用户的详细信息。 -总之用户登录成功是不会通知用户的。让我们将应用修改为,只有当用户没有登录时才显示登录表单,即 _user === null_ 。只有当用户登录成功后才会显示添加新的 Note,这样 user 状态才会包含信息 - - -让我们增加两个 辅助函数给 App 组件来生成表单。 + + 让我们在App组件中添加两个辅助函数来生成表单。 ```js const App = () => { @@ -189,35 +193,34 @@ const App = () => { const loginForm = () => (
    - username +
    - password +
    -
    + ) const noteForm = () => (
    - + -
    + ) return ( @@ -226,8 +229,9 @@ const App = () => { } ``` - -并按照条件来渲染它们: + + + 并有条件地渲染它们。 ```js const App = () => { @@ -244,11 +248,10 @@ const App = () => { return (

    Notes

    - - {user === null && loginForm()} // highlight-line - {user !== null && noteForm()} // highlight-line + {!user && loginForm()} // highlight-line + {user && noteForm()} // highlight-line
      - {notesToShow.map((note, i) => + {notesToShow.map(note => ( toggleImportanceOf(note.id)} /> - )} + ))}
    @@ -271,86 +274,97 @@ const App = () => { } ``` - -虽然看起来有点古怪,但在 React 中十分常见的一个[React trick](https://reactjs.org/docs/conditional-rendering.html#inline-if-with-logical--operator) ,即按条件渲染表单: + + 一个看起来有点奇怪,但常用的[React技巧](https://reactjs.org/docs/conditional-rendering.html#inline-if-with-logical--operator)被用来有条件地渲染表单。 ```js -{ - user === null && loginForm() -} +{!user && loginForm()} ``` - -如果第一个表达式计算为 false 或[falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy), 则不会执行第二个语句(生成表单) + + 如果第一条语句计算为false,或者是[falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy),第二条语句(生成表单)根本就不会被执行。 - -我们可以使用条件运算[conditional operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator)来让这个逻辑表达得更直白一些: + + 我们再做一个修改。如果用户已经登录,他们的名字就会显示在屏幕上。 ```js return (

    Notes

    + - - - {user === null ? - loginForm() : - noteForm() - } - -

    Notes

    + {!user && loginForm()} + // highlight-start + {user && ( +
    +

    {user.name} logged in

    + {noteForm()} +
    + )} + // highlight-end +
    +
    ) ``` - + +解决方案并不完美,但我们暂时就这样吧。 -如果 _user === null_ 是 [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy) _loginForm()_ 就会执行。如果不是,就执行 _noteForm()_. + + 我们的主要组件App目前太大。我们现在所做的改变清楚地表明,表单应该被重构为它们自己的组件。然而,我们将把这个问题留给一个可选的练习。 - -让我们再多做一点修改:如果用户登录,它们的名字就会展示在屏幕上: + + 目前的应用代码可以在[Github](https://github.com/fullstack-hy2020/part2-notes/tree/part5-2)上找到,分支part5-2。 -```js -return ( -
    -

    Notes

    +### Note on Using the Label Element - + +我们在登录表单的 input 字段中使用了 [label](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/label) 元素。用户名的 input 字段放置在相应的 label 元素内部: - {user === null ? - loginForm() : -
    -

    {user.name} logged in

    - {noteForm()} -
    - } - -

    Notes

    +```js +
    + +
    +// ... +``` - // ... + +我们为什么要以这种方式实现表单?从外观上看,使用更简单的代码,不使用单独的 label 元素也可以达到相同的效果: -
    -) +```js +
    + username + setUsername(target.value)} + /> +
    +// ... ``` - -这种解决方案看起来有点丑,但我们先这么放在这。 + +label 元素用于表单中,用于描述和命名 input 字段。它为输入字段提供描述,帮助用户理解应向每个字段输入什么信息。这种描述与相应的输入字段程可编程地关联,提高了表单的可访问性。 - -我们的主组件 App 现在看起来十分臃肿。我们现在做的修改意味着,表单应该重构到它自己的组件中。但我们把这个作为可选的练习放到课后。 + +这样,当输入字段被选中时,屏幕阅读器可以读出字段名称给用户听,点击标签的文本会自动聚焦到正确的输入字段。建议始终使用 label 元素与 input 字段配合使用,即使不使用它也能达到相同的外观效果。 - - -当前的应用代码可以在[Github](https://Github.com/fullstack-hy2020/part2-notes/tree/part5-2) part5-2 分支上找到。 + +有[几种方法](https://react.dev/reference/react-dom/components/input#providing-a-label-for-an-input)可以将特定 labelinput 元素关联起来。最简单的方法是将 input 元素放置在相应的 label 元素内部,正如本材料所示。这会自动将 label 与正确的输入字段关联起来,无需额外配置。 ### Creating new notes -【创建新的 Note】 - -成功登录后,token 被返回并存储到了 usertoken 状态中 + + 登录成功后返回的令牌被保存在应用的状态中--用户的字段token。 ```js const handleLogin = async (event) => { @@ -369,11 +383,11 @@ const handleLogin = async (event) => { } ``` - -让我们修复创建新 Note 的代码,来和后台对接好。也就是说把登录成功用户的 token 放到 HTTP 请求的认证头中。 + + 让我们修复创建新的注释,使其与后端一起工作。这意味着在HTTP请求的授权头中添加登录用户的令牌。 - -noteService 模块修改如下: + + noteService模块的变化是这样的。 ```js import axios from 'axios' @@ -383,7 +397,7 @@ let token = null // highlight-line // highlight-start const setToken = newToken => { - token = `bearer ${newToken}` + token = `Bearer ${newToken}` } // highlight-end @@ -395,7 +409,7 @@ const getAll = () => { const create = async newObject => { // highlight-start const config = { - headers: { Authorization: token }, + headers: { Authorization: token } } // highlight-end @@ -404,101 +418,83 @@ const create = async newObject => { } const update = (id, newObject) => { - const request = axios.put(`${ baseUrl } /${id}`, newObject) + const request = axios.put(`${ baseUrl }/${id}`, newObject) return request.then(response => response.data) } export default { getAll, create, update, setToken } // highlight-line ``` - - -noteService 模块包含一个私有变量 _token_。它的值可以通过 _setToken_ 函数来改变,这个函数通过模块对外开放。 _create_ 方法现在利用 async/await 语法,将 token 塞到了认证头中。头信息作为第三个入参数放到了 axios 的 post 方法中。 + + noteService模块包含一个私有变量_token_。它的值可以通过模块导出的函数_setToken_来改变。_create_,现在使用async/await语法,将token设置为Authorization头。这个头被作为post方法的第三个参数交给axios。 - - -登录的事件处理改为,对登录成功的用户必须执行 noteService.setToken(user.token) + + 负责登录的事件处理程序必须改变,以便在登录成功后调用noteService.setToken(user.token)方法。 ```js const handleLogin = async (event) => { event.preventDefault() - try { - const user = await loginService.login({ - username, password, - }) + try { + const user = await loginService.login({ username, password }) noteService.setToken(user.token) // highlight-line setUser(user) setUsername('') setPassword('') - } catch (exception) { + } catch { // ... } } ``` - -现在添加新的 Note 又可以正常工作了 - -### Saving the token to browsers local storage -【将 token 保存到浏览器的本地存储中】 + + 现在添加新的笔记又开始工作了! +### Saving the token to the browser's local storage + + 我们的应用有一个缺陷:当页面被重新渲染时,用户的登录信息消失了。这也拖慢了开发速度。例如,当我们测试创建新的笔记时,我们每次都要重新登录。 - + + 这个问题可以通过将登录信息保存到[本地存储](https://developer.mozilla.org/en-US/docs/Web/API/Storage)来轻松解决。本地存储是浏览器中的一个[键-值](https://en.wikipedia.org/wiki/Key-value_database)数据库。 -我们的应用有一个缺陷,就是当页面重新渲染时,user 的登录信息就没了。这同样会降低开发速度。比如当我们想要测试创建一个新的 Note,我们每次都要重新登录。 - - - -通过将登录信息存储到一个本地浏览器的 [key-value](https://en.wikipedia.org/wiki/Key-value_database) 数据库中,问题就能够被简单地解决掉了。 - - - -它的使用十分简单。一个值对应一个存储在数据库中的特定的键,通过 [setItem](https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem)方法进行保存,例如: + +它非常容易使用。通过[setItem](https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem)方法将与某个key对应的value保存到数据库中。例如。 ```js window.localStorage.setItem('name', 'juha tauriainen') ``` - -将字符串作为第二个参数,存储到了以name为键的键值对中。 + +将作为第二个参数的字符串保存为键name的值。 - - -该键的值可以通过[getItem](https://developer.mozilla.org/en-US/docs/Web/API/Storage/getItem)方法获得。 + +键的值可以通过方法[getItem](https://developer.mozilla.org/en-US/docs/Web/API/Storage/getItem)找到。 ```js window.localStorage.getItem('name') ``` - - -[removeItem](https://developer.mozilla.org/en-US/docs/Web/API/Storage/removeItem) 可以删除一个键 - - - -即使页面刷新,存储中的值也会保留。这个存储是[原生](https://developer.mozilla.org/en-US/docs/Glossary/Origin)-指定的,所以每个 web 应用都有自己的存储空间。 + + 和 [removeItem](https://developer.mozilla.org/en-US/docs/Web/API/Storage/removeItem) 删除一个键。 - + + 即使页面被重新渲染,本地存储中的值也会持续存在。这个存储是[origin](https://developer.mozilla.org/en-US/docs/Glossary/Origin)特定的,所以每个网络应用都有自己的存储。 -让我们将我们的应用扩展来将用户的登录信息存储到本地存储中。 + + 让我们扩展我们的应用,使其将登录用户的详细信息保存在本地存储中。 - + + 保存到存储空间的值是[DOMstrings](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage#description),所以我们不能原封不动地保存一个JavaScript对象。该对象必须首先被解析为JSON,使用_JSON.stringify_方法。相应地,当一个JSON对象从本地存储中读取时,必须用_JSON.parse_将其解析为JavaScript。 -存储到本地存储的值称为[DOMstrings](https://developer.mozilla.org/en-US/docs/Web/API/DOMString),所以我们不能存储一个 Javascript 对象。对象首先要通过 -_JSON.stringify_ 方法转换成 JSON。相应的,当从本地存储读取 JSON 对象时,还要使用 _JSON.parse_ 来将其解析回 Javascript。 - - -我们将登录方法改为如下方式: + + 登录方法的变化如下: ```js const handleLogin = async (event) => { event.preventDefault() try { - const user = await loginService.login({ - username, password, - }) + const user = await loginService.login({ username, password }) // highlight-start window.localStorage.setItem( @@ -515,38 +511,39 @@ _JSON.stringify_ 方法转换成 JSON。相应的,当从本地存储读取 JSO } ``` - -现在用户的详细信息被存储到本地存储了,并且能够在控制台看到。 + +登录用户的详细信息现在被保存到本地存储中,并且可以在控制台中查看(通过在控制台中输入_window.localStorage_)。 ![](../../images/5/3e.png) - -我们仍然需要修改我们的应用,以便当我们进入页面时,应用会检查是否能在本地存储中找到登录用户的详细信息,如果可以,将信息保存到应用的状态中,以及noteService中 + + 你也可以使用开发者工具检查本地存储。在Chrome上,进入Application标签,选择Local Storage(更多细节[这里](https://developers.google.com/web/tools/chrome-devtools/storage/localstorage))。在Firefox上,进入Storage标签,并选择Local Storage(详细信息[here](https://developer.mozilla.org/en-US/docs/Tools/Storage_Inspector))。 - + + 我们仍然需要修改我们的应用,以便当我们进入页面时,应用检查是否已经在本地存储中找到了登录用户的详细资料。如果可以,这些细节就会被保存到应用的状态和noteService。 -正确的方式是用一个[effect hook](https://reactjs.org/docs/hooks-effect.html): 这种机制我们在第2章节 [第2章](/zh/part2/从服务器获取数据#effect-hooks)分中见到过,当时是用来从服务器中获取所有 Note。 + +正确的方法是使用[效果钩子](https://reactjs.org/docs/hooks-effect.html):这是我们在[第二章节](/en/part2/getting_data_from_server#effect-hooks)中第一次遇到的机制,用来从服务器上获取笔记。 - -我们可以有多个effect hook,所以我们来创建一个hook 来处理首次登录页面: + + 我们可以有多个效果钩子,所以让我们创建第二个效果钩子来处理页面的第一次加载。 ```js const App = () => { - const [notes, setNotes] = useState([]) + const [notes, setNotes] = useState([]) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) const [errorMessage, setErrorMessage] = useState(null) - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [user, setUser] = useState(null) + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [user, setUser] = useState(null) useEffect(() => { - noteService - .getAll().then(initialNotes => { - setNotes(initialNotes) - }) + noteService.getAll().then(initialNotes => { + setNotes(initialNotes) + }) }, []) - + // highlight-start useEffect(() => { const loggedUserJSON = window.localStorage.getItem('loggedNoteappUser') @@ -562,104 +559,96 @@ const App = () => { } ``` - - -这个作为事件参数的空数组确保在[第一次](https://reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect)组件渲染完成后被执行。 - + + 空数组作为效果的参数,确保效果只在组件被渲染[首次](https://reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect)时执行。 -现在用户可以永久地保持登录状态了,我们应该实现一个登出功能来删除登录信息。同样我们把这个作为一个课后作业。 + + 现在,一个用户会在应用中永远保持登录状态。我们也许应该添加一个logout功能,从本地存储中删除登录的细节。然而,我们将把它作为一个练习。 - - - -我们也可以通过控制台来登出用户,现在我们就用这种方法,执行以下命令来登出: + + 我们可以使用控制台注销用户,目前这已经足够了。 + +你可以用命令注销。 ```js window.localStorage.removeItem('loggedNoteappUser') ``` - - -或者完全清空本地存储: + + 或者使用完全清空localstorage的命令。 ```js window.localStorage.clear() ``` - - -当前的应用代码可以在[Github](https://Github.com/fullstack-hy2020/part2-notes/tree/part5-3) part5-3 分支上找到。 + + 目前的应用代码可以在[Github](https://github.com/fullstack-hy2020/part2-notes/tree/part5-3)上找到,分支part5-3
    - ### Exercises 5.1.-5.4. - -现在我们将为上一章节创建的博客列表后端创建一个前端。 你可以使用 GitHub 上的[这个应用](https://GitHub.com/fullstack-hy2020/bloglist-frontend)作为你的解决方案的基础。 应用期望您的后端在3001端口上运行。 - -只要提交完成的解决方案就足够了。 您可以在每次练习之后进行一次提交,但这并不强制。 + + 我们现在将为我们在上一部分创建的博客列表后端创建一个前端。你可以使用GitHub上的[此应用](https://github.com/fullstack-hy2020/bloglist-frontend)作为你的解决方案的基础。该应用希望你的后端运行在3003端口。 - -开始的几次练习修改了我们已经学到的关于React的所有知识。 他们可能是有挑战性的,特别是如果你的后端内容没有完成。 + +提交你完成的解决方案就可以了。你可以在每次练习后做一次提交,但这不是必须的。 - -最好使用第4章节的 model answers 作为后端。 + + 前几个练习是对我们到目前为止所学的关于React的所有知识的回顾。它们可能很有挑战性,特别是如果你的后端不完整的话。 + + 最好是使用第四章节的模型答案中的后端。 - -在做这些练习时,请记住我们讨论过的所有调试方法,尤其要密切关注控制台。 + + 在做练习的时候,要记住我们说过的所有调试方法,特别是要注意观察控制台。 - -**警告:**如果你注意到你正在混合 async/await 和_then_ 命令,你99.9% 正在做错误的事情。 要么使用其中之一,不要两者都使用。 + + **警告:**如果你注意到你在同一函数中混入了async/await和_then_命令,那么99.9%的人肯定是做错了。请使用其中之一,不要同时使用。 -#### 5.1: bloglist frontend, 步骤1 - -使用如下命令从[Github](https://Github.com/fullstack-hy2020/bloglist-frontend)克隆应用: +#### 5.1: Blog List Frontend, step1 + + + 用命令从[Github](https://github.com/fullstack-hy2020/bloglist-frontend)克隆应用。 ```bash git clone https://github.com/fullstack-hy2020/bloglist-frontend ``` - -删除了克隆应用的 git 配置 +remove the git configuration of the cloned application ```bash cd bloglist-frontend // go to cloned repository rm -rf .git ``` - -应用以常规的方式启动,但是你必须先安装它的依赖项: + + 应用以常规方式启动,但你必须先安装其依赖关系。 ```bash npm install npm start ``` - -在前端实现登录功能。成功登录后返回的令牌保存到应用的user状态。 + + 在前端实现登录功能。登录成功后返回的令牌被保存到应用的状态user。 - -如果一个用户没有登录,那么登录表单就是可见的。 + + 如果用户没有登录,只有登录表单是可见的。 ![](../../images/5/4e.png) - - - -如果用户登录,则显示用户名和博客列表。 + +如果用户已登录,将显示用户的名字和博客列表。 ![](../../images/5/5e.png) + + 登录的用户的详细资料还不需要保存到本地存储。 - - -登录用户的用户详细信息不必保存到本地存储中。 - - -你可以像这样实现登录表单的条件渲染,例如: + + **NB** 你可以像这样实现登录表单的条件渲染,例如。 ```js if (user === null) { @@ -684,40 +673,65 @@ npm start } ``` -#### 5.2: bloglist frontend, 步骤2 - -使用本地存储使登录成为永久性的。同时实现一种注销的方法。 +#### 5.2: Blog List Frontend, step2 + + + 通过使用本地存储使登录成为"永久"。同时实现一个注销的方法。 ![](../../images/5/6e.png) - + 确保浏览器在注销后不会记住用户的详细信息。 -#### 5.3: bloglist frontend, 步骤3 - -展开你的应用,允许登录用户添加新的博客: +#### 5.3: Blog List Frontend, step3 -![](../../images/5/7e.png) + +扩展你的应用,允许登录的用户添加新的博客。 +![](../../images/5/7e.png) +#### 5.4: Blog List Frontend, step4 -#### 5.4*: bloglist frontend, 步骤4 - -在页面顶部实现通知,告知用户成功和不成功的操作结果。 例如,当添加一个新博客时,可以显示如下通知: + + 实施通知,在页面顶部告知用户成功和不成功的操作。例如,当一个新博客被添加时,可以显示以下通知。 ![](../../images/5/8e.png) - - -登录失败可显示如下通知: + + 登录失败可以显示以下通知。 ![](../../images/5/9e.png) - - -通知必须可见几秒钟,添加颜色不是强制性的。 + + 通知必须在几秒钟内可见。添加颜色并不是强制性的。
    +
    + +### A note on using local storage + + + 在上一部分的[结尾](/en/part4/token_authentication#problems-of-token-based-authentication)我们提到,基于令牌的认证的挑战是如何应对令牌持有者对API的访问需要被撤销的情况。 + + +这个问题有两种解决方案。第一个是限制令牌的有效期限。这迫使用户在令牌过期后重新登录到应用。另一种方法是将每个令牌的有效期信息保存到后端数据库中。这种解决方案通常被称为服务器端会话。 + + + 无论如何检查和确保令牌的有效性,如果应用有安全漏洞,允许[跨站脚本(XSS)](https://owasp.org/www-community/attacks/xss/)攻击,将令牌保存在本地存储中可能包含安全风险。如果应用允许用户注入任意的JavaScript代码(例如使用一个表单),然后由应用执行,那么XSS攻击就有可能。当以合理的方式使用React时,这应该是不可能的,因为[React对其渲染的所有文本进行消毒](https://reactjs.org/docs/introducing-jsx.html#jsx-prevents-injection-attacks),这意味着它不会将渲染的内容作为JavaScript执行。 + + + 如果想安全起见,最好的选择是不将令牌存储到本地存储。在泄漏令牌可能产生悲剧性后果的情况下,这可能是一个选择。 + + +有人建议将登录用户的身份保存为[httpOnly cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies),这样JavaScript代码就不能访问令牌了。这个解决方案的缺点是,它将使实施SPA-应用变得更加复杂。人们至少需要实现一个单独的页面来登录。 + + + 然而,注意到即使使用httpOnly cookies也不能保证任何事情是好的。甚至有人建议,只使用httpOnly cookies[并不比](https://academind.com/tutorials/localstorage-vs-cookies-xss/)使用本地存储更安全。 + + + 所以不管使用什么解决方案,最重要的是[尽量减少XSS攻击的风险](https://cheatsheetseries.owasp.org/cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.html)。 + +
    diff --git a/src/content/5/zh/part5b.md b/src/content/5/zh/part5b.md index baace29511d..c8e1d46ba20 100644 --- a/src/content/5/zh/part5b.md +++ b/src/content/5/zh/part5b.md @@ -8,27 +8,24 @@ lang: zh
    ### Displaying the login form only when appropriate -【在合适的时候展示登录表单】 - -让我们修改应用,让登录表单在默认情况下不显示 + + 让我们修改应用,使其默认不显示登录表单。 ![](../../images/5/10e.png) - -而当用户点击登录按钮时,登录表单再出现 + + 当用户按下login按钮时,登录表单就会出现。 ![](../../images/5/11e.png) - -用户可以通过单击 cancel 按钮关闭登录表单 + + 用户可以通过点击取消按钮来关闭登录表格。 - -我们首先将登录组件解耦出来: + + 我们先把登录表单提取到它自己的组件中。 ```js -import React from 'react' - const LoginForm = ({ handleSubmit, handleUsernameChange, @@ -65,13 +62,13 @@ const LoginForm = ({ export default LoginForm ``` - -状态以及所有相关的函数都在组件外进行定义,并作为属性传递给组件。 + + 状态和所有与之相关的功能都是在组件之外定义的,并作为prop传递给组件。 - -注意,属性是通过变量解构出来的,这意味着不是如下这种方式编写: + + 注意,props是通过destructuring分配给变量的,这意味着不用再写。 ```js const LoginForm = (props) => { @@ -95,13 +92,13 @@ const LoginForm = (props) => { } ``` - -例如当访问 _props_ 对象的 _props.handleSubmit_ 属性时,属性被直接赋值给它们自己的变量。 + +通过例如_props.handleSubmit_来访问_props_对象的属性,而是直接将属性分配给它们自己的变量。 - -一个快速的实现方式是改变 App 组件的 loginForm 函数,如下所示: + + 实现该功能的一个快速方法是像这样改变App组件的_loginForm_函数。 ```js const App = () => { @@ -136,11 +133,13 @@ const App = () => { } ``` - -App 组件状态当前包含了 loginVisible 这个布尔值,定义了登录表单是否应当展示给用户。 - -loginVisible 可以通过两个按钮切换,每个按钮都有自己的事件处理函数,这些函数直接定义在组件中。 + + App组件的状态现在包含布尔值loginVisible,它定义了登录表单是否应该显示给用户。 + + + + loginVisible的值是通过两个按钮来切换的。这两个按钮的事件处理程序都直接定义在组件中。 ```js @@ -148,9 +147,9 @@ loginVisible 可以通过两个按钮切换,每个按钮都有自己的事件 ``` - -组件是否可见被定义在了一个内联样式中[inline](/zh/part2/给_react应用加点样式#inline-styles) ,即[display](https://developer.mozilla.org/en-US/docs/Web/CSS/display) 属性值是 none的时候,组件就看不到了: + + 组件的可见性是通过给组件一个[inline](/en/part2/adding_styles_to_react_app#inline-styles)样式规则来定义的,其中[display](https://developer.mozilla.org/en-US/docs/Web/CSS/display)属性的值是none如果我们不希望组件被显示。 ```js const hideWhenVisible = { display: loginVisible ? 'none' : '' } @@ -165,26 +164,28 @@ const showWhenVisible = { display: loginVisible ? '' : 'none' }
    ``` - -我们再次使用三元运算符。如果 _loginVisible_ 是 true,组件的 CSS 规则为: + + + 我们又一次使用了 "问号 "三元运算符。如果_loginVisible_是true,那么该组件的CSS规则将是。 ```css display: 'none'; ``` - -如果 _loginVisible_ 是 falsedisplay 不会接受任何与组件可见性相关的值。 + + 如果_loginVisible_是false,那么display将不会收到与该组件的可见性有关的任何值。 + ### The components children, aka. props.children -【组件的 children,又叫 props.children】 - -用于控制登录表单是否可见的代码,应当被视作它自己的逻辑实体,出于这个原因,它最好从 App 组件中解耦到自己的组件中。 + + 与管理登录表单的可见性有关的代码可以被认为是它自己的逻辑实体,由于这个原因,最好把它从App组件中提取到它自己的独立组件中。 - -我们的目标是实现一个新的 Togglable 组件,按照如下方式进行使用: + + + 我们的目标是实现一个新的Togglable组件,可以按以下方式使用。 ```js @@ -198,12 +199,13 @@ display: 'none'; ``` - -这与我们之前的组件使用方法有一些不同。包含打开和关闭标签的组件将 LoginForm 包含在了里面。用 React 的术语来说, LoginForm 组件是 Togglable 的子组件。 + + 该组件的使用方式与我们以前的组件略有不同。该组件有开头和结尾标签,围绕着一个LoginForm组件。在React术语中,LoginFormTogglable的一个子组件。 + - -任何我们想要打开或关闭的组件都可以通过 Togglable 进行包裹,例如: + + 我们可以在Togglable的开头和结尾标签之间添加任何我们想要的React元素,比如说这样。 ```js @@ -212,11 +214,12 @@ display: 'none'; ``` - -Togglable 组件的代码如下: + + + Togglable组件的代码如下所示。 ```js -import React, { useState } from 'react' +import { useState } from 'react' const Togglable = (props) => { const [visible, setVisible] = useState(false) @@ -244,12 +247,13 @@ const Togglable = (props) => { export default Togglable ``` - -这个新的且比较有趣的代码就是 [props.children](https://reactjs.org/docs/glossary.html#propschildren), 它用来引用组件的子组件。子组件就是我们想要控制开启和关闭的 React 组件。 + + 代码中新的和有趣的部分是[props.children](https://reactjs.org/docs/glossary.html#propschildren),那是用来引用组件的子组件。子组件是我们在组件的打开和关闭标签之间定义的React元素。 + - -这一次,子组件被渲染到了用于渲染组件本身的代码中: + + 这一次,子组件是在用于渲染组件本身的代码中被渲染出来的。 ```js
    @@ -258,8 +262,9 @@ export default Togglable
    ``` - -并不像之前我们见到的使用的普通属性, children被 React 自动添加了,并始终存在,只要这个组件定义了关闭标签 _/>_ + + + 与我们之前看到的 "普通 "prop不同,children是由React自动添加的,并且一直存在。如果一个组件被定义了一个自动关闭的_/>_标签,像这样。 ```js ``` - -这时 props.children 是一个空的数组。 + + 那么props.children就是一个空数组。 - -Togglable 组件可被重用,我们可以用它创建新的切换可见性的功能,如对添加 Note 的表单添加类似的功能。 + + Togglable组件是可重复使用的,我们可以用它来给用于创建新笔记的表单添加类似的可见性切换功能。 - -在这之前,我们把创建 Note 的表单解耦到自己的组件中。 + + 在我们这样做之前,让我们把创建笔记的表单提取到自己的组件中。 ```js const NoteForm = ({ onSubmit, handleChange, value}) => { @@ -295,9 +300,8 @@ const NoteForm = ({ onSubmit, handleChange, value}) => { ) } ``` - - -下面让我们把组件定义在 Togglable 组件中 + + 接下来让我们在一个Togglable组件中定义这个表单组件。 ```js @@ -309,37 +313,35 @@ const NoteForm = ({ onSubmit, handleChange, value}) => { ``` - -您可以在 [这个仓库](https://github.com/fullstack-hy2020/part2-notes/tree/part5-4)5-4分支中找到我们当前应用的全部代码。 + + 你可以在[这个github仓库](https://github.com/fullstack-hy2020/part2-notes/tree/part5-4)的part5-4分支中找到我们当前应用的全部代码。 -### State of the forms -【表单的状态】 - - -应用的状态当前位于 App 组件中。 - -React[文档](https://reactjs.org/docs/lifting-state-up.html)阐述了关于在哪里放置状态: +### State of the forms -> Often, several components need to reflect the same changing data. We recommend lifting the shared state up to their closest common ancestor.
    -通常,几个组件需要反映相同的变化数据。 我们建议将共享状态提升到它们最接近的共同祖先。 + + 目前应用的状态在_App_组件中。 - -如果我们考虑一下表单的状态,例如一个新便笺的内容在创建之前,App 组件实际上并不需要它做任何事情。 - -我们也可以将表单的状态移动到相应的组件中。 + + React 文档对放置状态的位置进行了[如下](https://zh-hans.react.dev/learn/sharing-state-between-components)说明: + +> 有时,您希望两个组件的状态始终一起更改。要做到这一点,请从它们中删除状态,将其移动到它们最近的公共父级,然后通过 props 将其传递给它们。这被称为提升状态,这是你编写 React 代码时最常做的事情之一。 + + 如果我们考虑到表单的状态,例如一个新的笔记在创建之前的内容,_App_组件实际上并不需要它。 + + 我们也可以把表单的状态移到相应的组件上。 - -便笺的组件变化如下: + +一个笔记的组件是这样变化的: ```js -import React, {useState} from 'react' +import { useState } from 'react' const NoteForm = ({ createNote }) => { - const [newNote, setNewNote] = useState('') + const [newNote, setNewNote] = useState('') const handleChange = (event) => { setNewNote(event.target.value) @@ -369,24 +371,23 @@ const NoteForm = ({ createNote }) => {
    ) } -``` - - - - -newNote state 属性和负责更改它的事件处理程序已经从 App 组件移动到负责记录表单的组件。 - +export default NoteForm +``` - -现在只剩下一个props,即 createNote 函数,当创建新便笺时,表单将调用该函数。 + +**注意** 同时,我们改变了应用的行为,使得新的笔记默认为重要,也就是说,important 字段获得的值为 true。 + +newNote 状态变量和负责改变它的事件处理器已经从 _App_ 组件移动到负责笔记表单的组件。 + +现在只剩下一个 prop,即 _createNote_ 函数,当创建新的笔记时,表单会调用它。 - -既然我们已经摆脱了newNote 状态及其事件处理程序,那么 App 组件就变得更简单了。 - -用于创建新便笺的 addNote 函数接收一个新便笺作为参数,该函数是我们发送到表单的唯一props: + + +_App_ 组件现在变得更简单,因为我们已经摆脱了 newNote 状态和它的事件处理器。 +创建新笔记的 _addNote_ 函数接收一个新的笔记作为参数,函数是我们发送给表单的唯一 prop: ```js const App = () => { @@ -409,40 +410,35 @@ const App = () => { } ``` + +我们可以对登录表单做同样的事情,但我们将把这留作可选的练习。 - - -我们可以对 log in 表单执行同样的操作,但是我们将把它留给一个可选练习。 - - - - - -应用代码可以从[github](https://github.com/fullstack-hy2020/part2-notes/tree/part5-5)中找到,分支5-5 。 - + +应用程序代码可以在 [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-5) 上找到,分支为 part5-5。 ### References to components with ref -【引用具有 ref 的组件】 - + +我们目前的实现相当不错,但它有一个可以改进的方面。 -我们当前的实现还不错,但有个地方可以改进 + +创建新的笔记后,隐藏新的笔记表单是有意义的。目前,表单仍然可见。隐藏表单有一个小问题。可见性是由 Togglable 组件内部的 visible 状态变量控制的。 - + +解决这个问题的一个办法是将 Togglable 组件的状态控制移出组件。然而,我们现在不会这样做,因为我们希望组件负责自己的状态。所以我们必须找到另一种解决方案,并找到一种机制来从外部改变组件的状态。 -当我们创建了一个新的 Note,我们应当隐藏新建 Note 的表单。当前这个表单会持续可见,但隐藏这个表单有个小问题。可见性是透过Togglable 组件的visible 变量来控制的,我们怎么从外部进行访问呢? - - -实际上从父组件来关闭这个表单有许多方法,我们来使用 React 的 [ref](https://reactjs.org/docs/refs-and-the-dom.html)机制,它提供了一个组件的引用。 - - -我们把 App 组件按如下修改: + +有几种不同的方法可以实现从组件外部访问组件的函数,但让我们使用 React 的 [ref](https://react.dev/learn/referencing-values-with-refs) 机制,它提供了对组件的引用。 + + 让我们对App组件做如下修改。 ```js +import { useState, useEffect, useRef } from 'react' // highlight-line + const App = () => { // ... - const noteFormRef = React.createRef() // highlight-line + const noteFormRef = useRef() // highlight-line const noteForm = () => ( // highlight-line @@ -454,17 +450,16 @@ const App = () => { } ``` - - -[createRef](https://reactjs.org/docs/react-api.html#reactcreateref) 方法就是用来创建 noteFormRef 引用,它被加到了能够控制表单创建的 Togglable 组件, noteFormRef 变量就代表了组件的引用。 + + [useRef](https://reactjs.org/docs/hooks-reference.html#useref) 钩子被用来创建一个noteFormRef参考,它被分配给包含创建笔记表单的Togglable组件。noteFormRef变量作为该组件的引用。这个钩子确保了在组件的重新渲染过程中保持相同的引用(ref)。 - -我们同样要修改 Togglable 组件: + + 我们还对Togglable组件做了如下修改。 ```js -import React, { useState, useImperativeHandle } from 'react' // highlight-line +import { useState, useImperativeHandle } from 'react' // highlight-line -const Togglable = React.forwardRef((props, ref) => { // highlight-line +const Togglable = (props) => { // highlight-line const [visible, setVisible] = useState(false) const hideWhenVisible = { display: visible ? 'none' : '' } @@ -475,10 +470,8 @@ const Togglable = React.forwardRef((props, ref) => { // highlight-line } // highlight-start - useImperativeHandle(ref, () => { - return { - toggleVisibility - } + useImperativeHandle(props.ref, () => { + return { toggleVisibility } }) // highlight-end @@ -493,21 +486,16 @@ const Togglable = React.forwardRef((props, ref) => { // highlight-line
    ) -}) // highlight-line +} export default Togglable ``` - - -创建组件的函数被包裹在了[forwardRef](https://reactjs.org/docs/react-api.html#reactforwardref) 函数调用。利用这种方式可以访问赋给它的引用。 - - + + 该组件使用[useImperativeHandle](https://reactjs.org/docs/hooks-reference.html#useimperativehandle)钩子来使它的toggleVisibility函数在组件之外可用。 -组件利用[useImperativeHandle](https://reactjs.org/docs/hooks-reference.html#useimperativehandle) Hook来将toggleVisibility 函数能够被外部组件访问到。 - - -我们现在可以在 Note 创建后,通过调用 noteFormRef.current.toggleVisibility() 控制表单的可见性了 + + 我们现在可以在创建一个新的笔记后,通过调用noteFormRef.current.toggleVisibility()来隐藏这个表单。 ```js const App = () => { @@ -516,7 +504,7 @@ const App = () => { noteFormRef.current.toggleVisibility() // highlight-line noteService .create(noteObject) - .then(returnedNote => { + .then(returnedNote => { setNotes(notes.concat(returnedNote)) }) } @@ -524,25 +512,23 @@ const App = () => { } ``` - -总结一下,[useImperativeHandle](https://reactjs.org/docs/hooks-reference.html#useImperativeHandle)函数是一个 React hook,用于定义组件中的函数,该组件可以从组件外部调用。 + + 回顾一下,[useImperativeHandle](https://reactjs.org/docs/hooks-reference.html#useimperativehandle)函数是一个React钩子,用于在组件中定义可以从组件外部调用的函数。 - -这个技巧适用于改变组件的状态,但是看起来有点不舒服。 我们可以使用基于“旧的 React”类组件,用稍微简洁的代码实现相同的功能。 我们将在课程材料的第7章节看看这些类组件。 到目前为止,只有在这种情况下,使用 React hooks 导致的代码不比使用类组件更干净。 + + 这个技巧对于改变组件的状态是有效的,但它看起来有点不爽。我们可以使用 "老式React "基于类的组件,用稍微干净的代码完成同样的功能。我们将在教材的第7章节看一下这些类组件。到目前为止,这是唯一一种使用React钩子导致的代码不比使用类组件干净的情况。 - -还有[其他用例](https://reactjs.org/docs/refs-and-the-dom.html)用于 refs 而不是访问 React 组件。 + +除了访问React组件,还有[其他用例](https://reactjs.org/docs/refs-and-the-dom.html)的参考文献。 - -您可以在[这个仓库](https://github.com/fullstack-hy2020/part2-notes/tree/part5-6)的part5-6 分支中找到我们当前应用的全部代码。 + +你可以在[这个github仓库](https://github.com/fullstack-hy2020/part2-notes/tree/part5-6)的part5-6分支中找到我们当前应用的全部代码。 ### One point about components -【关于组件的一个点】 - - -当我们在 React 定义一个组件: + +当我们在React中定义一个组件。 ```js const Togglable = () => ... @@ -550,8 +536,9 @@ const Togglable = () => ... } ``` - -并按如下方式进行使用: + + + 然后像这样使用它。 ```js
    @@ -569,72 +556,68 @@ const Togglable = () => ...
    ``` - -我们创建了三个单独的组件,并且都有自己的状态: + + 我们创建了该组件的三个独立实例,它们都有自己的独立状态。 ![](../../images/5/12e.png) - -ref 属性用于为变量 togglable1、 togglable2 和 togglable3 中的每个组件分配一个引用。 + + + ref属性用于为变量togglable1togglable2togglable3中的每个组件分配一个引用。
    - - ### Exercises 5.5.-5.10. -#### 5.5 Blog list frontend, 步骤5 - -更改用于创建博客文章的表单,使其只在适当的时候显示。 使用类似于[课程材料前面所展示的功能](/zh/part5/props_children_与_proptypes#displaying-the-login-form-only-when-appropriate)。 如果您希望这样做,可以使用第5章节中定义的Togglable 组件。 +#### 5.5 Blog list frontend, step5 + + + 改变创建博客文章的表格,使其只在适当的时候显示。使用类似于[在本章节教材的前面](/en/part5/props_children_and_proptypes#displaying-the-login-form-only-when-appropriate)所示的功能。如果你想这样做,你可以使用第五章节中定义的Togglable组件。 - -默认情况下,窗体不可见 + +默认情况下,表单是不可见的 ![](../../images/5/13ae.png) - -当单击new note 按钮时,它会展开 + + 当点击创建新博客按钮时,它就会展开。 ![](../../images/5/13be.png) + +当一个新的博客被创建或点击取消按钮后时,该表单就会关闭。 - -当创建新博客时,表单将关闭。 +#### 5.6 Blog list frontend, step6 -#### 5.6 Blog list frontend, 步骤6 + + 把创建新博客的表单分离到它自己的组件中(如果你还没有这样做的话),并把创建新博客所需的所有状态移到这个组件中。 - - -将创建新 blog 的表单分离到它自己的组件中(如果您还没有这样做) ,并将创建新博客所需的所有状态移动到此组件。 + +该组件必须像本章节的[材料](/en/part5/props_children_and_proptypes)中的NoteForm组件那样工作。 - -这个组件必须像[这里](/zh/part5/props_children_与_proptypes)的NewNote 组件那样工作。 +#### 5.7* Blog list frontend, step7 -#### 5.7* Blog list frontend, 步骤7 + + 让我们为每个博客添加一个按钮,它可以控制是否显示关于博客的所有细节。 - -让我们为每个博客添加一个按钮,用于控制是否显示博客的所有细节。 - - -点击按钮时打开博客的详细信息。 + +当按钮被点击时,博客的全部细节就会打开。 ![](../../images/5/13ea.png) + +当再次点击按钮时,细节就会隐藏。 + + 此时,喜欢按钮不需要做任何事情。 - -当再次单击按钮时,细节将被隐藏。 - - -此时, like 按钮不需要做任何事情。 + + 图中所示的应用有一点额外的CSS来改善其外观。 - -图中显示的应用使用了一些附加的 CSS 来改善其外观。 - - -使用[inline](/zh/part2/给_react应用加点样式#inline-styles)样式向应用添加样式很容易,如第2章节所示: + + 如第二章节所示,使用[inline](/en/part2/adding_styles_to_react_app#inline-styles)样式很容易为应用添加样式。 ```js const Blog = ({ blog }) => { @@ -656,19 +639,16 @@ const Blog = ({ blog }) => { )} ``` - - - - -**注意:** 尽管该部分实现的功能与Togglable 组件提供的功能几乎完全相同,但该组件不能直接用于实现所需的行为。 最简单的解决方案是将状态添加到控制博客文章显示形式的博客文章中。 + + **NB:**尽管这部分实现的功能与Togglable组件提供的功能几乎相同,但不能直接使用该组件来实现所需的行为。最简单的解决方案是在博文中加入控制博文显示形式的状态。 -#### 5.8*: Blog list frontend, 步骤8 +#### 5.8: Blog list frontend, step8 - -实现 like 按钮的功能。 通过向后端中的博客文章的唯一地址发出 HTTP PUT 请求,可以增加like。 + + 实现喜欢按钮的功能。通过向后端的博文的唯一地址发出HTTP _PUT_请求来增加赞。 - -由于后端操作将替换整个 blog 文章,因此必须在请求主体中发送其所有字段。 如果你想在下面的博客文章中添加赞: + + 由于后端操作取代了整个博文,你必须在请求体中发送其所有字段。如果你想给下面的博文添加一个喜欢。 ```js { @@ -685,8 +665,8 @@ const Blog = ({ blog }) => { }, ``` - -您必须使用如下请求数据向地址 /api/blogs/5a43fde2cbd20b12a2c34e91发出 HTTP PUT 请求: + + 你必须向地址/api/blogs/5a43fde2cbd20b12a2c34e91发出一个HTTP PUT请求,请求数据如下。 ```js { @@ -698,265 +678,141 @@ const Blog = ({ blog }) => { } ``` - -**最后一个警告:** 如果您注意到在同一段代码中混用了 async/await 和 then 方法,那么几乎可以肯定您做错了什么。 坚持使用一种或另一种,永远不要同时使用两种,“以防万一”。 - -#### 5.9*: Blog list frontend, 步骤9 - -根据like 的数量修改应用以列出博客文章。 对博客文章进行排序可以使用数组[sort](https://developer.mozilla.org/en-us/docs/web/javascript/reference/global_objects/array/sort)方法。 - -#### 5.10*: Blog list frontend, 步骤10 - -添加一个新的按钮用于删除博客文章。还可以在后端实现删除博客文章的逻辑。 - - -您的应用可以是这样的: - -![](../../images/5/14ea.png) - - -用于删除博客文章的确认对话框很容易通过[window.confirm](https://developer.mozilla.org/en-us/docs/web/api/window/confirm)函数实现。 - - -只有当用户添加了博客文章时,才显示删除博客文章的按钮。 - -
    - + + **最后一个警告:**如果你注意到你在同一段代码中使用async/await和_then_方法,几乎可以肯定你做错了什么。坚持使用其中一个,而不要同时使用两个,"以防万一"。 -
    - -### PropTypes +#### 5.9: Blog List Frontend, step 9 - -Togglable 组件假定使用者会通过 buttonLabel 属性获传递按钮的文本。 如果我们忘记给组件定义: - -```js - buttonLabel forgotten... -``` + +我们注意到有些地方出问题了。当在应用中喜欢一篇博客时,添加该博客的用户的名字并未显示在其详细信息中: - +![浏览器显示在喜欢按钮下方缺少名字](../../images/5/59put.png) -应用会运行正常,但浏览器呈现一个没有 label text 的按钮。 + +当浏览器刷新时,人物的信息就显示出来了。这是不可接受的,找出问题所在并做出必要的修正。 - -如果我们希望使用 Togglable 组件时,强制给按钮一个 label text 属性值。 + +当然,也有可能你已经做得一切都正确,问题并没有出现在你的代码中。在那种情况下,你可以继续前进。 - -这个需求可以通过 [prop-types](https://github.com/facebook/prop-types) 包来定义,我们来安装一下: +#### 5.10: Blog List Frontend, step 10 -```js -npm install --save prop-types -``` + +修改应用程序,按照 likes 的数量对博客帖子进行排序。排序可以使用数组的 [sort](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) 方法。 - -我们可以定义 buttonLabel 属性定义为 mandatory,或按如下加入required 这种字符串类型的属性: +#### 5.11: Blog List Frontend, step 11 -```js -import PropTypes from 'prop-types' + +添加一个新的按钮用于删除博客帖子。同时,在前端实现删除博客帖子的逻辑。 -const Togglable = React.forwardRef((props, ref) => { - // .. -} - -Togglable.propTypes = { - buttonLabel: PropTypes.string.isRequired -} -``` - - -如果这时属性是 undefined,控制台就会展示如下的错误信息 - -![](../../images/5/15.png) - - -虽然应用程序仍然可以工作,没有任何东西强迫我们定义 PropTypes。 但它可以通过控制台飙红来提醒我们,因为不处理红色警告是非常不专业的做法。 - - -让我们给 LoginForm 组件同样定义一个 PropTypes。 + +你的应用程序可能看起来像这样: -```js -import PropTypes from 'prop-types' +![博客移除确认的浏览器](../../images/5/14ea.png) -const LoginForm = ({ - handleSubmit, - handleUsernameChange, - handlePasswordChange, - username, - password - }) => { - // ... - } + +使用 [window.confirm](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/confirm) 函数,实现删除博客帖子的确认对话框非常简单。 -LoginForm.propTypes = { - handleSubmit: PropTypes.func.isRequired, - handleUsernameChange: PropTypes.func.isRequired, - handlePasswordChange: PropTypes.func.isRequired, - username: PropTypes.string.isRequired, - password: PropTypes.string.isRequired -} -``` + +只有当博客帖子是由用户添加的时候,才显示删除博客帖子的按钮。 - -如果传递给 prop 的类型是错误的。例如,如果我们尝试定义 handleSubmit 成 string,那结果会出现如下警告: +
    -![](../../images/5/16.png) +
    ### ESlint - -在第三章节中我们配置了[ESlint](/zh/part3/es_lint与代码检查#lint) ,为后台代码控制了代码样式。让我们同样加到前台代码中。 + + 在第三章节,我们将[ESlint](/zh/part3/es_lint与代码检查#lint)代码风格工具配置到后端。让我们把ESlint也用在前端。 - -Create-react-app 已经默认为项目安装好了 ESlint, 所以我们需要做的就是定义自己的.eslintrc.js 文件 + +Vite 默认将 ESlint 安装到项目中,所以我们剩下要做的就是在 eslint.config.js 文件中定义我们想要的配置。 - -注意: 不要运行 eslint-- init 命令。 它将安装与 create-react-app 创建的配置文件不兼容的最新版本的 ESlint! - - -下面,我们将开始测试前端,为避免不想要和不相关的 lint 错误,我们先安装[eslint-jest-plugin](https://www.npmjs.com/package/eslint-plugin-jest) 库: + +让我们创建一个包含以下内容的 eslint.config.js 文件: ```js -npm add --save-dev eslint-plugin-jest -``` +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' - -让我们为 .eslintrc.js 添加如下内容 - -```js -module.exports = { - "env": { - "browser": true, - "es6": true, - "jest/globals": true - }, - "extends": [ - "eslint:recommended", - "plugin:react/recommended" - ], - "parserOptions": { - "ecmaFeatures": { - "jsx": true - }, - "ecmaVersion": 2018, - "sourceType": "module" - }, - "plugins": [ - "react", "jest" - ], - "rules": { - "indent": [ - "error", - 2 - ], - "linebreak-style": [ - "error", - "unix" - ], - "quotes": [ - "error", - "single" - ], - "semi": [ - "error", - "never" - ], - "eqeqeq": "error", - "no-trailing-spaces": "error", - "object-curly-spacing": [ - "error", "always" - ], - "arrow-spacing": [ - "error", { "before": true, "after": true } +export default [ + { ignores: ['dist'] }, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module' + } + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh + }, + rules: { + ...js.configs.recommended.rules, + ...reactHooks.configs.recommended.rules, + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true } + // highlight-start ], - "no-console": 0, - "react/prop-types": 0 + indent: ['error', 2], + 'linebreak-style': ['error', 'unix'], + quotes: ['error', 'single'], + semi: ['error', 'never'], + eqeqeq: 'error', + 'no-trailing-spaces': 'error', + 'object-curly-spacing': ['error', 'always'], + 'arrow-spacing': ['error', { before: true, after: true }], + 'no-console': 'off' + //highlight-end + } } -} -``` - - -注意: 如果你将 Visual Studio Code 与 ESLint 插件一起使用,你可能需要增加额外的workspace级别的设置才能使其正常工作。如果看到```Failed to load plugin react: Cannot find module 'eslint-plugin-react' ``` 说明需要一些额外的配置,增加```"eslint.workingDirectories": [{ "mode": "auto" }] ``` 到 workspace 的settings.json文件中就运行正常了,具体详见[这里](https://github.com/microsoft/vscode-eslint/issues/880#issuecomment-578052807) - - -让我们创建一个 [.eslintignore](https://eslint.org/docs/user-guide/configuring#ignoring-files-and-directories) 添加如下内容: - -```bash -node_modules -build +] ``` - -现在 buildnode_modules 这两个文件夹就不会被 lint 到了 - - -同样让我们为 lint 创建一个 npm 脚本: + +注意:如果你在 Visual Studio Code 里使用 ESLint 插件,你可能需要修改一些工作区设置。如果你看到 Failed to load plugin react: Cannot find module 'eslint-plugin-react',那么你需要添加额外的配置。把下一行到添加到 setting.json 中可能会有帮助: ```js -{ - // ... - { - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject", - "server": "json-server -p3001 db.json", - "eslint": "eslint ." // highlight-line - }, - // ... -} +"eslint.workingDirectories": [{ "mode": "auto" }] ``` - -组件 _Togglable_ 导致了一些烦人的警告:组件定义缺少显示名: - -![](../../images/5/25ea.png) - - - -React-devtools 还显示组件没有名称: - -![](../../images/5/25ea.png) - - - -幸运的是,这个问题很容易解决 + +更多信息见[这里](https://github.com/microsoft/vscode-eslint/issues/880#issuecomment-578052807)。 -```js -import React, { useState, useImperativeHandle } from 'react' -import PropTypes from 'prop-types' + +通常来说,为了运行 lint,你既可以使用命令行的命令 -const Togglable = React.forwardRef((props, ref) => { - // ... -}) +```bash +npm run lint +``` -Togglable.displayName = 'Togglable' // highlight-line + -export default Togglable -``` +也可以使用编辑器的 ESlint 插件。 - -您可以在[this github repository](https://github.com/fullstack-hy2020/part2-notes/tree/part5-7)的part5-7 分支中找到我们当前应用的全部代码。 + + 你可以在[这个github仓库](https://github.com/fullstack-hy2020/part2-notes/tree/part5-7)的part5-7分支中找到我们当前应用的全部代码。
    +### Exercise 5.12. -### Exercises 5.11.-5.12. -#### 5.11: Blog list frontend, 步骤11 - -为应用的一个组件定义 PropTypes。 +#### 5.12: Blog List Frontend, step 12 -#### 5.12: Blog list frontend, 步骤12 -向项目中添加 ESlint。根据您的喜好定义配置。修复所有的lint错误。 +将 ESlint 添加到项目中。根据你的喜好定义配置。然后修复所有的 linter 错误。 - -Create-react-app 默认已经在项目中安装了 ESlint,所以剩下要做的就是在中定义你想要的 .eslintrc.js 文件。 - - -注意: 不要运行 eslint-- init 命令。 它将安装与 create-react-app 创建的配置文件不兼容的最新版本的 ESlint! + +Vite 已经默认在项目中安装了 ESlint,所以你需要做的就是在 eslint.config.js 文件中定义你想要的配置。
    - diff --git a/src/content/5/zh/part5c.md b/src/content/5/zh/part5c.md index ed52ea61832..2eeb7267957 100644 --- a/src/content/5/zh/part5c.md +++ b/src/content/5/zh/part5c.md @@ -5,29 +5,83 @@ letter: c lang: zh --- -
    +
    + +2024年3月3日,本部分使用的测试库从Jest更改为Vitest。如果您已经开始使用Jest进行本部分的工作,您可以在[这里](https://github.com/fullstack-hy2020/fullstack-hy2020.github.io/blob/02d8be28b1c9190f48976fbbd2435b63261282df/src/content/5/zh/part5c.md)查看旧内容。 + +
    +
    +有许多不同的方法可以测试React应用程序。让我们来看看它们。 - -测试 React 应用程序有许多不同的方法。 接下来让我们看看它们。 +本课程以前使用了Facebook开发的[Jest](http://jestjs.io/)库来测试React组件。我们现在使用来自Vite开发人员的新一代测试工具,称为[Vitest](https://vitest.dev/)。除了配置之外,这两个库提供了相同的编程接口,因此在测试代码中几乎没有任何区别。 - -测试将使用与前一章节相同的由 Facebook 开发的 Jest 测试库来实现。create-react-app 默认添加了 Jest 。 +让我们首先安装Vitest和模拟Web浏览器的[jsdom](https://github.com/jsdom/jsdom)库: - +``` +npm install --save-dev vitest jsdom +``` -除了 Jest 之外,我们还需要另一个测试库,它将帮助我们以测试目的渲染组件。 目前最好的选择是[react-testing-library](https://github.com/testing-library/react-testing-library) ,这个库在最近几年迅速流行起来。 +除了Vitest之外,我们还需要另一个测试库,用于帮助我们渲染组件进行测试。目前最好的选择是[react-testing-library](https://github.com/testing-library/react-testing-library),它在最近的时间内迅速增长了人气。还值得使用[jest-dom](https://github.com/testing-library/jest-dom)库扩展测试的表达能力。 - -让我们用以下命令来安装这个库: +让我们使用以下命令安装这些库: ```js npm install --save-dev @testing-library/react @testing-library/jest-dom ``` + +在我们进行第一个测试之前,我们需要进行一些配置。 + + +我们在package.json文件中添加一个脚本来运行测试: + +```js +{ + "scripts": { + // ... + "test": "vitest run" + } + // ... +} +``` + + +让我们在项目根目录中创建一个名为_testSetup.js_的文件,内容如下: + +```js +import { afterEach } from 'vitest' +import { cleanup } from '@testing-library/react' +import '@testing-library/jest-dom/vitest' + +afterEach(() => { + cleanup() +}) +``` + + +现在,在每个测试之后,将执行_cleanup_函数,该函数重置了模拟浏览器的jsdom。 + + +将_vite.config.js_文件扩展如下: + +```js +export default defineConfig({ + // ... + test: { + environment: 'jsdom', + globals: true, + setupFiles: './testSetup.js', + } +}) +``` + + +通过设置 _globals: true_ ,我们无需在测试中导入关键字,如 _describe_ 、 _test_ 和 _expect_ 。 + -让我们首先为负责渲染 Note 的组件编写测试: +让我们首先为负责渲染注释的组件编写测试: ```js const Note = ({ note, toggleImportance }) => { @@ -44,22 +98,19 @@ const Note = ({ note, toggleImportance }) => { } ``` - -注意,li 元素具有 [CSS](https://reactjs.org/docs/dom-elements.html#classname) classname note ,用于访问我们测试中的组件。 + +请注意,_li_元素的[CSS](https://react.dev/learn#adding-styles)属性className的值为_note_,可以用于在我们的测试中访问该组件。 ### Rendering the component for tests -【为测试渲染组件】 - -我们将在 src/components/Note.test.js 中编写测试,它与组件本身在同一个目录中。 + +我们将在与组件本身位于同一目录的 _src/components/Note.test.jsx_ 文件中编写测试。 -第一个测试验证组件是否渲染了 Note 的内容: +第一个测试验证组件是否呈现了注释的内容: ```js -import React from 'react' -import '@testing-library/jest-dom/extend-expect' -import { render } from '@testing-library/react' +import { render, screen } from '@testing-library/react' import Note from './Note' test('renders content', () => { @@ -68,157 +119,255 @@ test('renders content', () => { important: true } - const component = render( - - ) + render() - expect(component.container).toHaveTextContent( - 'Component testing is done with react-testing-library' - ) + const element = screen.getByText('Component testing is done with react-testing-library') + expect(element).toBeDefined() }) ``` - -在初始配置之后,测试使用 react-testing-library 提供的 [render](https://testing-library.com/docs/react-testing-library/api#render) 方法渲染组件: + +在初始配置之后,测试使用了由react-testing-library提供的 [render](https://testing-library.com/docs/react-testing-library/api#render) 函数来渲染组件: ```js -const component = render( - -) +render() ``` - -通常 React 会将组件渲染给DOM。 我们使用的 render 方法以适合于测试的格式渲染组件,而不需要将它们渲染给 DOM。 + +通常,React组件会渲染到 [DOM](https://developer.mozilla.org/zh-CN/docs/Web/API/Document_Object_Model) 中。我们使用的 render 方法以适合测试的格式渲染组件,而无需将其渲染到DOM中。 - -Render 返回一个具有多个[属性](https://testing-library.com/docs/react-testing-library/api#render-result)的对象。 其中一个属性称为container,它包含由组件渲染的所有 HTML。 - - -在except中,我们验证组件是否渲染出正确的文本,在本例中,该文本就是 Note 的内容: + +我们可以使用 [screen](https://testing-library.com/docs/queries/about#screen) 对象来访问渲染的组件。我们使用screen的 [getByText](https://testing-library.com/docs/queries/bytext) 方法来搜索具有注释内容的元素,并确保它存在: ```js -expect(component.container).toHaveTextContent( - 'Component testing is done with react-testing-library' -) + const element = screen.getByText('Component testing is done with react-testing-library') + expect(element).toBeDefined() ``` -### Running tests -【运行测试】 - - -Create-react-app 默认情况下将测试配置为在 watch 模式下运行,这意味着 _npm test_ 命令在测试结束后不会退出,而是等待对代码进行更改。 一旦保存了对代码的新的更改,测试就会自动执行,然后 Jest 等待新的更改。 + +使用Vitest的 [expect](https://vitest.dev/api/expect.html#expect) 命令来检查元素的存在性。expect从其参数生成断言,可以使用各种条件函数来测试其有效性。现在,我们使用了 [toBeDefined](https://vitest.dev/api/expect.html#tobedefined) ,它测试expect的 _element_ 参数是否存在。 - -如果你想“正常地”运行测试,你可以使用以下命令: + +使用命令_npm test_运行测试: ```js -CI=true npm test +$ npm test + +> notes-frontend@0.0.0 test +> vitest run + + + RUN v3.2.3 /home/vejolkko/repot/fullstack-examples/notes-frontend + + ✓ src/components/Note.test.jsx (1 test) 19ms + ✓ renders content 18ms + + Test Files 1 passed (1) + Tests 1 passed (1) + Start at 14:31:54 + Duration 874ms (transform 51ms, setup 169ms, collect 19ms, tests 19ms, environment 454ms, prepare 87ms) ``` - + +Eslint在测试中抱怨关键字 _test_ 和 _expect_。这个问题可以通过在 eslint.config.js 中添加如下配置来解决: -注意: 如果您没有安装 Watchman,控制台可能会发出警告。 Watchman 是 Facebook 开发的一个应用程序,可以监视文件的变化。 这个程序加快了测试的执行速度,至少从 macOS Sierra 开始,在 watch 模式下运行测试会向控制台发出一些警告,这些警告可以通过安装 Watchman 来消除。 +```js +// ... - +export default [ + // ... + // highlight-start + { + files: ['**/*.test.{js,jsx}'], + languageOptions: { + globals: { + ...globals.vitest + } + } + } + // highlight-end +] +``` -在不同操作系统上安装 Watchman 的说明可以在 Watchman 官方网站上找到: https://facebook.github.io/Watchman/ + +这让 ESLint 知道在测试文件中 Vitest 关键字是全局可用的。 ### Test file location +在 React 中,测试文件的位置至少有 [两种不同的约定](https://medium.com/@JeffLombardJr/organizing-tests-in-jest-17fc431ff850)。我们按照当前标准创建了我们的测试文件,将它们放在与被测组件相同的目录中。 -在 React 中,关于测试文件的位置(至少)有[两个不同的约定](https://medium.com/@JeffLombardJr/organizing-tests-in-jest-17fc431ff850) 。 我们当前的标准是创建测试文件,将它们放在与被测试组件相同的目录中。 + +另一个约定是将测试文件“正常”存储在单独的 _test_ 目录中。无论我们选择哪种约定,几乎可以肯定会有人认为是错误的。 - -另一个约定是将测试文件“正常”存储在它们自己的单独目录中。 无论我们选择哪种惯例,都会觉得另一种是完全错误的。 - - -就我个人而言,我不喜欢这种将测试和应用程序代码存储在同一个目录中的方式。 我们之所以选择遵循这个约定,是因为它是在 create-react-app 创建的应用程序中默认配置的。 + +我不喜欢将测试和应用程序代码存储在同一目录中的这种方式。然而,我们现在遵循此约定,因为它是小项目的最佳实践。 ### Searching for content in a component -【在组件中搜寻内容】 - -react-testing-library 包提供了许多不同的方法来研究被测试组件的内容。 让我们稍微扩展一下我们的测试: + +react-testing-library 包提供了多种不同的方法来调查被测组件的内容。实际上,我们的测试中的 _expect_ 根本不需要: ```js +import { render, screen } from '@testing-library/react' +import Note from './Note' + test('renders content', () => { const note = { content: 'Component testing is done with react-testing-library', important: true } - const component = render( - - ) + render() - // method 1 - expect(component.container).toHaveTextContent( - 'Component testing is done with react-testing-library' - ) + const element = screen.getByText('Component testing is done with react-testing-library') - // method 2 - const element = component.getByText( - 'Component testing is done with react-testing-library' + expect(element).toBeDefined() // highlight-line +}) +``` + + +如果 _getByText_ 没有找到它正在查找的元素,测试将失败。 + + +_getByText_ 命令默认情况下,会搜索只含**作为参数提供的文本**且不包含其他内容的元素。让我们假设一个组件会以如下方式将文本渲染到 HTML 元素中: + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' : 'make important' + + return ( +
  • + Your awesome note: {note.content} // highlight-line + +
  • ) +} + +export default Note +``` + + +测试使用的 _getByText_ 方法 无法 找到该元素: + +```js +test('renders content', () => { + const note = { + content: 'Does not work anymore :(', + important: true + } + + render() + + const element = screen.getByText('Does not work anymore :(') + expect(element).toBeDefined() +}) +``` - // method 3 - const div = component.container.querySelector('.note') - expect(div).toHaveTextContent( - 'Component testing is done with react-testing-library' - ) + +如果我们想查找包含特定文本的元素,可以使用一个额外的选项: + +```js +const element = screen.getByText( + 'Does not work anymore :(', { exact: false } +) +``` + + +或者我们可以使用 _findByText_ 方法: + +```js +const element = await screen.findByText('Does not work anymore :(') +``` + + +需要注意的是,与其它 _ByText_ 方法不同,_findByText_ 返回的是一个 promise! + + + +在某些情况下,查询方法 _queryByText_ 的另一种形式非常有用。该方法会返回元素,但如果未找到,则不会引发异常。 + + +我们例如可以使用该方法来确保某些内容没有被渲染到组件中: + +```js +test('does not render this', () => { + const note = { + content: 'This is a reminder', + important: true + } + + render() + + const element = screen.queryByText('do not want this thing to be rendered') + expect(element).toBeNull() }) ``` - -第一种方法是使用toHaveTextContent方法从组件渲染的整个 HTML 代码中搜索匹配的文本。 + +还存在其他方法,例如 [getByTestId](https://testing-library.com/docs/queries/bytestid/),它根据专门为测试目的创建的 id 字段来搜索元素。 - -toHaveTextContent 是许多“匹配器”方法之一,这些方法是由[jest-dom](https://github.com/testing-library/jest-dom#toHaveTextContent)库提供的。 + +们还可以使用 [CSS 选择器](https://developer.mozilla.org/zh-CN/docs/Web/CSS/CSS_Selectors) 通过使用 [querySelector](https://developer.mozilla.org/zh-CN/docs/Web/API/Document/querySelector) 方法来查找呈现的元素对象 [container](https://testing-library.com/docs/react-testing-library/api/#container-1),这是 render 返回的字段之一: - -第二种方法是使用 render 方法返回对象的[getByText](https://testing-library.com/docs/dom-testing-library/api-queries#bytext) 。 该方法返回包含给定文本的元素。如果不存在此类元素,则发生异常。 出于这个原因,我们在技术上不需要指定任何额外的except。 +```js +import { render, screen } from '@testing-library/react' +import Note from './Note' - -第三种方法是搜索由组件渲染的特定元素,该组件使用[querySelector](https://developer.mozilla.org/en-us/docs/web/api/document/querySelector)方法,该方法接收[CSS 选择器](https://developer.mozilla.org/en-us/docs/web/CSS/css_selectors)作为其参数。 +test('renders content', () => { + const note = { + content: 'Component testing is done with react-testing-library', + important: true + } + + const { container } = render() // highlight-line + +// highlight-start + const div = container.querySelector('.note') + expect(div).toHaveTextContent( + 'Component testing is done with react-testing-library' + ) + // highlight-end +}) +``` - -最后两个方法使用getByTextquerySelector 方法从渲染的组件中查找匹配某些条件的元素。 - -有许多类似的查询方法[可用](https://testing-library.com/docs/dom-testing-library/api-queries)。 + +**注意:**选择元素的更一致方法是使用 [数据属性](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Global_attributes/data-*),该属性专门为测试目的而定义。使用 _react-testing-library_,我们可以利用 [getByTestId](https://testing-library.com/docs/queries/bytestid/) 方法来选择具有指定 _data-testid_ 属性的元素。 ### Debugging tests -【调试测试】 在编写测试时,我们通常会遇到许多不同类型的问题。 - -由 render 方法返回的对象具有一个调试方法[debug](https://testing-library.com/docs/react-testing-library/api#debug) ,该方法可用于将组件渲染的 HTML 打印到控制台。 让我们通过对代码进行以下更改来尝试一下: + +对象 _screen_ 具有方法 [debug](https://testing-library.com/docs/dom-testing-library/api-debugging#screendebug),可用于将组件的 HTML 打印到终端。如果我们按如下方式更改测试: ```js +import { render, screen } from '@testing-library/react' +import Note from './Note' + test('renders content', () => { const note = { content: 'Component testing is done with react-testing-library', important: true } - const component = render( - - ) + render() - component.debug() // highlight-line + screen.debug() // highlight-line // ... + }) ``` - -我们可以在控制台中看到由组件生成的 HTML: + +HTML被打印到控制台: ```js -console.log node_modules/@testing-library/react/dist/index.js:90 +console.log
  • ``` - -还可以搜索组件的一小部分并打印其 HTML 代码。 为了做到这一点,我们需要 _prettyDOM_ 方法,该方法可以从 @testing-library/dom 包中导入,该包通过 react-testing-library 自动安装了: + +也可以使用相同的方法将所需元素打印到控制台: ```js -import React from 'react' -import '@testing-library/jest-dom/extend-expect' -import { render } from '@testing-library/react' -import { prettyDOM } from '@testing-library/dom' // highlight-line +import { render, screen } from '@testing-library/react' import Note from './Note' test('renders content', () => { @@ -249,20 +395,20 @@ test('renders content', () => { important: true } - const component = render( - - ) - const li = component.container.querySelector('li') - - console.log(prettyDOM(li)) // highlight-line + render() + + const element = screen.getByText('Component testing is done with react-testing-library') + + screen.debug(element) // highlight-line + + expect(element).toBeDefined() }) ``` - -我们使用选择器查找组件内部的 li 元素,并将其 HTML 输出到控制台: + +现在打印出所需元素的HTML: ```js -console.log src/components/Note.test.js:21
  • @@ -274,422 +420,550 @@ console.log src/components/Note.test.js:21 ``` ### Clicking buttons in tests -【在测试中点击按钮】 +除了显示内容之外,Note 组件还确保在按下与注释关联的按钮时调用 _toggleImportance_ 事件处理程序函数。 -除了显示内容之外, Note 组件还确保在按下与 Note 关联的按钮时,调用 _toggleImportance_ 事件处理函数。 + +让我们安装一个库 [user-event](https://testing-library.com/docs/user-event/intro),它使模拟用户输入变得更容易: + +```bash +npm install --save-dev @testing-library/user-event +``` -测试这个功能可以这样完成: +可以像这样测试此功能: ```js -import React from 'react' -import { render, fireEvent } from '@testing-library/react' // highlight-line -import { prettyDOM } from '@testing-library/dom' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' // highlight-line import Note from './Note' // ... -test('clicking the button calls event handler once', () => { +test('clicking the button calls event handler once', async () => { const note = { content: 'Component testing is done with react-testing-library', important: true } + + const mockHandler = vi.fn() // highlight-line - const mockHandler = jest.fn() - - const component = render( - + render( + // highlight-line ) - const button = component.getByText('make not important') - fireEvent.click(button) + const user = userEvent.setup() // highlight-line + const button = screen.getByText('make not important') // highlight-line + await user.click(button) // highlight-line - expect(mockHandler.mock.calls).toHaveLength(1) + expect(mockHandler.mock.calls).toHaveLength(1) // highlight-line }) ``` - -关于这个测试有一些有趣的事情。 事件处理程序是用 Jest 定义的 [mock](https://facebook.github.io/jest/docs/en/mock-functions.html) 函数: + +此测试有一些与之相关的有趣之处。事件处理程序是一个使用 Vitest 定义的 [mock](https://vitest.dev/api/mock) 函数: + +```js +const mockHandler = vi.fn() +``` + + +[session](https://testing-library.com/docs/user-event/setup/) 启动以与呈现的组件进行交互: ```js -const mockHandler = jest.fn() +const user = userEvent.setup() ``` -测试根据渲染组件的文本找到按钮,然后单击元素: +测试基于呈现组件的文本找到按钮并单击元素: ```js -const button = getByText('make not important') -fireEvent.click(button) +const button = screen.getByText('make not important') +await user.click(button) ``` - -使用 [fireEvent](https://testing-library.com/docs/api-events#fireevent) 方法进行单击。 + +单击使用 userEvent 库的 [click](https://testing-library.com/docs/user-event/convenience/#click) 方法进行。 - -测试的期望验证 mock function 是否只被调用了一次。 + +测试的期望使用 [toHaveLength](https://vitest.dev/api/expect.html#tohavelength) 来验证mock 函数已被调用一次: ```js expect(mockHandler.mock.calls).toHaveLength(1) ``` - + +[Mock 对象和函数](https://en.wikipedia.org/wiki/Mock_object) 是测试中常用的 [stub](https://en.wikipedia.org/wiki/Method_stub) 组件,用于替换被测组件的依赖项。Mocks 可以返回硬编码的响应,并验证 mock 函数被调用的次数以及使用什么参数。 -[模拟对象和函数](https://en.wikipedia.org/wiki/Mock_object) 是测试中常用的根组件,用于替换被测试组件的依赖项。 通过 mock 可以返回硬编码的响应,并验证调用 mock 函数的次数和参数。【TODO】 + +在我们的示例中,mock 函数是一个完美的选择,因为它可以很容易地用于验证该方法被调用一次。 -在我们的示例中,mock 函数是一个完美的选择,因为它可以很容易地用于验证方法是否只被调用一次。 +让我们为Togglable组件编写一些测试。让我们将togglableContent CSS 类名添加到返回子组件的 div。 -### Tests for the Togglable component -【测试可切换组件】 +### Tests for the Togglable component - -让我们为 Togglable 组件编写一些测试。 让我们将 togglableContent CSS classname 添加到返回子组件的 div。 + +让我们为 Togglable 组件编写一些测试。测试如下: ```js -const Togglable = React.forwardRef((props, ref) => { - // ... - - return ( -
    -
    - -
    -
    // highlight-line - {props.children} - -
    -
    - ) -}) -``` - - -测试结果如下: - -```js -import React from 'react' -import '@testing-library/jest-dom/extend-expect' -import { render, fireEvent } from '@testing-library/react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import Togglable from './Togglable' describe('', () => { - let component - beforeEach(() => { - component = render( + render( -
    +
    togglable content
    ) }) test('renders its children', () => { - expect( - component.container.querySelector('.testDiv') - ).toBeDefined() + screen.getByText('togglable content') }) test('at start the children are not displayed', () => { - const div = component.container.querySelector('.togglableContent') - - expect(div).toHaveStyle('display: none') + const element = screen.getByText('togglable content') + expect(element).not.toBeVisible() }) - test('after clicking the button, children are displayed', () => { - const button = component.getByText('show...') - fireEvent.click(button) + test('after clicking the button, children are displayed', async () => { + const user = userEvent.setup() + const button = screen.getByText('show...') + await user.click(button) - const div = component.container.querySelector('.togglableContent') - expect(div).not.toHaveStyle('display: none') + const element = screen.getByText('togglable content') + expect(element).toBeVisible() }) - }) ``` - -_beforeEach_ 函数在每个测试之前会被调用,然后将 Togglable 组件渲染到 _component_ 变量中 - - + +_beforeEach_ 函数在每个测试之前调用,然后渲染Togglable组件。 -第一个测试验证 Togglable 组件是否渲染了其子组件 `
    `。 - - -剩下的测试使用 [toHaveStyle](https://www.npmjs.com/package/@testing-library/jest-dom#tohavestyle) 方法,通过检查 div 元素的样式是否包含`{ display: 'none' }` ,来验证 Togglable 组件的子组件最初是否可见。 另一个测试验证按钮被按下时组件是可见的,这意味着隐藏组件的样式不再附着到这个组件中。 - - -根据按钮所包含的文本再次搜索按钮。 这个按钮也可以通过 CSS 选择器来定位: + +第一个测试验证Togglable组件是否渲染其子组件 ```js -const button = component.container.querySelector('button') +
    + togglable content +
    ``` - -该组件包含两个按钮,但由于 [querySelector](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector) 返回第一个匹配按钮,因此我们碰巧得到了所需的按钮。 + +其余的测试使用 _toBeVisible_ 方法来验证 Togglable 组件的子组件最初是不可见的,即 div 元素的样式中包含 _{ display: 'none' }_。另一个测试验证当按下按钮时组件变为可见,这意味着隐藏它的样式 不再 分配给组件。 -我们还可以添加一个测试,通过点击组件的第二个按钮来验证可见内容是否可以隐藏: +我们还可以添加一个测试,该测试可用于验证可以通过单击组件的第二个按钮来隐藏可见的内容: ```js -test('toggled content can be closed', () => { - const button = component.container.querySelector('button') - fireEvent.click(button) +describe('', () => { - const closeButton = component.container.querySelector( - 'button:nth-child(2)' - ) - fireEvent.click(closeButton) + // ... + + test('toggled content can be closed', async () => { + const user = userEvent.setup() + const button = screen.getByText('show...') + await user.click(button) + + const closeButton = screen.getByText('cancel') + await user.click(closeButton) - const div = component.container.querySelector('.togglableContent') - expect(div).toHaveStyle('display: none') + const element = screen.getByText('togglable content') + expect(element).not.toBeVisible() + }) }) ``` - -我们定义了一个选择器,它返回第二个按钮: `button:nth-child(2)`。 根据组件中按钮的顺序查找元素是不明智的,建议根据文本查找元素: +### Testing the forms + + +我们在之前的测试中已经使用了 [user-event](https://testing-library.com/docs/user-event/intro) 的 _click_ 函数来单击按钮。 ```js -test('toggled content can be closed', () => { - const button = component.getByText('show...') - fireEvent.click(button) +const user = userEvent.setup() +const button = screen.getByText('show...') +await user.click(button) +``` - const closeButton = component.getByText('cancel') - fireEvent.click(closeButton) + +我们还可以使用userEvent模拟文本输入。 - const div = component.container.querySelector('.togglableContent') - expect(div).toHaveStyle('display: none') -}) -``` + +让我们为NoteForm组件做一个测试。组件的代码如下。 + +```js +import { useState } from 'react' - -我们使用的 getByText 方法只是我提供的众多[查询](https://testing-library.com/docs/api-queries#queries)中的一个。 +const NoteForm = ({ createNote }) => { + const [newNote, setNewNote] = useState('') + + const addNote = event => { + event.preventDefault() + createNote({ + content: newNote, + important: true + }) + setNewNote('') + } + return ( +
    +

    Create a new note

    +
    + setNewNote(event.target.value)} + /> + +
    +
    + ) +} -### Testing the forms -【测试表单】 +export default NoteForm +``` + +该表单通过调用作为道具 _createNote_ 接收到的函数来工作,其中包含新便笺的详细信息。 - -在前面的测试中,我们已经使用了[fireEvent](https://testing-library.com/docs/api-events#fireEvent)函数来单击按钮。 + +测试如下: ```js -const button = component.getByText('show...') -fireEvent.click(button) -``` +import { render, screen } from '@testing-library/react' +import NoteForm from './NoteForm' +import userEvent from '@testing-library/user-event' + +test(' updates parent state and calls onSubmit', async () => { + const createNote = vi.fn() + const user = userEvent.setup() + + render() + const input = screen.getByRole('textbox') + const sendButton = screen.getByText('save') + await user.type(input, 'testing a form...') + await user.click(sendButton) - -实际上,我们不仅可以使用fireEvent 为按钮组件创建click 事件。 - -我们还可以使用fireEvent 来模拟文本输入。 + expect(createNote.mock.calls).toHaveLength(1) + expect(createNote.mock.calls[0][0].content).toBe('testing a form...') +}) +``` + +测试使用函数 [getByRole](https://testing-library.com/docs/queries/byrole) 访问输入字段。 + +userEvent 的 [type](https://testing-library.com/docs/user-event/utility#type) 方法用于向输入字段写入文本。 - -让我们对NoteForm 组件进行测试 + + +第一个测试期望确保提交表单会调用 _createNote_ 方法。 +第二个期望检查事件处理程序是否使用正确的参数调用——即在填写表单时创建具有正确内容的便笺。 + + +值得注意的是,好的旧 _console.log_ 在测试中照常工作。例如,如果您想查看 mock 对象存储的调用是什么样的,可以执行以下操作 ```js -import React, { useState } from 'react' +test(' updates parent state and calls onSubmit', async() => { + const user = userEvent.setup() + const createNote = vi.fn() -const NoteForm = ({ createNote }) => { - const [newNote, setNewNote] = useState('') + render() - const handleChange = (event) => { - setNewNote(event.target.value) - } + const input = screen.getByRole('textbox') + const sendButton = screen.getByText('save') - const addNote = (event) => { - event.preventDefault() - createNote({ - content: newNote, - important: Math.random() > 0.5, - }) + await user.type(input, 'testing a form...') + await user.click(sendButton) - setNewNote('') - } + console.log(createNote.mock.calls) // highlight-line +}) +``` + + +在运行测试的过程中,打印以下内容 + +``` +[ [ { content: 'testing a form...', important: true } ] ] +``` + +### About finding the elements + + +让我们假设表单有两个输入字段 + +```js +const NoteForm = ({ createNote }) => { + // ... return ( -
    // highlight-line +

    Create a new note

    setNewNote(event.target.value)} /> + // highlight-start + + // highlight-end
    ) } +``` -export default NoteForm + +现在我们的测试用于查找输入字段的方法 + +```js +const input = screen.getByRole('textbox') ``` + +会导致错误: + +![node error that shows two elements with textbox since we use getByRole](../../images/5/40.png) + +错误消息建议使用getAllByRole。测试可以修复如下: - -该表单通过调用作为props接收的 createNote 函数以及新便笺的细节来工作。 +```js +const inputs = screen.getAllByRole('textbox') +await user.type(inputs[0], 'testing a form...') +``` + +方法getAllByRole现在返回一个数组,正确的输入字段是数组的第一个元素。然而,这种方法有点可疑,因为它依赖于输入字段的顺序。 - -测试内容如下: + +如果为输入字段定义了 label,可以使用 getByLabelText 方法通过它定位输入字段。例如,如果我们给输入字段添加了一个 label: ```js -import React from 'react' -import { render, fireEvent } from '@testing-library/react' -import '@testing-library/jest-dom/extend-expect' -import NoteForm from './NoteForm' + // ... + // highlight-line + // ... +``` + + +测试可以按以下方式定位输入字段: +```js test(' updates parent state and calls onSubmit', () => { - const createNote = jest.fn() + const createNote = vi.fn() - const component = render( - - ) + render() - const input = component.container.querySelector('input') - const form = component.container.querySelector('form') + const input = screen.getByLabelText('content') // highlight-line + const sendButton = screen.getByText('save') - fireEvent.change(input, { - target: { value: 'testing of forms could be easier' } - }) - fireEvent.submit(form) + userEvent.type(input, 'testing a form...' ) + userEvent.click(sendButton) expect(createNote.mock.calls).toHaveLength(1) - expect(createNote.mock.calls[0][0].content).toBe('testing of forms could be easier' ) + expect(createNote.mock.calls[0][0].content).toBe('testing a form...' ) }) ``` + +通常输入字段有一个占位符文本,提示用户期望哪种类型的输入。让我们在表单中添加一个占位符: + +```js +const NoteForm = ({ createNote }) => { + // ... + + return ( +
    +

    Create a new note

    + +
    + setNewNote(event.target.value)} + placeholder='write note content here' // highlight-line + /> + + +
    +
    + ) +} +``` + + +现在使用 [getByPlaceholderText](https://testing-library.com/docs/queries/byplaceholdertext) 方法可以轻松找到正确的输入字段: +```js +test(' updates parent state and calls onSubmit', () => { + const createNote = vi.fn() - -我们可以通过为input 字段创建一个change 事件,并定义一个包含写入字段的文本的对象来模拟对input 字段的写入。 + render() - -表单通过模拟submit 事件发送到表单。 + const input = screen.getByPlaceholderText('write note content here') // highlight-line + const sendButton = screen.getByText('save') + userEvent.type(input, 'testing a form...') + userEvent.click(sendButton) - -第一个测试期望确保提交表单调用 createNote 方法。 - -第二个期望检查,使用正确的参数调用事件处理程序——即在填写表单时创建具有正确内容的通知。 + expect(createNote.mock.calls).toHaveLength(1) + expect(createNote.mock.calls[0][0].content).toBe('testing a form...') +}) +``` -### Test coverage -【测试覆盖范围】 +Sometimes, finding the correct element using the methods described above can be challenging. In such cases, an alternative is the method querySelector of the _container_ object, which is returned by _render_, as was mentioned [earlier in this part](/en/part5/testing_react_apps#searching-for-content-in-a-component). Any CSS selector can be used with this method for searching elements in tests. +有时候,使用上述方法找到正确的元素可能会很困难。在这种情况下,一个替代方法是使用 _render_ 返回的 _container_ 对象上的 querySelector 方法,正如[本部分前面](/en/part5/testing_react_apps#searching-for-content-in-a-component)提到的。任何 CSS 选择器都可以与这个方法一起在测试中搜索元素。 + +例如,考虑我们为输入字段定义一个唯一的 _id_: - -通过运行如下命令,我们可以很容易地找到我们测试的覆盖范围[coverage](https://github.com/facebookincubator/create-react-app/blob/ed5c48c81b2139b4414810e1efe917e04c96ee8d/packages/react-scripts/template/README.md#coverage-reporting) +```js +const NoteForm = ({ createNote }) => { + // ... + return ( +
    +

    Create a new note

    +
    + setNewNote(event.target.value)} + id='note-input' // highlight-line + /> + + +
    +
    + ) +} +``` + + +现在可以在测试中找到输入元素,如下所示: + ```js -CI=true npm test -- --coverage +const { container } = render() + +const input = container.querySelector('#note-input') ``` -![](../../images/5/18ea.png) + +但是,我们将坚持在测试中使用 _getByPlaceholderText_ 的方法。 - - coverage/lcov-report目录将生成相当原始的 HTML raport。 +### Test coverage - -该报告将告诉我们,每个组件中未经测试的代码行: + +我们可以通过使用以下命令运行测试来轻松找出我们测试的[覆盖率](https://vitest.dev/guide/coverage.html#coverage)。 -![](../../images/5/19ea.png) +```js +npm test -- --coverage +``` + +当你第一次运行该命令时,Vitest 会询问你是否要安装必需的库 _@vitest/coverage-v8_。安装它,然后再次运行该命令: +![terminal output of test coverage](../../images/5/18new.png) - -您可以在[this Github repository](https://github.com/fullstack-hy2020/part2-notes/tree/part5-8)的part5-8 分支中找到我们当前应用的全部代码。 -
    + +HTML 报告将生成到coverage目录。 +该报告将告诉我们每个组件中未测试代码的行: +![HTML report of the test coverage](../../images/5/19newer.png) + +让我们把 coverage/ 添加到 .gitignore 文件中,以将其内容排除在版本控制之外: -
    +```js +//... +coverage/ +``` -### Exercises 5.13.-5.16. + +你可以在 [这个 GitHub 仓库](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-8) 的part5-8分支中找到我们当前应用程序的完整代码。 +
    -#### 5.13: Blog list tests, 步骤2 +
    - -做一个测试,检查显示博客的组件是否渲染了博客的标题和作者,但默认情况下不渲染其 url 或赞数 +### Exercises 5.13.-5.16. - -向组件中添加 css 类以帮助进行必要的测试。 +#### 5.13: Blog List Tests, step 1 -#### 5.14: Blog list tests, 步骤3 + +进行一项测试,检查显示博客的组件是否渲染了博客的标题和作者,但默认情况下不渲染其 URL 或点赞数。 - -做一个测试,当点击控制显示的详细信息的按钮时,检查博客的网址和赞的数量。 + +根据需要向组件添加 CSS 类以帮助测试。 -#### 5.15: Blog list tests, 步骤2 - -进行一个测试,确保如果单击like 按钮两次,那么作为props接收的组件的事件处理程序将被调用两次。 +#### 5.14: Blog List Tests, step 2 -#### 5.16*: Blog list tests, 步骤4 - -为新的博客表单做一个测试。 测试应该检查,当调用新建博客时,表单是否使用正确的细节调用它作为props接收的事件处理程序。 + +进行一项测试,检查在单击控制显示详情的按钮时显示博客的 URL 和点赞数。 - -例如,如果你给出一个input 元素 id'author' : +#### 5.15: Blog List Tests, step 3 -```js - {}} -/> -``` + +进行一项测试,以确保如果点赞按钮被单击两次,则组件作为道具接收的事件处理程序将被调用两次。 - -您可以使用如下命令访问字段的内容: +#### 5.16: Blog List Tests, step 4 -```js -const author = component.container.querySelector('#author') -``` + +为新博客表单进行一项测试。该测试应检查表单在创建新博客时使用正确的详细信息调用它作为道具接收的事件处理程序。
    -
    - ### Frontend integration tests -【前端集成测试】 - -在课程教材的前面章节,我们为后端编写了集成测试,测试其逻辑并通过后端提供的 API 连接数据库。 在编写这些测试时,我们有意识地不编写单元测试,因为后端的代码相当简单,但是我们应用中的错误可能发生在更复杂的场景中,而集成测试非常适合这些场景。 + +在课程材料的上一部分中,我们为后端编写了集成测试,该测试测试了它的逻辑并通过后端提供的 API 连接数据库。在编写这些测试时,我们有意识地决定不编写单元测试,因为该后端代码相当简单,并且我们应用程序中的错误很可能发生在比单元测试更适合的更复杂的情况下。 -到目前为止,我们对前端的所有测试都是单元测试,这些测试验证了单个组件的正确功能。单元测试有时很有用,但即使是一套完整的单元测试套件也不足以验证应用作为一个整体是否工作。 +到目前为止,我们对前端的所有测试都是单元测试,这些测试验证了各个组件的正确功能。单元测试有时很有用,但即使是全面的单元测试套件也不足以验证应用程序作为一个整体是否正常工作。 - -我们也可以对前端进行集成测试。集成测试可测试多组件的协作。这比单元测试要困难得多,因为我们必须(例如)从服务器模拟数据。 - - -我们决定集中采用端到端的测试以测试整个应用程序,我们将在本章的最后一节中进行研究。 + +我们还可以对前端进行集成测试。集成测试测试了多个组件的协作。这比单元测试困难得多,因为我们必须模拟来自服务器的数据。 +我们选择专注于进行端到端测试来测试整个应用程序。我们将在本部分的最后一章中进行端到端测试。 ### Snapshot testing -【快照测试】 - -Jest 提供了一种与“传统”测试完全不同的替代方法,称为[snapshot](https://facebook.github.io/Jest/docs/en/snapshot-testing.html)。 快照测试的有趣特性是开发人员不需要自己定义任何测试,只需要采用快照测试即可。 + +Vitest 提供了一种与“传统”测试完全不同的替代方案,称为[快照](https://vitest.dev/guide/snapshot)测试。快照测试的有趣之处在于,开发人员自己不需要定义任何测试,采用快照测试很简单。 -基本原则是比较组件更改后定义的 HTML 代码和更改前存在的 HTML 代码。 +基本原则是将组件更改后定义的 HTML 代码与更改前存在的 HTML 代码进行比较。 - -如果快照注意到组件定义的 HTML 中发生了一些变化,那么它要么是新功能,要么是由于意外造成的“ bug”。 如果组件的 HTML 代码发生更改,快照测试会通知开发人员。 开发人员必须告诉 Jest 是否需要更改。 如果 HTML 代码的更改是意想不到的,那么它一定会隐含一个 bug,而由于快照测试,开发人员可以很容易地意识到这些潜在的问题。 + +如果快照注意到组件定义的 HTML 中发生了一些变化,那么它要么是新功能,要么是意外造成的“错误”。快照测试会通知开发人员组件的 HTML 代码是否发生变化。开发人员必须告诉 Vitest 更改是需要的还是不需要的。如果对 HTML 代码的更改是意外的,那么它强烈暗示存在错误,并且开发人员可以轻松地通过快照测试了解这些潜在问题。
    - diff --git a/src/content/5/zh/part5d.md b/src/content/5/zh/part5d.md index f48df111364..23875cd72db 100644 --- a/src/content/5/zh/part5d.md +++ b/src/content/5/zh/part5d.md @@ -6,557 +6,604 @@ lang: zh ---
    - -到目前为止,我们已经使用集成测试在 API 级别上测试了整个后端,并使用单元测试测试了一些前端组件。 + +到目前为止,我们已经使用集成测试在 API 层面上测试了整个后端,并使用单元测试测试了一些前端组件。 + +接下来我们将探讨一种使用端到端(E2E)测试来测试[系统整体](https://en.wikipedia.org/wiki/System_testing)的方法。 - -接下来,我们将研究一种使用端到端End to End (E2E)测试,将[系统作为一个整体](https://en.wikipedia.org/wiki/system_testing)的测试方法。 + +我们可以使用浏览器和测试库对 Web 应用进行 E2E 测试。有多种测试库可用,一个例子是 [Selenium](http://www.seleniumhq.org/),它几乎可以与任何浏览器一起使用。另一个浏览器选项是所谓的[无头浏览器](https://en.wikipedia.org/wiki/Headless_browser),这是一种没有图形用户界面的浏览器。例如,Chrome 可以在无头模式下使用。 + +E2E 测试可能是最有用的测试类别,因为它们通过与真实用户使用相同的界面来测试系统。 + +它们也有一些缺点。配置 E2E 测试比单元或集成测试更具挑战性。并且它们往往较慢,对于大型系统,执行时间可能是几分钟甚至几小时。这对开发是不利的,因为在编码的过程中,能够尽可能频繁地运行测试是有益的,这可以防范代码[回归](https://en.wikipedia.org/wiki/Regression_testing)。 - -我们可以使用浏览器和测试库对 web 应用进行 E2E 测试。 有多个库可用,例如[Selenium](http://www.seleniumhq.org/) ,几乎可以用于任何浏览器。 + +E2E 测试还可能[不稳定](https://hackernoon.com/flaky-tests-a-war-that-never-ends-9aa32fdef359)。有些测试可能前一次通过了,但后一次失败了,即使代码根本没有改变。 - -另一个浏览器选项是所谓的[headless browsers](https://en.wikipedia.org/wiki/Headless_browser) ,这是一种没有用户界面的浏览器。 + +目前,最容易用于 E2E 测试的两个库或许就是 [Cypress](https://www.cypress.io/) 和 [Playwright](https://playwright.dev/)。 - -例如,Chrome 可以在 headless 模式下使用。 + +从 [npmtrends.com](https://npmtrends.com/cypress-vs-playwright) 的统计数据来看,Playwright 在 2024 年的下载量已经超过了 Cypress,并且其受欢迎的程度仍在持续增长: - -E2E 测试可能是最有用的一类测试,因为它们测试系统的界面与真实用户使用的界面相同。 +![cypress vs playwright in npm trends](../../images/5/cvsp.png) + +这门课程多年来一直使用 Cypress。现在 Playwright 成为了一个新的选项。你可以选择用 Cypress 或 Playwright 完成 E2E 测试部分。这两个库的运行原理非常相似,所以你的选择并不重要。然而,Playwright 目前是课程首选的 E2E 测试库。 - -它们也有一些缺点。 配置 E2E 测试比单元测试或集成测试更具挑战性。 它们也往往非常慢,对于一个大型系统,它们的执行时间可能是几分钟,甚至几小时。 这对开发是不利的,因为在编码期间,如果遇到代码[回归测试](https://en.wikipedia.org/wiki/regression_testing) ,能够尽可能多地运行测试是有益的。 + +如果你的选择是 Playwright,请继续。如果你最终使用 Cypress,请点[这里](/en/part5/end_to_end_testing_cypress)。 - -E2E 测试也可能是[片状的](https://hackernoon.com/flaky-tests-a-war-that-never-ends-9aa32fdef359)。 - -有些测试可能一次通过,另一次失败,即使代码根本没有改变。 +### Playwright -### Cypress + +[Playwright](https://playwright.dev/) 是 E2E 测试领域的新来者,它在 2023 年底开始迅速流行起来。Playwright 在易用性方面与 Cypress 大致相当。这两个库在工作方式上略有不同。Cypress 与大多数适合 E2E 测试的库相比,有着根本性的不同,因为 Cypress 测试完全在浏览器中运行。而 Playwright 的测试则是在 Node 进程中执行,该进程通过编程接口与浏览器连接。 - -在过去的一年里,E2E库[Cypress](https://www.cypress.io/)变得非常流行。 Cypress是非常容易使用,与Selenium相比需要少得多麻烦和头痛问题。 - -它的操作原理与大多数 E2E 测试库完全不同,因为 Cypress 测试完全在浏览器中运行。 - -其他库在一个 node 进程中运行测试,进程通过一个 API 连接到浏览器。 + +许多博客都写过关于库的比较,比如[这篇](https://www.lambdatest.com/blog/cypress-vs-playwright/)和[这篇](https://www.browserstack.com/guide/playwright-vs-cypress)。 - -让我们为便笺应用做一些端到端的测试。 + +很难说哪个库更好。Playwright 的一个优势是它的浏览器支持;Playwright 支持 Chrome、Firefox 以及基于 Webkit 的浏览器如 Safari。目前,Cypress 也支持所有这些浏览器,尽管 Webkit 的支持是实验性的,并且不支持 Cypress 的所有功能。在撰写本文时(2024年3月1日),我个人偏好稍微倾向于 Playwright。 - -我们首先将 Cypress 安装到前端 ,作为开发依赖项 + +现在让我们来探索 Playwright。 -```js -npm install --save-dev cypress -``` +### Initializing tests - -通过添加一个 npm-script 来运行它: + +与后端测试和在 React 前端进行的单元测试不同,端到端测试不需要位于代码所在的同一 npm 项目中。让我们使用 _npm init_ 命令为端到端测试创建一个完全独立的项目。然后在新的项目目录中运行以下命令来安装 Playwright: ```js -{ - // ... - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject", - "server": "json-server -p3001 db.json", - "cypress:open": "cypress open" // highlight-line - }, - // ... -} +npm init playwright@latest ``` + +安装脚本会询问几个问题,按如下方式回答: +![answer: javascript, tests, false, true](../../images/5/play0.png) - -与前端的单元测试不同,Cypress 测试可以位于前端或后端仓库中,甚至可以位于它们自己的单独仓库中。 - + +请注意,在安装 Playwright 时,您的操作系统可能不支持 Playwright 提供的所有浏览器,您可能会看到如下错误消息: +``` +Webkit 18.0 (playwright build v2070) downloaded to /home/user/.cache/ms-playwright/webkit-2070 +Playwright Host validation warning: +╔══════════════════════════════════════════════════════╗ +║ Host system is missing dependencies to run browsers. ║ +║ Missing libraries: ║ +║ libicudata.so.66 ║ +║ libicui18n.so.66 ║ +║ libicuuc.so.66 ║ +║ libjpeg.so.8 ║ +║ libwebp.so.6 ║ +║ libpcre.so.3 ║ +║ libffi.so.7 ║ +╚══════════════════════════════════════════════════════╝ +``` + +如果是这种情况,你可以在你的 _package.json_ 中使用 `--project=` 指定要测试的特定浏览器: +```js + "test": "playwright test --project=chromium --project=firefox", +``` - -这些测试要求测试系统正常运行。 与我们的后端集成测试不同,Cypress 测试在系统运行时不启动。 + +或者从你的 _playwright.config.js_ 文件中删除任何有问题的浏览器的条目: +```js + projects: [ + // ... + //{ + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + //}, + // ... + ] +``` - -让我们在后端中添加一个 npm-script,在测试模式下启动它,或者使NODE\_ENV设置 为test。 + +让我们在 _package.json_ 中定义一个 npm 脚本用于运行测试和测试报告: ```js { // ... "scripts": { - "start": "cross-env NODE_ENV=production node index.js", - "dev": "cross-env NODE_ENV=development nodemon index.js", - "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend", - "deploy": "git push heroku master", - "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy", - "logs:prod": "heroku logs --tail", - "lint": "eslint .", - "test": "cross-env NODE_ENV=test jest --verbose --runInBand", - "start:test": "cross-env NODE_ENV=test node index.js" // highlight-line + "test": "playwright test", + "test:report": "playwright show-report" }, // ... } ``` + +在安装过程中,以下内容将打印到控制台: - - -当后端和前端都在运行时,我们可以使用如下命令启动 Cypress - -```js -npm run cypress:open +``` +And check out the following files: + - ./tests/example.spec.js - Example end-to-end test + - ./tests-examples/demo-todo-app.spec.js - Demo Todo App end-to-end tests + - ./playwright.config.js - Playwright Test configuration ``` + +也就是说,这是安装创建的项目中几个示例测试的位置。 + +让我们运行测试: - -当我们第一次运行 Cypress 时,它会创建一个Cypress 目录。 它包含一个集成 子目录,我们将在其中放置测试。 Cypress 为我们创建了一系列测试示例,但是我们将删除所有这些并在文件note\_app.spec.js 中创建我们自己的测试: +```bash +$ npm test -```js -describe('Note app', function() { - it('front page can be opened', function() { - cy.visit('http://localhost:3000') - cy.contains('Notes') - cy.contains('Note app, Department of Computer Science, University of Helsinki 2020') - }) -}) -``` +> notes-e2e@1.0.0 test +> playwright test +Running 6 tests using 5 workers + 6 passed (3.9s) - -我们从打开的窗口开始测试: +To open last HTML report run: -![](../../images/5/40ea.png) + npx playwright show-report +``` + +测试通过。更详细的测试报告可以通过输出的建议命令或我们刚刚定义的 npm 脚本打开: +``` +npm run test:report +``` - -运行测试会打开你的浏览器,并显示应用在运行测试时的行为: + +测试也可以使用以下命令通过图形界面运行: -![](../../images/5/32ae.png) +``` +npm run test -- --ui +``` + +tests/example.spec.js 中的示例测试看起来是这样的: +```js +// @ts-check +import { test, expect } from '@playwright/test'; - -测试的结构应该看起来很熟悉。 他们使用describe 块对不同的测试用例进行分组,就像 Jest 那样。 测试用例已经用it 方法定义了。 - -Cypress从[Mocha](https://mochajs.org/)测试库中借用了这些部件,并在底层使用。 +test('has title', async ({ page }) => { + await page.goto('https://playwright.dev/'); // highlight-line + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Playwright/); +}); +test('get started link', async ({ page }) => { + await page.goto('https://playwright.dev/'); - -[cy.visit](https://docs.Cypress.io/api/commands/visit.html)和[cy.contains](https://docs.Cypress.io/api/commands/contains.html)是 Cypress 命令,它们的用途非常明显。 - -[cy.visit](https://docs.cypress.io/api/commands/visit.html)将浏览器中打开的网址作为参数进行测试。 [cy.contains](https://docs.cypress.io/api/commands/contains.html)将搜索的字符串作为参数。 + // Click the get started link. + await page.getByRole('link', { name: 'Get started' }).click(); + // Expects page to have a heading with the name of Installation. + await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); +}); +``` - -我们可以使用箭头函数声明测试 + +测试函数的第一行说明这些测试正在测试 https://playwright.dev/ 页面。 -```js -describe('Note app', () => { // highlight-line - it('front page can be opened', () => { // highlight-line - cy.visit('http://localhost:3000') - cy.contains('Notes') - cy.contains('Note app, Department of Computer Science, University of Helsinki 2020') - }) -}) -``` +### Testing our own code + +现在让我们移除示例测试,开始测试我们自己的应用程序。 + +Playwright 测试假设在执行测试时系统正在运行。与后端集成测试等例子不同,Playwright 测试在测试过程中不会启动被测试的系统。 - -然而,Mocha [建议](https://mochajs.org/#arrow-functions)不要使用箭头函数,因为它们在某些情况下可能会导致一些问题。 + +让我们为后端创建一个 npm 脚本,这将使其能够在测试模式下启动,即使 NODE\_ENV 的值为 test。 +```js +{ + // ... + "scripts": { + "start": "cross-env NODE_ENV=production node index.js", + "dev": "cross-env NODE_ENV=development node --watch index.js", + "test": "cross-env NODE_ENV=test node --test", + "lint": "eslint .", + // ... + "start:test": "cross-env NODE_ENV=test node --watch index.js" // highlight-line + }, + // ... +} +``` - -如果cy.contains 没有找到正在搜索的文本,则测试不会通过。 - -所以如果我们像这样扩展测试 + +让我们启动前端和后端,并为应用程序创建第一个测试文件 tests/note\_app.spec.js : ```js -describe('Note app', function() { - it('front page can be opened', function() { - cy.visit('http://localhost:3000') - cy.contains('Notes') - cy.contains('Note app, Department of Computer Science, University of Helsinki 2020') - }) +const { test, expect } = require('@playwright/test') -// highlight-start - it('front page contains random text', function() { - cy.visit('http://localhost:3000') - cy.contains('wtf is this app?') - }) -// highlight-end +test('front page can be opened', async ({ page }) => { + await page.goto('http://localhost:5173') + + const locator = page.getByText('Notes') + await expect(locator).toBeVisible() + await expect(page.getByText('Note app, Department of Computer Science, University of Helsinki 2024')).toBeVisible() }) ``` + +首先,测试使用 [page.goto](https://playwright.dev/docs/writing-tests#navigation) 方法打开应用程序。之后,它使用 [page.getByText](https://playwright.dev/docs/api/class-page#page-get-by-text) 方法获取与包含文本 Notes 的元素对应的[定位器](https://playwright.dev/docs/locators)。 + +[toBeVisible](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-be-visible) 方法确保与定位器对应的元素在页面上可见。 - -测试失败了 + +第二次检查没有使用辅助变量。 -![](../../images/5/33ea.png) + +测试失败了,因为测试中混入了一个旧年份。Playwright 在浏览器中打开了测试报告,很明显 Playwright 实际上是用三种不同的浏览器进行了测试:Chrome、Firefox 和 Webkit,即 Safari 使用的浏览器引擎: +![test report showing the test failing in three different browsers](../../images/5/play2.png) + +通过点击其中一个浏览器的报告,我们可以看到一个更详细的错误信息: - -让我们从测试中删除失败的代码。 +![test error message](../../images/5/play3a.png) -### Writing to a form -【写入表单】 - - -让我们扩展测试,测试登录功能,登录到我们的应用。 - -我们假设后端包含一个用户名为mluukkai 和密码salainen 的用户。 + +从宏观角度来看,测试使用所有三种常用浏览器引擎当然是非常好的,但这会很慢,在开发测试时,最好主要只用一种浏览器进行。您可以通过命令行参数定义要使用的浏览器引擎: +```js +npm test -- --project chromium +``` - -测试从打开登录表单开始。 + +现在让我们修正测试中的年份,并在测试中添加一个 _describe_ 块: ```js -describe('Note app', function() { - // ... +const { test, describe, expect } = require('@playwright/test') + +describe('Note app', () => { // highlight-line + test('front page can be opened', async ({ page }) => { + await page.goto('http://localhost:5173') - it('login form can be opened', function() { - cy.visit('http://localhost:3000') - cy.contains('login').click() + const locator = page.getByText('Notes') + await expect(locator).toBeVisible() + await expect(page.getByText('Note app, Department of Computer Science, University of Helsinki 2025')).toBeVisible() }) }) ``` + +在继续之前,让我们再分析一次测试。我们注意到,当测试通过时,执行速度相当快,但如果测试未通过,执行速度会慢很多。这是因为 Playwright 的策略是等待搜索的元素直到[它们渲染并准备好执行](https://playwright.dev/docs/actionability)。如果找不到元素,会抛出 _TimeoutError_ 并导致测试失败。Playwright 默认等待元素的时间[取决于测试中使用的函数](https://playwright.dev/docs/test-timeouts#introduction),通常是 5 秒或 30 秒。 + +在开发测试时,将等待时间减少到几秒钟可能更明智。根据[文档](https://playwright.dev/docs/test-timeouts),这可以通过按以下方式修改 _playwright.config.js_ 文件来实现: - -测试首先通过文本搜索登录按钮,然后用命令[cy.click](https://docs.cypress.io/api/commands/click.html#syntax)单击该按钮。 - +```js +export default defineConfig({ + // ... + timeout: 3000, // highlight-line + fullyParallel: false, // highlight-line + workers: 1, // highlight-line + // ... +}) +``` + +我们还对文件进行了另外两项更改,并指定所有测试应[逐个执行](https://playwright.dev/docs/test-parallel)。在默认配置下,执行是并行进行的,而由于我们的测试使用数据库,并行执行会导致问题。 - - -我们的两个测试都是以同样的方式开始的,都是通过打开http://localhost:3000 页面,所以我们应该在每个测试之前,将共享部分分隔为beforeEach 块运行: +### Writing on the form + +让我们编写一个新的测试,尝试登录应用程序。假设数据库中存储了一个用户,用户名为 mluukkai,密码为 salainen。 + +让我们从打开登录表单开始。 ```js -describe('Note app', function() { - // highlight-start - beforeEach(function() { - cy.visit('http://localhost:3000') - }) - // highlight-end +describe('Note app', () => { + // ... - it('front page can be opened', function() { - cy.contains('Notes') - cy.contains('Note app, Department of Computer Science, University of Helsinki 2020') - }) + test('user can log in', async ({ page }) => { + await page.goto('http://localhost:5173') - it('login form can be opened', function() { - cy.contains('login').click() + await page.getByRole('button', { name: 'login' }).click() }) }) ``` + +测试首先使用方法 [page.getByRole](https://playwright.dev/docs/api/class-page#page-get-by-role) 根据其文本获取按钮。该方法返回与 Button 元素对应的 [定位器](https://playwright.dev/docs/api/class-locator)。然后通过定位器的方法 [click](https://playwright.dev/docs/api/class-locator#locator-click) 进行按钮点击。 + +在开发测试时,你可以使用 Playwright 的 [UI 模式](https://playwright.dev/docs/test-ui-mode),即用户界面版本。让我们按照以下方式以 UI 模式开始测试: - -登录字段包含两个input 字段,测试应该将这两个字段写入其中。 - +``` +npm test -- --ui +``` + +我们现在看到测试找到了按钮 - - [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax)命令允许通过 CSS 选择器搜索元素。 +![playwright UI rendering the notes app while testing it](../../images/5/play4.png) + +点击后,表单将出现 +![playwright UI rendering the login form of the notes app](../../images/5/play5.png) - -我们可以访问页面上的第一个和最后一个input字段,并使用命令[cy.type](https://docs.cypress.io/api/commands/type.html#syntax 文件夹)向它们写入内容,如下所示: + +当表单打开时,测试应查找文本字段,并在其中输入用户名和密码。让我们首先尝试使用方法 [page.getByRole](https://playwright.dev/docs/api/class-page#page-get-by-role)。 ```js -it('user can login', function () { - cy.contains('login').click() - cy.get('input:first').type('mluukkai') - cy.get('input:last').type('salainen') -}) -``` - - -这个测试是有效的。 问题是,如果我们稍后添加更多的input字段,测试将中断,因为它期望需要的字段是页面上的第一个和最后一个。 - - +describe('Note app', () => { + // ... - -最好是给我们的input提供唯一的 id 并通过id找到它们。 - -我们更改登录表单,如下所示 + test('user can log in', async ({ page }) => { + await page.goto('http://localhost:5173') -```js -const LoginForm = ({ ... }) => { - return ( -
    -

    Login

    -
    -
    - username - -
    -
    - password - -
    - -
    -
    - ) -} + await page.getByRole('button', { name: 'login' }).click() + await page.getByRole('textbox').fill('mluukkai') // highlight-line + }) +}) ``` + +这导致了一个错误: +```bash +Error: locator.fill: Error: strict mode violation: getByRole('textbox') resolved to 2 elements: + 1) aka locator('div').filter({ hasText: /^username$/ }).getByRole('textbox') + 2) aka locator('input[type="password"]') +``` - -我们还为提交按钮添加了一个 id,这样我们就可以在测试中访问它。 - + +现在的问题是,_getByRole_ 找到了两个文本字段,调用 [fill](https://playwright.dev/docs/api/class-locator#locator-fill) 方法会失败,因为它假设只找到了一个文本字段。解决这个问题的一种方案是用 [first](https://playwright.dev/docs/api/class-locator#locator-first) 和 [last](https://playwright.dev/docs/api/class-locator#locator-last) 这两个方法: +```js +describe('Note app', () => { + // ... - -测试变成了 + test('user can log in', async ({ page }) => { + await page.goto('http://localhost:5173') -```js -describe('Note app', function() { - // .. - it('user can log in', function() { - cy.contains('login').click() - cy.get('#username').type('mluukkai') // highlight-line - cy.get('#password').type('salainen') // highlight-line - cy.get('#login-button').click() // highlight-line - - cy.contains('Matti Luukkainen logged in') // highlight-line + await page.getByRole('button', { name: 'login' }).click() + // highlight-start + await page.getByRole('textbox').first().fill('mluukkai') + await page.getByRole('textbox').last().fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + // highlight-end }) }) ``` + +在文本字段中输入后,测试会点击 _login_ 按钮,并检查应用程序是否在屏幕上渲染了已登录用户的信息。 + +如果有超过两个文本字段,使用 _first_ 和 _last_ 方法就不够了。一种可能是使用 [all](https://playwright.dev/docs/api/class-locator#locator-all) 方法,它将找到的定位器转换成一个可以索引的数组: - -最后一行确保登录成功。 - - +```js +describe('Note app', () => { + // ... + test('user can log in', async ({ page }) => { + await page.goto('http://localhost:5173') - -注意 CSS 的 [id-选择器](https://developer.mozilla.org/en-us/docs/web/CSS/id_selectors)是 # ,所以如果我们想搜索 id 是 username 的元素,CSS 选择器是# username。 + await page.getByRole('button', { name: 'login' }).click() + // highlight-start + const textboxes = await page.getByRole('textbox').all() -### Some things to note -【有些事情需要注意】 - -测试首先单击打开登录表单的按钮,如下所示 + await textboxes[0].fill('mluukkai') + await textboxes[1].fill('salainen') + // highlight-end -```js -cy.contains('login').click() + await page.getByRole('button', { name: 'login' }).click() + + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + }) +}) ``` + +这个和上一个版本的测试工作都可以运行。然而,它们都存在问题,因为如果注册表单被更改,测试可能会失效,因为它们依赖于表单字段在页面上按特定顺序排列。 - -填写完表格后,单击提交按钮即可提交表格 + +如果元素在测试中难以定位,你可以为其分配一个单独的 test-id 属性,并使用 [getByTestId](https://playwright.dev/docs/api/class-page#page-get-by-test-id) 方法在测试中查找该元素。 + + +现在让我们利用登录表单的现有元素。登录表单的输入字段已被分配了唯一的 labels: ```js -cy.get('#login-button').click() +// ... +
    +
    + // highlight-line +
    +
    + // highlight-line +
    + +
    +// ... ``` - -两个按钮都有文本login,但它们是两个单独的按钮。 - - -实际上,这两个按钮一直都在应用的 DOM 中,但是由于其中一个是 display:none 每次只有一个按钮可见。 + +输入字段可以并且应该使用 [getByLabel](https://playwright.dev/docs/api/class-page#page-get-by-label) 方法通过 labels 在测试中定位: +```js +describe('Note app', () => { + // ... + test('user can log in', async ({ page }) => { + await page.goto('http://localhost:5173') - -如果我们通过文本搜索按钮,[cy.contains](https://docs.cypress.io/api/commands/contains.html#syntax)将返回第一个按钮,或者打开登录表单的按钮。 - -即使按钮不可见,也会发生这种情况。 - -正因为如此,我们给出了提交按钮 id login-button,我们可以用它来访问它。 + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') // highlight-line + await page.getByLabel('password').fill('salainen') // highlight-line + + await page.getByRole('button', { name: 'login' }).click() + + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + }) +}) +``` + +在定位元素时,最好利用界面中用户可见的内容,因为这样可以最好地模拟用户在导航应用时实际找到所需输入字段的方式。 + +请注意,在此阶段通过测试需要后端测试数据库中存在一个用户,用户名为 mluukkai,密码为 salainen。如有需要,请创建用户! - -现在我们注意到,我们的测试使用的变量 cy 给了我们一个讨厌的 Eslint 错误 +### Test Initialization -![](../../images/5/30ea.png) + +由于两个测试都从打开页面 http://localhost:5173 开始,建议在 beforeEach 块中隔离每个测试执行之前的公共部分: +```js +const { test, describe, expect, beforeEach } = require('@playwright/test') +describe('Note app', () => { + // highlight-start + beforeEach(async ({ page }) => { + await page.goto('http://localhost:5173') + }) + // highlight-end - -我们可以通过安装[eslint-plugin-cypress](https://github.com/cypress-io/eslint-plugin-cypress)作为开发依赖项来摆脱这个报错 + test('front page can be opened', async ({ page }) => { + const locator = page.getByText('Notes') + await expect(locator).toBeVisible() + await expect(page.getByText('Note app, Department of Computer Science, University of Helsinki 2025')).toBeVisible() + }) -```js -npm install eslint-plugin-cypress --save-dev + test('user can log in', async ({ page }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + }) +}) ``` +### Testing note creation - -改变 .eslintrc.js中的配置如下: + +接下来,让我们创建一个测试,该测试向应用程序添加一个新的笔记: ```js -module.exports = { - "env": { - "browser": true, - "es6": true, - "jest/globals": true, - "cypress/globals": true // highlight-line - }, - "extends": [ - // ... - ], - "parserOptions": { - // ... - }, - "plugins": [ - "react", "jest", "cypress" // highlight-line - ], - "rules": { - // ... - } -} -``` +const { test, describe, expect, beforeEach } = require('@playwright/test') -### Testing new note form -【测试新建便笺的表单】 - -下面让我们添加测试来测试新建便笺的功能: +describe('Note app', () => { + // ... -```js -describe('Note app', function() { - // .. - // highlight-start - describe('when logged in', function() { - beforeEach(function() { - cy.contains('login').click() - cy.get('input:first').type('mluukkai') - cy.get('input:last').type('salainen') - cy.get('#login-button').click() + describe('when logged in', () => { + beforeEach(async ({ page }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() }) - // highlight-end - // highlight-start - it('a new note can be created', function() { - cy.contains('new note').click() - cy.get('input').type('a note created by cypress') - cy.contains('save').click() - - cy.contains('a note created by cypress') + test('a new note can be created', async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('a note created by playwright') + await page.getByRole('button', { name: 'save' }).click() + await expect(page.getByText('a note created by playwright')).toBeVisible() }) - }) - // highlight-end + }) }) ``` + +测试定义在其自己的 _describe_ 块中。创建笔记需要用户登录,这由 _beforeEach_ 块处理。 - - -测试已经在它自己的describe 块中定义了。 - -只有登录的用户才能创建新的便笺,因此我们将登录添加到应用的beforeEach 块中。 - - -测试相信,在创建新便笺时,页面只包含一个input,因此它会像这样搜索该便笺 + +该测试相信在创建新笔记时页面上只有一个输入字段,因此它按以下方式搜索: ```js -cy.get('input') +page.getByRole('textbox') ``` + +如果有更多字段,测试就会失败。由于这个原因,最好给表单输入添加一个 test-id,并基于这个 id 在测试中查找它。 + +**注意:**测试只会在第一次通过。其原因是它的期望 - -如果页面包含更多的input,测试就会中断 - -![](../../images/5/31ea.png) - - - - - -由于这一点,最好再给input一个id,并通过id来搜索它。 +```js +await expect(page.getByText('a note created by playwright')).toBeVisible() +``` + +当同一个笔记在应用程序中创建多次时会导致问题。这个问题将在下一章解决。 - -测试的结构如下: + +测试的结构看起来是这样的: ```js -describe('Note app', function() { - // ... +const { test, describe, expect, beforeEach } = require('@playwright/test') - it('user can log in', function() { - cy.contains('login').click() - cy.get('#username').type('mluukkai') - cy.get('#password').type('salainen') - cy.get('#login-button').click() +describe('Note app', () => { + // .... - cy.contains('Matti Luukkainen logged in') + test('user can log in', async ({ page }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() }) - describe('when logged in', function() { - beforeEach(function() { - cy.contains('login').click() - cy.get('input:first').type('mluukkai') - cy.get('input:last').type('salainen') - cy.get('#login-button').click() + describe('when logged in', () => { + beforeEach(async ({ page }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() }) - it('a new note can be created', function() { - // ... + test('a new note can be created', async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('a note created by playwright') + await page.getByRole('button', { name: 'save' }).click() + await expect(page.getByText('a note created by playwright')).toBeVisible() }) }) }) ``` + +由于我们已禁止测试并行运行,Playwright 会按照测试代码中出现的顺序执行测试。也就是说,首先执行用户登录的测试,用户登录应用程序的操作会先完成。接着执行创建新笔记的测试,该测试也会在 beforeEach 块中执行登录操作。为什么还要这么做呢?用户不是已经在之前的测试中登录了吗?不,因为每个测试的执行都是从浏览器的“零状态”开始的,之前测试对浏览器状态所做的所有更改都会被重置。 +### Controlling the state of the database - -Cypress 按照测试在代码中的顺序运行测试。 所以它首先运行user can log in,用户在这里登录。 然后 cypress 将运行 a new note can be createdbeforeEach 也会执行一遍登录。 - -为什么这样做? 用户在第一次测试后没有登录吗? - -没有,因为就浏览器而言,每个测试都是从零开始的。 - -在每次测试后,对浏览器状态的所有更改都会被重置。 + +如果测试需要能够修改服务器的数据库,情况将立即变得复杂起来。理想情况下,服务器的数据库在每次运行测试时都应该是相同的,这样我们的测试才能可靠且容易地重复。 -### Controlling the state of the database -【控制数据库状态】 + +与单元测试和集成测试一样,对于 E2E 测试,在运行测试之前最好清空数据库,并可能要对其进行格式化。E2E 测试的挑战在于它们无法访问数据库。 - -如果测试需要能够修改服务器的数据库,那么情况会立即变得更加复杂。 理想情况下,每次运行测试时,服务器的数据库应该是相同的,这样我们的测试就可以可靠且容易地重复。 - - -与单元测试和集成测试一样,E2E 测试最好是在测试运行之前清空数据库并尽可能格式化数据库。 E2E 测试的挑战在于,他们无法访问数据库。 - - -解决方案是为测试创建后端的 API 接口。 - -我们可以使用这些接口清空数据库。 - -让我们为测试创建一个新的路由 + +解决方案是为后端测试创建 API 端点。我们可以使用这些端点来清空数据库。让我们在 controllers 文件夹中的 testing.js 文件里创建一个新的路由用于测试 ```js const router = require('express').Router() @@ -573,9 +620,8 @@ router.post('/reset', async (request, response) => { module.exports = router ``` - - -如果应用在 test-模式上运行,则只将其添加到后端: + +并且仅在应用程序以测试模式运行时才将其添加到后端: ```js // ... @@ -597,173 +643,130 @@ app.use(middleware.errorHandler) module.exports = app ``` + +修改后,对 /api/testing/reset 端点的 HTTP POST 请求会清空数据库。通过使用此命令启动后端以确保其在测试模式下运行(该命令之前已在 package.json 文件中配置): +```js + npm run start:test +``` - -更改之后,对/api/testing/reset 接口的 HTTP POST 请求将清空数据库。 - - - -修改后的后端代码可以在[github](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part5-1)分支part5-1 中找到。 - - - - -接下来,我们将更改beforeEach 块,以便在运行测试之前清空服务器的数据库。 - + +修改后的后端代码可以在 [GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part5-1) 的 part5-1 分支上找到。 + +接下来,我们将修改 _beforeEach_ 块,使其在运行测试前清空服务器的数据库。 - -目前不可能通过前端的 UI 添加新用户,因此我们从 beforeEach 块向后端添加一个新用户。 + +目前无法通过前端 UI 添加新用户,因此我们用 beforeEach 块从后端添加一个新用户。 ```js -describe('Note app', function() { - beforeEach(function() { - // highlight-start - cy.request('POST', 'http://localhost:3001/api/testing/reset') - const user = { - name: 'Matti Luukkainen', - username: 'mluukkai', - password: 'salainen' - } - cy.request('POST', 'http://localhost:3001/api/users/', user) - // highlight-end - cy.visit('http://localhost:3000') +describe('Note app', () => { + beforeEach(async ({ page, request }) => { + await request.post('http://localhost:3001/api/testing/reset') + await request.post('http://localhost:3001/api/users', { + data: { + name: 'Matti Luukkainen', + username: 'mluukkai', + password: 'salainen' + } + }) + + await page.goto('http://localhost:5173') }) - - it('front page can be opened', function() { + + test('front page can be opened', () => { // ... }) - it('user can login', function() { + test('user can login', () => { // ... }) - describe('when logged in', function() { + describe('when logged in', () => { // ... }) }) ``` + +在初始化的过程中,测试使用参数 _request_ 的方法 [post](https://playwright.dev/docs/api/class-apirequestcontext#api-request-context-post) 向后端发送 HTTP 请求。 + +与之前不同,现在后端测试总是从相同的状态开始,即数据库中有一个用户且没有笔记。 - -在对测试进行格式化时,使用[cy.request](https://docs.cypress.io/api/commands/request.html)对后端进行 HTTP 请求。 - + +让我们做一个测试,检查笔记的重要性是否可以更改。 + +进行这个测试有几种不同的方法。 - -与以前不同的是,现在每次测试都以相同的状态从后端开始。 后端将包含一个用户,没有便笺。 - - - -让我们再添加一个测试,我们可以改变便笺的重要性。 - -首先,我们改变前端,这样一个新的便笺默认是不重要的,或者说important 字段是false: + +在下文中,我们首先查找一个笔记并点击其带有文本 make not important 的按钮。之后,我们检查该笔记是否包含带有 make important 的按钮。 ```js -const NoteForm = ({ createNote }) => { +describe('Note app', () => { // ... - const addNote = (event) => { - event.preventDefault() - createNote({ - content: newNote, - important: false // highlight-line - }) - - setNewNote('') - } - // ... -} -``` - - - -有多种方法可以测试这一点。 在下面的示例中,我们首先搜索一个便笺,然后单击它的make important 按钮。 然后我们检查便笺现在是否包含一个make not important 按钮。 - -```js -describe('Note app', function() { - // ... - - describe('when logged in', function() { + describe('when logged in', () => { // ... - describe('and a note exists', function () { - beforeEach(function () { - cy.contains('new note').click() - cy.get('input').type('another note cypress') - cy.contains('save').click() + // highlight-start + describe('and a note exists', () => { + beforeEach(async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('another note by playwright') + await page.getByRole('button', { name: 'save' }).click() }) - it('it can be made important', function () { - cy.contains('another note cypress') - .contains('make important') - .click() - - cy.contains('another note cypress') - .contains('make not important') + test('importance can be changed', async ({ page }) => { + await page.getByRole('button', { name: 'make not important' }).click() + await expect(page.getByText('make important')).toBeVisible() }) + // highlight-end }) }) }) ``` + +第一个命令首先搜索包含文本 another note by playwright 的组件,并在其中找到按钮 make not important 并点击它。 - -第一个命令搜索包含文本another note cypress 的组件,然后搜索其中的make important 按钮。 然后点击按钮。 - - - -第二个命令检查按钮上的文本是否更改为make not important。 - - - - -测试和当前的前端代码可以从[github](https://github.com/fullstack-hy2020/part2-notes/tree/part5-9)分支part5-9 中找到。 + +第二个命令确保该按钮的文本已更改为 make important。 -### Failed login test -【测试登录失败】 - -让我们做一个测试,如果密码是错误的,确保登录失败。 + +测试代码当前位于 [GitHub](https://github.com/fullstack-hy2020/notes-e2e/tree/part5-1) 的分支 part5-1 上。 - -Cypress 默认情况下每次都会运行所有测试,并且随着测试数量的增加,它开始变得相当耗时。 - -当开发一个新的测试或者调试一个失败的测试时,我们可以用it.only 而不是it 来定义测试,这样 Cypress 就只能运行所需的测试。 - -当测试所有工作时,我们可以删除 .only。 +### Test for failed login + +现在我们来做一个测试,确保如果密码错误,登录尝试会失败。 - -我们测试的第一个版本如下: + +测试的第一个版本看起来是这样的: ```js -describe('Note app', function() { +describe('Note app', () => { // ... - it.only('login fails with wrong password', function() { - cy.contains('login').click() - cy.get('#username').type('mluukkai') - cy.get('#password').type('wrong') - cy.get('#login-button').click() + test('login fails with wrong password', async ({ page }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('wrong') + await page.getByRole('button', { name: 'login' }).click() - cy.contains('wrong credentials') + await expect(page.getByText('wrong credentials')).toBeVisible() }) // ... -)} +}) ``` + +该测试通过方法 [page.getByText](https://playwright.dev/docs/api/class-page#page-get-by-text) 验证应用程序是否打印错误消息。 - - -该测试使用[cy.contains](https://docs.cypress.io/api/commands/contains.html#syntax)来确保应用输出错误消息。 - - - - -应用将错误消息渲染给一个带有 CSS 类为error 的组件: + +应用程序将错误消息渲染到 CSS 类为 error 的元素中: ```js const Notification = ({ message }) => { @@ -779,430 +782,370 @@ const Notification = ({ message }) => { } ``` - - - -我们可以让测试确保错误消息被渲染给了正确的组件,或者说带有 CSS 类为error 的组件: - + +我们可以优化测试,以确保错误消息正好打印在正确位置,即 CSS 类为 error 的元素中: ```js -it('login fails with wrong password', function() { +test('login fails with wrong password', async ({ page }) => { // ... - cy.get('.error').contains('wrong credentials') // highlight-line + const errorDiv = page.locator('.error') // highlight-line + await expect(errorDiv).toContainText('wrong credentials') }) ``` + +因此,测试使用 [page.locator](https://playwright.dev/docs/api/class-page#page-locator) 方法查找 CSS 类为 error 的组件,并将其存储在变量中。可以通过期望 [toContainText](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-contain-text) 来验证与组件关联的文本的正确性。请注意,[CSS 类选择器](https://developer.mozilla.org/en-US/docs/Web/CSS/Class_selectors)以点开头,因此 error 的类选择器是 .error。 - - -首先,我们使用[cy.get](https://docs.cypress.io/api/commands/get.html#syntax)来搜索带有 CSS 类为error 的组件。 然后我们检查是否可以从这个组件中找到错误消息。 - -注意,[CSS 类选择器](https://developer.mozilla.org/en-us/docs/web/CSS/class_selectors)以句号开始,所以类为error 的选择器是 .error。 - - - - -我们可以使用[should](https://docs.cypress.io/api/commands/should.html)语法来做同样的事情: + +可以使用 [toHaveCSS](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-css) 匹配器来测试应用程序的 CSS 样式。例如,我们可以确保错误消息的颜色是红色,并且它周围有边框: ```js -it('login fails with wrong password', function() { +test('login fails with wrong password', async ({ page }) => { // ... - cy.get('.error').should('contain', 'wrong credentials') // highlight-line + const errorDiv = page.locator('.error') + await expect(errorDiv).toContainText('wrong credentials') + await expect(errorDiv).toHaveCSS('border-style', 'solid') // highlight-line + await expect(errorDiv).toHaveCSS('color', 'rgb(255, 0, 0)') // highlight-line }) ``` + +给 Playwright 的颜色必须定义为 [rgb](https://rgbcolorcode.com/color/red) 代码。 - - -使用 should 比使用contains 稍微复杂一些,但它允许比contains 更多样化的测试,contains 是仅基于文本内容的。 - - - - -最常用的断言列表可以在[这里](https://docs.cypress.io/guides/references/assertions.html#Common-Assertions)找到。 - - - - -例如,我们可以确保错误消息是红色的,并且有一个边框: + +让我们完成测试,以便它也能确保应用程序**不会渲染**描述成功登录的文本 Matti Luukkainen logged in: ```js -it('login fails with wrong password', function() { - // ... - - cy.get('.error').should('contain', 'wrong credentials') - cy.get('.error').should('have.css', 'color', 'rgb(255, 0, 0)') - cy.get('.error').should('have.css', 'border-style', 'solid') +test('login fails with wrong password', async ({ page }) =>{ + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('wrong') + await page.getByRole('button', { name: 'login' }).click() + + const errorDiv = page.locator('.error') + await expect(errorDiv).toContainText('wrong credentials') + await expect(errorDiv).toHaveCSS('border-style', 'solid') + await expect(errorDiv).toHaveCSS('color', 'rgb(255, 0, 0)') + + await expect(page.getByText('Matti Luukkainen logged in')).not.toBeVisible() // highlight-line }) ``` +### Running tests one by one - - -Cypress 需要将颜色设置为[rgb](https://rgbcolorcode.com/color/red)。 - - - - -因为所有测试都是针对我们使用[cy.get](https://docs.cypress.io/api/commands/get.html#syntax)访问到的同一个组件,所以我们可以使用[and](https://docs.cypress.io/api/commands/and.html)链接它们。 + +默认情况下,Playwright 总是运行所有测试,并且随着测试数量的增加,运行时间会变得很长。在开发新测试或调试有问题的测试时,可以用 test.only 而不是 test 来定义测试,这样 Playwright 将只运行该测试: ```js -it('login fails with wrong password', function() { - // ... - - cy.get('.error') - .should('contain', 'wrong credentials') - .and('have.css', 'color', 'rgb(255, 0, 0)') - .and('have.css', 'border-style', 'solid') -}) -``` +describe(() => { + // this is the only test executed! + test.only('login fails with wrong password', async ({ page }) => { // highlight-line + // ... + }) - -让我们完成测试,这样它还可以检查应用没把渲染成功消息'Matti Luukkainen logged in'展示出来: + // this test is skipped... + test('user can login with correct credentials', async ({ page }) => { + // ... + }) -```js -it.only('login fails with wrong password', function() { - cy.contains('login').click() - cy.get('#username').type('mluukkai') - cy.get('#password').type('wrong') - cy.get('#login-button').click() - - cy.get('.error') - .should('contain', 'wrong credentials') - .and('have.css', 'color', 'rgb(255, 0, 0)') - .and('have.css', 'border-style', 'solid') - - cy.get('html').should('not.contain', 'Matti Luukkainen logged in') // highlight-line + // ... }) ``` + +当该测试一切妥当后,only 可以并且**应该**被删除。 + +运行单个测试的另一个选项是使用命令行参数: - +``` +npm test -- -g "login fails with wrong password" +``` -Should 应当总是与get 链接(或其他某个可链接命令) +### Helper functions for tests - -我们使用cy.get('html') 访问应用的所有可见内容。 + +我们的应用测试现在看起来是这样的: -### Bypassing the UI -【绕过用户界面】 - -目前我们有如下测试: +```js +const { test, describe, expect, beforeEach } = require('@playwright/test') -```js -describe('Note app', function() { - it('user can login', function() { - cy.contains('login').click() - cy.get('#username').type('mluukkai') - cy.get('#password').type('salainen') - cy.get('#login-button').click() +describe('Note app', () => { + // ... - cy.contains('Matti Luukkainen logged in') + test('user can login with correct credentials', async ({ page }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() }) - it.only('login fails with wrong password', function() { + test('login fails with wrong password', async ({ page }) =>{ // ... }) - describe('when logged in', function() { - beforeEach(function() { - cy.contains('login').click() - cy.get('input:first').type('mluukkai') - cy.get('input:last').type('salainen') - cy.get('#login-button').click() + describe('when logged in', () => { + beforeEach(async ({ page, request }) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill('mluukkai') + await page.getByLabel('password').fill('salainen') + await page.getByRole('button', { name: 'login' }).click() }) - it('a new note can be created', function() { - // ... + test('a new note can be created', async ({ page }) => { + // ... }) - + + // ... }) }) ``` + +首先测试登录功能。之后,另一个 _describe_ 块包含一组假设用户已登录的测试,登录在用于初始化的 _beforeEach_ 块中完成。 - -首先我们测试登录。 然后,在他们自己的 describe 块中,我们有一系列测试,期望用户登录。 用户会在beforeEach 块中登录。 + +如前所述,每个测试都从初始状态开始执行(此时数据库被清空并创建一个用户),因此即使代码中定义的测试出现在另一个测试之后,它也不会从之前测试留下的状态开始! + +测试中还应尽量避免重复代码。让我们将处理登录的代码作为辅助函数隔离出来,例如放到文件 _tests/helper.js_ 中: +```js +const loginWith = async (page, username, password) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill(username) + await page.getByLabel('password').fill(password) + await page.getByRole('button', { name: 'login' }).click() +} - -正如我们上面所说的,每个测试都是从零开始的! 测试不是从以前状态结束的状态开始的。 +export { loginWith } +``` + +测试将变得更简单和清晰: +```js +const { test, describe, expect, beforeEach } = require('@playwright/test') +const { loginWith } = require('./helper') // highlight-line - -Cypress 文档给了我们如下建议: [完全测试登录流程——但只有一次!](https://docs.Cypress.io/guides/getting-started/testing-your-app.html#logging-in) +describe('Note app', () => { + // ... - -因此,Cypress 建议我们不要使用beforeEach 块中的表单登录用户,而是[绕过 UI](https://docs.Cypress.io/guides/getting-started/testing-your-app.html#bypassing-your-UI) ,对后端执行 HTTP 请求以登录。 原因是,使用 HTTP 请求登录要比填写表单快得多。 + test('user can log in', async ({ page }) => { + await loginWith(page, 'mluukkai', 'salainen') // highlight-line + await expect(page.getByText('Matti Luukkainen logged in')).toBeVisible() + }) + test('login fails with wrong password', async ({ page }) => { + await loginWith(page, 'mluukkai', 'wrong') // highlight-line - -我们的情况比 Cypress 文档中的示例要复杂一些,因为当用户登录时,我们的应用将其详细信息保存到了 localStorage 中。 - -然而,Cypress 也可以处理这个问题。 - -代码如下 + const errorDiv = page.locator('.error') + // ... + }) -```js -describe('when logged in', function() { - beforeEach(function() { - // highlight-start - cy.request('POST', 'http://localhost:3001/api/login', { - username: 'mluukkai', password: 'salainen' - }).then(response => { - localStorage.setItem('loggedNoteappUser', JSON.stringify(response.body)) - cy.visit('http://localhost:3000') + describe('when logged in', () => { + beforeEach(async ({ page }) => { + await loginWith(page, 'mluukkai', 'salainen') // highlight-line }) - // highlight-end - }) - it('a new note can be created', function() { // ... }) - - // ... }) ``` + +Playwright 还提供了一个[解决方案](https://playwright.dev/docs/auth),即在测试前执行一次登录,然后每个测试都从应用程序已经登录的状态开始。为了让我们能够使用这种方法,应用程序的测试数据初始化应该与现在稍有不同。在当前的解决方案中,每次测试前都会重置数据库,因此测试前只登录一次的是不可能的。为了使用 Playwright 提供的测试前登录,用户应该在测试前只初始化一次。我们为了简化起见,坚持当前的解决方案。 + +相应的重复代码实际上也适用于创建新笔记。为此,有一个测试使用表单创建笔记。而在更改笔记重要性的测试的 _beforeEach_ 初始化块中,也使用表单创建笔记: - -我们可以使用 then 方法访问对[cy.request](https://docs.cypress.io/api/commands/request.html)的响应。 在底层,cy.request和所有 Cypress 命令一样,都是[promises](https://docs.Cypress.io/guides/core-concepts/introduction-to-Cypress.html#commands-are-promises)。 - -回调函数将登录用户的详细信息保存到 localStorage,然后重新加载页面。 - -现在,和用户使用登录表单登录没有区别。 - - - - -如果在应用中编写新的测试,我们必须在多个地方使用登录代码。 - -我们应该使它成为一个[自定义命令](https://docs.cypress.io/api/cypress-api/custom-commands.html)。 - +```js +describe('Note app', function() { + // ... + describe('when logged in', () => { + test('a new note can be created', async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('a note created by playwright') + await page.getByRole('button', { name: 'save' }).click() + await expect(page.getByText('a note created by playwright')).toBeVisible() + }) - -自定义命令在cypress/support/commands.js. 中声明。 - -登录的代码如下: + describe('and a note exists', () => { + beforeEach(async ({ page }) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill('another note by playwright') + await page.getByRole('button', { name: 'save' }).click() + }) -```js -Cypress.Commands.add('login', ({ username, password }) => { - cy.request('POST', 'http://localhost:3001/api/login', { - username, password - }).then(({ body }) => { - localStorage.setItem('loggedNoteappUser', JSON.stringify(body)) - cy.visit('http://localhost:3000') + test('it can be made important', async ({ page }) => { + // ... + }) + }) }) }) ``` + +创建笔记的功能也被隔离到它的辅助函数中。文件 _tests/helper.js_ 扩展如下: +```js +const loginWith = async (page, username, password) => { + await page.getByRole('button', { name: 'login' }).click() + await page.getByLabel('username').fill(username) + await page.getByLabel('password').fill(password) + await page.getByRole('button', { name: 'login' }).click() +} - -使用我们的自定义命令非常简单,我们的测试也变得更简洁: - -```js -describe('when logged in', function() { - beforeEach(function() { - // highlight-start - cy.login({ username: 'mluukkai', password: 'salainen' }) - // highlight-end - }) - - it('a new note can be created', function() { - // ... - }) +// highlight-start +const createNote = async (page, content) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill(content) + await page.getByRole('button', { name: 'save' }).click() +} +// highlight-end - // ... -}) +export { loginWith, createNote } // highlight-line ``` - - - -这同样适用于创建一个新的便笺,现在我们思考一下。 我们有一个测试,使用该表单制作一个新的便笺。 我们还在测试的beforeEach 块中新建了一个便笺,改变了便笺的重要性: + +测试被简化如下: ```js -describe('Note app', function() { +const { test, describe, expect, beforeEach } = require('@playwright/test') +const { createNote, loginWith } = require('./helper') // highlight-line + +describe('Note app', () => { // ... - describe('when logged in', function() { - it('a new note can be created', function() { - cy.contains('new note').click() - cy.get('input').type('a note created by cypress') - cy.contains('save').click() + describe('when logged in', () => { + beforeEach(async ({ page }) => { + await loginWith(page, 'mluukkai', 'salainen') + }) - cy.contains('a note created by cypress') + test('a new note can be created', async ({ page }) => { + await createNote(page, 'a note created by playwright') // highlight-line + await expect(page.getByText('a note created by playwright')).toBeVisible() }) - describe('and a note exists', function () { - beforeEach(function () { - cy.contains('new note').click() - cy.get('input').type('another note cypress') - cy.contains('save').click() + describe('and a note exists', () => { + beforeEach(async ({ page }) => { + await createNote(page, 'another note by playwright') // highlight-line }) - it('it can be made important', function () { - // ... + test('importance can be changed', async ({ page }) => { + await page.getByRole('button', { name: 'make not important' }).click() + await expect(page.getByText('make important')).toBeVisible() }) }) }) }) ``` - - - -让我们为制作新便笺创建一个新的自定义命令。 该命令将使用 HTTP POST 请求生成一个新的记录: + +我们的测试中还有一个烦人的特性。前端地址 http://localhost:5173 和后端地址 http://localhost:3001 都是硬编码在测试中的。其中,后端的地址实际上是无用的,因为在前端的 Vite 配置中定义了一个代理,该代理会将前端发送到地址 http://localhost:5173/api 的所有请求转发到后端地址: ```js -Cypress.Commands.add('createNote', ({ content, important }) => { - cy.request({ - url: 'http://localhost:3001/api/notes', - method: 'POST', - body: { content, important }, - headers: { - 'Authorization': `bearer ${JSON.parse(localStorage.getItem('loggedNoteappUser')).token}` +export default defineConfig({ + server: { + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, } - }) - - cy.visit('http://localhost:3000') + }, + // ... }) ``` + +因此,我们可以把测试中的所有 _http://localhost:3001/api/..._ 替换为 _http://localhost:5173/api/..._ - - -该命令期望用户登录,并将用户的详细信息保存到 localStorage。 - - - - -现在格式块变成: +We can now define the _baseUrl_ for the application in the tests configuration file playwright.config.js: +现在我们可以在测试配置文件 playwright.config.js 中定义应用程序的 _baseUrl_: ```js -describe('Note app', function() { +export default defineConfig({ + // ... + use: { + baseURL: 'http://localhost:5173', + // ... + }, // ... +}) +``` - describe('when logged in', function() { - it('a new note can be created', function() { - // ... - }) + +所有在测试中使用应用程序 url 的命令,例如 - describe('and a note exists', function () { - beforeEach(function () { - // highlight-start - cy.createNote({ - content: 'another note cypress', - important: false - }) - // highlight-end - }) +```js +await page.goto('http://localhost:5173') +await page.post('http://localhost:5173/api/testing/reset') +``` - it('it can be made important', function () { - // ... - }) - }) - }) -}) + +现在都可以转换为: + +```js +await page.goto('/') +await page.post('/api/testing/reset') ``` + +测试的当前代码在 [GitHub](https://github.com/fullstack-hy2020/notes-e2e/tree/part5-2) 上,分支为 part5-2。 +### Note importance change revisited - -测试和前端代码可以从[github](https://github.com/fullstack-hy2020/part2-notes/tree/part5-10)分支part5-10 中找到。 + +让我们看看之前做的测试,它验证了可以更改笔记的重要性。 -### Changing the importance of a note -【改变便笺的重要性】 - -最后,让我们看一下我们为改变便笺的重要性所做的测试。 - -首先我们要改变块,让它创建三个便笺而不是一个: + +让我们更改测试的初始化块,使其创建两个笔记而不是一个: ```js -describe('when logged in', function() { - describe('and several notes exist', function () { - beforeEach(function () { +describe('when logged in', () => { + // ... + describe('and several notes exists', () => { // highlight-line + beforeEach(async ({ page }) => { // highlight-start - cy.createNote({ content: 'first note', important: false }) - cy.createNote({ content: 'second note', important: false }) - cy.createNote({ content: 'third note', important: false }) + await createNote(page, 'first note') + await createNote(page, 'second note') // highlight-end }) - it('one of those can be made important', function () { - cy.contains('second note') - .contains('make important') - .click() + test('one of those can be made nonimportant', async ({ page }) => { + const otherNoteElement = page.getByText('first note') - cy.contains('second note') - .contains('make not important') + await otherNoteElement + .getByRole('button', { name: 'make not important' }).click() + await expect(otherNoteElement.getByText('make important')).toBeVisible() }) }) }) ``` + +测试首先使用 _page.getByText_ 方法搜索与第一个创建的笔记对应的元素,并将其存储在一个变量中。之后,在元素内部搜索带有文本 _make not important_ 的按钮并点击该按钮。最后,测试验证按钮的文本是否已更改为 _make important_。 - - -[cy.contains](https://docs.cypress.io/api/commands/contains.html) 命令实际上是如何工作的? - - - -当我们在 Cypress [Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner.html)中单击 _cy.contains('second note')_ 命令时,我们会看到该命令搜索包含文本second note 的元素: - -![](../../images/5/34ea.png) - - - - - - -通过单击下一行 _.contains('make important')_ ,我们可以看到测试使用 - - -对应于second note的'make important'按钮: - -![](../../images/5/35ea.png) - - - - - -链接时,第二个contains 命令会继续从第一个命令找到的组件中搜索。 - - - - -如果我们没有把这些命令串起来,而是把它们这么写: + +测试也可以不使用辅助变量来编写: ```js -cy.contains('second note') -cy.contains('make important').click() -``` - - - -结果会完全不同。 测试的第二行会点击一个错误便笺的按钮: - -![](../../images/5/36ea.png) - - - - -在编写测试代码时,您应该检查测试运行程序是否使用了正确的组件! - +test('one of those can be made nonimportant', async ({ page }) => { + page.getByText('first note') + .getByRole('button', { name: 'make not important' }).click() + await expect(page.getByText('first note').getByText('make important')) + .toBeVisible() +}) +``` - -让我们更改 Note 组件,以便将 Note 的文本渲染为span。 + +让我们修改 _Note_ 组件,使笔记的文本渲染在 _span_ 元素内部 ```js const Note = ({ note, toggleImportance }) => { @@ -1218,293 +1161,350 @@ const Note = ({ note, toggleImportance }) => { } ``` + +测试会失败!问题的原因是命令 _page.getByText('first note')_ 现在返回的是一个仅包含文本的 _span_ 元素,而按钮位于其外部。 + +解决这个问题的方法如下: - -我们的测试结束了! 正如测试运行程序所揭示的, _cy.contains('second note')_现在返回包含文本的组件,而按钮不在其中。 - -![](../../images/5/37ea.png) - +```js +test('one of those can be made nonimportant', async ({ page }) => { + const otherNoteText = page.getByText('first note') // highlight-line + const otherNoteElement = otherNoteText.locator('..') // highlight-line + await otherNoteElement.getByRole('button', { name: 'make not important' }).click() + await expect(otherNoteElement.getByText('make important')).toBeVisible() +}) +``` + +现在第一行代码查找包含第一个创建的笔记的文本的 _span_ 元素。在第二行中,使用函数 _locator_,并将 _.._ 作为参数传入,这会获取元素的父元素。locator 函数非常灵活,我们利用了它不仅接受 CSS 选择器,还接受 [XPath](https://developer.mozilla.org/en-US/docs/Web/XPath) 选择器[作为参数](https://playwright.dev/docs/locators#locate-by-css-or-xpath)的特性。用 CSS 可以表达相同的功能,但在此情况下,XPath 提供了一种最简单的方式来查找元素的父元素。 - -解决这个问题的方法如下: + +当然,这个测试也可以只用一个辅助变量来编写: ```js -it('other of those can be made important', function () { - cy.contains('second note').parent().find('button').click() - cy.contains('second note').parent().find('button') - .should('contain', 'make not important') +test('one of those can be made nonimportant', async ({ page }) => { + const secondNoteElement = page.getByText('second note').locator('..') + await secondNoteElement.getByRole('button', { name: 'make not important' }).click() + await expect(secondNoteElement.getByText('make important')).toBeVisible() }) ``` + +让我们修改测试,以便创建三个笔记,并更改第二个创建的笔记的重要性: +```js +describe('when logged in', () => { + beforeEach(async ({ page }) => { + await loginWith(page, 'mluukkai', 'salainen') + }) - -在第一行中,我们使用[parent](https://docs.cypress.io/api/commands/parent.htm)命令来访问包含second note 的元素的父元素,并在其中找到按钮。 - - -然后我们点击按钮,检查上面的文本是否改变。 + test('a new note can be created', async ({ page }) => { + await createNote(page, 'a note created by playwright', true) + await expect(page.getByText('a note created by playwright')).toBeVisible() + }) - -注意,我们使用命令[find](https://docs.cypress.io/api/commands/find.html#syntax)来搜索按钮。 我们不能在这里使用[cy.get](https://docs.cypress.io/api/commands/get.html) ,因为它总是从 整个页面进行搜索,并返回页面上的所有5个按钮。 + describe('and several notes exists', () => { + beforeEach(async ({ page }) => { + await createNote(page, 'first note') + await createNote(page, 'second note') + await createNote(page, 'third note') // highlight-line + }) + test('one of those can be made nonimportant', async ({ page }) => { + const otherNoteText = page.getByText('second note') // highlight-line + const otherNoteElement = otherNoteText.locator('..') + + await otherNoteElement.getByRole('button', { name: 'make not important' }).click() + await expect(otherNoteElement.getByText('make important')).toBeVisible() + }) + }) +}) +``` + +不知为何,测试开始变得不可靠,有时通过,有时不通过。是时候撸起袖子,学习如何调试测试了。 - -不幸的是,我们现在在测试中有一些复制/粘贴,因为搜索正确按钮的代码总是相同的。 +### Test development and debugging + +如果测试未通过,并且你怀疑问题出在测试而非代码上,你应该以[调试](https://playwright.dev/docs/debug#run-in-debug-mode-1)模式运行测试。 - -在这种情况下,可以使用[as](https://docs.cypress.io/api/commands/as.html)命令: + +以下命令以调试模式运行有问题的测试: -```js -it.only('other of those can be made important', function () { - cy.contains('second note').parent().find('button').as('theButton') - cy.get('@theButton').click() - cy.get('@theButton').should('contain', 'make not important') -}) +``` +npm test -- -g'one of those can be made nonimportant' --debug ``` + +Playwright-inspector 会逐步显示测试进度。点击顶部的箭头-点按钮可让测试进入下一步。通过定位器找到的元素以及与浏览器的交互都在浏览器中可视化显示: - -现在第一行找到正确的按钮,并使用as 保存为theButton。 下面的代码行可以使用命名元素 cy.get('@theButton')来获取。 +![playwright inspector highlighting element found by the selected locator in the application](../../images/5/play6a.png) -### Running and debugging the tests -【运行和调试测试】 + +默认情况下,调试会逐条地执行测试命令。如果测试比较复杂,逐条调试到感兴趣的部分可能会非常费劲。可以通过使用命令 _await page.pause()_ 来避免这种情况: - -最后,还有一些关于 Cypress 如何工作和调试测试的注意事项。 +```js +describe('Note app', () => { + beforeEach(async ({ page, request }) => { + // ... + }) - -Cypress 测试的形式给人的印象是,测试是正常的 JavaScript 代码,我们可以试试这个: + describe('when logged in', () => { + beforeEach(async ({ page }) => { + // ... + }) -```js -const button = cy.contains('login') -button.click() -debugger() -cy.contains('logout').click() + describe('and several notes exists', () => { + beforeEach(async ({ page }) => { + await createNote(page, 'first note') + await createNote(page, 'second note') + await createNote(page, 'third note') + }) + + test('one of those can be made nonimportant', async ({ page }) => { + await page.pause() // highlight-line + const otherNoteText = page.getByText('second note') + const otherNoteElement = otherNoteText.locator('..') + + await otherNoteElement.getByRole('button', { name: 'make not important' }).click() + await expect(otherNoteElement.getByText('make important')).toBeVisible() + }) + }) + }) +}) ``` + +现在,你可以通过按下检查器中的绿色箭头符号,一步跳转到 _page.pause()_。 + + +当我们运行测试并跳转到 _page.pause()_ 命令时,我们发现了一个有趣的事实: - -但是这不起作用,当 Cypress 运行测试时,它会将每个 cy 命令添加到一个执行队列中。 - -当执行测试方法的代码时,Cypres 将逐个执行队列中的每个命令。 +![playwright inspector showing the state of the application at page.pause](../../images/5/play6b.png) + +浏览器似乎没有渲染在 _beforeEach_ 块中创建的所有笔记。问题出在哪里? + +问题的原因是,当测试创建一个笔记时,它会在服务器响应之前就开始创建下一个笔记,而新添加的笔记被渲染在屏幕上。这反过来可能导致一些笔记丢失(在图片中,这发生在创建第二个笔记时),因为当服务器响应时,浏览器会根据插入操作开始时的笔记状态重新渲染。 - -Cypress 命令总是返回 _undefined_ ,因此上面代码中的_button.click()_会导致错误。 试图启动调试器不会在执行命令之间停止代码,但会在执行任何命令之前停止。 + +这个问题可以通过“减慢”插入操作来解决,在插入操作后使用 [waitFor](https://playwright.dev/docs/api/class-locator#locator-wait-for) 以等待插入的笔记被渲染: +```js +const createNote = async (page, content) => { + await page.getByRole('button', { name: 'new note' }).click() + await page.getByRole('textbox').fill(content) + await page.getByRole('button', { name: 'save' }).click() + await page.getByText(content).waitFor() // highlight-line +} +``` + +在 UI 模式下运行测试可能很有用,它可以替代或者配合调试模式。如前所述,测试用以下命令在 UI 模式下启动: - -Cypress 命令是类似 promises,所以如果我们想访问它们的返回值,我们必须使用[then](https://docs.Cypress.io/api/commands/then.html)命令。 +``` +npm run test -- --ui +``` - -例如,下面的测试将打印应用中的按钮数,然后单击第一个按钮: + +使用 Playwright 的[跟踪查看器](https://playwright.dev/docs/trace-viewer-intro)几乎与 UI 模式相同。其想法是保存测试的“视觉跟踪”,在测试完成后如有必要可以查看。通过以下方式运行测试可以保存跟踪: -```js -it('then example', function() { - cy.get('button').then( buttons => { - console.log('number of buttons', buttons.length) - cy.wrap(buttons[0]).click() - }) -}) +``` +npm run test -- --trace on ``` + +如果需要,可以使用这个命令查看跟踪 +``` +npx playwright show-report +``` - -使用调试器停止测试执行是[可行的](https://docs.cypress.io/api/commands/debug.html)。 只有当 Cypress 测试运行程序的开发人员控制台打开时,调试器才会启动。 + +或者使用我们定义的 npm 脚本 _npm run test:report_ + +跟踪看起来几乎和 UI 模式下运行测试一样。 + +UI 模式和跟踪查看器还提供了定位器的辅助搜索功能。这是通过点击下栏左侧的双圆圈,然后点击所需的用户界面元素来完成的。Playwright 显示元素定位器: - -开发控制台在调试测试时非常有用。 - -你可以在 Network 选项卡上看到测试完成的 HTTP 请求,控制台选项卡会显示关于测试的信息: +![playwright's trace viewer with red arrows pointing at the locator assisted search location and to the element selected with it showing a suggested locator for the element](../../images/5/play8.png) -![](../../images/5/38ea.png) + +Playwright 建议以下作为第三个笔记的定位器 +```js +page.locator('li').filter({ hasText: 'third note' }).getByRole('button') +``` + +方法 [page.locator](https://playwright.dev/docs/api/class-page#page-locator) 被调用,参数为 _li_,即我们在页面上搜索所有 li 元素,总共有三个。之后,使用 [locator.filter](https://playwright.dev/docs/api/class-locator#locator-filter) 方法,我们缩小范围到包含文本 third note 的 li 元素,并使用 [locator.getByRole](https://playwright.dev/docs/api/class-locator#locator-get-by-role) 方法获取其内部的按钮元素。 - -到目前为止,我们已经使用图形化的测试运行了 Cypress 测试。 - -也可以[从命令行](https://docs.cypress.io/guides/guides/command-line.html)运行它们。 我们只需要为它添加一个 npm 脚本: + +Playwright 生成的定位器与我们的测试中使用的定位器略有不同,后者是 ```js - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject", - "server": "json-server -p3001 --watch db.json", - "cypress:open": "cypress open", - "test:e2e": "cypress run" // highlight-line - }, +page.getByText('first note').locator('..').getByRole('button', { name: 'make not important' }) ``` + +哪个定位器更好可能是一个主观的问题。 + +Playwright 还包含一个[测试生成器](https://playwright.dev/docs/codegen-intro),可以通过用户界面“录制”测试。测试生成器使用以下命令启动: - -现在,我们可以使用命令npm run test: e2e 从命令行运行测试 +``` +npx playwright codegen http://localhost:5173/ +``` -![](../../images/5/39ea.png) + +当 _Record_ 模式开启时,测试生成器会在 Playwright 检查器中“录制”用户的交互,可以把这些定位器和操作复制到测试中: +![playwright's record mode enabled with its output in the inspector after user interaction](../../images/5/play9.png) + +除了命令行,Playwright 还可以通过 [VS Code](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) 插件使用。该插件提供许多便捷功能,例如在调试测试时使用断点。 - -注意,测试执行的视频将被保存到cypress/videos/中,因此您可能应该用gitignore忽略这个目录。 + +为了避免问题并增加理解,浏览 Playwright 的高质量[文档](https://playwright.dev/docs/intro)绝对值得。最重要的部分列在下表: + +- [定位器](https://playwright.dev/docs/locators)部分为在测试中查找元素提供了良好的提示 + +- [操作]((https://playwright.dev/docs/input))部分说明了如何在测试中模拟与浏览器的交互 + +- [断言](https://playwright.dev/docs/test-assertions)部分展示了 Playwright 为测试提供的不同预期 + +详细内容可以在 [API](https://playwright.dev/docs/api/class-playwright) 描述中找到,特别有用的是测试里对应于应用程序浏览器窗口的 [Page](https://playwright.dev/docs/api/class-page) 类,以及在测试中用于搜索元素的 [Locator](https://playwright.dev/docs/api/class-locator) 类。 - -前端和测试代码可以在[github](https://github.com/fullstack-hy2020/part2-notes/tree/part5-11)分支part5-11 中找到。 + +测试的最终版本完整地托管在 [GitHub](https://github.com/fullstack-hy2020/notes-e2e/tree/part5-3) 上,分支为 part5-3。 -
    + +前端代码的最终版本完整地托管在 [GitHub](https://github.com/fullstack-hy2020/part2-notes-frontend/tree/part5-9) 上,分支为 part5-9。 +
    +### Exercises 5.17.-5.23. -### Exercises 5.17.-5.22. - - -在这一章节的最后练习中,我们将为我们的博客应用做一些 E2E 测试。 - -这部分的材料应该足以完成这些练习。 - -你绝对应该看看 Cypress [文档](https://docs.Cypress.io/guides/overview/why-Cypress.html#in-a-nutshell 文档)。 这可能是我见过的最好的开源项目文档。 - - -我特别推荐阅读《Cypress 简介》 [Introduction to Cypress](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Cypress-Can-Be-Simple-Sometimes),其中说到 + +在本部分的最后几个练习中,让我们为博客应用做一些 E2E 测试。上述材料应该足以完成大部分练习。然而,你绝对应该阅读 Playwright 的[文档](https://playwright.dev/docs/intro)和 [API 描述](https://playwright.dev/docs/api/class-playwright),至少要阅读上一章末尾提到的部分。 -> This is the single most important guide for understanding how to test with Cypress. Read it. Understand it.
    -这是了解如何使用Cypress进行测试的最重要的指南。读一读,了解一下 +#### 5.17: Blog List End To End Testing, step 1 -#### 5.17: bloglist end to end testing, 步骤1 + +为测试创建一个新的 npm 项目,并在其中配置 Playwright。 + +编写一个测试,确保应用程序默认显示登录表单。 - -在项目中配置 Cypress。做一个测试,检查应用是否默认显示登录表单。 + +测试的主体应如下: - -测试的结构必须如下 +```js +const { test, expect, beforeEach, describe } = require('@playwright/test') -```js -describe('Blog app', function() { - beforeEach(function() { - cy.request('POST', 'http://localhost:3001/api/testing/reset') - cy.visit('http://localhost:3000') +describe('Blog app', () => { + beforeEach(async ({ page }) => { + await page.goto('http://localhost:5173') }) - it('Login from is shown', function() { + test('Login form is shown', async ({ page }) => { // ... }) }) -``` - +``` - -格式化博客的beforeEach 必须清空数据库,例如使用 [教材](/zh/part5/端到端测试#controlling-the-state-of-the-database)中使用的方法。 - +#### 5.18: Blog List End To End Testing, step 2 -#### 5.18: bloglist end to end testing, 步骤2 - -对登录进行测试。测试成功和失败的登录尝试。 + +进行登录测试。成功和失败的登录都要测试。为了测试,需要在 _beforeEach_ 块中创建一个用户。 - -在beforeEach 块中为测试创建一个新用户。 + +测试的主体扩展如下 - -测试结构是这样扩展的 +```js +const { test, expect, beforeEach, describe } = require('@playwright/test') -```js -describe('Blog app', function() { - beforeEach(function() { - cy.request('POST', 'http://localhost:3001/api/testing/reset') - // create here a user to backend - cy.visit('http://localhost:3000') +describe('Blog app', () => { + beforeEach(async ({ page, request }) => { + // empty the db here + // create a user for the backend here + // ... }) - it('Login from is shown', function() { + test('Login form is shown', async ({ page }) => { // ... }) - describe('Login',function() { - it('succeeds with correct credentials', function() { + describe('Login', () => { + test('succeeds with correct credentials', async ({ page }) => { // ... }) - it('fails with wrong credentials', function() { + test('fails with wrong credentials', async ({ page }) => { // ... }) }) }) ``` - -可选的附加练习Optional bonus exercise: 检查显示未成功登入的通知是否显示为红色。 - -#### 5.19: bloglist end to end testing, 步骤3 + +_beforeEach_ 块必须清空数据库,例如使用我们在[材料](/en/part5/end_to_end_testing_playwright#controlling-the-state-of-the-database)中使用的 reset 方法。 - -做一个测试,检查登录用户是否可以创建一个新的博客。 - -测试的结构可以如下 +#### 5.19: Blog List End To End Testing, step 3 -```js -describe('Blog app', function() { - // ... + +编写一个测试,验证登录用户可以创建博客。测试的主体可能如下所示 - describe.only('When logged in', function() { - beforeEach(function() { - // log in user here - }) - - it('A blog can be created', function() { - // ... - }) +```js +describe('When logged in', () => { + beforeEach(async ({ page }) => { + // ... }) + test('a new blog can be created', async ({ page }) => { + // ... + }) }) ``` + +该测试应确保创建的博客在博客列表中可见。 +#### 5.20: Blog List End To End Testing, step 4 - -这个测试必须确保,一个新的博客被添加到所有的博客列表中。 - -#### 5.20: bloglist end to end testing, 步骤4 + +编写一个测试,确保博客可以被点赞。 +#### 5.21: Blog List End To End Testing, step 5 - -做一个测试,检查用户是否能点赞博客。 + +编写一个测试,确保添加博客的用户可以删除博客。如果你在删除操作中使用 _window.confirm_ 对话框,你可能需要去 Google 搜索如何在 Playwright 测试中使用该对话框。 -#### 5.21: bloglist end to end testing, 步骤5 - -做一个测试来确保,创建博客的用户可以删除它。 +#### 5.22: Blog List End To End Testing, step 6 + +编写一个测试,确保只有添加博客的用户能看见博客的删除按钮。 +#### 5.23: Blog List End To End Testing, step 7 - -可选附加练习Optional bonus exercise: 检查其他用户不能删除的博客。 + +编写一个测试,确保博客按照点赞数排序,点赞数最多的博客排在最前面。 -#### 5.22: bloglist end end testing, 步骤 6 - -先做一个检查,看看博客是否按照喜好排序,最喜欢的博客放最前面。 - - -这项工作可能有点棘手。 一个解决方案是找到所有的博客,然后在[then](https://docs.cypress.io/api/commands/then.html#dom-element)命令的回调函数中对它们进行比较。 - - -这是本章节的最后一个练习,是时候将您的代码推送到 github,并标记您在[exercise submission system](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)中完成的练习。 + +这项任务比之前的要困难得多。 + +这是该部分的最后一个任务,现在可以将代码推送到 GitHub,并在[练习提交系统](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)中标记已完成的任务。
    - diff --git a/src/content/5/zh/part5e.md b/src/content/5/zh/part5e.md new file mode 100644 index 00000000000..d01600ab1ce --- /dev/null +++ b/src/content/5/zh/part5e.md @@ -0,0 +1,1318 @@ +--- +mainImage: ../../../images/part-5.svg +part: 5 +letter: e +lang: zh +--- + +
    + + +[Cypress](https://www.cypress.io/) 在过去几年一直是最受欢迎的 E2E 测试库,但 Playwright 正在迅速获得市场份额。本课程多年来一直使用 Cypress。现在 Playwright 成为了一个新的补充。你可以选择用 Cypress 或 Playwright 完成课程的 E2E 测试部分。这两个库的运行原理非常相似,所以你的选择并不重要。然而,目前 Playwright 是课程首选的 E2E 测试库。 + + +如果你选择 Cypress,请继续。如果你最终使用 Playwright,请点[这里](/en/part5/end_to_end_testing_playwright)。 + +### Cypress + + + E2E库[Cypress](https://www.cypress.io/)在去年开始流行。Cypress特别容易使用,与Selenium等相比,它需要的麻烦和头绪要少得多。 + +它的操作原理与大多数E2E测试库完全不同,因为Cypress测试完全在浏览器中运行。 + + 其他库在一个Node进程中运行测试,该进程通过API与浏览器相连。 + + + 让我们为我们的笔记应用做一些端到端的测试。 + + + 我们首先将Cypress安装到前端作为开发依赖项 + +```js +npm install --save-dev cypress +``` + + + 并添加一个npm-script来运行它。 + +```js +{ + // ... + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "server": "json-server -p3001 db.json", + "cypress:open": "cypress open" // highlight-line + }, + // ... +} +``` + + + 与前端的单元测试不同,Cypress的测试可以在前端或后端仓库中,甚至可以在它们自己的独立仓库中。 + + + 测试需要被测系统正在运行。与我们的后端集成测试不同,Cypress测试在运行时不会启动系统。 + + + 让我们给后端添加一个npm-script,在测试模式下启动它,或者让NODE\_ENV成为测试。 + +```js +{ + // ... + "scripts": { + "start": "cross-env NODE_ENV=production node index.js", + "dev": "cross-env NODE_ENV=development nodemon index.js", + "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend", + "deploy": "git push heroku master", + "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy", + "logs:prod": "heroku logs --tail", + "lint": "eslint .", + "test": "cross-env NODE_ENV=test jest --verbose --runInBand", + "start:test": "cross-env NODE_ENV=test node index.js" // highlight-line + }, + // ... +} +``` + + + NB!为了让Cypress与WSL2一起工作,可能需要先做一些额外的配置。这两个[链接](https://docs.cypress.io/guides/getting-started/installing-cypress#Windows-Subsystem-for-Linux)是[开始](https://nickymeuleman.netlify.app/blog/gui-on-wsl2-cypress)的好地方。 + + + NB!对于使用m1 CPU而不是intel CPU的macbook,cypress不能工作,因为它还不支持m1。要解决这个问题,安装Rosetta 2然后配置你的终端是必须的。关于一步一步的说明,请按照[这里](https://www.cypress.io/blog/2021/01/20/running-cypress-on-the-apple-m1-silicon-arm-architecture-using-rosetta-2/)。 + + + 当后端和前端都在运行时,我们可以用以下命令启动Cypress + +```js +npm run cypress:open +``` + + + 当我们第一次运行Cypress时,它会创建一个cypress目录。它包含一个integration子目录,我们将在那里放置我们的测试。Cypress在两个子目录中为我们创建了一堆测试示例:integration/1-getting-startedintegration/2-advanced-examples目录。我们可以删除这两个目录,在文件note_app.spec.js中做我们自己的测试。 + +```js +describe('Note app', function() { + it('front page can be opened', function() { + cy.visit('http://localhost:3000') + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2022') + }) +}) +``` + + + 我们从打开的窗口开始进行测试。 + +![](../../images/5/40x.png) + + + **注意**:删除示例测试后,你可能需要重新启动Cypress。 + + + 运行测试会打开你的浏览器,并显示应用在运行测试时的表现。 + +![](../../images/5/32x.png) + + + 测试的结构应该看起来很熟悉。他们使用describe块来分组不同的测试用例,像Jest那样。测试用例已经用it方法进行了定义。 + + Cypress从它在引擎盖下使用的[Mocha](https://mochajs.org/)测试库中借用了这些部分。 + + + [cy.visit](https://docs.cypress.io/api/commands/visit.html)和[cy.contains](https://docs.cypress.io/api/commands/contains.html)是Cypress的命令,它们的目的非常明显。 + + [cy.visit](https://docs.cypress.io/api/commands/visit.html)在测试所使用的浏览器中打开作为参数给它的网页地址。[cy.contains](https://docs.cypress.io/api/commands/contains.html)搜索它从网页上收到的作为参数的字符串。 + + + 我们可以用一个箭头函数来声明这个测试 + +```js +describe('Note app', () => { // highlight-line + it('front page can be opened', () => { // highlight-line + cy.visit('http://localhost:3000') + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2022') + }) +}) +``` + + + 然而,Mocha[建议](https://mochajs.org/#arrow-functions)不要使用箭头函数,因为它们在某些情况下可能导致一些问题。 + + + 如果cy.contains没有找到它要搜索的文本,测试就不会通过。 因此,如果我们像这样扩展我们的测试 + +```js +describe('Note app', function() { + it('front page can be opened', function() { + cy.visit('http://localhost:3000') + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2022') + }) + +// highlight-start + it('front page contains random text', function() { + cy.visit('http://localhost:3000') + cy.contains('wtf is this app?') + }) +// highlight-end +}) +``` + + + 测试失败 + +![](../../images/5/33x.png) + + + 让我们从测试中删除失败的代码。 + +### Writing to a form + + + 让我们扩展我们的测试,使测试尝试登录到我们的应用。 + + 我们假设我们的后端包含一个用户名mluukkai和密码salainen的用户。 + + +测试从打开登录表单开始。 + +```js +describe('Note app', function() { + // ... + + it('login form can be opened', function() { + cy.visit('http://localhost:3000') + cy.contains('login').click() + }) +}) +``` + + + 测试首先通过文本搜索登录按钮,并通过命令[cy.click](https://docs.cypress.io/api/commands/click.html#Syntax)点击按钮。 + + + 我们的两个测试都是以同样的方式开始的,打开http://localhost:3000页面,所以我们应该 + +将共享部分分离成一个beforeEach块,在每个测试前运行。 + +```js +describe('Note app', function() { + // highlight-start + beforeEach(function() { + cy.visit('http://localhost:3000') + }) + // highlight-end + + it('front page can be opened', function() { + cy.contains('Notes') + cy.contains('Note app, Department of Computer Science, University of Helsinki 2022') + }) + + it('login form can be opened', function() { + cy.contains('login').click() + }) +}) +``` + + + 登录字段包含两个输入字段,测试应该把它们写进。 + + + [cy.get](https://docs.cypress.io/api/commands/get.html#Syntax)命令允许通过CSS选择器搜索元素。 + + + 我们可以访问页面上的第一个和最后一个输入字段,并通过[cy.type](https://docs.cypress.io/api/commands/type.html#Syntax)命令写入它们,就像这样。 + +```js +it('user can login', function () { + cy.contains('login').click() + cy.get('input:first').type('mluukkai') + cy.get('input:last').type('salainen') +}) +``` + + + 这个测试是有效的。问题是,如果我们以后添加更多的输入字段,测试就会中断,因为它期望它需要的字段是页面上的第一个和最后一个。 + + + 最好是给我们的输入以唯一的ids,并使用这些来找到它们。 + +我们像这样改变我们的登录表格。 + +```js +const LoginForm = ({ ... }) => { + return ( +
    +

    Login

    +
    +
    + username + +
    +
    + password + +
    + +
    +
    + ) +} +``` + + + 我们还为我们的提交按钮添加了一个ID,这样我们就可以在测试中访问它。 + + + 测试变成了。 + +```js +describe('Note app', function() { + // .. + it('user can log in', function() { + cy.contains('login').click() + cy.get('#username').type('mluukkai') // highlight-line + cy.get('#password').type('salainen') // highlight-line + cy.get('#login-button').click() // highlight-line + + cy.contains('Matti Luukkainen logged in') // highlight-line + }) +}) +``` + + + 最后一行确保登录成功。 + + + 注意CSS的[id-selector](https://developer.mozilla.org/en-US/docs/Web/CSS/ID_selectors)是#,所以如果我们想搜索一个id为username的元素,CSS选择器是#username。 + +### Some things to note + + + 测试首先点击按钮,打开登录表单,就像这样 + +```js +cy.contains('login').click() +``` + + +当表格填写完毕后,点击提交按钮,表格被提交。 + +```js +cy.get('#login-button').click() +``` + + +两个按钮都有文本login,但它们是两个独立的按钮。 + +实际上两个按钮一直都在应用的DOM中,但由于其中一个按钮的display:none样式,每次只有一个是可见的。 + + + 如果我们通过文本搜索一个按钮,[cy.contains](https://docs.cypress.io/api/commands/contains.html#Syntax)将返回其中的第一个,或打开登录表单的那个。 + +即使该按钮不可见,也会发生这种情况。 + +为了避免名称冲突,我们给了提交按钮一个id login-button,我们可以用它来访问。 + + + 现在我们注意到,我们测试使用的变量_cy_给了我们一个讨厌的Eslint错误 + +![](../../images/5/30ea.png) + + +我们可以通过安装[eslint-plugin-cypress](https://github.com/cypress-io/eslint-plugin-cypress)作为开发依赖来摆脱它。 + +```js +npm install eslint-plugin-cypress --save-dev +``` + + + 然后改变.eslintrc.js中的配置,像这样。 + +```js +module.exports = { + "env": { + "browser": true, + "es6": true, + "jest/globals": true, + "cypress/globals": true // highlight-line + }, + "extends": [ + // ... + ], + "parserOptions": { + // ... + }, + "plugins": [ + "react", "jest", "cypress" // highlight-line + ], + "rules": { + // ... + } +} +``` + +### Testing new note form + + + 接下来我们添加测试 "新笔记 "功能的测试。 + +```js +describe('Note app', function() { + // .. + // highlight-start + describe('when logged in', function() { + beforeEach(function() { + cy.contains('login').click() + cy.get('input:first').type('mluukkai') + cy.get('input:last').type('salainen') + cy.get('#login-button').click() + }) + // highlight-end + + // highlight-start + it('a new note can be created', function() { + cy.contains('new note').click() + cy.get('input').type('a note created by cypress') + cy.contains('save').click() + + cy.contains('a note created by cypress') + }) + }) + // highlight-end +}) +``` + + + 这个测试已经被定义在它自己的describe块中。 + + 只有登录的用户才能创建新的笔记,所以我们在beforeEach块中加入了登录应用。 + + + 测试相信当创建一个新的笔记时,页面只包含一个输入,所以它像这样搜索它。 + +```js +cy.get('input') +``` + + + 如果该页面包含更多的输入,测试就会中断。 + +![](../../images/5/31x.png) + + +由于这个原因,最好还是给输入一个id,并通过它的id搜索元素。 + + + 测试的结构看起来是这样的。 + +```js +describe('Note app', function() { + // ... + + it('user can log in', function() { + cy.contains('login').click() + cy.get('#username').type('mluukkai') + cy.get('#password').type('salainen') + cy.get('#login-button').click() + + cy.contains('Matti Luukkainen logged in') + }) + + describe('when logged in', function() { + beforeEach(function() { + cy.contains('login').click() + cy.get('input:first').type('mluukkai') + cy.get('input:last').type('salainen') + cy.get('#login-button').click() + }) + + it('a new note can be created', function() { + // ... + }) + }) +}) +``` + + + Cypress按照代码中的顺序运行测试。因此,首先它运行user can log in,其中用户登录。然后,Cypress将运行一个新的笔记可以被创建,为此,一个beforeEach块也会登录。 + + 为什么这样做?用户在第一次测试后不是已经登录了吗? + +不是,因为就浏览器而言,每个测试都是从零开始的。 + +每次测试后,对浏览器状态的所有改变都是相反的。 + +### Controlling the state of the database + + + 如果测试需要能够修改服务器的数据库,情况会立即变得更加复杂。理想情况下,每次我们运行测试时,服务器的数据库应该是相同的,所以我们的测试可以可靠地、容易地重复。 + + + 与单元和集成测试一样,对于E2E测试,最好是在测试运行前清空数据库,并可能将其格式化。E2E测试的挑战是他们不能访问数据库。 + + +解决方法是为测试的后端创建API端点。 + + 我们可以使用这些端点清空数据库。 + + 让我们为测试创建一个新的路由器。 + +```js +const testingRouter = require('express').Router() +const Note = require('../models/note') +const User = require('../models/user') + +testingRouter.post('/reset', async (request, response) => { + await Note.deleteMany({}) + await User.deleteMany({}) + + response.status(204).end() +}) + +module.exports = testingRouter +``` + + +并将其添加到后端 如果应用在测试模式下运行。 + +```js +// ... + +app.use('/api/login', loginRouter) +app.use('/api/users', usersRouter) +app.use('/api/notes', notesRouter) + +// highlight-start +if (process.env.NODE_ENV === 'test') { + const testingRouter = require('./controllers/testing') + app.use('/api/testing', testingRouter) +} +// highlight-end + +app.use(middleware.unknownEndpoint) +app.use(middleware.errorHandler) + +module.exports = app +``` + + + 更改后,对/api/testing/reset端点的HTTP POST请求会清空数据库。确保你的后端在测试模式下运行,用这个命令启动它(之前在package.json文件中配置)。 +```js + npm run start:test +``` + + + 修改后的后端代码可以在[GitHub](https://github.com/fullstack-hy2020/part3-notes-backend/tree/part5-1)分支part5-1找到。 + + + 接下来我们将修改beforeEach块,以便在运行测试之前清空服务器的数据库。 + + + 目前不可能通过前端的用户界面添加新用户,所以我们从beforeEach块向后端添加一个新用户。 + +```js +describe('Note app', function() { + beforeEach(function() { + // highlight-start + cy.request('POST', 'http://localhost:3001/api/testing/reset') + const user = { + name: 'Matti Luukkainen', + username: 'mluukkai', + password: 'salainen' + } + cy.request('POST', 'http://localhost:3001/api/users/', user) + // highlight-end + cy.visit('http://localhost:3000') + }) + + it('front page can be opened', function() { + // ... + }) + + it('user can login', function() { + // ... + }) + + describe('when logged in', function() { + // ... + }) +}) +``` + + +在格式化过程中,测试用[cy.request](https://docs.cypress.io/api/commands/request.html)向后端做HTTP请求。 + + + 与先前不同,现在测试开始时,后端每次都处于相同的状态。后端将包含一个用户,没有注释。 + + + 让我们再增加一个测试,检查我们是否可以改变笔记的重要性。 + + 首先我们改变前端,使新的笔记默认为不重要,或者重要字段为false。 + +```js +const NoteForm = ({ createNote }) => { + // ... + + const addNote = (event) => { + event.preventDefault() + createNote({ + content: newNote, + important: false // highlight-line + }) + + setNewNote('') + } + // ... +} +``` + + + 有多种方法来测试。在下面的例子中,我们首先搜索一个笔记,并点击其重要按钮。然后我们检查该笔记现在是否包含一个使之不重要按钮。 + +```js +describe('Note app', function() { + // ... + + describe('when logged in', function() { + // ... + + describe('and a note exists', function () { + beforeEach(function () { + cy.contains('new note').click() + cy.get('input').type('another note cypress') + cy.contains('save').click() + }) + + it('it can be made important', function () { + cy.contains('another note cypress') + .contains('make important') + .click() + + cy.contains('another note cypress') + .contains('make not important') + }) + }) + }) +}) +``` + + + 第一条命令搜索一个包含另一个笔记cypress文本的组件,然后搜索其中的make important按钮。然后它就点击这个按钮。 + + + 第二个命令检查按钮上的文字是否已经变成了使之不重要。 + + + 测试和当前的前端代码可以从[GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part5-9)分支part5-9找到。 + +### Failed login test + + + 让我们做一个测试,确保在密码错误的情况下,登录尝试失败。 + + + Cypress默认每次都会运行所有测试,随着测试数量的增加,它开始变得相当耗时。 + + 当开发一个新的测试或调试一个损坏的测试时,我们可以用it.only代替it来定义测试,这样Cypress将只运行所需的测试。 + + 当测试正常时,我们可以删除.only。 + + +我们测试的第一个版本如下。 + +```js +describe('Note app', function() { + // ... + + it.only('login fails with wrong password', function() { + cy.contains('login').click() + cy.get('#username').type('mluukkai') + cy.get('#password').type('wrong') + cy.get('#login-button').click() + + cy.contains('wrong credentials') + }) + + // ... +)} +``` + + + 测试使用[cy.contains](https://docs.cypress.io/api/commands/contains.html#Syntax)来确保应用打印出错误信息。 + + + 应用将错误信息渲染到一个具有CSS类error的组件。 + +```js +const Notification = ({ message }) => { + if (message === null) { + return null + } + + return ( +
    // highlight-line + {message} +
    + ) +} +``` + + + 我们可以让测试确保错误信息被渲染到正确的组件,也就是具有CSS类error的组件。 + + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error').contains('wrong credentials') // highlight-line +}) +``` + + + 首先我们使用[cy.get](https://docs.cypress.io/api/commands/get.html#Syntax)来搜索一个具有CSS类error的组件。然后我们检查是否可以从这个组件中找到错误信息。 + + 注意[CSS类选择器](https://developer.mozilla.org/en-US/docs/Web/CSS/Class_selectors)以句号开头,所以error类的选择器是.error。 + + + 我们可以用[should](https://docs.cypress.io/api/commands/should.html)的语法做同样的事情。 + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error').should('contain', 'wrong credentials') // highlight-line +}) +``` + + + 使用should比使用contains要麻烦一些,但它允许比contains更多样化的测试,后者只基于文本内容工作。 + + + 可以和should一起使用的最常见的断言列表可以在[这里](https://docs.cypress.io/guides/references/assertions.html#Common-Assertions)找到。 + + + 例如,我们可以确保错误信息是红色的,并且有一个边框。 + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error').should('contain', 'wrong credentials') + cy.get('.error').should('have.css', 'color', 'rgb(255, 0, 0)') + cy.get('.error').should('have.css', 'border-style', 'solid') +}) +``` + + + Cypress要求颜色以[rgb](https://rgbcolorcode.com/color/red)形式给出。 + + + 因为所有的测试都是针对我们使用[cy.get](https://docs.cypress.io/api/commands/get.html#Syntax)访问的同一个组件,我们可以使用[and](https://docs.cypress.io/api/commands/and.html)将它们连锁起来。 + +```js +it('login fails with wrong password', function() { + // ... + + cy.get('.error') + .should('contain', 'wrong credentials') + .and('have.css', 'color', 'rgb(255, 0, 0)') + .and('have.css', 'border-style', 'solid') +}) +``` + + + 让我们完成这个测试,以便它也能检查应用是否渲染成功信息 "Matti Luukkainen logged in"。 + +```js +it('login fails with wrong password', function() { + cy.contains('login').click() + cy.get('#username').type('mluukkai') + cy.get('#password').type('wrong') + cy.get('#login-button').click() + + cy.get('.error') + .should('contain', 'wrong credentials') + .and('have.css', 'color', 'rgb(255, 0, 0)') + .and('have.css', 'border-style', 'solid') + + cy.get('html').should('not.contain', 'Matti Luukkainen logged in') // highlight-line +}) +``` + +Should should always be chained with get (or another chainable command). + + 我们使用cy.get("html")来访问应用的整个可见内容。 + + + **注意:**一些CSS属性[在Firefox上的表现不同](https://github.com/cypress-io/cypress/issues/9349)。如果你用Firefox运行测试。 + + + ![running](https://user-images.githubusercontent.com/4255997/119015927-0bdff800-b9a2-11eb-9234-bb46d72c0368.png) + + + 那么涉及到 "border-style"、"border-radius "和 "padding "的测试,在Chrome或Electron上会通过,但在Firefox上会失败。 + + + ![borderstyle](https://user-images.githubusercontent.com/4255997/119016340-7b55e780-b9a2-11eb-82e0-bab0418244c0.png) + +### Bypassing the UI + + + 目前我们有以下的测试。 + +```js +describe('Note app', function() { + it('user can login', function() { + cy.contains('login').click() + cy.get('#username').type('mluukkai') + cy.get('#password').type('salainen') + cy.get('#login-button').click() + + cy.contains('Matti Luukkainen logged in') + }) + + it('login fails with wrong password', function() { + // ... + }) + + describe('when logged in', function() { + beforeEach(function() { + cy.contains('login').click() + cy.get('input:first').type('mluukkai') + cy.get('input:last').type('salainen') + cy.get('#login-button').click() + }) + + it('a new note can be created', function() { + // ... + }) + + }) +}) +``` + + + 首先我们测试登录。然后,在他们自己的描述块中,我们有一系列的测试,期望用户能够登录。用户在beforeEach块中被登录。 + + + 正如我们上面所说的,每个测试都是从零开始的!测试不会从之前测试结束的状态开始。 + + + Cypress文档给了我们以下建议。[完全测试登录流程--但只测试一次!](https://docs.cypress.io/guides/getting-started/testing-your-app.html#Logging-in)。 + + 因此,Cypress建议我们不要在beforeEach块中使用表单来登录用户,而是[绕过UI](https://docs.cypress.io/guides/getting-started/testing-your-app.html#Bypassing-your-UI),向后端发出HTTP请求来登录。这样做的原因是,用HTTP请求登录要比填表快得多。 + + + 我们的情况比Cypress文档中的例子要复杂一些,因为当用户登录时,我们的应用会将他们的详细信息保存到localStorage中。 + + 然而,Cypress也可以处理这个问题。 + +代码如下 + +```js +describe('when logged in', function() { + beforeEach(function() { + // highlight-start + cy.request('POST', 'http://localhost:3001/api/login', { + username: 'mluukkai', password: 'salainen' + }).then(response => { + localStorage.setItem('loggedNoteappUser', JSON.stringify(response.body)) + cy.visit('http://localhost:3000') + }) + // highlight-end + }) + + it('a new note can be created', function() { + // ... + }) + + // ... +}) +``` + + + 我们可以用_then_方法访问[cy.request](https://docs.cypress.io/api/commands/request.html)的响应。 在引擎盖下cy.request,像所有的Cypress命令一样,是[promises](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Commands-Are-Promises)。 + +回调函数将登录用户的详细信息保存到localStorage,并重新加载页面。 + +现在与用户用登录表格登录没有区别。 + + + 如果我们给我们的应用写新的测试,我们必须在多个地方使用登录代码。 + +我们应该把它变成一个[自定义命令](https://docs.cypress.io/api/cypress-api/custom-commands.html)。 + + +自定义命令在cypress/support/commands.js中声明。 + + 登录的代码如下。 + +```js +Cypress.Commands.add('login', ({ username, password }) => { + cy.request('POST', 'http://localhost:3001/api/login', { + username, password + }).then(({ body }) => { + localStorage.setItem('loggedNoteappUser', JSON.stringify(body)) + cy.visit('http://localhost:3000') + }) +}) +``` + + + 使用我们的自定义命令很容易,我们的测试也变得更干净。 + +```js +describe('when logged in', function() { + beforeEach(function() { + // highlight-start + cy.login({ username: 'mluukkai', password: 'salainen' }) + // highlight-end + }) + + it('a new note can be created', function() { + // ... + }) + + // ... +}) +``` + + + 现在我们想想,这同样适用于创建一个新的笔记。我们有一个测试,使用表单制作一个新的笔记。我们也在测试改变笔记的重要性的beforeEach块中制作一个新的笔记。 + +```js +describe('Note app', function() { + // ... + + describe('when logged in', function() { + it('a new note can be created', function() { + cy.contains('new note').click() + cy.get('input').type('a note created by cypress') + cy.contains('save').click() + + cy.contains('a note created by cypress') + }) + + describe('and a note exists', function () { + beforeEach(function () { + cy.contains('new note').click() + cy.get('input').type('another note cypress') + cy.contains('save').click() + }) + + it('it can be made important', function () { + // ... + }) + }) + }) +}) +``` + + + 让我们制作一个新的自定义命令来制作一个新的注释。该命令将通过HTTP POST请求制作一个新的笔记。 + +```js +Cypress.Commands.add('createNote', ({ content, important }) => { + cy.request({ + url: 'http://localhost:3001/api/notes', + method: 'POST', + body: { content, important }, + headers: { + 'Authorization': `bearer ${JSON.parse(localStorage.getItem('loggedNoteappUser')).token}` + } + }) + + cy.visit('http://localhost:3000') +}) +``` + + + 该命令希望用户已经登录,并且用户的详细信息被保存到localStorage。 + + +现在格式化块变成。 + +```js +describe('Note app', function() { + // ... + + describe('when logged in', function() { + it('a new note can be created', function() { + // ... + }) + + describe('and a note exists', function () { + beforeEach(function () { + // highlight-start + cy.createNote({ + content: 'another note cypress', + important: false + }) + // highlight-end + }) + + it('it can be made important', function () { + // ... + }) + }) + }) +}) +``` + + + 测试和前端代码可以从[GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part5-10)分支part5-10找到。 + +### Changing the importance of a note + + + 最后让我们来看看我们为改变笔记的重要性所做的测试。 + + 首先,我们要改变格式化块,使其创建三个注释而不是一个。 + +```js +describe('when logged in', function() { + describe('and several notes exist', function () { + beforeEach(function () { + // highlight-start + cy.createNote({ content: 'first note', important: false }) + cy.createNote({ content: 'second note', important: false }) + cy.createNote({ content: 'third note', important: false }) + // highlight-end + }) + + it('one of those can be made important', function () { + cy.contains('second note') + .contains('make important') + .click() + + cy.contains('second note') + .contains('make not important') + }) + }) +}) +``` + + + [cy.contains](https://docs.cypress.io/api/commands/contains.html)命令实际上是如何工作的? + + +当我们在Cypress [Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner.html)中点击_cy.contains("second note")_命令时,我们看到该命令在搜索包含文本second note的元素。 + +![](../../images/5/34x.png) + + + 通过点击下一行_.contains("make important")_我们看到该测试使用了 + +对应于第二个笔记的"使重要"按钮。 + +![](../../images/5/35x.png) + + +当连锁时,第二个contains命令继续从第一个命令找到的组件中进行搜索。 + + + 如果我们没有将这些命令连接起来,而是写成: + +```js +cy.contains('second note') +cy.contains('make important').click() +``` + + +结果就会完全不同。测试的第二行会点击一个错误的笔记的按钮。 + +![](../../images/5/36x.png) + + + 在编写测试代码时,你应该在测试运行器中检查测试是否使用了正确的组件! + + + 让我们改变_Note_组件,使笔记的文本被渲染成span。 + +```js +const Note = ({ note, toggleImportance }) => { + const label = note.important + ? 'make not important' : 'make important' + + return ( +
  • + {note.content} // highlight-line + +
  • + ) +} +``` + + + 我们的测试失败了!正如测试运行器所显示的,_cy.contains("second note")_现在返回包含文本的组件,而按钮不在其中。 + +![](../../images/5/37x.png) + + + 解决这个问题的一个方法是如下。 + +```js +it('one of those can be made important', function () { + cy.contains('second note').parent().find('button').click() + cy.contains('second note').parent().find('button') + .should('contain', 'make not important') +}) +``` + + + 在第一行中,我们使用[parent](https://docs.cypress.io/api/commands/parent.html)命令来访问包含second note的元素的父元素,并从其中找到按钮。 + + 然后我们点击按钮,并检查上面的文字是否改变。 + + + 注意,我们使用命令[find](https://docs.cypress.io/api/commands/find.html#Syntax)来搜索按钮。我们不能在这里使用[cy.get](https://docs.cypress.io/api/commands/get.html),因为它总是从整个页面搜索,并且会返回页面上的所有5个按钮。 + + + + 不幸的是,我们现在有一些复制粘贴的测试,因为搜索右边按钮的代码总是相同的。 + + +在这种情况下,可以使用[as](https://docs.cypress.io/api/commands/as.html)命令。 + +```js +it('one of those can be made important', function () { + cy.contains('second note').parent().find('button').as('theButton') + cy.get('@theButton').click() + cy.get('@theButton').should('contain', 'make not important') +}) +``` + + + 现在第一行找到了右边的按钮,并使用as将其保存为theButton。下面几行可以用cy.get("@theButton")来使用这个命名的元素。 + +### Running and debugging the tests + + + 最后,关于Cypress如何工作和调试你的测试的一些说明。 + + + Cypress测试的形式给人的印象是测试是正常的JavaScript代码,例如我们可以这样尝试。 + +```js +const button = cy.contains('login') +button.click() +debugger() +cy.contains('logout').click() +``` + + + 但这并不可行。当Cypress运行一个测试时,它将每个_cy_命令添加到一个执行队列中。 + + 当测试方法的代码被执行后,Cypress将逐一执行队列中的每个命令。 + + +Cypress命令总是返回_undefined_,所以上述代码中的_button.click()_会导致一个错误。试图启动调试器不会在执行命令之间停止代码,而是在任何命令被执行之前。 + + +Cypress命令类似于 promise ,所以如果我们想访问它们的返回值,我们必须使用[then](https://docs.cypress.io/api/commands/then.html)命令来完成。 + + 例如,下面的测试将打印应用中的按钮数量,并点击第一个按钮。 + +```js +it('then example', function() { + cy.get('button').then( buttons => { + console.log('number of buttons', buttons.length) + cy.wrap(buttons[0]).click() + }) +}) +``` + + +用调试器停止测试的执行是[可能的](https://docs.cypress.io/api/commands/debug.html)。只有当Cypress test runner's developer console打开时,调试器才会启动。 + + +当调试你的测试时,开发者控制台是各种有用的。 + +你可以在网络标签上看到测试所做的HTTP请求,控制台标签将显示你的测试信息。 + +![](../../images/5/38ea.png) + + + 到目前为止,我们使用图形化的测试运行器运行我们的Cypress测试。 + +也可以[从命令行](https://docs.cypress.io/guides/guides/command-line.html)运行它们。我们只需要为它添加一个npm脚本。 + +```js + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "server": "json-server -p3001 --watch db.json", + "cypress:open": "cypress open", + "test:e2e": "cypress run" // highlight-line + }, +``` + + + 现在我们可以用命令npm run test:e2e从命令行运行我们的测试。 + +![](../../images/5/39x.png) + + + 注意,测试执行的视频将被保存到cypress/videos/,所以你可能应该git忽略这个目录。 + + + 前端和测试代码可以从[GitHub](https://github.com/fullstack-hy2020/part2-notes/tree/part5-11)分支part5-11找到。 + +
    + +
    + +### Exercises 5.17.-5.22. + + + 在这部分的最后一个练习中,我们将为我们的博客应用做一些E2E测试。 + + 这一部分的材料应该足以完成练习。 + + 你绝对应该看看Cypress的[文档](https://docs.cypress.io/guides/overview/why-cypress.html#In-a-nutshell)。这可能是我见过的最好的开源项目的文档。 + + + 我特别推荐阅读[Cypress简介](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Cypress-Can-Be-Simple-Sometimes),其中指出 + + + > 这是了解如何使用Cypress进行测试的唯一最重要的指南。阅读它。理解它。 + +#### 5.17: Blog List End to end testing, step1 + + + 为你的项目配置Cypress。做一个测试,检查应用是否默认显示登录表单。 + + + 该测试的结构必须如下。 + +```js +describe('Blog app', function() { + beforeEach(function() { + cy.request('POST', 'http://localhost:3003/api/testing/reset') + cy.visit('http://localhost:3000') + }) + + it('Login form is shown', function() { + // ... + }) +}) +``` + + + beforeEach 格式化的博客必须清空数据库,例如使用我们在[材料](/en/part5/end_to_end_testing#controlling-the-state-of-the-database)中使用的方法。 + +#### 5.18: Blog List End to end testing, step2 + + + 进行登录测试。测试成功和不成功的登录尝试。 + + 在测试的beforeEach块中制作一个新用户。 + + +测试结构是这样扩展的。 + +```js +describe('Blog app', function() { + beforeEach(function() { + cy.request('POST', 'http://localhost:3003/api/testing/reset') + // create here a user to backend + cy.visit('http://localhost:3000') + }) + + it('Login form is shown', function() { + // ... + }) + + describe('Login',function() { + it('succeeds with correct credentials', function() { + // ... + }) + + it('fails with wrong credentials', function() { + // ... + }) + }) +}) +``` + +Optional bonus exercise: Check that the notification shown with unsuccessful login is displayed red. + +#### 5.19: Blog List End to end testing, step3 + + + 做一个测试,检查一个登录的用户是否可以创建一个新的博客。 + +测试的结构可以是这样的。 + +```js +describe('Blog app', function() { + // ... + + describe('When logged in', function() { + beforeEach(function() { + // log in user here + }) + + it('A blog can be created', function() { + // ... + }) + }) + +}) +``` + + + 该测试必须确保一个新的博客被添加到所有博客的列表中。 + +#### 5.20: Blog List End to end testing, step4 + + +做一个测试,检查用户是否可以喜欢一个博客。 + +#### 5.21: Blog List End to end testing, step5 + + + 做一个测试,确保创建博客的用户可以删除它。 + +Optional bonus exercise: also check that other users cannot delete the blog. + +#### 5.22: Blog List End to end testing, step6 + + + 做一个测试,检查博客是否按照喜欢程度排序,喜欢最多的博客排在前面。 + + + 这个练习比之前的练习要棘手一些。一个解决方案是为包裹博客内容的元素添加一个特定的类,并使用[eq](https://docs.cypress.io/api/commands/eq#Syntax)方法来获取特定索引中的博客元素。 + +```js +cy.get('.blog').eq(0).should('contain', 'The title with the most likes') +cy.get('.blog').eq(1).should('contain', 'The title with the second most likes') +``` + + + 注意,如果你连续多次点击一个喜欢的按钮,你可能最终会遇到问题。这可能是因为Cypress的点击速度太快了,以至于它没有时间在点击之间更新应用的状态。对此的一个补救措施是,在所有点击之间等待喜欢的数量更新。 + + + 这是本章节的最后一个练习,是时候将你的代码推送到github,并在[练习提交系统](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)中标记你完成的练习。 + +
    diff --git a/src/content/6/en/part6.md b/src/content/6/en/part6.md index bba79e33d0d..5bbda9fc14f 100644 --- a/src/content/6/en/part6.md +++ b/src/content/6/en/part6.md @@ -6,6 +6,13 @@ lang: en
    -So far, we have placed the application's state and state logic directly inside React-components. When applications grow larger, state management should be moved outside React-components. In this part, we will introduce the Redux-library, which is currently the most popular solution for managing the state of React-applications. +So far, we have placed the application's state and state logic directly inside React components. When applications grow larger, state management should be moved outside React components. In this part, we will introduce the Redux library, which is currently the most popular solution for managing the state of React applications. + +We'll learn about the lightweight version of Redux directly supported by React, namely the React context and useReducer hook, as well as the React Query library that simplifies the server state management. + +Part updated 12th October 2025 +- Node updated to version 22.18.0 +- Jest replaced with Vitest +- Axios replaced with Fetch API
    diff --git a/src/content/6/en/part6a.md b/src/content/6/en/part6a.md index dec7f76da73..4e029387030 100644 --- a/src/content/6/en/part6a.md +++ b/src/content/6/en/part6a.md @@ -7,52 +7,44 @@ lang: en
    - -So far, we have followed the state management conventions recommended by React. We have placed the state and the methods for handling it to [the root component](https://reactjs.org/docs/lifting-state-up.html) of the application. The state and its handler methods have then been passed to other components with props. This works up to a certain point, but when applications grow larger, state management becomes challenging. +So far, we have followed the state management conventions recommended by React. We have placed the state and the functions for handling it in the [higher level](https://react.dev/learn/sharing-state-between-components) of the component structure of the application. Quite often most of the app state and state altering functions reside directly in the root component. The state and its handler methods have then been passed to other components with props. This works up to a certain point, but when applications grow larger, state management becomes challenging. ### Flux-architecture - -Facebook developed the [Flux](https://facebook.github.io/flux/docs/in-depth-overview/)- architecture to make state management easier. In Flux, the state is separated completely from the React-components into its own stores. +Already years ago Facebook developed the [Flux](https://facebookarchive.github.io/flux/docs/in-depth-overview)-architecture to make state management of React apps easier. In Flux, the state is separated from the React components and into its own stores. State in the store is not changed directly, but with different actions. +When an action changes the state of the store, the views are rerendered: -When an action changes the state of the store, the views are rerendered: +![diagram action->dispatcher->store->view](../../images/6/flux1.png) -![](https://facebook.github.io/flux/img/overview/flux-simple-f8-diagram-1300w.png) +If some action on the application, for example pushing a button, causes the need to change the state, the change is made with an action. +This causes re-rendering the view again: -If some action on the application, for example pushing a button, causes the need to change the state, the change is made with an action. -This causes rerendering the view again: +![same diagram as above but with action looping back](../../images/6/flux2.png) -![](https://facebook.github.io/flux/img/overview/flux-simple-f8-diagram-with-client-action-1300w.png) - -Flux offers a standard way for how and where the application's state is kept and how it is modified. +Flux offers a standard way for how and where the application's state is kept and how it is modified. ### Redux -Facebook has an implementation for Flux, but we will be using the [Redux](https://redux.js.org) - library. It works with the same principle, but is a bit simpler. Facebook also uses Redux now instead of their original Flux. - +Facebook has an implementation for Flux, but we will be using the [Redux](https://redux.js.org) library. It works with the same principle but is a bit simpler. Facebook also uses Redux now instead of their original Flux. -We will get to know Redux by implementing a counter application yet again: +We will get to know Redux by implementing a counter application yet again: -![](../../images/6/1.png) +![browser counter application](../../images/6/1.png) - -Create a new create-react-app-application and install redux with the command +Create a new Vite application and install redux with the command ```bash -npm install redux --save +npm install redux ``` +As in Flux, in Redux the state is also stored in a [store](https://redux.js.org/tutorials/essentials/part-1-overview-concepts#store). -As in Flux, in Redux the state is also stored in a [store](https://redux.js.org/basics/store). - - -The whole state of the application is stored into one JavaScript-object in the store. Because our application only needs the value of the counter, we will save it straight to the store. If the state was more complicated, different things in the state would be saved as separate fields of the object. - +The whole state of the application is stored in one JavaScript object in the store. Because our application only needs the value of the counter, we will save it straight to the store. If the state was more complicated, different things in the state would be saved as separate fields of the object. -The state of the store is changed with [actions](https://redux.js.org/basics/actions). Actions are objects, which have at least a field determining the type of the action. -Our application needs for example the following action: +The state of the store is changed with [actions](https://redux.js.org/tutorials/essentials/part-1-overview-concepts#actions). Actions are objects, which have at least a field determining the type of the action. +Our application needs for example the following action: ```js { @@ -60,14 +52,11 @@ Our application needs for example the following action: } ``` +If there is data involved with the action, other fields can be declared as needed. However, our counting app is so simple that the actions are fine with just the type field. -If there is data involved with the action, other fields can be declared as needed. However, our counting app is so simple that the actions are fine with just the type field. +The impact of the action to the state of the application is defined using a [reducer](https://redux.js.org/tutorials/essentials/part-1-overview-concepts#reducers). In practice, a reducer is a function that is given the current state and an action as parameters. It returns a new state. - -The impact of the action to the state of the application is defined using a [reducer](https://redux.js.org/basics/reducers). In practice, a reducer is a function which is given the current state and an action as parameters. It returns a new state. - - -Let's now define a reducer for our application: +Let's now define a reducer for our application at main.jsx. The file initially looks like this: ```js const counterReducer = (state, action) => { @@ -83,17 +72,16 @@ const counterReducer = (state, action) => { } ``` +The first parameter is the state in the store. The reducer returns a new state based on the _action_ type. So, e.g. when the type of Action is INCREMENT, the state gets the old value plus one. If the type of Action is ZERO the new value of state is zero. -The first parameter is the state in the store. Reducer returns a new state based on the actions type. - +Let's change the code a bit. We have used if-else statements to respond to an action and change the state. However, the [switch](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/switch) statement is the most common approach to writing a reducer. -Let's change the code a bit. It is customary to use the [switch](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/switch) -command instead of ifs in a reducer. - - -Let's also define a [default value](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters) of 0 for the parameter state. Now the reducer works even if the store -state has not been primed yet. +Let's also define a [default value](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters) of 0 for the parameter state. Now the reducer works even if the store state has not been primed yet. ```js +// highlight-start const counterReducer = (state = 0, action) => { + // highlight-end switch (action.type) { case 'INCREMENT': return state + 1 @@ -102,39 +90,50 @@ const counterReducer = (state = 0, action) => { case 'ZERO': return 0 default: // if none of the above matches, code comes here - return state + return state } } ``` - -Reducer is never supposed to be called directly from the applications code. Reducer is only given as a parameter to the _createStore_-function which creates the store: +The reducer is never supposed to be called directly from the application's code. It is only given as a parameter to the _createStore_ function which creates the store: ```js -import { createStore } from 'redux' +import { createStore } from 'redux' // highlight-line const counterReducer = (state = 0, action) => { - // ... + switch (action.type) { + case 'INCREMENT': + return state + 1 + case 'DECREMENT': + return state - 1 + case 'ZERO': + return 0 + default: + return state + } } -const store = createStore(counterReducer) +const store = createStore(counterReducer) // highlight-line ``` +The code editor may warn that _createStore_ is deprecated. Let's ignore this for now; there is a more detailed explanation about this further below. -The store now uses the reducer to handle actions, which are dispatched or 'sent' to the store with its [dispatch](https://redux.js.org/api/store#dispatchaction)-method. +The store now uses the reducer to handle actions, which are dispatched or 'sent' to the store with its [dispatch](https://redux.js.org/tutorials/essentials/part-1-overview-concepts#dispatch) method. ```js -store.dispatch({type: 'INCREMENT'}) +store.dispatch({ type: 'INCREMENT' }) ``` - You can find out the state of the store using the method [getState](https://redux.js.org/api/store#getstate). - -For example the following code: +For example the following code: ```js +// ... + const store = createStore(counterReducer) + +// highlight-start console.log(store.getState()) store.dispatch({type: 'INCREMENT'}) store.dispatch({type: 'INCREMENT'}) @@ -143,23 +142,20 @@ console.log(store.getState()) store.dispatch({type: 'ZERO'}) store.dispatch({type: 'DECREMENT'}) console.log(store.getState()) +// highlight-end ``` - would print the following to the console -
    +```
     0
     3
     -1
    -
    - - -because at first the state of the store is 0. After three INCREMENT-actions the state is 3. In the end, after ZERO and DECREMENT actions, the state is -1. - +``` -The third important method the store has is [subscribe](https://redux.js.org/api/store#subscribelistener), which is used to create callback functions the store calls when its state is changed. +because at first, the state of the store is 0. After three INCREMENT actions the state is 3. In the end, after the ZERO and DECREMENT actions, the state is -1. +The third important method that the store has is [subscribe](https://redux.js.org/api/store#subscribelistener), which is used to create callback functions that the store calls whenever an action is dispatched to the store. If, for example, we would add the following function to subscribe, every change in the store would be printed to the console. @@ -170,42 +166,43 @@ store.subscribe(() => { }) ``` - so the code ```js +// ... + const store = createStore(counterReducer) +// highlight-start store.subscribe(() => { const storeNow = store.getState() console.log(storeNow) }) +// highlight-end +// highlight-start store.dispatch({ type: 'INCREMENT' }) store.dispatch({ type: 'INCREMENT' }) store.dispatch({ type: 'INCREMENT' }) store.dispatch({ type: 'ZERO' }) store.dispatch({ type: 'DECREMENT' }) +// highlight-end ``` - would cause the following to be printed -
    +```
     1
     2
     3
     0
     -1
    -
    - - +``` -The code of our counter application is the following. All of the code has been written in the same file, so store is straight available for the React-code. We will get to know better ways to structure React/Redux-code later. +The code of our counter application is the following. All of the code has been written in the same file, so store is directly available for the React code. We will get to know better ways to structure React/Redux code later. The file main.jsx looks as follows: ```js -import React from 'react' -import ReactDOM from 'react-dom' +import ReactDOM from 'react-dom/client' import { createStore } from 'redux' const counterReducer = (state = 0, action) => { @@ -226,66 +223,82 @@ const store = createStore(counterReducer) const App = () => { return (
    -
    - {store.getState()} -
    - - -
    ) } +const root = ReactDOM.createRoot(document.getElementById('root')) + const renderApp = () => { - ReactDOM.render(, document.getElementById('root')) + root.render() } renderApp() store.subscribe(renderApp) ``` +There are a few notable things in the code. +App renders the value of the counter by asking it from the store with the method _store.getState()_. The action handlers of the buttons dispatch the right actions to the store. -There are a few notable things in the code. -App renders the value of the counter by asking it from the store with the method _store.getState()_. The actionhandlers of the buttons dispatch the right actions to the store. +When the state in the store is changed, React is not able to automatically re-render the application. Thus we have registered a function _renderApp_, which renders the whole app, to listen for changes in the store with the _store.subscribe_ method. Note that we have to immediately call the _renderApp_ method. Without the call, the first rendering of the app would never happen. +### A note about the use of createStore -When the state in the store is changed, React is not able to automatically rerender the application. Thus we have registered a function _renderApp_, which renders the whole app, to listen for changes in the store with the _store.subscribe_ method. Note that we have to immediately call the _renderApp_ method. Without the call the first rendering of the app would never happen. +The most observant will notice that the name of the function createStore is overlined. If you move the mouse over the name, an explanation will appear -### Redux-notes +![vscode error showing createStore deprecated, use configureStore instead](../../images/6/30new.png) + +The full explanation is as follows +>We recommend using the configureStore method of the @reduxjs/toolkit package, which replaces createStore. +> +>Redux Toolkit is our recommended approach for writing Redux logic today, including store setup, reducers, data fetching, and more. +> +>For more details, please read this Redux docs page: +> +>configureStore from Redux Toolkit is an improved version of createStore that simplifies setup and helps avoid common bugs. +> +>You should not be using the redux core package by itself today, except for learning purposes. The createStore method from the core redux package will not be removed, but we encourage all users to migrate to using Redux Toolkit for all Redux code. -Our aim is to modify our note application to use Redux for state management. However, let's first cover a few key concepts through a simplified note application. +So, instead of the function createStore, it is recommended to use the slightly more "advanced" function configureStore, and we will also use it when we have achieved the basic functionality of Redux. +Side note: createStore is defined as "deprecated", which usually means that the feature will be removed in some newer version of the library. The explanation above and this [discussion](https://stackoverflow.com/questions/71944111/redux-createstore-is-deprecated-cannot-get-state-from-getstate-in-redux-ac) reveal that createStore will not be removed, and it has been given the status deprecated, perhaps with slightly incorrect reasons. So the function is not obsolete, but today there is a more preferable, new way to do almost the same thing. + +### Redux-notes -The first version of our application is the following +We aim to modify our note application to use Redux for state management. However, let's first cover a few key concepts through a simplified note application. + +The first version of our application, written in the file main.jsx, looks as follows: ```js +import ReactDOM from 'react-dom/client' +import { createStore } from 'redux' + const noteReducer = (state = [], action) => { - if (action.type === 'NEW_NOTE') { - state.push(action.data) - return state + switch (action.type) { + case 'NEW_NOTE': + state.push(action.payload) + return state + default: + return state } - - return state } const store = createStore(noteReducer) store.dispatch({ type: 'NEW_NOTE', - data: { + payload: { content: 'the app state is in redux store', important: true, id: 1 @@ -294,7 +307,7 @@ store.dispatch({ store.dispatch({ type: 'NEW_NOTE', - data: { + payload: { content: 'state changes are made with actions', important: false, id: 2 @@ -302,30 +315,37 @@ store.dispatch({ }) const App = () => { - return( + return (
      - {store.getState().map(note=> + {store.getState().map(note => (
    • {note.content} {note.important ? 'important' : ''}
    • - )} -
    + ))} +
    ) } -``` +const root = ReactDOM.createRoot(document.getElementById('root')) -So far the application does not have the functionality for adding new notes, although it is possible to do so by dispatching NEW\_NOTE actions. +const renderApp = () => { + root.render() +} + +renderApp() +store.subscribe(renderApp) +``` +So far the application does not have the functionality for adding new notes, although it is possible to do so by dispatching NEW\_NOTE actions. -Now the actions have a type and a field data, which contains the note to be added: +Now the actions have a type and a field payload, which contains the note to be added: ```js { type: 'NEW_NOTE', - data: { + payload: { content: 'state changes are made with actions', important: false, id: 2 @@ -333,83 +353,133 @@ Now the actions have a type and a field data, which contains the note to } ``` +The choice of the field name is not random. The general convention is that actions have exactly two fields, type telling the type and payload containing the data included with the Action. + ### Pure functions, immutable -The initial version of reducer is very simple: +The initial version of the reducer is very simple: ```js const noteReducer = (state = [], action) => { - if (action.type === 'NEW_NOTE') { - state.push(action.data) - return state + switch (action.type) { + case 'NEW_NOTE': + state.push(action.payload) + return state + default: + return state } - - return state } ``` +The state is now an Array. NEW\_NOTE-type actions cause a new note to be added to the state with the [push](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push) method. -The state is now an Array. NEW\_NOTE- type actions cause a new note to be added to the state with the [push](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push) method. - +The application seems to be working, but the reducer we have declared is bad. It breaks the [basic assumption](https://redux.js.org/tutorials/essentials/part-1-overview-concepts#reducers) that reducers must be [pure functions](https://en.wikipedia.org/wiki/Pure_function). -The application seems to be working, but the reducer we have declared is bad. It breaks the [basic assumption](https://github.com/reactjs/redux/blob/master/docs/basics/Reducers.md#handling-actions) of Redux reducer that reducers must be [pure functions](https://en.wikipedia.org/wiki/Pure_function). +Pure functions are such, that they do not cause any side effects and they must always return the same response when called with the same parameters. +We added a new note to the state with the method _state.push(action.payload)_ which changes the state of the state-object. This is not allowed. The problem is easily solved by using the [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat) method, which creates a new array, which contains all the elements of the old array and the new element: -Pure functions are such, that they do not cause any side effects and they must always return the same response when called with the same parameters. +```js +const noteReducer = (state = [], action) => { + switch (action.type) { + case 'NEW_NOTE': + return state.concat(action.payload) // highlight-line + default: + return state + } +} +``` +A reducer state must be composed of [immutable](https://en.wikipedia.org/wiki/Immutable_object) objects. If there is a change in the state, the old object is not changed, but it is replaced with a new, changed, object. This is exactly what we did with the new reducer: the old array is replaced with the new one. -We added a new note to the state with the method _state.push(action.data)_ which changes the state of the state-object. This is not allowed. The problem is easily solved by using the [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat) method, which creates a new array, which contains all the elements of the old array and the new element: +Let's expand our reducer so that it can handle the change of a note's importance: ```js -const noteReducer = (state = [], action) => { - if (action.type === 'NEW_NOTE') { - return state.concat(action.data) +{ + type: 'TOGGLE_IMPORTANCE', + payload: { + id: 2 } - - return state } ``` +Since we do not have any code which uses this functionality yet, we are expanding the reducer in the 'test-driven' way. -A reducer state must be composed of [immutable](https://en.wikipedia.org/wiki/Immutable_object) objects. If there is a change in the state, the old object is not changed, but it is replaced with a new, changed, object. This is exactly what we did with the new reducer: the old array is replaced with the new. +### Configuring the test environment - -Let's expand our reducer so that it can handle the change of a notes importance: +We have to first configure the [Vitest](https://vitest.dev/) testing library for the project. Let's install it as a development dependency for the application: ```js +npm install --save-dev vitest +``` + +Let us expand package.json with a script for running the tests: + +```json { - type: 'TOGGLE_IMPORTANCE', - data: { - id: 2 + // ... + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview", + "test": "vitest" // highlight-line + }, + // ... +} +``` + +To make testing easier, let's move the reducer's code to its own module, to the file src/reducers/noteReducer.js: + +```js +const noteReducer = (state = [], action) => { + switch (action.type) { + case 'NEW_NOTE': + return state.concat(action.payload) + default: + return state } } + +export default noteReducer ``` +The file main.jsx changes as follows: -Since we do not have any code which uses this functionality yet, we are expanding the reducer in the 'test driven' way. -Let's start by creating a test for handling the action NEW\_NOTE. +```js +import ReactDOM from 'react-dom/client' +import { createStore } from 'redux' +import noteReducer from './reducers/noteReducer' // highlight-line +const store = createStore(noteReducer) + +// ... +``` -To make testing easier, we'll first move the reducer's code to its own module to file src/reducers/noteReducer.js. We'll also add the library [deep-freeze](https://github.com/substack/deep-freeze), which can be used to ensure that the reducer has been correctly defined as a immutable function. -Let's install the library as a development dependency +We'll also add the library [deep-freeze](https://www.npmjs.com/package/deep-freeze), which can be used to ensure that the reducer has been correctly defined as an immutable function. +Let's install the library as a development dependency: ```js npm install --save-dev deep-freeze ``` +We are now ready to write tests. + +### Tests for noteReducer -The test, which we define in file src/reducers/noteReducer.test.js, has the following content: +Let's start by creating a test for handling the action NEW\_NOTE. The test, which we define in file src/reducers/noteReducer.test.js, has the following content: ```js -import noteReducer from './noteReducer' import deepFreeze from 'deep-freeze' +import { describe, expect, test } from 'vitest' +import noteReducer from './noteReducer' describe('noteReducer', () => { test('returns new state with action NEW_NOTE', () => { const state = [] const action = { type: 'NEW_NOTE', - data: { + payload: { content: 'the app state is in redux store', important: true, id: 1 @@ -420,16 +490,16 @@ describe('noteReducer', () => { const newState = noteReducer(state, action) expect(newState).toHaveLength(1) - expect(newState).toContainEqual(action.data) + expect(newState).toContainEqual(action.payload) }) }) ``` +Run the test with npm test. The test ensures that the new state returned by the reducer is an array containing a single element, which is the same object as the one in the action’s payload field. -The deepFreeze(state) command ensures that the reducer does not change the state of the store given to it as a parameter. If the reducer uses the _push_ command to manipulate the state, the test will not pass - -![](../../images/6/2.png) +The deepFreeze(state) command ensures that the reducer does not change the state of the store given to it as a parameter. If the reducer used the _push_ command to manipulate the state, the test would fail: +![terminal showing test failure and error about not using array.push](../../images/6/2.png) Now we'll create a test for the TOGGLE\_IMPORTANCE action: @@ -445,11 +515,12 @@ test('returns new state with action TOGGLE_IMPORTANCE', () => { content: 'state changes are made with actions', important: false, id: 2 - }] + } + ] const action = { type: 'TOGGLE_IMPORTANCE', - data: { + payload: { id: 2 } } @@ -469,57 +540,52 @@ test('returns new state with action TOGGLE_IMPORTANCE', () => { }) ``` - So the following action ```js { type: 'TOGGLE_IMPORTANCE', - data: { + payload: { id: 2 + } } ``` - has to change the importance of the note with the id 2. - The reducer is expanded as follows ```js const noteReducer = (state = [], action) => { switch(action.type) { case 'NEW_NOTE': - return state.concat(action.data) + return state.concat(action.payload) + // highlight-start case 'TOGGLE_IMPORTANCE': { - const id = action.data.id + const id = action.payload.id const noteToChange = state.find(n => n.id === id) - const changedNote = { - ...noteToChange, - important: !noteToChange.important + const changedNote = { + ...noteToChange, + important: !noteToChange.important } - return state.map(note => - note.id !== id ? note : changedNote - ) - } + return state.map(note => (note.id !== id ? note : changedNote)) + } + // highlight-end default: return state } } ``` +We create a copy of the note whose importance has changed with the syntax [familiar from part 2](/en/part2/altering_data_in_server#changing-the-importance-of-notes), and replace the state with a new state containing all the notes which have not changed and the copy of the changed note changedNote. -We create a copy of the note which importance has changed with the syntax [familiar from part 2](/en/part2/altering_data_in_server#changing-the-importance-of-notes), and replace the state with a new state containing all the notes which have not changed and the copy of the changed note changedNote. - - -Let's recap what goes on in the code. First, we search for a specific note object, the importance of which we want to change: +Let's recap what goes on in the code. First, we search for a specific note object, the importance of which we want to change: ```js const noteToChange = state.find(n => n.id === id) ``` - -then we create a new object, which is a copy of the original note, only the value of the important field has been changed to the opposite of what it was: +then we create a new object, which is a copy of the original note, only the value of the important field has been changed to the opposite of what it was: ```js const changedNote = { @@ -528,53 +594,45 @@ const changedNote = { } ``` - -A new state is then returned. We create it by taking all of the notes from the old state except for the desired note, which we replace with its slightly altered copy: +A new state is then returned. We create it by taking all of the notes from the old state except for the desired note, which we replace with its slightly altered copy: ```js -state.map(note => - note.id !== id ? note : changedNote -) +state.map(note => (note.id !== id ? note : changedNote)) ``` ### Array spread syntax +Because we now have quite good tests for the reducer, we can refactor the code safely. -Because we now have quite good tests for the reducer, we can refactor the code safely. - - -Adding a new note creates the state it returns with Arrays _concat_-function. Let's take a look at how we can achieve the same by using the JavaScript [array spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_operator) -syntax: +Adding a new note creates the state returned from the Array's _concat_ function. Let's take a look at how we can achieve the same by using the JavaScript [array spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_operator) syntax: ```js const noteReducer = (state = [], action) => { switch(action.type) { case 'NEW_NOTE': - return [...state, action.data] - case 'TOGGLE_IMPORTANCE': + return [...state, action.payload] // highlight-line + case 'TOGGLE_IMPORTANCE': { // ... + } default: return state } } ``` - The spread -syntax works as follows. If we declare ```js const numbers = [1, 2, 3] ``` - -...numbers breaks the array up into individual elements, which can place i.e to another array. +...numbers breaks the array up into individual elements, which can be placed in another array. ```js [...numbers, 4, 5] ``` - -and the result is an array `[1, 2, 3, 4, 5]`. - +and the result is an array [1, 2, 3, 4, 5]. If we would have placed the array to another array without the spread @@ -582,11 +640,9 @@ If we would have placed the array to another array without the spread [numbers, 4, 5] ``` +the result would have been [ [1, 2, 3], 4, 5]. -the result would have been `[ [1, 2, 3], 4, 5]`. - - -When we take elements from an array by [destructuring](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment), a similar looking syntax is used to gather the rest of the elements: +When we take elements from an array by [destructuring](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment), a similar-looking syntax is used to gather the rest of the elements: ```js const numbers = [1, 2, 3, 4, 5, 6] @@ -604,14 +660,11 @@ console.log(rest) // prints [3, 4, 5, 6] ### Exercises 6.1.-6.2. +Let's make a simplified version of the unicafe exercise from part 1. Let's handle the state management with Redux. -Let's make a simplified version of the unicafe-exercise from part 1. Let's handle the state management with Redux. - - -You can take the project from this repository https://github.com/fullstack-hy2020/unicafe-redux for the base of your project. - +You can take the code from this repository for the base of your project. -Start by removing the git-configuration of the cloned repository, and by installing dependencies +Start by removing the git configuration of the cloned repository, and by installing dependencies ```bash cd unicafe-redux // go to the directory of cloned repository @@ -619,13 +672,11 @@ rm -rf .git npm install ``` -#### 6.1: unicafe revisited, step1 +#### 6.1: Unicafe Revisited, step 1 +Before implementing the functionality of the UI, let's implement the functionality required by the store. -Before implementing the functionality of the UI, let's implement the functionality required by the store. - - -We have to save the number of each kind of feedback to the store, so the form of the state in the store is: +We have to save the number of each kind of feedback to the store, so the form of the state in the store is: ```js { @@ -635,8 +686,7 @@ We have to save the number of each kind of feedback to the store, so the form of } ``` - -The project has the following base for a reducer: +The project has the following base for a reducer: ```js const initialState = { @@ -654,20 +704,21 @@ const counterReducer = (state = initialState, action) => { return state case 'BAD': return state - case 'ZERO': + case 'RESET': + return state + default: return state } - return state } export default counterReducer ``` - and a base for its tests ```js import deepFreeze from 'deep-freeze' +import { describe, expect, test } from 'vitest' import counterReducer from './reducer' describe('unicafe reducer', () => { @@ -678,7 +729,6 @@ describe('unicafe reducer', () => { } test('should return a proper initial state when called with undefined state', () => { - const state = {} const action = { type: 'DO_NOTHING' } @@ -704,25 +754,21 @@ describe('unicafe reducer', () => { }) ``` - **Implement the reducer and its tests.** +The provided first test should pass without any changes. Redux expects that the reducer returns the original state when it is called with a first parameter - which represents the previous state - with the value undefined. -In the tests, make sure that the reducer is an immutable function with the deep-freeze-library. -Ensure that the provided first test passes, because Redux expects that the reducer returns a sensible original state when it is called so that the first parameter state, which represents the previous state, is -undefined. - +Start by expanding the reducer so that both tests pass. After that, add the remaining tests for the different actions of the reducer and implement the corresponding functionality in the reducer. -Start by expanding the reducer so that both tests pass. Then add the rest of the tests, and finally the functionality which they are testing. +In the tests, make sure that the reducer is an immutable function with the deep-freeze library. A good model for the reducer is the [redux-notes](/en/part6/flux_architecture_and_redux#pure-functions-immutable) example above. +#### 6.2: Unicafe Revisited, step2 -A good model for the reducer is the [redux-notes](/en/part6/flux_architecture_and_redux#pure-functions-immutable) -example above. +Now implement the actual functionality of the application. -#### 6.2: unicafe revisited, step2 +Your application can have a modest appearance, nothing else is needed but buttons and the number of reviews for each type: - -Now implement the actual functionality of the application. +![browser showing good bad ok buttons](../../images/6/50new.png)
    @@ -730,75 +776,77 @@ Now implement the actual functionality of the application. ### Uncontrolled form - -Let's add the functionality for adding new notes and changing their importance: +Let's add the functionality for adding new notes and changing their importance: ```js -const generateId = () => - Number((Math.random() * 1000000).toFixed(0)) +// ... + +const generateId = () => Number((Math.random() * 1000000).toFixed(0)) // highlight-line const App = () => { - const addNote = (event) => { + // highlight-start + const addNote = event => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' store.dispatch({ type: 'NEW_NOTE', - data: { + payload: { content, important: false, id: generateId() } }) } + // highlight-end - const toggleImportance = (id) => { + // highlight-start + const toggleImportance = id => { store.dispatch({ type: 'TOGGLE_IMPORTANCE', - data: { id } + payload: { id } }) } + // highlight-end return (
    + // highlight-start
    + // highlight-end
      - {store.getState().map(note => -
    • toggleImportance(note.id)} - > + {store.getState().map(note => ( +
    • toggleImportance(note.id)}> // highlight-line {note.content} {note.important ? 'important' : ''}
    • - )} + ))}
    ) } -``` +// ... +``` -The implementation of both functionalities is straightforward. It is noteworthy that we have not bound the state of the form fields to the state of the App component like we have previously done. React calls this kind of form [uncontrolled](https://reactjs.org/docs/uncontrolled-components.html). - +The implementation of both functionalities is straightforward. It is noteworthy that we have not bound the state of the form fields to the state of the App component like we have previously done. React calls this kind of form [uncontrolled](https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable). ->Uncontrolled forms have certain limitations (for example, dynamic error messages or disabling the submit button based on input are not possible). However they are suitable for our current needs. +>Uncontrolled forms have certain limitations (for example, dynamic error messages or disabling the submit button based on input are not possible). However they are suitable for our current needs. You can read more about uncontrolled forms [here](https://goshakkk.name/controlled-vs-uncontrolled-inputs-react/). - -The method handling adding new notes is simple, it just dispatches the action for adding notes: +The method for adding new notes is simple, it dispatches the action for adding notes: ```js -addNote = (event) => { +addNote = event => { event.preventDefault() - const content = event.target.note.value // highlight-line + const content = event.target.note.value event.target.note.value = '' store.dispatch({ type: 'NEW_NOTE', - data: { + payload: { content, important: false, id: generateId() @@ -807,8 +855,13 @@ addNote = (event) => { } ``` +The content of the new note is obtained directly from the form’s input field, which can be accessed through the event object: -We can get the content of the new note straight from the form field. Because the field has a name, we can access the content via the event object event.target.note.value. +```js +const content = event.target.note.value +``` + +Please note that the input field must have a name in order to access its value: ```js
    @@ -817,30 +870,29 @@ We can get the content of the new note straight from the form field. Because the
    ``` - -A note's importance can be changed by clicking its name. The event handler is very simple: +A note's importance can be changed by clicking its name. The event handler is very simple: ```js -toggleImportance = (id) => { +toggleImportance = id => { store.dispatch({ type: 'TOGGLE_IMPORTANCE', - data: { id } + payload: { id } }) } ``` ### Action creators -We begin to notice that, even in applications as simple as ours, using Redux can simplify the frontend code. However, we can do a lot better. +We begin to notice that, even in applications as simple as ours, using Redux can simplify the frontend code. However, we can do a lot better. -It is actually not necessary for React-components to know the Redux action types and forms. -Let's separate creating actions into their own functions: +React components don't need to know the Redux action types and forms. +Let's separate creating actions into separate functions: ```js -const createNote = (content) => { +const createNote = content => { return { type: 'NEW_NOTE', - data: { + payload: { content, important: false, id: generateId() @@ -848,21 +900,21 @@ const createNote = (content) => { } } -const toggleImportanceOf = (id) => { +const toggleImportanceOf = id => { return { type: 'TOGGLE_IMPORTANCE', - data: { id } + payload: { id } } } ``` -Functions that create actions are called [action creators](https://redux.js.org/advanced/async-actions#synchronous-action-creators). +Functions that create actions are called [action creators](https://redux.js.org/tutorials/essentials/part-1-overview-concepts#action-creators). -The App component does not have to know anything about the inner representation of the actions anymore, it just gets the right action by calling the creator-function: +The App component does not have to know anything about the inner representation of the actions anymore, it just gets the right action by calling the creator function: ```js const App = () => { - const addNote = (event) => { + const addNote = event => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' @@ -870,7 +922,7 @@ const App = () => { } - const toggleImportance = (id) => { + const toggleImportance = id => { store.dispatch(toggleImportanceOf(id))// highlight-line } @@ -878,67 +930,82 @@ const App = () => { } ``` +### Forwarding Redux Store to various components -### Forwarding Redux-Store to various components - -Aside from the reducer, our application is in one file. This is of course not sensible, and we should separate App into its own module. - -Now the question is, how can the App access the store after the move? And more broadly, when a component is composed of many smaller components, there must be a way for all of the components to access the store. +Aside from the reducer, our application is in one file. This is of course not sensible, and we should separate App into its module. - -There are multiple ways to share the redux-store with components. First we will look into the newest, and possibly the easiest way using the [hooks](https://react-redux.js.org/api/hooks)-api of the [react-redux](https://react-redux.js.org/) library. +Now the question is, how can the App access the store after the move? And more broadly, when a component is composed of many smaller components, there must be a way for all of the components to access the store. +There are multiple ways to share the Redux store with the components. First, we will look into the newest, and possibly the easiest way, which is using the [hooks](https://react-redux.js.org/api/hooks) API of the [react-redux](https://react-redux.js.org/) library. - -First we install react-redux +First, we install react-redux -```js -npm install --save react-redux +```bash +npm install react-redux ``` - -Next we move the _App_ component into its own file _App.js_. Let's see how this effects the rest of the application files. - -_Index.js_ becomes: +Let's organize the application code more sensibly into several different files. The file _main.jsx_ looks as follows after the changes: ```js -import React from 'react' -import ReactDOM from 'react-dom' +import ReactDOM from 'react-dom/client' import { createStore } from 'redux' -import { Provider } from 'react-redux' // highlight-line +import { Provider } from 'react-redux' + import App from './App' import noteReducer from './reducers/noteReducer' const store = createStore(noteReducer) -ReactDOM.render( - // highlight-line +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) +``` + +Note, that the application is now defined as a child of a [Provider](https://react-redux.js.org/api/provider) component provided by the react-redux library. The application's store is given to the Provider as its attribute store: + +```js +const store = createStore(noteReducer) + +ReactDOM.createRoot(document.getElementById('root')).render( + // highlight-line - , // highlight-line - document.getElementById('root') + // highlight-line ) ``` - -Note, that the application is now defined as a child of a [Provider](https://react-redux.js.org/api/provider) -component provided by the react redux library. -The application's store is given to the Provider as its attribute -store. +This makes the store accessible to all components in the application, as we will soon see. -Defining the action creators has been moved to the file reducers/noteReducer.js where the reducer is defined. File looks like this: +Defining the action creators has been moved to the file src/reducers/noteReducer.js where the reducer is defined. That file looks like this: ```js const noteReducer = (state = [], action) => { - // ... + switch (action.type) { + case 'NEW_NOTE': + return [...state, action.payload] + case 'TOGGLE_IMPORTANCE': { + const id = action.payload.id + const noteToChange = state.find(n => n.id === id) + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + return state.map(note => (note.id !== id ? note : changedNote)) + } + default: + return state + } } const generateId = () => Number((Math.random() * 1000000).toFixed(0)) -export const createNote = (content) => { // highlight-line +export const createNote = (content) => { return { type: 'NEW_NOTE', - data: { + payload: { content, important: false, id: generateId() @@ -946,27 +1013,23 @@ export const createNote = (content) => { // highlight-line } } -export const toggleImportanceOf = (id) => { // highlight-line +export const toggleImportanceOf = (id) => { return { type: 'TOGGLE_IMPORTANCE', - data: { id } + payload: { id } } } export default noteReducer ``` -If the application has many components which need the store, the App-component must pass store as props to all of those components. - -The module now has multiple [export](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export) commands. - -The reducer function is still returned with the export default command, so the reducer can be imported the usual way: +The module now has multiple [export](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export) commands. The reducer function is still returned with the export default command, so the reducer can be imported the usual way: ```js import noteReducer from './reducers/noteReducer' ``` -A module can have only one default export, but multiple "normal" exports +A module can have only one default export, but multiple "normal" exports: ```js export const createNote = (content) => { @@ -978,37 +1041,32 @@ export const toggleImportanceOf = (id) => { } ``` - Normally (not as defaults) exported functions can be imported with the curly brace syntax: ```js -import { createNote } from './../reducers/noteReducer' +import { createNote } from '../../reducers/noteReducer' ``` - -Code for the App component +Next, we move the _App_ component into its own file _src/App.jsx_. The content of the file is as follows: ```js -import React from 'react' -import { - createNote, toggleImportanceOf -} from './reducers/noteReducer' -import { useSelector, useDispatch } from 'react-redux' // highlight-line +import { createNote, toggleImportanceOf } from './reducers/noteReducer' +import { useSelector, useDispatch } from 'react-redux' const App = () => { - const dispatch = useDispatch() // highlight-line - const notes = useSelector(state => state) // highlight-line + const dispatch = useDispatch() + const notes = useSelector(state => state) const addNote = (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' - dispatch(createNote(content)) // highlight-line + dispatch(createNote(content)) } const toggleImportance = (id) => { - dispatch(toggleImportanceOf(id)) // highlight-line + dispatch(toggleImportanceOf(id)) } return ( @@ -1018,7 +1076,7 @@ const App = () => {
      - {notes.map(note => // highlight-line + {notes.map(note =>
    • toggleImportance(note.id)} @@ -1034,18 +1092,16 @@ const App = () => { export default App ``` - -There are a few things to note in the code. Previously the code dispatched actions by calling the dispatch method of the redux-store: +There are a few things to note in the code. Previously the code dispatched actions by calling the dispatch method of the Redux store: ```js store.dispatch({ type: 'TOGGLE_IMPORTANCE', - data: { id } + payload: { id } }) ``` - -Now it does it with the dispatch-function from the [useDispatch](https://react-redux.js.org/api/hooks#usedispatch) -hook. +Now it does it with the dispatch function from the [useDispatch](https://react-redux.js.org/api/hooks#usedispatch) hook. ```js import { useSelector, useDispatch } from 'react-redux' // highlight-line @@ -1062,15 +1118,10 @@ const App = () => { } ``` - -The useDispatch-hook provides any React component access to the dispatch-function of the redux-store defined in index.js. -This allows all components to make changes to the state of the redux-store. - +The useDispatch hook provides any React component access to the dispatch function of the Redux store defined in main.jsx. This allows all components to make changes to the state of the Redux store. - The component can access the notes stored in the store with the [useSelector](https://react-redux.js.org/api/hooks#useselector)-hook of the react-redux library. - ```js import { useSelector, useDispatch } from 'react-redux' // highlight-line @@ -1081,17 +1132,14 @@ const App = () => { } ``` - -useSelector receives a function as a paramter. The function either searches for or selects data from the redux-store. +useSelector receives a function as a parameter. The function either searches for or selects data from the Redux store. Here we need all of the notes, so our selector function returns the whole state: - ```js state => state ``` - -which is a shorthand for +which is a shorthand for: ```js (state) => { @@ -1099,31 +1147,31 @@ which is a shorthand for } ``` -Usually selector functions are a bit more interesting, and return only selected parts of the contents of the redux-store. +Usually, selector functions are a bit more interesting and return only selected parts of the contents of the Redux store. We could for example return only notes marked as important: ```js const importantNotes = useSelector(state => state.filter(note => note.important)) ``` +The current version of the application can be found on [GitHub](https://github.com/fullstack-hy2020/redux-notes/tree/part6-0), branch part6-0. + ### More components - -Let's separate creating a new note into its own component. +Let's separate the form responsible for creating a new note into its own component in the file src/components/NoteForm.jsx: ```js -import React from 'react' -import { useDispatch } from 'react-redux' // highlight-line -import { createNote } from '../reducers/noteReducer' // highlight-line +import { useDispatch } from 'react-redux' +import { createNote } from '../reducers/noteReducer' -const NewNote = (props) => { - const dispatch = useDispatch() // highlight-line +const NoteForm = () => { + const dispatch = useDispatch() const addNote = (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' - dispatch(createNote(content)) // highlight-line + dispatch(createNote(content)) } return ( @@ -1134,43 +1182,39 @@ const NewNote = (props) => { ) } -export default NewNote +export default NoteForm ``` -Unlike in the React code we did without Redux, the event handler for changing the state of the app (which now lives in Redux) has been moved away from the App to a child component. The logic for changing the state in Redux is still neatly separated from the whole React part of the application. +Unlike in the React code we did without Redux, the event handler for changing the state of the app (which now lives in Redux) has been moved away from the App to a child component. The logic for changing the state in Redux is still neatly separated from the whole React part of the application. - -We'll also separate the list of notes and displaying a single note into their own components (which will both be placed in the Notes.js file ): +We'll also separate the list of notes and displaying a single note into their own components Let's place both in the file src/components/Notes.jsx: ```js -import React from 'react' -import { useDispatch, useSelector } from 'react-redux' // highlight-line -import { toggleImportanceOf } from '../reducers/noteReducer' // highlight-line +import { useDispatch, useSelector } from 'react-redux' +import { toggleImportanceOf } from '../reducers/noteReducer' const Note = ({ note, handleClick }) => { - return( + return (
    • - {note.content} + {note.content} {note.important ? 'important' : ''}
    • ) } const Notes = () => { - const dispatch = useDispatch() // highlight-line - const notes = useSelector(state => state) // highlight-line + const dispatch = useDispatch() + const notes = useSelector(state => state) - return( + return (
        - {notes.map(note => + {notes.map(note => ( - dispatch(toggleImportanceOf(note.id)) - } + handleClick={() => dispatch(toggleImportanceOf(note.id))} /> - )} + ))}
      ) } @@ -1178,32 +1222,31 @@ const Notes = () => { export default Notes ``` -The logic for changing the importance of a note is now in the component managing the list of notes. - +The logic for changing the importance of a note is now in the component managing the list of notes. -There is not much code left in App: +Only a small amount of code remains in the file App.jsx: ```js -const App = () => { +import NoteForm from './components/NoteForm' +import Notes from './components/Notes' +const App = () => { return (
      - - + +
      ) } -``` -Note, responsible for rendering a single note, is very simple, and is not aware that the event handler it gets as props dispatches an action. These kind of components are called [presentational](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) in React terminology. +export default App +``` +Note, responsible for rendering a single note, is very simple and is not aware that the event handler it gets as props dispatches an action. These kinds of components are called [presentational](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) in React terminology. Notes, on the other hand, is a [container](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) component, as it contains some application logic: it defines what the event handlers of the Note components do and coordinates the configuration of presentational components, that is, the Notes. - -We will return to the presentational/container division later in this part. - -The code of the Redux application can be found on [Github](https://github.com/fullstack-hy2020/redux-notes/tree/part6-1), branch part6-1. +The code of the Redux application can be found on [GitHub](https://github.com/fullstack-hy2020/redux-notes/tree/part6-1), on the branch part6-1.
    @@ -1211,68 +1254,55 @@ The code of the Redux application can be found on [Github](https://github.com/fu ### Exercises 6.3.-6.8. +Let's make a new version of the anecdote voting application from part 1. Take the project from this repository as the base of your solution. -Let's make a new version of the anecdote voting application from part 1. Take the project from this repository https://github.com/fullstack-hy2020/redux-anecdotes to base your solution on. - - -If you clone the project into an existing git-repository, remove the git-configuration of the cloned application: +If you clone the project into an existing git repository, remove the git configuration of the cloned application: ```bash cd redux-anecdotes // go to the cloned repository rm -rf .git ``` - -The application can be started as usual, but you have to install the dependencies first: +The application can be started as usual, but you have to install the dependencies first: ```bash npm install -npm start +npm run dev ``` - After completing these exercises, your application should look like this: -![](../../images/6/3.png) - -#### 6.3: anecdotes, step1 - +![browser showing anecdotes and vote buttons](../../images/6/3.png) -Implement the functionality for voting anecdotes. The amount of votes must be saved to a Redux-store. +#### 6.3: Anecdotes, step 1 -#### 6.4: anecdotes, step2 +Implement the functionality for voting anecdotes. The number of votes must be saved to a Redux store. +#### 6.4: Anecdotes, step 2 -Implement the functionality for adding new anecdotes. +Implement the functionality for adding new anecdotes. +You can keep the form uncontrolled like we did [earlier](/en/part6/flux_architecture_and_redux#uncontrolled-form). -You can keep the form uncontrolled, like we did [earlier](/en/part6/flux_architecture_and_redux#uncontrolled-form). +#### 6.5: Anecdotes, step 3 -#### 6.5*: anecdotes, step3 +Make sure that the anecdotes are ordered by the number of votes. +#### 6.6: Anecdotes, step 4 -Make sure that the anecdotes are ordered by the number of votes. +If you haven't done so already, separate the creation of action-objects to [action creator](https://read.reduxbook.com/markdown/part1/04-action-creators.html)-functions and place them in the src/reducers/anecdoteReducer.js file, so do what we have been doing since the chapter [action creators](/en/part6/flux_architecture_and_redux#action-creators). -#### 6.6: anecdotes, step4 +#### 6.7: Anecdotes, step 5 +Separate the creation of new anecdotes into a component called AnecdoteForm. Move all logic for creating a new anecdote into this new component. -If you haven't done so already, separate the creation of action-objects to [action creator](https://redux.js.org/basics/actions#action-creators)-functions and place them in the src/reducers/anecdoteReducer.js file, so do like we have been doing since the chapter [action creators](/en/part6/flux_architecture_and_redux#action-creators). +#### 6.8: Anecdotes, step 6 -#### 6.7: anecdotes, step5 +Separate the rendering of the anecdote list into a component called AnecdoteList. Move all logic related to voting for an anecdote to this new component. - -Separate the creation of new anecdotes into its own component called AnecdoteForm. Move all logic for creating a new anecdote into this new component. - -#### 6.8: anecdotes, step6 - - -Separate the rendering of the anecdote list into its own component called AnecdoteList. Move all logic related to voting for an anecdote to this new component. - - -Now the App component should look like this: +Now the App component should look like this: ```js -import React from 'react' import AnecdoteForm from './components/AnecdoteForm' import AnecdoteList from './components/AnecdoteList' @@ -1280,12 +1310,13 @@ const App = () => { return (

    Anecdotes

    + -
    ) } export default App ``` +
    diff --git a/src/content/6/en/part6b.md b/src/content/6/en/part6b.md index 70b1adcbef1..d6b387f311f 100644 --- a/src/content/6/en/part6b.md +++ b/src/content/6/en/part6b.md @@ -7,13 +7,12 @@ lang: en
    +Let's continue our work with the simplified [Redux version](/en/part6/flux_architecture_and_redux#redux-notes) of our notes application. -Let's continue our work with the simplified [redux version](/en/part6/flux_architecture_and_redux#redux-notes) of our notes application. - - -In order to ease our development, let's change our reducer so that the store gets initialized with a state that contains a couple of notes: +To ease our development, let's change our reducer so that the store gets initialized with a state that contains a couple of notes: ```js +// highlight-start const initialState = [ { content: 'reducer defines how redux store works', @@ -26,29 +25,27 @@ const initialState = [ id: 2, }, ] +//highlight-end -const noteReducer = (state = initialState, action) => { +const noteReducer = (state = initialState, action) => { // highlight-line // ... } // ... + export default noteReducer ``` - ### Store with complex state - Let's implement filtering for the notes that are displayed to the user. The user interface for the filters will be implemented with [radio buttons](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/radio): -![](../../images/6/01e.png) - +![browser with important/not radio buttons and list](../../images/6/01f.png) Let's start with a very simple and straightforward implementation: ```js -import React from 'react' -import NewNote from './components/NewNote' +import NoteForm from './components/NoteForm' import Notes from './components/Notes' const App = () => { @@ -60,15 +57,27 @@ const App = () => { return (
    - - //highlight-start + + //highlight-start
    - all filterSelected('ALL')} /> - important filterSelected('IMPORTANT')} /> - nonimportant filterSelected('NONIMPORTANT')} /> + filterSelected('ALL')} + /> + all + filterSelected('IMPORTANT')} + /> + important + filterSelected('NONIMPORTANT')} + /> + nonimportant
    //highlight-end @@ -77,14 +86,11 @@ const App = () => { } ``` - Since the name attribute of all the radio buttons is the same, they form a button group where only one option can be selected. - The buttons have a change handler that currently only prints the string associated with the clicked button to the console. - -We decide to implement the filter functionality by storing the value of the filter in the redux store in addition to the notes themselves. The state of the store should look like this after making these changes: +In the following section, we will implement filtering by storing both the notes as well as the value of the filter in the redux store. When we are finished, we would like the state of the store to look like this: ```js { @@ -96,102 +102,78 @@ We decide to implement the filter functionality by storing the value of the f } ``` - -Only the array of notes is stored in the state of the current implementation of our application. In the new implementation the state object has two properties, notes that contains the array of notes and filter that contains a string indicating which notes should be displayed to the user. +Only the array of notes was stored in the state of the previous implementation of our application. In the new implementation, the state object has two properties, notes that contains the array of notes and filter that contains a string indicating which notes should be displayed to the user. ### Combined reducers - -We could modify our current reducer to deal with the new shape of the state. However, a better solution in this situation is to define a new separate reducer for the state of the filter: +We could modify our current reducer to deal with the new shape of the state. However, a better solution in this situation is to define a new separate reducer for the state of the filter. Let's also create a new _action creator_ function and place the code in the module src/reducers/filterReducer.js: ```js const filterReducer = (state = 'ALL', action) => { switch (action.type) { case 'SET_FILTER': - return action.filter + return action.payload default: return state } } -``` - - -The actions for changing the state of the filter look like this: - -```js -{ - type: 'SET_FILTER', - filter: 'IMPORTANT' -} -``` - - -Let's also create a new _action creator_ function. We will write the code for the action creator in a new src/reducers/filterReducer.js module: - -```js -const filterReducer = (state = 'ALL', action) => { - // ... -} export const filterChange = filter => { return { type: 'SET_FILTER', - filter, + payload: filter } } export default filterReducer ``` +The actions for changing the state of the filter look like this: -We can create the actual reducer for our application by combining the two existing reducers with the [combineReducers](https://redux.js.org/api/combinereducers) function. +```js +{ + type: 'SET_FILTER', + payload: 'IMPORTANT' +} +``` +We can create the actual reducer for our application by combining the two existing reducers with the [combineReducers](https://redux.js.org/api/combinereducers) function. -Let's define the combined reducer in the index.js file: +Let's define the combined reducer in the main.jsx file. The updated content of the file is as follows: ```js -import React from 'react' -import ReactDOM from 'react-dom' -import { createStore, combineReducers } from 'redux' // highlight-line -import { Provider } from 'react-redux' -import App from './App' +import ReactDOM from 'react-dom/client' +import { Provider } from 'react-redux' +import { createStore, combineReducers } from 'redux' +import App from './App' +import filterReducer from './reducers/filterReducer' import noteReducer from './reducers/noteReducer' -import filterReducer from './reducers/filterReducer' // highlight-line - // highlight-start const reducer = combineReducers({ notes: noteReducer, filter: filterReducer }) - // highlight-end const store = createStore(reducer) console.log(store.getState()) -ReactDOM.render( - /* +ReactDOM.createRoot(document.getElementById('root')).render( - - , - */ -
    , - document.getElementById('root') +
    + ) ``` Since our application breaks completely at this point, we render an empty div element instead of the App component. +Thanks to the console.log command, the state of the store is printed to the console: -The state of the store gets printed to the console: - -![](../../images/6/4e.png) - +![devtools console showing notes array data](../../images/6/4e.png) As we can see from the output, the store has the exact shape we wanted it to! - Let's take a closer look at how the combined reducer is created: ```js @@ -201,65 +183,87 @@ const reducer = combineReducers({ }) ``` - The state of the store defined by the reducer above is an object with two properties: notes and filter. The value of the notes property is defined by the noteReducer, which does not have to deal with the other properties of the state. Likewise, the filter property is managed by the filterReducer. - -Before we make more changes to the code, let's take a look at how different actions change the state of the store defined by the combined reducer. Let's add the following to the index.js file: +Before we make more changes to the code, let's take a look at how different actions change the state of the store defined by the combined reducer. Let's temporarily add the following lines to the file main.jsx: ```js +// ... + +const store = createStore(reducer) + +console.log(store.getState()) + +// highlight-start import { createNote } from './reducers/noteReducer' import { filterChange } from './reducers/filterReducer' -//... +// highlight-end + +// highlight-start store.subscribe(() => console.log(store.getState())) store.dispatch(filterChange('IMPORTANT')) store.dispatch(createNote('combineReducers forms one reducer from many simple reducers')) -``` +// highlight-end +ReactDOM.createRoot(document.getElementById('root')).render( + +
    + +) +``` By simulating the creation of a note and changing the state of the filter in this fashion, the state of the store gets logged to the console after every change that is made to the store: -![](../../images/6/5e.png) +![devtools console output showing notes filter and new note](../../images/6/5e.png) - -At this point it is good to become aware of a tiny but important detail. If we add a console log statement to the beginning of both reducers: +At this point, it is good to become aware of a tiny but important detail. If we add a console log statement to the beginning of both reducers: ```js const filterReducer = (state = 'ALL', action) => { - console.log('ACTION: ', action) + console.log('ACTION: ', action) // highlight-line // ... } ``` - Based on the console output one might get the impression that every action gets duplicated: -![](../../images/6/6.png) +![devtools console output showing duplicated actions in note and filter reducers](../../images/6/6.png) +Is there a bug in our code? No. The combined reducer works in such a way that every action gets handled in every part of the combined reducer, or in other words, every reducer "listens" to all of the dispatched actions and does something with them if it has been instructed to do so. Typically only one reducer is interested in any given action, but there are situations where multiple reducers change their respective parts of the state based on the same action. -Is there a bug in our code? No. The combined reducer works in such a way that every action gets handled in every part of the combined reducer. Typically only one reducer is interested in any given action, but there are situations where multiple reducers change their respective parts of the state based on the same action. +### Finishing the filters +Let's finish the application so that it uses the combined reducer. Let's remove the extra test code from the file main.jsx and restore _App_ as the rendered component. The updated content of the file is as follows: -### Finishing the filters +```js +import ReactDOM from 'react-dom/client' +import { Provider } from 'react-redux' +import { createStore, combineReducers } from 'redux' +import App from './App' +import filterReducer from './reducers/filterReducer' +import noteReducer from './reducers/noteReducer' -Let's finish the application so that it uses the combined reducer. We start by changing the rendering of the application and hooking up the store to the application in the index.js file: +const reducer = combineReducers({ + notes: noteReducer, + filter: filterReducer +}) -```js -ReactDOM.render( +const store = createStore(reducer) + +console.log(store.getState()) + +ReactDOM.createRoot(document.getElementById('root')).render( - , - document.getElementById('root') + ) ``` Next, let's fix a bug that is caused by the code expecting the application store to be an array of notes: -![](../../images/6/7ea.png) +![browser TypeError: notes.map is not a function](../../images/6/7v.png) - - It's an easy fix. Because the notes are in the store's field notes, we only have to make a little change to the selector function: ```js @@ -283,51 +287,47 @@ const Notes = () => { } ``` - Previously the selector function returned the whole state of the store: ```js const notes = useSelector(state => state) ``` - And now it returns only its field notes ```js const notes = useSelector(state => state.notes) ``` - -Let's extract the visibility filter into its own src/components/VisibilityFilter.js component: +Let's extract the visibility filter into its own src/components/VisibilityFilter.jsx component: ```js -import React from 'react' -import { filterChange } from '../reducers/filterReducer' import { useDispatch } from 'react-redux' +import { filterChange } from '../reducers/filterReducer' -const VisibilityFilter = (props) => { +const VisibilityFilter = () => { const dispatch = useDispatch() return (
    - all - dispatch(filterChange('ALL'))} /> - important + all dispatch(filterChange('IMPORTANT'))} /> - nonimportant + important dispatch(filterChange('NONIMPORTANT'))} /> + nonimportant
    ) } @@ -335,18 +335,17 @@ const VisibilityFilter = (props) => { export default VisibilityFilter ``` -With the new component App can be simplified as follows: +With the new component, App can be simplified as follows: ```js -import React from 'react' +import NoteForm from './components/NoteForm' import Notes from './components/Notes' -import NewNote from './components/NewNote' import VisibilityFilter from './components/VisibilityFilter' const App = () => { return (
    - +
    @@ -365,38 +364,35 @@ const Notes = () => { const dispatch = useDispatch() // highlight-start const notes = useSelector(state => { - if ( state.filter === 'ALL' ) { + if (state.filter === 'ALL') { return state.notes } - return state.filter === 'IMPORTANT' + return state.filter === 'IMPORTANT' ? state.notes.filter(note => note.important) : state.notes.filter(note => !note.important) }) // highlight-end - return( + return (
      - {notes.map(note => + {notes.map(note => ( - dispatch(toggleImportanceOf(note.id)) - } + handleClick={() => dispatch(toggleImportanceOf(note.id))} /> - )} + ))}
    ) +} ``` - We only make changes to the selector function, which used to be ```js useSelector(state => state.notes) ``` - Let's simplify the selector by destructuring the fields from the state it receives as a parameter: ```js @@ -410,164 +406,417 @@ const notes = useSelector(({ filter, notes }) => { }) ``` -There is a slight cosmetic flaw in our application. Even though the filter is set to ALL by default, the associated radio button is not selected. Naturally this issue can be fixed, but since this is an unpleasant but ultimately harmless bug we will save the fix for later. +There is a slight cosmetic flaw in our application. Even though the filter is set to ALL by default, the associated radio button is not selected. Naturally, this issue can be fixed, but since this is an unpleasant but ultimately harmless bug we will save the fix for later. -### Redux DevTools +The current version of the application can be found on [GitHub](https://github.com/fullstack-hy2020/redux-notes/tree/part6-2), branch part6-2. + +
    -There is an extension [Redux DevTools](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd) that can be installed on Chrome, in which the state of the Redux-store and the action that changes it can be monitored from the console of the browser. +
    + +### Exercise 6.9 + +#### 6.9 Anecdotes, step 7 + +Implement filtering for the anecdotes that are displayed to the user. -When debugging, in addition to the browser extension we also have the software library [redux-devtools-extension](https://www.npmjs.com/package/redux-devtools-extension). Let's install it using the command: +![browser showing filtering of anecdotes](../../images/6/9ea.png) + +Store the state of the filter in the redux store. It is recommended to create a new reducer, action creators, and a combined reducer for the store using the combineReducers function. + +Create a new Filter component for displaying the filter. You can use the following code as a template for the component: ```js -npm install --save redux-devtools-extension +const Filter = () => { + const handleChange = (event) => { + // input-field value is in variable event.target.value + } + const style = { + marginBottom: 10 + } + + return ( +
    + filter +
    + ) +} + +export default Filter ``` -We'll have to slightly change the definition of the store to get the library up and running: +
    + +
    + +### Redux Toolkit and Refactoring the Store Configuration + +As we have seen so far, Redux's configuration and state management implementation requires quite a lot of effort. This is manifested for example in the reducer and action creator-related code which has somewhat repetitive boilerplate code. [Redux Toolkit](https://redux-toolkit.js.org/) is a library that solves these common Redux-related problems. The library for example greatly simplifies the configuration of the Redux store and offers a large variety of tools to ease state management. + +Let's start using Redux Toolkit in our application by refactoring the existing code. First, we will need to install the library: + +```bash +npm install @reduxjs/toolkit +``` + +Next, open the main.jsx file which currently creates the Redux store. Instead of Redux's createStore function, let's create the store using Redux Toolkit's [configureStore](https://redux-toolkit.js.org/api/configureStore) function: ```js -// ... -import { createStore, combineReducers } from 'redux' -import { composeWithDevTools } from 'redux-devtools-extension' // highlight-line +import ReactDOM from 'react-dom/client' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' // highlight-line -import noteReducer from './reducers/noteReducer' +import App from './App' import filterReducer from './reducers/filterReducer' +import noteReducer from './reducers/noteReducer' -const reducer = combineReducers({ - notes: noteReducer, - filter: filterReducer + // highlight-start +const store = configureStore({ + reducer: { + notes: noteReducer, + filter: filterReducer + } }) +// highlight-end -const store = createStore( - reducer, - // highlight-start - composeWithDevTools() - // highlight-end +console.log(store.getState()) + +ReactDOM.createRoot(document.getElementById('root')).render( + + + ) +``` + +We already got rid of a few lines of code, now we don't need the combineReducers function to create the store's reducer. We will soon see that the configureStore function has many additional benefits such as the effortless integration of development tools and many commonly used libraries without the need for additional configuration. + +Let's further clean up the main.jsx file by moving the code related to the creation of the Redux store into a separate file. Let's create a new file src/store.js: +```js +import { configureStore } from '@reduxjs/toolkit' + +import noteReducer from './reducers/noteReducer' +import filterReducer from './reducers/filterReducer' + +const store = configureStore({ + reducer: { + notes: noteReducer, + filter: filterReducer + } +}) export default store ``` -Now when you open the console, the redux tab looks like this: +After the changes, the content of the main.jsx is the following: -![](../../images/6/11ea.png) +```js +import ReactDOM from 'react-dom/client' +import { Provider } from 'react-redux' -The effect of each to the store can be easily observed +import App from './App' +import store from './store' -![](../../images/6/12ea.png) +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) +``` -It's also possible to dispatch actions to the store using the console +### Redux Toolkit and Refactoring Reducers -![](../../images/6/13ea.png) +Let's move on to refactoring the reducers, which brings forth the benefits of the Redux Toolkit. With Redux Toolkit, we can easily create reducer and related action creators using the [createSlice](https://redux-toolkit.js.org/api/createSlice) function. We can use the createSlice function to refactor the reducer and action creators in the reducers/noteReducer.js file in the following manner: -You can find the code for our current application in its entirety in the part6-2 branch of [this Github repository](https://github.com/fullstack-hy2020/redux-notes/tree/part6-2). +```js +import { createSlice } from '@reduxjs/toolkit' // highlight-line -
    +const initialState = [ + { + content: 'reducer defines how redux store works', + important: true, + id: 1, + }, + { + content: 'state of store can contain any data', + important: false, + id: 2, + }, +] -
    +const generateId = () => + Number((Math.random() * 1000000).toFixed(0)) + +// highlight-start +const noteSlice = createSlice({ + name: 'notes', + initialState, + reducers: { + createNote(state, action) { + const content = action.payload + + state.push({ + content, + important: false, + id: generateId(), + }) + }, + toggleImportanceOf(state, action) { + const id = action.payload + + const noteToChange = state.find(n => n.id === id) + + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + + return state.map(note => + note.id !== id ? note : changedNote + ) + } + }, +}) +// highlight-end +// highlight-start +export const { createNote, toggleImportanceOf } = noteSlice.actions +export default noteSlice.reducer +// highlight-end +``` -### Exercises 6.9.-6.12. +The createSlice function's name parameter defines the prefix which is used in the action's type values. For example, the createNote action defined later will have the type value of notes/createNote. It is a good practice to give the parameter a value which is unique among the reducers. This way there won't be unexpected collisions between the application's action type values. +The initialState parameter defines the reducer's initial state. +The reducers parameter takes the reducer itself as an object, of which functions handle state changes caused by certain actions. Note that the action.payload in the function contains the argument provided by calling the action creator: +```js +dispatch(createNote('Redux Toolkit is awesome!')) +``` + +This dispatch call is equivalent to dispatching the following object: + +```js +dispatch({ type: 'notes/createNote', payload: 'Redux Toolkit is awesome!' }) +``` -Let's continue working on the anecdote application using redux that we started in exercise 6.3. +If you followed closely, you might have noticed that inside the createNote action, there seems to happen something that violates the reducers' immutability principle mentioned earlier: +```js +createNote(state, action) { + const content = action.payload -#### 6.9 Better anecdotes, step7 + state.push({ + content, + important: false, + id: generateId(), + }) +} +``` - -Start using React dev tools. Move defining the Redux-store into its own file store.js. +We are mutating state argument's array by calling the push method instead of returning a new instance of the array. What's this all about? -#### 6.10 Better anecdotes, step8 +Redux Toolkit utilizes the [Immer](https://immerjs.github.io/immer/) library with reducers created by createSlice function, which makes it possible to mutate the state argument inside the reducer. Immer uses the mutated state to produce a new, immutable state and thus the state changes remain immutable. Note that state can be changed without "mutating" it, as we have done with the toggleImportanceOf action. In this case, the function directly returns the new state. Nevertheless mutating the state will often come in handy especially when a complex state needs to be updated. -The application has a ready-made body for the Notification component: +The createSlice function returns an object containing the reducer as well as the action creators defined by the reducers parameter. The reducer can be accessed by the noteSlice.reducer property, whereas the action creators by the noteSlice.actions property. We can produce the file's exports in the following way: ```js -import React from 'react' +const noteSlice = createSlice({ + // ... +}) -const Notification = () => { - const style = { - border: 'solid', - padding: 10, - borderWidth: 1 +// highlight-start +export const { createNote, toggleImportanceOf } = noteSlice.actions +export default noteSlice.reducer +// highlight-end +``` + +The imports in other files will work just as they did before: + +```js +import noteReducer, { createNote, toggleImportanceOf } from './reducers/noteReducer' +``` + +We need to alter the action type names in the tests due to the conventions of ReduxToolkit: + +```js +import deepFreeze from 'deep-freeze' +import { describe, expect, test } from 'vitest' +import noteReducer from './noteReducer' + +describe('noteReducer', () => { + test('returns new state with action notes/createNote', () => { // highlight-line + const state = [] + const action = { + type: 'notes/createNote', // highlight-line + payload: 'the app state is in redux store' // highlight-line + } + + deepFreeze(state) + const newState = noteReducer(state, action) + + expect(newState).toHaveLength(1) + expect(newState.map(note => note.content)).toContainEqual(action.payload) // highlight-line + }) +}) + +test('returns new state with action notes/toggleImportanceOf', () => { // highlight-line + const state = [ + { + content: 'the app state is in redux store', + important: true, + id: 1 + }, + { + content: 'state changes are made with actions', + important: false, + id: 2 + } + ] + + const action = { + type: 'notes/toggleImportanceOf', // highlight-line + payload: 2 // highlight-line } - return ( -
    - render here notification... -
    - ) -} -export default Notification + deepFreeze(state) + const newState = noteReducer(state, action) + + expect(newState).toHaveLength(2) + + expect(newState).toContainEqual(state[0]) + + expect(newState).toContainEqual({ + content: 'state changes are made with actions', + important: true, + id: 2 + }) +}) ``` +You can find the code for our current application in its entirety in the part6-3 branch of [this GitHub repository](https://github.com/fullstack-hy2020/redux-notes/tree/part6-3). + +### Redux Toolkit and console.log + +As we have learned, console.log is an extremely powerful tool; it often saves us from trouble. -Extend the component so that it renders the message stored in the redux store, making the component to take the form: +Let's try to print the state of the Redux Store to the console in the middle of the reducer created with the function createSlice: ```js -import React from 'react' -import { useSelector } from 'react-redux' // highlight-line +const noteSlice = createSlice({ + name: 'notes', + initialState, + reducers: { + // ... + toggleImportanceOf(state, action) { + const id = action.payload + + const noteToChange = state.find(n => n.id === id) + + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + + console.log(state) // highlight-line + + return state.map(note => + note.id !== id ? note : changedNote + ) + } + }, +}) +``` -const Notification = () => { - const notification = useSelector(/*s omething here */) // highlight-line - const style = { - border: 'solid', - padding: 10, - borderWidth: 1 - } - return ( -
    - {notification} // highlight-line -
    - ) -} +When we now change the importance of a note by clicking its name, the following is printed to the console + +![devtools console showing Handler,Target as null but IsRevoked as true](../../images/6/40new.png) + +The output is interesting but not very useful. This is about the previously mentioned Immer library used by the Redux Toolkit internally to save the state of the Store. + +The state can be converted to a human-readable format by using the [current](https://redux-toolkit.js.org/api/other-exports#current) function from the immer library. The function can be imported with the following command: + +```js +import { current } from '@reduxjs/toolkit' ``` -You will have to make changes to the application's existing reducer. Create a separate reducer for the new functionality and refactor the application so that it uses a combined reducer as shown in this part of the course material. +and after this, the state can be printed to the console with the following command: -The application does not have to use the Notification component in any intelligent way at this point in the exercises. It is enough for the application to display the initial value set for the message in the notificationReducer. +```js +console.log(current(state)) +``` -#### 6.11 Better anecdotes, step9 +Console output is now human readable -Extend the application so that it uses the Notification component to display a message for the duration of five seconds when the user votes for an anecdote or creates a new anecdote: +![dev tools showing array of 2 notes](../../images/6/41new.png) -![](../../images/6/8ea.png) +### Redux DevTools +[Redux DevTools](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd) is a Chrome addon that offers useful development tools for Redux. It can be used for example to inspect the Redux store's state and dispatch actions through the browser's console. When the store is created using Redux Toolkit's configureStore function, no additional configuration is needed for Redux DevTools to work. -It's recommended to create separate [action creators](https://redux.js.org/basics/actions#action-creators) for setting and removing notifications. +Once the addon is installed, clicking the Redux tab in the browser's developer tools, the Redux DevTools should open: +![browser with redux addon in devtools](../../images/6/42new.png) -#### 6.12* Better anecdotes, step10 +You can inspect how dispatching a certain action changes the state by clicking the action: +![devtools inspecting state tree in redux](../../images/6/43new.png) -Implement filtering for the anecdotes that are displayed to the user. +It is also possible to dispatch actions to the store using the development tools: -![](../../images/6/9ea.png) +![devtools redux dispatching createNote with payload](../../images/6/44new.png) +
    -Store the state of the filter in the redux store. It is recommended to create a new reducer and action creators for this purpose. +
    +### Exercises 6.10.-6.13. -Create a new Filter component for displaying the filter. You can use the following code as a template for the component: +Let's continue working on the anecdote application using Redux that we started in exercise 6.3. -```js -import React from 'react' +#### 6.10 Anecdotes, step 8 -const Filter = () => { - const handleChange = (event) => { - // input-field value is in variable event.target.value - } +Install Redux Toolkit for the project. Move the Redux store creation into the file store.js and use Redux Toolkit's configureStore to create the store. + +Change the definition of the filter reducer and action creators to use the Redux Toolkit's createSlice function. + +Also, start using Redux DevTools to debug the application's state easier. + +#### 6.11 Anecdotes, step 9 + +Change also the definition of the anecdote reducer and action creators to use the Redux Toolkit's createSlice function. + +#### 6.12 Anecdotes, step 10 + +The application has a ready-made body for the Notification component: + +```js +const Notification = () => { const style = { + border: 'solid', + padding: 10, + borderWidth: 1, marginBottom: 10 } return (
    - filter + render here notification...
    ) } -export default Filter +export default Notification ``` +Extend the component so that it renders the message stored in the Redux store. Create a separate reducer for the new functionality by using the Redux Toolkit's createSlice function. + +The application does not have to use the Notification component intelligently at this point in the exercises. It is enough for the application to display the initial value set for the message in the notificationReducer. + +#### 6.13 Anecdotes, step 11 + +Extend the application so that it uses the Notification component to display a message for five seconds when the user votes for an anecdote or creates a new anecdote: + +![browser showing message of having voted](../../images/6/8eb.png) + +It's recommended to create separate [action creators](https://redux-toolkit.js.org/api/createSlice#reducers) for setting and removing notifications. +
    diff --git a/src/content/6/en/part6c.md b/src/content/6/en/part6c.md index 056a38f6957..9a2d2f2c5db 100644 --- a/src/content/6/en/part6c.md +++ b/src/content/6/en/part6c.md @@ -7,11 +7,11 @@ lang: en
    +### Setting up JSON Server -Let's expand the application, such that the notes are stored to the backend. We'll use [json-server](/en/part2/getting_data_from_server), familiar from part 2. +Let's expand the application so that the notes are stored in the backend. We'll use [json-server](/en/part2/getting_data_from_server), familiar from part 2. - -The initial state of the database is stored into the file db.json, which is placed in the root of the project: +The initial state of the database is stored in the file db.json, which is placed in the root of the project: ```json { @@ -30,147 +30,181 @@ The initial state of the database is stored into the file db.json, which } ``` - - -We'll install json-server for the project... +We'll install json-server for the project: ```js -npm install json-server --save +npm install json-server --save-dev ``` - - and add the following line to the scripts part of the file package.json ```js "scripts": { - "server": "json-server -p3001 --watch db.json", + "server": "json-server -p 3001 db.json", // ... } ``` Now let's launch json-server with the command _npm run server_. -Next we'll create a method into the file services/notes.js, which uses axios to fetch data from the backend +### Fetch API -```js -import axios from 'axios' +In software development, it is often necessary to consider whether a certain functionality should be implemented using an external library or whether it is better to utilize the native solutions provided by the environment. Both approaches have their own advantages and challenges. -const baseUrl = 'http://localhost:3001/notes' +In the earlier parts of this course, we used the [Axios](https://axios-http.com/docs/intro) library to make HTTP requests. Now, let's explore an alternative way to make HTTP requests using the native [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). -const getAll = async () => { - const response = await axios.get(baseUrl) - return response.data -} +It is typical for an external library like Axios to be implemented using other external libraries. For example, if you install Axios in your project with the command _npm install axios_, the console output will be: -export default { getAll } -``` +```bash +$ npm install axios -We'll add axios to the project +added 23 packages, and audited 302 packages in 1s -```js -npm install axios --save +71 packages are looking for funding + run `npm fund` for details + +found 0 vulnerabilities ``` -We'll change the initialization of the state in noteReducer, such that by default there are no notes: +So, in addition to the Axios library, the command would install over 20 other npm packages that Axios needs to function. + +The Fetch API provides a similar way to make HTTP requests as Axios, but using the Fetch API does not require installing any external libraries. Maintaining the application becomes easier when there are fewer libraries to update, and security is also improved because the potential attack surface of the application is reduced. The security and maintainability of applications is discussed further in [part 7](https://fullstackopen.com/en/part7/class_components_miscellaneous#react-node-application-security) of the course. + +In practice, requests are made using the _fetch()_ function. The syntax used differs somewhat from Axios. We will also soon notice that Axios has taken care of some things for us and made our lives easier. However, we will now use the Fetch API, as it is a widely used native solution that every Full Stack developer should be familiar with. + +### Getting data from the backend + +Let's create a method for fetching data from the backend in the file src/services/notes.js: ```js -const noteReducer = (state = [], action) => { - // ... +const baseUrl = 'http://localhost:3001/notes' + +const getAll = async () => { + const response = await fetch(baseUrl) + + if (!response.ok) { + throw new Error('Failed to fetch notes') + } + + const data = await response.json() + return data } + +export default { getAll } ``` -A quick way to initialize the state based on the data on the server is to fetch the notes in the file index.js and dispatch the action NEW\_NOTE for each of them: +Let's take a closer look at the implementation of the _getAll_ method. The notes are now fetched from the backend by calling the _fetch()_ function, which is given the backend's URL as an argument. The request type is not explicitly defined, so _fetch_ performs its default action, which is a GET request. + +Once the response has arrived, the success of the request is checked using the _response.ok_ property, and an error is thrown if necessary: ```js -// ... -import noteService from './services/notes' // highlight-line +if (!response.ok) { + throw new Error('Failed to fetch notes') +} +``` -const reducer = combineReducers({ - notes: noteReducer, - filter: filterReducer, -}) +The _response.ok_ attribute is set to _true_ if the request was successful, meaning the response status code is between 200 and 299. For all other status codes, such as 404 or 500, it is set to _false_. -const store = createStore(reducer) +Note that _fetch_ does not automatically throw an error even if the response status code is, for example, 404. Error handling must be implemented manually, as we have done here. -// highlight-start -noteService.getAll().then(notes => - notes.forEach(note => { - store.dispatch({ type: 'NEW_NOTE', data: note }) - }) -) -// highlight-end +If the request is successful, the data contained in the response is converted to JSON format: -// ... +```js +const data = await response.json() ``` +_fetch_ does not automatically convert any data included in the response to JSON format; the conversion must be done manually. It is also important to note that _response.json()_ is an asynchronous method, so the await keyword. - -Let's add support in the reducer for the action INIT\_NOTES, using which the initialization can be done by dispatching a single action. Let's also create an action creator function _initializeNotes_. +Let's further simplify the code by directly returning the data returned by the _response.json()_ method: ```js -// ... -const noteReducer = (state = [], action) => { - console.log('ACTION:', action) - switch (action.type) { - case 'NEW_NOTE': - return [...state, action.data] - case 'INIT_NOTES': // highlight-line - return action.data // highlight-line - // ... - } -} +const getAll = async () => { + const response = await fetch(baseUrl) -export const initializeNotes = (notes) => { - return { - type: 'INIT_NOTES', - data: notes, + if (!response.ok) { + throw new Error('Failed to fetch notes') } -} -// ... + return await response.json() // highlight-line +} ``` +### Initializing the store with data fetched from the server -index.js simplifies: +Let's now modify our application so that the application state is initialized with notes fetched from the server. -```js -import noteReducer, { initializeNotes } from './reducers/noteReducer' -// ... +In the file noteReducer.js, change the initialization of the notes state so that by default there are no notes: -noteService.getAll().then(notes => - store.dispatch(initializeNotes(notes)) -) +```js +const noteSlice = createSlice({ + name: 'notes', + initialState: [], // highlight-line + // ... +}) ``` +Let's add an action creator called setNotes, which allows us to directly replace the array of notes. We can create the desired action creator using the createSlice function as follows: + +```js +// ... + +const noteSlice = createSlice({ + name: 'notes', + initialState: [], + reducers: { + createNote(state, action) { + const content = action.payload + state.push({ + content, + important: false, + id: generateId() + }) + }, + toggleImportanceOf(state, action) { + const id = action.payload + const noteToChange = state.find(n => n.id === id) + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + return state.map(note => (note.id !== id ? note : changedNote)) + }, + // highlight-start + setNotes(state, action) { + return action.payload + } + // highlight-end + } +}) -> **NB:** why didn't we use await in place of promises and event handlers (registered to _then_-methods)? -> -> Await only works inside async functions, and the code in index.js is not inside a function, so due to the simple nature of the operation, we'll abstain from using async this time. +export const { createNote, toggleImportanceOf, setNotes } = noteSlice.actions // highlight-line +export default noteSlice.reducer +``` -We do, however, decide to move the initialization of the notes into the App component, and, as usual when fetching data from a server, we'll use the effect hook. +Let's implement the initialization of notes in the App component. As is usually the case when fetching data from a server, we will use the useEffect hook: ```js -import React, {useEffect} from 'react' // highlight-line -import NewNote from './components/NowNote' +import { useEffect } from 'react' // highlight-line +import { useDispatch } from 'react-redux' // highlight-line + +import NoteForm from './components/NoteForm' import Notes from './components/Notes' import VisibilityFilter from './components/VisibilityFilter' -import noteService from './services/notes' -import { initializeNotes } from './reducers/noteReducer' // highlight-line -import { useDispatch } from 'react-redux' // highlight-line +import { setNotes } from './reducers/noteReducer' // highlight-line +import noteService from './services/notes' // highlight-line const App = () => { - const dispatch = useDispatch() + const dispatch = useDispatch() // highlight-line + // highlight-start useEffect(() => { - noteService - .getAll().then(notes => dispatch(initializeNotes(notes))) - }, []) + noteService.getAll().then(notes => dispatch(setNotes(notes))) + }, [dispatch]) // highlight-end return (
    - +
    @@ -180,90 +214,103 @@ const App = () => { export default App ``` - -Using the useEffect hook causes an eslint-warning: +The notes are fetched from the server using the _getAll()_ method we defined, and then stored in the Redux store by dispatching the action returned by the _setNotes_ action creator. These operations are performed inside the useEffect hook, meaning they are executed when the App component is rendered for the first time. + +Let's take a closer look at a small detail. We have added the _dispatch_ variable to the dependency array of the useEffect hook. If we try to use an empty dependency array, ESLint gives the following warning: React Hook useEffect has a missing dependency: 'dispatch'. What does this mean? + +Logically, the code would work exactly the same even if we used an empty dependency array, because dispatch refers to the same function throughout the execution of the program. However, it is considered good programming practice to add all variables and functions used inside the _useEffect_ hook that are defined within the component to the dependency array. This helps to avoid unexpected bugs. -![](../../images/6/26ea.png) +### Sending data to the backend - -We can get rid of it by doing the following: +Next, let's implement the functionality for sending a new note to the server. This will also give us an opportunity to practice how to make a POST request using the _fetch()_ method. + +Let's extend the code in src/services/notes.js that handles communication with the server as follows: ```js -const App = () => { - const dispatch = useDispatch() - useEffect(() => { - noteService - .getAll().then(notes => dispatch(initializeNotes(notes))) - }, [dispatch]) // highlight-line +const baseUrl = 'http://localhost:3001/notes' - // ... +const getAll = async () => { + const response = await fetch(baseUrl) + + if (!response.ok) { + throw new Error('Failed to fetch notes') + } + + return await response.json() } -``` - -Now the variable dispatch we define in the _App_ component, which practically is the dispatch function of the redux-store, has been added to the array useEffect receives as a parameter. -**If** the value of the dispatch-variable would change during runtime, -the effect would be executed again. This however cannot happen in our application, so the warning is unnecessary. +// highlight-start +const createNew = async (content) => { + const response = await fetch(baseUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content, important: false }), + }) + + if (!response.ok) { + throw new Error('Failed to create note') + } + + return await response.json() +} +// highlight-end - -Another way to get rid of the warning would be to disable eslint on that line: +export default { getAll, createNew } // highlight-line +``` + +Let's take a closer look at the implementation of the _createNew_ method. The first parameter of the _fetch()_ function specifies the URL to which the request is made. The second parameter is an object that defines other details of the request, such as the request type, headers, and the data sent with the request. We can further clarify the code by storing the object that defines the request details in a separate options variable: ```js -const App = () => { - const dispatch = useDispatch() - useEffect(() => { - noteService - .getAll().then(notes => dispatch(initializeNotes(notes))) - // highlight-start - },[]) // eslint-disable-line react-hooks/exhaustive-deps +const createNew = async (content) => { + // highlight-start + const options = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content, important: false }), + } + + const response = await fetch(baseUrl, options) // highlight-end - // ... + if (!response.ok) { + throw new Error('Failed to create note') + } + + return await response.json() } ``` - -Generally disabling eslint when it throws a warning is not a good idea. Even though the eslint rule in question has caused some [arguments](https://github.com/facebook/create-react-app/issues/6880), we will use the first solution. +Let's take a closer look at the options object: - -More about the need to define the hooks dependencies in [the react documentation](https://reactjs.org/docs/hooks-faq.html#is-it-safe-to-omit-functions-from-the-list-of-dependencies). +- method defines the type of the request, which in this case is POST +- headers defines the request headers. We add the header _'Content-Type': 'application/json'_ to let the server know that the data sent with the request is in JSON format, so it can handle the request correctly +- body contains the data sent with the request. You cannot directly assign a JavaScript object to this field; it must first be converted to a JSON string by calling the _JSON.stringify()_ function -We can do the same thing when it comes to creating a new note. Let's expand the code communicating with the server as follows: +As with a GET request, the response status code is checked for errors: ```js -const baseUrl = 'http://localhost:3001/notes' - -const getAll = async () => { - const response = await axios.get(baseUrl) - return response.data +if (!response.ok) { + throw new Error('Failed to create note') } +``` -// highlight-start -const createNew = async (content) => { - const object = { content, important: false } - const response = await axios.post(baseUrl, object) - return response.data -} -// highlight-end +If the request is successful, JSON Server returns the newly created note, for which it has also generated a unique id. However, the data contained in the response still needs to be converted to JSON format using the _response.json()_ method: -export default { - getAll, - createNew, -} +```js +return await response.json() ``` -The method _addNote_ of the component NoteForm changes slightly: +Let's then modify our application's NoteForm component so that a new note is sent to the backend. The component's _addNote_ method will change slightly: ```js -import React from 'react' import { useDispatch } from 'react-redux' import { createNote } from '../reducers/noteReducer' import noteService from '../services/notes' // highlight-line -const NewNote = (props) => { +const NoteForm = (props) => { const dispatch = useDispatch() - const addNote = async (event) => { + const addNote = async (event) => { // highlight-line event.preventDefault() const content = event.target.note.value event.target.note.value = '' @@ -279,56 +326,60 @@ const NewNote = (props) => { ) } -export default NewNote +export default NoteForm ``` -Because the backend generates ids for the notes, we'll change the action creator _createNote_ +When a new note is created in the backend by calling the _createNew()_ method, the return value is an object representing the note, to which the backend has generated a unique id. Therefore, let's modify the action creator createNote defined in notesReducer.js as follows: ```js -export const createNote = (data) => { - return { - type: 'NEW_NOTE', - data, - } -} +const noteSlice = createSlice({ + name: 'notes', + initialState: [], + reducers: { + createNote(state, action) { + state.push(action.payload) // highlight-line + }, + // .. + }, +}) ``` -Changing the importance of notes could be implemented using the same principle, meaning making an asynchronous method call to the server and then dispatching an appropriate action. +Changing the importance of notes could be implemented using the same principle, by making an asynchronous method call to the server and then dispatching an appropriate action. -The current state of the code for the application can be found on [github](https://github.com/fullstack-hy2020/redux-notes/tree/part6-3) in the branch part6-3. +The current state of the code for the application can be found on [GitHub](https://github.com/fullstack-hy2020/redux-notes/tree/part6-4) in the branch part6-4.
    -### Exercises 6.13.-6.14. +### Exercises 6.14.-6.15. -#### 6.13 Anecdotes and the backend, step1 +#### 6.14 Anecdotes and the Backend, step 1 -When the application launches, fetch the anecdotes from the backend implemented using json-server. +When the application launches, fetch the anecdotes from the backend implemented using json-server. Use the Fetch API to make the HTTP request. As the initial backend data, you can use, e.g. [this](https://github.com/fullstack-hy2020/misc/blob/master/anecdotes.json). -#### 6.14 Anecdotes and the backend, step2 +#### 6.15 Anecdotes and the Backend, step 2 -Modify the creation of new anecdotes, such that the anecdotes are stored in the backend. +Modify the creation of new anecdotes, so that the anecdotes are stored in the backend. Utilize the Fetch API in your implementation once again.
    -### Asynchronous actions and redux thunk +### Asynchronous actions and Redux Thunk -Our approach is OK, but it is not great that the communication with the server happens inside the functions of the components. It would be better if the communication could be abstracted away from the components, such that they don't have to do anything else but call the appropriate action creator. As an example, App would initialize the state of the application as follows: +Our approach is quite good, but it is not great that the communication with the server happens inside the functions of the components. It would be better if the communication could be abstracted away from the components so that they don't have to do anything else but call the appropriate action creator. As an example, App would initialize the state of the application as follows: ```js const App = () => { const dispatch = useDispatch() useEffect(() => { - dispatch(initializeNotes())) - },[dispatch]) - + dispatch(initializeNotes()) + }, [dispatch]) + // ... } ``` @@ -336,7 +387,7 @@ const App = () => { and NoteForm would create a new note as follows: ```js -const NewNote = () => { +const NoteForm = () => { const dispatch = useDispatch() const addNote = async (event) => { @@ -350,163 +401,193 @@ const NewNote = () => { } ``` -Both components would only use the function provided to them as a prop without caring about the communication with the server that is happening in the background. +In this implementation, both components would dispatch an action without the need to know about the communication with the server that happens behind the scenes. These kinds of async actions can be implemented using the [Redux Thunk](https://github.com/reduxjs/redux-thunk) library. The use of the library doesn't need any additional configuration or even installation when the Redux store is created using the Redux Toolkit's configureStore function. -Now let's install the [redux-thunk](https://github.com/gaearon/redux-thunk)-library, which enables us to create asynchronous actions. Installation is done with the command: +Thanks to Redux Thunk, it is possible to define action creators that return a function instead of an object. This makes it possible to implement asynchronous action creators that first wait for some asynchronous operation to complete and only then dispatch the actual action. -```js -npm install --save redux-thunk -``` - -The redux-thunk-library is a so-called redux-middleware, which must be initialized along with the initialization of the store. While we're here, let's extract the definition of the store into its own file src/store.js: +If an action creator returns a function, Redux automatically passes the Redux store's dispatch and getState methods as arguments to the returned function. This allows us to define an action creator called initializeNotes in the noteReducer.js file, which fetches the initial notes from the server, as follows: ```js -import { createStore, combineReducers, applyMiddleware } from 'redux' -import thunk from 'redux-thunk' -import { composeWithDevTools } from 'redux-devtools-extension' - -import noteReducer from './reducers/noteReducer' -import filterReducer from './reducers/filterReducer' +import { createSlice } from '@reduxjs/toolkit' +import noteService from '../services/notes' // highlight-line -const reducer = combineReducers({ - notes: noteReducer, - filter: filterReducer, +const noteSlice = createSlice({ + name: 'notes', + initialState: [], + reducers: { + createNote(state, action) { + state.push(action.payload) + }, + toggleImportanceOf(state, action) { + const id = action.payload + const noteToChange = state.find((n) => n.id === id) + const changedNote = { + ...noteToChange, + important: !noteToChange.important, + } + return state.map((note) => (note.id !== id ? note : changedNote)) + }, + setNotes(state, action) { + return action.payload + }, + }, }) -const store = createStore( - reducer, - composeWithDevTools( - applyMiddleware(thunk) - ) -) - -export default store -``` - -After the changes the file src/index.js looks like this - -```js -import React from 'react' -import ReactDOM from 'react-dom' -import { Provider } from 'react-redux' -import store from './store' // highlight-line -import App from './App' - -ReactDOM.render( - - - , - document.getElementById('root') -) -``` +const { setNotes } = noteSlice.actions // highlight-line -Thanks to redux-thunk, it is possible to define action creators so that they return a function having the dispatch-method of redux-store as its parameter. As a result of this, one can make asynchronous action creators, which first wait for some operation to finish, after which they then dispatch the real action. - - - -Now we can define the action creator, initializeNotes, that initializes the state of the notes as follows: - -```js +// highlight-start export const initializeNotes = () => { - return async dispatch => { + return async (dispatch) => { const notes = await noteService.getAll() - dispatch({ - type: 'INIT_NOTES', - data: notes, - }) + dispatch(setNotes(notes)) } } +// highlight-end + +export const { createNote, toggleImportanceOf } = noteSlice.actions // highlight-line + +export default noteSlice.reducer ``` -In the inner function, meaning the asynchronous action, the operation first fetches all the notes from the server and then dispatches the notes to the action, which adds them to the store. +In its inner function, that is, in the asynchronous action, the operation first fetches all notes from the server and then dispatches the action to add the notes to the store. It is noteworthy that Redux automatically passes a reference to the _dispatch_ method as an argument to the function, so the action creator _initializeNotes_ does not require any parameters. + +The action creator _setNotes_ is no longer exported outside the module, since the initial state of the notes will now be set using the asynchronous action creator _initializeNotes_ we created. However, we still use the _setNotes_ action creator within the module. The component App can now be defined as follows: ```js +import { useEffect } from 'react' +import { useDispatch } from 'react-redux' + +import NoteForm from './components/NoteForm' +import Notes from './components/Notes' +import VisibilityFilter from './components/VisibilityFilter' +import { initializeNotes } from './reducers/noteReducer' // highlight-line + const App = () => { const dispatch = useDispatch() - // highlight-start useEffect(() => { - dispatch(initializeNotes()) - },[dispatch]) - // highlight-end + dispatch(initializeNotes()) // highlight-line + }, [dispatch]) return (
    - +
    ) } + +export default App ``` -The solution is elegant. The initialization logic for the notes has been completely separated to outside the React component. +The solution is elegant. The initialization logic for the notes has been completely separated from the React component. -The action creator _createNew_, which adds a new note looks like this +Next, let's create an asynchronous action creator called _appendNote_: ```js -export const createNote = content => { - return async dispatch => { +import { createSlice } from '@reduxjs/toolkit' +import noteService from '../services/notes' + +const noteSlice = createSlice({ + name: 'notes', + initialState: [], + reducers: { + createNote(state, action) { + state.push(action.payload) + }, + toggleImportanceOf(state, action) { + const id = action.payload + const noteToChange = state.find((n) => n.id === id) + const changedNote = { + ...noteToChange, + important: !noteToChange.important, + } + return state.map((note) => (note.id !== id ? note : changedNote)) + }, + setNotes(state, action) { + return action.payload + }, + }, +}) + +const { createNote, setNotes } = noteSlice.actions // highlight-line + +export const initializeNotes = () => { + return async (dispatch) => { + const notes = await noteService.getAll() + dispatch(setNotes(notes)) + } +} + +// highlight-start +export const appendNote = (content) => { + return async (dispatch) => { const newNote = await noteService.createNew(content) - dispatch({ - type: 'NEW_NOTE', - data: newNote, - }) + dispatch(noteSlice.actions.createNote(newNote)) } } +// highlight-end + +export const { toggleImportanceOf } = noteSlice.actions // highlight-line + +export default noteSlice.reducer ``` -The principle here is the same: first an asynchronous operation is executed, after which the action changing the state of the store is dispatched. +The principle is the same once again. First, an asynchronous operation is performed, and once it is completed, an action that updates the store's state is dispatched. The _createNote_ action creator is no longer exported outside the file; it is used only internally in the implementation of the _appendNote_ function. -The component NewNote changes as follows: +The component NoteForm changes as follows: ```js -const NewNote = () => { +import { useDispatch } from 'react-redux' +import { appendNote } from '../reducers/noteReducer' // highlight-line + +const NoteForm = () => { const dispatch = useDispatch() - + const addNote = async (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' - dispatch(createNote(content)) //highlight-line + dispatch(appendNote(content)) // highlight-line } return (
    - +
    ) } ``` -The current state of the code for the application can be found on [github](https://github.com/fullstack-hy2020/redux-notes/tree/part6-4) in the branch part6-4. +The current state of the code for the application can be found on [GitHub](https://github.com/fullstack-hy2020/redux-notes/tree/part6-5) in the branch part6-5. + +Redux Toolkit offers a multitude of tools to simplify asynchronous state management. Suitable tools for this use case are for example the [createAsyncThunk](https://redux-toolkit.js.org/api/createAsyncThunk) function and the [RTK Query](https://redux-toolkit.js.org/rtk-query/overview) API.
    +### Exercises 6.16.-6.19. -### Exercises 6.15.-6.18. - -#### 6.15 Anecdotes and the backend, step3 - -Modify the initialization of redux-store to happen using asynchronous action creators, which are made possible by the redux-thunk-library. +#### 6.16 Anecdotes and the Backend, step 3 -#### 6.16 Anecdotes and the backend, step4 +Modify the initialization of the Redux store to happen using asynchronous action creators, which are made possible by the Redux Thunk library. -Also modify the creation of a new anecdote to happen using asynchronous action creators, made possible by the redux-thunk-library. +#### 6.17 Anecdotes and the Backend, step 4 +Also modify the creation of a new anecdote to happen using asynchronous action creators, made possible by the Redux Thunk library. -#### 6.17 Anecdotes and the backend, step5 +#### 6.18 Anecdotes and the Backend, step 5 -Voting does not yet save changes to the backend. Fix the situation with the help of the redux-thunk-library. +Voting does not yet save changes to the backend. Fix the situation with the help of the Redux Thunk library and the Fetch API. -#### 6.18 Anecdotes and the backend, step6 +#### 6.19 Anecdotes and the Backend, step 6 -The creation of notifications is still a bit tedious, since one has to do two actions and use the _setTimeout_ function: +The creation of notifications is still a bit tedious since one has to do two actions and use the _setTimeout_ function: ```js dispatch(setNotification(`new anecdote '${content}'`)) @@ -515,13 +596,13 @@ setTimeout(() => { }, 5000) ``` -Make an asynchronous action creator, which enables one to provide the notification as follows: +Make an action creator, which enables one to provide the notification as follows: ```js dispatch(setNotification(`you voted '${anecdote.content}'`, 10)) ``` -the first parameter is the text to be rendered and the second parameter is the time to display the notification given in seconds. +The first parameter is the text to be rendered and the second parameter is the time to display the notification given in seconds. Implement the use of this improved notification in your application. diff --git a/src/content/6/en/part6d.md b/src/content/6/en/part6d.md index 7e6874f8392..936c7669898 100644 --- a/src/content/6/en/part6d.md +++ b/src/content/6/en/part6d.md @@ -1,5 +1,5 @@ --- -mainImage: ../../../images/part-5.svg +mainImage: ../../../images/part-6.svg part: 6 letter: d lang: en @@ -7,643 +7,909 @@ lang: en
    - -So far we have used our redux-store with the help of the [hook](https://react-redux.js.org/api/hooks)-api from react-redux. -Practically this has meant using the [useSelector](https://react-redux.js.org/api/hooks#useselector) and [useDispatch](https://react-redux.js.org/api/hooks#usedispatch) functions. +At the end of this part, we will look at a few more different ways to manage the state of an application. -To finish this part we will look into another older and more complicated way to use redux, the [connect](https://github.com/reactjs/react-redux/blob/master/docs/api.md#connectmapstatetoprops-mapdispatchtoprops-mergeprops-options)-function provided by react-redux. +Let's continue with the note application. We will focus on communication with the server. Let's start the application from scratch. The first version is as follows: - -In new applications you should absolutely use the hook-api, but knowing how to use connect is useful when maintaining older projects using redux. - -### Using the connect-function to share the redux store to components - -Let's modify the Notes component so that instead of using the hook-api (the _useDispatch_ and _useSelector_ functions ) it uses the _connect_-function. -We have to modify the following parts of the component: +```js +const App = () => { + const addNote = async (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + console.log(content) + } -````js -import React from 'react' -import { useDispatch, useSelector } from 'react-redux' // highlight-line -import { toggleImportanceOf } from '../reducers/noteReducer' + const toggleImportance = (note) => { + console.log('toggle importance of', note.id) + } -const Notes = () => { - // highlight-start - const dispatch = useDispatch() - const notes = useSelector(({filter, notes}) => { - if ( filter === 'ALL' ) { - return notes - } - return filter === 'IMPORTANT' - ? notes.filter(note => note.important) - : notes.filter(note => !note.important) - }) - // highlight-end + const notes = [] - return( -
      - {notes.map(note => - - dispatch(toggleImportanceOf(note.id)) // highlight-line - } - /> - )} -
    + return ( +
    +

    Notes app

    +
    + + +
    + {notes.map((note) => ( +
  • toggleImportance(note)}> + {note.content} + {note.important ? 'important' : ''} +
  • + ))} +
    ) } -export default Notes -```` +export default App +``` -The _connect_ function can be used for transforming "regular" React components so that the state of the Redux store can be "mapped" into the component's props. +The initial code is on GitHub in this [repository](https://github.com/fullstack-hy2020/query-notes/tree/part6-0), in the branch part6-0. -Let's first use the connect function to transform our Notes component into a connected component: +### Managing data on the server with the React Query library -```js -import React from 'react' -import { connect } from 'react-redux' // highlight-line -import { toggleImportanceOf } from '../reducers/noteReducer' +We shall now use the [React Query](https://tanstack.com/query/latest) library to store and manage data retrieved from the server. The latest version of the library is also called TanStack Query, but we stick to the familiar name. -const Notes = () => { - // ... -} +Install the library with the command -const ConnectedNotes = connect()(Notes) // highlight-line -export default ConnectedNotes // highlight-line +```bash +npm install @tanstack/react-query ``` -The module exports the connected component that works exactly like the previous regular component for now. +A few additions to the file main.jsx are needed to pass the library functions to the entire application: -The component needs the list of notes and the value of the filter from the Redux store. The _connect_ function accepts a so-called [mapStateToProps](https://github.com/reduxjs/react-redux/blob/master/docs/api/connect.md#mapstatetoprops-state-ownprops--object) function as its first parameter. The function can be used for defining the props of the connected component that are based on the state of the Redux store. +```js +import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' // highlight-line + +import App from './App.jsx' -If we define: +const queryClient = new QueryClient() // highlight-line +createRoot(document.getElementById('root')).render( + // highlight-line + + // highlight-line +) +``` + +Let's use [JSON Server](https://github.com/typicode/json-server) as in the previous parts to simulate the backend. JSON Server is preconfigured in the example project, and the project root contains a file db.json that by default has two notes. You can start the server with: ```js -const Notes = (props) => { // highlight-line - const dispatch = useDispatch() +npm run server +``` -// highlight-start - const notesToShow = () => { - if ( props.filter === 'ALL ') { - return props.notes +We can now retrieve the notes in the App component. The code expands as follows: + +```js +import { useQuery } from '@tanstack/react-query' // highlight-line + +const App = () => { + const addNote = async (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + console.log(content) + } + + const toggleImportance = (note) => { + console.log('toggle importance of', note.id) + } + + // highlight-start + const result = useQuery({ + queryKey: ['notes'], + queryFn: async () => { + const response = await fetch('http://localhost:3001/notes') + if (!response.ok) { + throw new Error('Failed to fetch notes') + } + return await response.json() } - - return props.filter === 'IMPORTANT' - ? props.notes.filter(note => note.important) - : props.notes.filter(note => !note.important) + }) + + console.log(JSON.parse(JSON.stringify(result))) + + if (result.isLoading) { + return
    loading data...
    } + + const notes = result.data // highlight-end - return( -
      - {notesToShow().map(note => // highlight-line - - dispatch(toggleImportanceOf(note.id)) - } - /> - )} -
    + return ( + // ... ) } - -const mapStateToProps = (state) => { - return { - notes: state.notes, - filter: state.filter, - } -} - -const ConnectedNotes = connect(mapStateToProps)(Notes) // highlight-line - -export default ConnectedNotes ``` +Fetching data from the server is done, as in the previous chapter, using the Fetch API's fetch method. However, the method call is now wrapped into a [query](https://tanstack.com/query/latest/docs/react/guides/queries) formed by the [useQuery](https://tanstack.com/query/latest/docs/react/reference/useQuery) function. The call to useQuery takes as its parameter an object with the fields queryKey and queryFn. The value of the queryKey field is an array containing the string notes. It acts as the [key](https://tanstack.com/query/latest/docs/react/guides/query-keys) for the defined query, i.e. the list of notes. -The Notes component can access the state of the store directly, e.g. through props.notes that contains the list of notes. Similarly, props.filter references the value of the filter. +The return value of the useQuery function is an object that indicates the status of the query. The output to the console illustrates the situation: -The situation that results from using connect with the mapStateToProps function we defined can be visualized like this: +![browser devtools showing success status](../../images/6/60new.png) -![](../../images/6/24c.png) +That is, the first time the component is rendered, the query is still in loading state, i.e. the associated HTTP request is pending. At this stage, only the following is rendered: +```html +
    loading data...
    +``` -The Notes component has "direct access" via props.notes and props.filter for inspecting the state of the Redux store. - -The _NoteList_ component actually does not need the information about which filter is selected, so we can move the filtering logic elsewhere. -We just have to give it correctly filtered notes in the _notes_ prop: +However, the HTTP request is completed so quickly that not even Max Verstappen would be able to see the text. When the request is completed, the component is rendered again. The query is in the state success on the second rendering, and the field data of the query object contains the data returned by the request, i.e. the list of notes that is rendered on the screen. -```js -const Notes = (props) => { // highlight-line - const dispatch = useDispatch() +So the application retrieves data from the server and renders it on the screen without using the React hooks useState and useEffect used in chapters 2-5 at all. The data on the server is now entirely under the administration of the React Query library, and the application does not need the state defined with React's useState hook at all! - return( -
      - {props.notes.map(note => - - dispatch(toggleImportanceOf(note.id)) - } - /> - )} -
    - ) -} +Let's move the function making the actual HTTP request to its own file src/requests.js -// highlight-start -const mapStateToProps = (state) => { - if ( state.filter === 'ALL' ) { - return { - notes: state.notes - } - } +```js +const baseUrl = 'http://localhost:3001/notes' - return { - notes: (state.filter === 'IMPORTANT' - ? state.notes.filter(note => note.important) - : state.notes.filter(note => !note.important) - ) +export const getNotes = async () => { + const response = await fetch(baseUrl) + if (!response.ok) { + throw new Error('Failed to fetch notes') } + return await response.json() } -// highlight-end +``` -const ConnectedNotes = connect(mapStateToProps)(Notes) -export default ConnectedNotes - ``` +The App component is now slightly simplified: -### mapDispatchToProps +```js +import { useQuery } from '@tanstack/react-query' +import { getNotes } from './requests' // highlight-line -Now we have gotten rid of _useSelector_, but Notes still uses the _useDispatch_ hook and the _dispatch_ function returning it: +const App = () => { + // ... -```js -const Notes = (props) => { - const dispatch = useDispatch() // highlight-line + const result = useQuery({ + queryKey: ['notes'], + queryFn: getNotes // highlight-line + }) - return( -
      - {props.notes.map(note => - - dispatch(toggleImportanceOf(note.id)) // highlight-line - } - /> - )} -
    - ) + // ... } ``` -The second parameter of the _connect_ function can be used for defining [mapDispatchToProps](https://github.com/reduxjs/react-redux/blob/master/docs/api/connect.md#mapdispatchtoprops-object--dispatch-ownprops--object) which is a group of action creator functions passed to the connected component as props. Let's make the following changes to our existing connect operation: +The current code for the application is in [GitHub](https://github.com/fullstack-hy2020/query-notes/tree/part6-1) in the branch part6-1. + +### Synchronizing data to the server using React Query +Data is already successfully retrieved from the server. Next, we will make sure that the added and modified data is stored on the server. Let's start by adding new notes. + +Let's make a function createNote to the file requests.js for saving new notes: ```js -const mapStateToProps = (state) => { - return { - notes: state.notes, - filter: state.filter, +const baseUrl = 'http://localhost:3001/notes' + +export const getNotes = async () => { + const response = await fetch(baseUrl) + if (!response.ok) { + throw new Error('Failed to fetch notes') } + return await response.json() } // highlight-start -const mapDispatchToProps = { - toggleImportanceOf, +export const createNote = async (newNote) => { + const options = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newNote) + } + + const response = await fetch(baseUrl, options) + + if (!response.ok) { + throw new Error('Failed to create note') + } + + return await response.json() } // highlight-end - -const ConnectedNotes = connect( - mapStateToProps, - mapDispatchToProps // highlight-line -)(Notes) - -export default ConnectedNotes ``` -Now the component can directly dispatch the action defined by the _toggleImportanceOf_ action creator by calling the function through its props: +The App component will change as follows ```js -const Notes = (props) => { - return( -
      - {props.notes.map(note => - props.toggleImportanceOf(note.id)} - /> - )} -
    - ) +import { useQuery, useMutation } from '@tanstack/react-query' // highlight-line +import { getNotes, createNote } from './requests' // highlight-line + +const App = () => { + //highlight-start + const newNoteMutation = useMutation({ + mutationFn: createNote, + }) + // highlight-end + + const addNote = async (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + newNoteMutation.mutate({ content, important: true }) // highlight-line + } + + // + } ``` -This means that instead of dispatching the action like this: +To create a new note, a [mutation](https://tanstack.com/query/latest/docs/react/guides/mutations) is defined using the function [useMutation](https://tanstack.com/query/latest/docs/react/reference/useMutation): ```js -dispatch(toggleImportanceOf(note.id)) +const newNoteMutation = useMutation({ + mutationFn: createNote, +}) ``` -When using _connect_ we can simply do this: +The parameter is the function we added to the file requests.js, which uses Fetch API to send a new note to the server. + +The event handler addNote performs the mutation by calling the mutation object's function mutate and passing the new note as an argument: ```js -props.toggleImportanceOf(note.id) +newNoteMutation.mutate({ content, important: true }) ``` -There is no need to call the _dispatch_ function separately since _connect_ has already modified the _toggleImportanceOf_ action creator into a form that contains the dispatch. +Our solution is good. Except it doesn't work. The new note is saved on the server, but it is not updated on the screen. + +In order to render a new note as well, we need to tell React Query that the old result of the query whose key is the string notes should be [invalidated](https://tanstack.com/query/latest/docs/react/guides/invalidations-from-mutations). -It can take some to time to wrap your head around how _mapDispatchToProps_ works, especially once we take a look at an [alternative way of using it](/en/part6/connect#alternative-way-of-using-map-dispatch-to-props). +Fortunately, invalidation is easy, it can be done by defining the appropriate onSuccess callback function to the mutation: -The resulting situation from using _connect_ can be visualized like this: +```js +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' // highlight-line +import { getNotes, createNote } from './requests' -![](../../images/6/25b.png) +const App = () => { + const queryClient = useQueryClient() // highlight-line -In addition to accessing the store's state via props.notes and props.filter, the component also references a function that can be used for dispatching TOGGLE\_IMPORTANCE-type actions via its toggleImportanceOf prop. + const newNoteMutation = useMutation({ + mutationFn: createNote, + onSuccess: () => { // highlight-line + queryClient.invalidateQueries({ queryKey: ['notes'] }) // highlight-line + }, // highlight-line + }) -The code for the newly refactored Notes component looks like this: + // ... +} +``` + +Now that the mutation has been successfully executed, a function call is made to ```js -import React from 'react' -import { connect } from 'react-redux' -import { toggleImportanceOf } from '../reducers/noteReducer' +queryClient.invalidateQueries({ queryKey: ['notes'] }) +``` -const Notes = (props) => { - return( -
      - {props.notes.map(note => - props.toggleImportanceOf(note.id)} - /> - )} -
    - ) -} +This in turn causes React Query to automatically update a query with the key notes, i.e. fetch the notes from the server. As a result, the application renders the up-to-date state on the server, i.e. the added note is also rendered. -const mapStateToProps = (state) => { - if ( state.filter === 'ALL' ) { - return { - notes: state.notes - } +Let us also implement the change in the importance of notes. A function for updating notes is added to the file requests.js: + +```js +export const updateNote = async (updatedNote) => { + const options = { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updatedNote) } - return { - notes: (state.filter === 'IMPORTANT' - ? state.notes.filter(note => note.important) - : state.notes.filter(note => !note.important) - ) + const response = await fetch(`${baseUrl}/${updatedNote.id}`, options) + + if (!response.ok) { + throw new Error('Failed to update note') } -} -const mapDispatchToProps = { - toggleImportanceOf + return await response.json() } - -export default connect( - mapStateToProps, - mapDispatchToProps -)(Notes) ``` -Let's also use _connect_ to create new notes: +Updating the note is also done by mutation. The App component expands as follows: ```js -import React from 'react' -import { connect } from 'react-redux' -import { createNote } from '../reducers/noteReducer' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { getNotes, createNote, updateNote } from './requests' // highlight-line + +const App = () => { + const queryClient = useQueryClient() + + const newNoteMutation = useMutation({ + mutationFn: createNote, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notes'] }) + } + }) + + // highlight-start + const updateNoteMutation = useMutation({ + mutationFn: updateNote, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notes'] }) + } + }) + // highlight-end -const NewNote = (props) => { // highlight-line - const addNote = async (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' - props.createNote(content) // highlight-line + newNoteMutation.mutate({ content, important: true }) } - return ( -
    - - -
    - ) -} + const toggleImportance = (note) => { + updateNoteMutation.mutate({...note, important: !note.important }) // highlight-line + } -// highlight-start -export default connect( - null, - { createNote } -)(NewNote) -// highlight-end + // ... +} ``` -Since the component does not need to access the store's state, we can simply pass null as the first parameter to _connect_. +So again, a mutation was created that invalidated the query notes so that the updated note is rendered correctly. Using mutations is easy, the method mutate receives a note as a parameter, the importance of which is been changed to the negation of the old value. +The current code for the application is on [GitHub](https://github.com/fullstack-hy2020/query-notes/tree/part6-2) in the branch part6-2. -You can find the code for our current application in its entirety in the part6-5 branch of [this Github repository](https://github.com/fullstack-hy2020/redux-notes/tree/part6-5). +### Optimizing the performance -### Referencing action creators passed as props - -Let's direct our attention to one interesting detail in the NewNote component: +The application works well, and the code is relatively simple. The ease of making changes to the list of notes is particularly surprising. For example, when we change the importance of a note, invalidating the query notes is enough for the application data to be updated: ```js -import React from 'react' -import { connect } from 'react-redux' -import { createNote } from '../reducers/noteReducer' // highlight-line - -const NewNote = (props) => { - - const addNote = async (event) => { - event.preventDefault() - const content = event.target.note.value - event.target.note.value = '' - props.createNote(content) // highlight-line +const updateNoteMutation = useMutation({ + mutationFn: updateNote, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notes'] }) // highlight-line } - - return ( -
    - - -
    - ) -} - -export default connect( - null, - { createNote } // highlight-line -)(NewNote) +}) ``` -Developers who are new to connect may find it puzzling that there are two versions of the createNote action creator in the component. +The consequence of this, of course, is that after the PUT request that causes the note change, the application makes a new GET request to retrieve the query data from the server: +![devtools network tab with highlight over 3 and notes requests](../../images/6/61new.png) -The function must be referenced as props.createNote through the component's props, as this is the version that contains the automatic dispatch added by _connect_. +If the amount of data retrieved by the application is not large, it doesn't really matter. After all, from a browser-side functionality point of view, making an extra HTTP GET request doesn't really matter, but in some situations it might put a strain on the server. +If necessary, it is also possible to optimize performance [by manually updating](https://tanstack.com/query/latest/docs/react/guides/updates-from-mutation-responses) the query state maintained by React Query. -Due to the way that the action creator is imported: +The change for the mutation adding a new note is as follows: ```js -import { createNote } from './../reducers/noteReducer' +const App = () => { + const queryClient = useQueryClient() + + const newNoteMutation = useMutation({ + mutationFn: createNote, + // highlight-start + onSuccess: (newNote) => { + const notes = queryClient.getQueryData('notes') + queryClient.setQueryData('notes', notes.concat(newNote)) + // highlight-end + } + }) + + // ... +} ``` -The action creator can also be referenced directly by calling _createNote_. You should not do this, since this is the unmodified version of the action creator that does not contain the added automatic dispatch. -If we print the functions to the console from the code (we have not yet looked at this useful debugging trick): +That is, in the onSuccess callback, the queryClient object first reads the existing notes state of the query and updates it by adding a new note, which is obtained as a parameter of the callback function. The value of the parameter is the value returned by the function createNote, defined in the file requests.js as follows: ```js -const NewNote = (props) => { - console.log(createNote) - console.log(props.createNote) +export const createNote = async (newNote) => { + const options = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newNote) + } - const addNote = (event) => { - event.preventDefault() - const content = event.target.note.value - event.target.note.value = '' - props.createNote(content) + const response = await fetch(baseUrl, options) + + if (!response.ok) { + throw new Error('Failed to create note') } + return await response.json() +} +``` + +It would be relatively easy to make a similar change to a mutation that changes the importance of the note, but we leave it as an optional exercise. + +Finally, note an interesting detail. React Query refetches all notes when we switch to another browser tab and then return to the application's tab. This can be observed in the Network tab of the Developer Console: + +![dev tools notes app with an arrow in a new tab and another arrow on console's network tab over notes request as 200](../../images/6/62new-2025.png) + +What is going on? By reading the [documentation](https://tanstack.com/query/latest/docs/react/reference/useQuery), we notice that the default functionality of React Query's queries is that the queries (whose status is stale) are updated when window focus changes. If we want, we can turn off the functionality by creating a query as follows: + +```js +const App = () => { + // ... + const result = useQuery({ + queryKey: ['notes'], + queryFn: getNotes, + refetchOnWindowFocus: false // highlight-line + }) + // ... } ``` -We can see the difference between the two functions: +If you put a console.log statement to the code, you can see from browser console how often React Query causes the application to be re-rendered. The rule of thumb is that rerendering happens at least whenever there is a need for it, i.e. when the state of the query changes. You can read more about it e.g. [here](https://tkdodo.eu/blog/react-query-render-optimizations). + +The code for the application is in [GitHub](https://github.com/fullstack-hy2020/query-notes/tree/part6-3) in the branch part6-3. + +React Query is a versatile library that, based on what we have already seen, simplifies the application. Does React Query make more complex state management solutions such as Redux unnecessary? No. React Query can partially replace the state of the application in some cases, but as the [documentation](https://tanstack.com/query/latest/docs/react/guides/does-this-replace-client-state) states + +- React Query is a server-state library, responsible for managing asynchronous operations between your server and client +- Redux, etc. are client-state libraries that can be used to store asynchronous data, albeit inefficiently when compared to a tool like React Query + +So React Query is a library that maintains the server state in the frontend, i.e. acts as a cache for what is stored on the server. React Query simplifies the processing of data on the server, and can in some cases eliminate the need for data on the server to be saved in the frontend state. + +Most React applications need not only a way to temporarily store the served data, but also some solution for how the rest of the frontend state (e.g. the state of forms or notifications) is handled. + +
    + +
    + +### Exercises 6.20.-6.22. + +Now let's make a new version of the anecdote application that uses the React Query library. Take [this project](https://github.com/fullstack-hy2020/query-anecdotes) as your starting point. The project has a ready-installed JSON Server, the operation of which has been slightly modified (Review the _server.js_ file for more details. Make sure you're connecting to the correct _PORT_). Start the server with npm run server. + +Use the Fetch API to make requests. + +NOTE: Part 6 was updated on 12th of Octorber 2025 to use the Fetch API, which is introduced in part 6c. If you started working through this part before that date, you may still use Axios in the exercises if you prefer. -![](../../images/6/10.png) +#### Exercise 6.20 -The first function is a regular action creator whereas the second function contains the additional dispatch to the store that was added by connect. +Implement retrieving anecdotes from the server using React Query. -Connect is an incredibly useful tool although it may seem difficult at first due to its level of abstraction. +The application should work in such a way that if there are problems communicating with the server, only an error page will be displayed: -### Alternative way of using mapDispatchToProps +![browser saying anecdote service not available due to problems in server on localhost](../../images/6/65new.png) -We defined the function for dispatching actions from the connected NewNote component in the following way: +You can find [here](https://tanstack.com/query/latest/docs/react/guides/queries) info how to detect the possible errors. + +You can simulate a problem with the server by e.g. turning off the JSON Server. Please note that in a problem situation, the query is first in the state isLoading for a while, because if a request fails, React Query tries the request a few times before it states that the request is not successful. You can optionally specify that no retries are made: ```js -const NewNote = () => { - // ... -} +const result = useQuery( + { + queryKey: ['anecdotes'], + queryFn: getAnecdotes, + retry: false + } +) +``` + +or that the request is retried e.g. only once: -export default connect( - null, - { createNote } -)(NewNote) +```js +const result = useQuery( + { + queryKey: ['anecdotes'], + queryFn: getAnecdotes, + retry: 1 + } +) ``` +#### Exercise 6.21 + +Implement adding new anecdotes to the server using React Query. The application should render a new anecdote by default. Note that the content of the anecdote must be at least 5 characters long, otherwise the server will reject the POST request. You don't have to worry about error handling now. + +#### Exercise 6.22 + +Implement voting for anecdotes using again the React Query. The application should automatically render the increased number of votes for the voted anecdote. + +
    + +
    + +### useReducer -The connect expression above enables the component to dispatch actions for creating new notes with the props.createNote('a new note') command. +So even if the application uses React Query, some kind of solution is usually needed to manage the rest of the frontend state (for example, the state of forms). Quite often, the state created with useState is a sufficient solution. Using Redux is of course possible, but there are other alternatives. +Let's look at a simple counter application. The application displays the counter value, and offers three buttons to update the counter status: -The functions passed in mapDispatchToProps must be action creators, that is, functions that return Redux actions. +![browser showing + - 0 buttons and 7 above](../../images/6/63new.png) +We shall now implement the counter state management using a Redux-like state management mechanism provided by React's built-in [useReducer](https://react.dev/reference/react/useReducer) hook. -It is worth noting that the mapDispatchToProps parameter is a JavaScript object, as the definition: +The application's initial code is on [GitHub](https://github.com/fullstack-hy2020/hook-counter/tree/part6-1) in the branch part6-1. The file App.jsx looks as follows: ```js -{ - createNote +import { useReducer } from 'react' + +const counterReducer = (state, action) => { + switch (action.type) { + case 'INC': + return state + 1 + case 'DEC': + return state - 1 + case 'ZERO': + return 0 + default: + return state + } +} + +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) + + return ( +
    +
    {counter}
    +
    + + + +
    +
    + ) } + +export default App +``` + +The hook [useReducer](https://react.dev/reference/react/useReducer) provides a mechanism to create a state for an application. The parameter for creating a state is the reducer function that handles state changes, and the initial value of the state: + +```js +const [counter, counterDispatch] = useReducer(counterReducer, 0) ``` -Is just shorthand for defining the object literal: +The reducer function that handles state changes is similar to Redux's reducers, i.e. the function gets as parameters the current state and the action that changes the state. The function returns the new state updated based on the type and possible contents of the action: ```js -{ - createNote: createNote +const counterReducer = (state, action) => { + switch (action.type) { + case 'INC': + return state + 1 + case 'DEC': + return state - 1 + case 'ZERO': + return 0 + default: + return state + } } ``` -Which is an object that has a single createNote property with the createNote function as its value. +In our example, actions have nothing but a type. If the action's type is INC, it increases the value of the counter by one, etc. Like Redux's reducers, actions can also contain arbitrary data, which is usually put in the action's payload field. -Alternatively, we could pass the following function definition as the second parameter to _connect_: +The function useReducer returns an array that contains an element to access the current value of the state (first element of the array), and a dispatch function (second element of the array) to change the state: ```js -const NewNote = (props) => { - // ... -} +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) // highlight-line -// highlight-start -const mapDispatchToProps = dispatch => { - return { - createNote: value => { - dispatch(createNote(value)) - }, - } + return ( +
    +
    {counter}
    // highlight-line +
    + // highlight-line + + +
    +
    + ) } -// highlight-end +``` -export default connect( - null, - mapDispatchToProps -)(NewNote) +As can be seen the state change is done exactly as in Redux, the dispatch function is given the appropriate state-changing action as a parameter: + +```js +counterDispatch({ type: "INC" }) ``` +### Passing state via props +When the application is split into multiple components, the counter value and the dispatch function used to manage it must somehow be passed to the other components as well. One solution is to pass these as props in the usual way. + +Let's define a separate Display component for the application, whose responsibility is to show the counter value. The contents of the file src/components/Display.jsx should be: -In this alternative definition, mapDispatchToProps is a function that _connect_ will invoke by passing it the _dispatch_-function as its parameter. The return value of the function is an object that defines a group of functions that get passed to the connected component as props. Our example defines the function passed as the createNote prop: ```js -value => { - dispatch(createNote(value)) +const Display = ({ counter }) => { + return
    {counter}
    } + +export default Display ``` +Additionally, let's define a Button component that is responsible for the application's buttons: -Which simply dispatches the action created with the createNote action creator. +```js +const Button = ({ dispatch, type, label }) => { + return ( + + ) +} -The component then references the function through its props by calling props.createNote: +export default Button +``` + +The file App.jsx changes as follows: ```js -const NewNote = (props) => { - const addNote = (event) => { - event.preventDefault() - const content = event.target.note.value - event.target.note.value = '' - props.createNote(content) +import { useReducer } from 'react' + +import Button from './components/Button' // highlight-line +import Display from './components/Display' // highlight-line + +const counterReducer = (state, action) => { + switch (action.type) { + case 'INC': + return state + 1 + case 'DEC': + return state - 1 + case 'ZERO': + return 0 + default: + return state } +} + +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) return ( -
    - - -
    +
    + // highlight-line +
    + // highlight-start +
    +
    ) } ``` +The application has now been split into multiple components. The state management is defined in the file App.jsx, from which the values and functions needed for state management are passed to child components as props. -The concept is quite complex and describing it through text is challenging. In most cases it is sufficient to use the simpler form of mapDispatchToProps. However, there are situations where the more complicated definition is necessary, like if the dispatched actions need to reference [the props of the component](https://github.com/gaearon/redux-devtools/issues/250#issuecomment-186429931). +The solution works, but is not optimal. If the component structure gets complicated, e.g. the dispatcher should be forwarded using props through many components to the components that need it, even though the components in between in the component tree do not need the dispatcher. This phenomenon is called prop drilling. -The creator of Redux Dan Abramov has created a wonderful tutorial called [Getting started with Redux](https://egghead.io/courses/getting-started-with-redux) that you can find on Egghead.io. I highly recommend the tutorial to everyone. The last four videos discuss the _connect_ method, particularly the more "complicated" way of using it. +### Using context for passing the state to components -### Presentational/Container revisited +React's built-in [Context API](https://react.dev/learn/passing-data-deeply-with-context) provides a solution for us. React's context is a kind of global state of the application, to which it is possible to give direct access to any component app. -The refactored Notes component is almost entirely focused on rendering notes and is quite close to being a so-called [presentational component](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0). According to the [description](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) provided by Dan Abramov, presentation components: +Let us now create a context in the application that stores the state management of the counter. -- Are concerned with how things look. -- May contain both presentational and container components inside, and usually have some DOM markup and styles of their own. -- Often allow containment via props.children. -- Have no dependencies on the rest of the app, such as Redux actions or stores. -- Don’t specify how the data is loaded or mutated. -- Receive data and callbacks exclusively via props. -- Rarely have their own state (when they do, it’s UI state rather than data). -- Are written as functional components unless they need state, lifecycle hooks, or performance optimizations. +The context is created with React's hook [createContext](https://react.dev/reference/react/createContext). Let's create a context in the file src/CounterContext.jsx: -The _connected component_ that is created with the _connect_ function: +```js +import { createContext } from 'react' + +const CounterContext = createContext() + +export default CounterContext +``` + +The App component can now provide a context to its child components as follows: ```js -const mapStateToProps = (state) => { - if ( state.filter === 'ALL' ) { - return { - notes: state.notes - } - } +import { useReducer } from 'react' - return { - notes: (state.filter === 'IMPORTANT' - ? state.notes.filter(note => note.important) - : state.notes.filter(note => !note.important) - ) - } -} +import Button from './components/Button' +import Display from './components/Display' +import CounterContext from './CounterContext' // highlight-line + +// ... -const mapDispatchToProps = { - toggleImportanceOf, +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) + + return ( + // highlight-line + // highlight-line +
    + // highlight-start +
    +
    // highlight-line + ) } +``` + +As can be seen, providing the context is done by wrapping the child components inside the CounterContext.Provider component and setting a suitable value for the context. + +The context value is now an object with the attributes counter and counterDispatch. The counter field contains the counter's value and counterDispatch the dispatch function used to change the value. -export default connect( - mapStateToProps, - mapDispatchToProps -)(Notes) +Other components can now access the context using the [useContext](https://react.dev/reference/react/useContext) hook. The Display component changes as follows: + +```js +import { useContext } from 'react' // highlight-line +import CounterContext from './CounterContext' // highlight-line + +const Display = () => { // highlight-line + const { counter } = useContext(CounterContext) // highlight-line + + return
    {counter}
    +} ``` -Fits the description of a container component. According to the [description](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) provided by Dan Abramov, container components: +Display component therefore no longer needs props; it obtains the counter value by calling the useContext hook with the CounterContext object as its argument. -- Are concerned with how things work. -- May contain both presentational and container components inside but usually don’t have any DOM markup of their own except for some wrapping divs, and never have any styles. -- Provide the data and behavior to presentational or other container components. -- Call Redux actions and provide these as callbacks to the presentational components. -- Are often stateful, as they tend to serve as data sources. -- Are usually generated using higher order components such as connect from React Redux, rather than written by hand. +Similarly, the Button component becomes: -Dividing the application into presentational and container components is one way of structuring React applications that has been deemed beneficial. The division may be a good design choice or it may not, it depends on the context. +```js +import { useContext } from 'react' // highlight-line +import CounterContext from './CounterContext' // highlight-line + +const Button = ({ type, label }) => { // highlight-line + const { counterDispatch } = useContext(CounterContext) // highlight-line + + return ( + + ) +} +``` -Abramov attributes the following [benefits](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) to the division: +Components therefore receive the value provided by the context provider. In this case the context is an object with a field counter that represents the counter's value and a field counterDispatch that is the dispatch function used to change the counter's state. -- Better separation of concerns. You understand your app and your UI better by writing components this way. -- Better reusability. You can use the same presentational component with completely different state sources, and turn those into separate container components that can be further reused. -- Presentational components are essentially your app’s “palette”. You can put them on a single page and let the designer tweak all their variations without touching the app’s logic. You can run screenshot regression tests on that page. +Components access the attributes they need using JavaScript's destructuring syntax: -Abramov mentions the term [high order component](https://reactjs.org/docs/higher-order-components.html). The Notes component is an example of a regular component, whereas the connect method provided by React-Redux is an example of a high order component. Essentially, a high order component is a function that accept a "regular" component as its parameter, that then returns a new "regular" component as its return value. +```js +const { counter } = useContext(CounterContext) +``` -High order components, or HOCs, are a way of defining generic functionality that can be applied to components. This is a concept from functional programming that very slightly resembles inheritance in object oriented programming. +The current code for the application is in [GitHub](https://github.com/fullstack-hy2020/hook-counter/tree/part6-2) in the branch part6-2. -HOCs are in fact a generalization of the [High Order Function](https://en.wikipedia.org/wiki/Higher-order_function) (HOF) concept. HOFs are functions that either accept functions as parameters or return functions. We have actually been using HOFs throughout the course, e.g. all of the methods used for dealing with arrays like _map, filter and find_ are HOFs. +### Defining the counter context in a separate file - -After the React hook-api was published, HOCs have become less and less popular. Almost all libraries which used to be based on HOCs have now been modified to use hooks. Most of the time hook based apis are a lot simpler than HOC based ones, as is the case with redux as well. +Our application has an annoying feature, that the functionality of the counter state management is partly defined in the App component. Now let's move everything related to the counter to CounterContext.jsx: -### Redux and the component state +```js +import { createContext, useReducer } from 'react' + +const counterReducer = (state, action) => { + switch (action.type) { + case 'INC': + return state + 1 + case 'DEC': + return state - 1 + case 'ZERO': + return 0 + default: + return state + } +} -We have come a long way in this course and, finally, we have come to the point at which we are using React "the right way", meaning React only focuses on generating the views, and the application state is separated completely from the React components and passed on to Redux, its actions, and its reducers. +const CounterContext = createContext() -What about the _useState_-hook, which provides components with their own state? Does it have any role if an application is using Redux or some other external state management solution? If the application has more complicated forms, it may be beneficial to implement their local state using the state provided by the _useState_ function. One can, of course, have Redux manage the state of the forms, however, if the state of the form is only relevant when filling the form (e.g. for validation) it may be wise to leave the management of state to the component responsible for the form. +export const CounterContextProvider = (props) => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) - -Should we always use redux? Probably not. Dan Abramov, the developer of redux, discusses this in his article [You Might Not Need Redux](https://medium.com/@dan_abramov/you-might-not-need-redux-be46360cf367). + return ( + + {props.children} + + ) +} -Nowadays it is possible to implement redux-like state management without redux by using the React [context](https://reactjs.org/docs/context.html)-api and the [useReducer](https://reactjs.org/docs/hooks-reference.html#usereducer)-hook. -More about this [here](https://www.simplethread.com/cant-replace-redux-with-hooks/) and [here](https://hswolff.com/blog/how-to-usecontext-with-usereducer/). We will also practice this in -[part 9](/en/part9). +export default CounterContext +``` -
    +The file now exports, in addition to the CounterContext object corresponding to the context, the CounterContextProvider component, which is practically a context provider whose value is a counter and a dispatcher used for its state management. -
    +Let's enable the context provider by making a change in main.jsx: -### Exercises 6.19.-6.21. +```js +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' -#### 6.19 anecdotes and connect, step1 +import App from './App' +import { CounterContextProvider } from './CounterContext' // highlight-line -The redux store is currently passed to all of the components through props. +createRoot(document.getElementById('root')).render( + + // highlight-line + + // highlight-line + +) -Add the [react-redux](https://github.com/reactjs/react-redux) package to your application, and modify the AnecdoteList so that it accesses the store's state with the help of the _connect_ function. +``` -Voting for and creating new anecdotes **does not need to work** after this exercise. +Now the context defining the value and functionality of the counter is available to all components of the application. -The mapStateToProps function you will need in this exercise is approximately the following: +The App component is simplified to the following form: ```js -const mapStateToProps = (state) => { - // sometimes it is useful to console log from mapStateToProps - console.log(state) - return { - anecdotes: state.anecdotes, - filter: state.filter - } +import Button from './components/Button' +import Display from './components/Display' + +const App = () => { + return ( +
    + +
    +
    +
    + ) } + +export default App ``` -#### 6.20 anecdotes and connect, step2 +The context is still used in the same way, and no changes are needed in the other components. For example, the Button component is defined as follows: + +```js +import { useContext } from 'react' +import CounterContext from '../CounterContext' -Do the same for the Filter and AnecdoteForm components. +const Button = ({ type, label }) => { + const { counterDispatch } = useContext(CounterContext) + + return ( + + ) +} -#### 6.21 anecdotes, the grand finale +export default Button +``` - -You (probably) have one nasty bug in your application. If the user clicks the vote button multiple times in a row, the notification is displayed funnily. For example if a user votes twice in three seconds, -the last notification is only displayed for two seconds (assuming the notification is normally shown for 5 seconds). This happens because removing the first notification accidentally removes the second notification. +The solution is quite elegant. The entire state of the application, i.e. the value of the counter and the code for managing it, is now isolated in the file CounterContext. Components access the part of the context they need by using the useContext hook and JavaScript's destructuring syntax. - -Fix the bug so that after multiple votes in a row, the notification for the last vote is displayed for five seconds. -This can be done by cancelling the removal of the previous notification when a new notification is displayed whenever necessary. -The [documentation](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout) for the setTimeout function might also be useful for this. +The final code for the application is in [GitHub](https://github.com/fullstack-hy2020/hook-counter/tree/part6-3) in the branch part6-3.
    +### Exercises 6.23.-6.24. + +#### Exercise 6.23. + +The application has a Notification component for displaying notifications to the user. + +Implement the application's notification state management using the useReducer hook and context. The notification should tell the user when a new anecdote is created or an anecdote is voted on: + +![browser showing notification for added anecdote](../../images/6/66new.png) + +The notification is displayed for five seconds. + +#### Exercise 6.24. + +As stated in exercise 6.21, the server requires that the content of the anecdote to be added is at least 5 characters long. Now implement error handling for the insertion. In practice, it is sufficient to display a notification to the user in case of a failed POST request: + +![browser showing error notification for trying to add too short of an anecdoate](../../images/6/67new.png) + +The error condition should be handled in the callback function registered for it, see [here](https://tanstack.com/query/latest/docs/react/reference/useMutation) how to register a function. + This was the last exercise for this part of the course and it's time to push your code to GitHub and mark all of your completed exercises to the [exercise submission system](https://studies.cs.helsinki.fi/stats/courses/fullstackopen).
    + +
    + +### Which state management solution to choose? + +In chapters 1-5, all state management of the application was done using React's hook useState. Asynchronous calls to the backend required the use of the useEffect hook in some situations. In principle, nothing else is needed. + +A subtle problem with a solution based on a state created with the useState hook is that if some part of the application's state is needed by multiple components of the application, the state and the functions for manipulating it must be passed via props to all components that handle the state. Sometimes props need to be passed through multiple components, and the components along the way may not even be interested in the state in any way. This somewhat unpleasant phenomenon is called prop drilling. + +Over the years, several alternative solutions have been developed for state management of React applications, which can be used to ease problematic situations (e.g. prop drilling). However, no solution has been "final", all have their own pros and cons, and new solutions are being developed all the time. + +The situation may confuse a beginner and even an experienced web developer. Which solution should be used? + +For a simple application, useState is certainly a good starting point. If the application is communicating with the server, the communication can be handled in the same way as in chapters 1-5, using the state of the application itself. Recently, however, it has become more common to move the communication and associated state management at least partially under the control of React Query (or some other similar library). If you are concerned about useState and the prop drilling it entails, using context may be a good option. There are also situations where it may make sense to handle some of the state with useState and some with contexts. + +The most comprehensive and robust state management solution is Redux, which is a way to implement the so-called [Flux](https://facebookarchive.github.io/flux/docs/in-depth-overview/) architecture. Redux is slightly older than the solutions presented in this section. The rigidity of Redux has been the motivation for many new state management solutions, such as React's useReducer. Some of the criticisms of Redux's rigidity have already become obsolete thanks to the [Redux Toolkit](https://redux-toolkit.js.org/). + +Over the years, there have also been other state management libraries developed that are similar to Redux, such as the newer entrant [Recoil](https://recoiljs.org/) and the slightly older [MobX](https://mobx.js.org/). However, according to [Npm trends](https://npmtrends.com/mobx-vs-recoil-vs-redux), Redux still clearly dominates, and in fact seems to be increasing its lead: + +![graph showing redux growing in popularity over past 5 years](../../images/6/64new.png) + +Also, Redux does not have to be used in its entirety in an application. It may make sense, for example, to manage the form state outside of Redux, especially in situations where the state of a form does not affect the rest of the application. It is also perfectly possible to use Redux and React Query together in the same application. + +The question of which state management solution should be used is not at all straightforward. It is impossible to give a single correct answer. It is also likely that the selected state management solution may turn out to be suboptimal as the application grows to such an extent that the solution has to be changed even if the application has already been put into production use. + +
    diff --git a/src/content/6/es/part6.md b/src/content/6/es/part6.md new file mode 100644 index 00000000000..9e233df1977 --- /dev/null +++ b/src/content/6/es/part6.md @@ -0,0 +1,17 @@ +--- +mainImage: ../../../images/part-6.svg +part: 6 +lang: es +--- + +
    + +Hasta ahora, hemos colocado el estado de la aplicación y la lógica de estado directamente dentro de los componentes de React. Cuando las aplicaciones crecen, la administración del estado debe trasladarse fuera de los componentes de React. En esta parte, presentaremos la librería Redux, que actualmente es la solución más popular para administrar el estado de las aplicaciones React. + +Aprenderemos sobre la versión ligera de Redux compatible directamente con React, es decir el contexto de React y el hook useRedux, también sobre la librería React Query que simplifica la gestión de estados de la aplicación. + +Parte actualizada el 23 de Agosto de 2023 +- Create React App reemplazado con Vite +- React Query actualizado a la versión 4 + +
    diff --git a/src/content/6/es/part6a.md b/src/content/6/es/part6a.md new file mode 100644 index 00000000000..931e3cee50d --- /dev/null +++ b/src/content/6/es/part6a.md @@ -0,0 +1,1266 @@ +--- +mainImage: ../../../images/part-6.svg +part: 6 +letter: a +lang: es +--- + +
    + +Hasta ahora, hemos seguido las convenciones de gestión de estado recomendadas por React. Hemos colocado el estado y las funciones para manejarlo en el [nivel superior](https://es.react.dev/learn/sharing-state-between-components) de la estructura de componentes de la aplicación. A menudo, la mayoría del estado de la aplicación y los métodos para modificarlo residen directamente en el componente raíz. Luego, el estado y sus métodos de control se han pasado a otros componentes con props. Esto funciona hasta cierto punto, pero cuando las aplicaciones crecen, la gestión del estado se vuelve desafiante. + +### Arquitectura de Flux + +Facebook desarrolló la arquitectura [Flux](https://facebookarchive.github.io/flux/docs/in-depth-overview/) para facilitar la gestión del estado. En Flux, el estado se separa completamente de los componentes de React en sus propios stores(almacenes). +El estado en el store no se cambia directamente, sino con diferentes actions(acciones). + +Cuando una acción cambia el estado de un store, las vistas se vuelven a generar: + +![diagrama action->dispatcher->store->view](../../images/6/flux1.png) + +Si alguna acción en la aplicación, por ejemplo presionar un botón, provoca la necesidad de cambiar el estado, el cambio se realiza con una acción. +Esto hace que se vuelva a renderizar la vista: + +![mismo diagrama que arriba pero con la acción retrocediendo](../../images/6/flux2.png) + +Flux ofrece una manera estándar de cómo y dónde se mantiene el estado de la aplicación y cómo se modifica. + +### Redux + +Facebook tiene una implementación para Flux, pero usaremos la librería [Redux](https://redux.js.org). Funciona con el mismo principio, pero es un poco más sencilla. Facebook también usa Redux ahora en lugar de su Flux original. + +Conoceremos Redux implementando una aplicación de contador una vez más: + +![aplicación de contador en el navegador](../../images/6/1.png) + +Crea una nueva aplicación Vite e instala redux con el comando + +```bash +npm install redux +``` + +Como en Flux, en Redux el estado también se almacena en un [store](https://redux.js.org/tutorials/essentials/part-1-overview-concepts#store). + +Todo el estado de la aplicación se almacena en un objeto JavaScript en el store. Debido a que nuestra aplicación solo necesita el valor del contador, lo guardaremos directamente en el store. Si el estado fuera más complicado, diferentes elementos del estado se guardarían como campos separados del objeto. + +El estado del store se cambia con [acciones](https://redux.js.org/tutorials/essentials/part-1-overview-concepts#actions). Las acciones son objetos que tienen al menos un campo que determina el tipo de acción. +Nuestra aplicación necesita, por ejemplo, la siguiente acción: + +```js +{ + type: 'INCREMENT' +} +``` + +Si hay datos relacionados con la acción, se pueden declarar otros campos según sea necesario. Sin embargo, nuestra aplicación de contador es tan simple que las acciones están bien con solo el campo de tipo. + +El impacto de la acción sobre el estado de la aplicación se define mediante un [reducer](https://redux.js.org/tutorials/essentials/part-1-overview-concepts#reducers). En la práctica, un reducer es una función a la que se le da el estado actual y una acción como parámetros. Devuelve un nuevo estado. + +Definamos ahora un reducer para nuestra aplicación: + +```js +const counterReducer = (state, action) => { + if (action.type === 'INCREMENT') { + return state + 1 + } else if (action.type === 'DECREMENT') { + return state - 1 + } else if (action.type === 'ZERO') { + return 0 + } + + return state +} +``` + +El primer parámetro es el estado en el store. El reducer devuelve un nuevo estado basado en el tipo de _acción_. Entonces, por ejemplo, cuando el tipo de acción es INCREMENT, el estado obtiene el valor antiguo más uno. Si el tipo de acción es ZERO, el nuevo valor del estado es cero. + +Cambiemos un poco el código. Hemos utilizado declaraciones if-else para responder a una acción y cambiar el estado. Sin embargo, la declaración [switch](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Statements/switch) es el enfoque más común para escribir un reducer. + +También definamos un [valor predeterminado](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Functions/Default_parameters) de 0 para el parámetro state. Ahora, el reducer funciona incluso si el estado del store aún no se ha inicializado. + +```js +// highlight-start +const counterReducer = (state = 0, action) => { + // highlight-end + switch (action.type) { + case 'INCREMENT': + return state + 1 + case 'DECREMENT': + return state - 1 + case 'ZERO': + return 0 + default: // if none of the above matches, code comes here + return state + } +} +``` + +El reducer nunca debe ser llamado directamente desde el código de la aplicación. Solo es proporcionado como parámetro a la función _createStore_ que crea el store: + +```js +// highlight-start +import { createStore } from 'redux' +// highlight-end + +const counterReducer = (state = 0, action) => { + // ... +} + +// highlight-start +const store = createStore(counterReducer) +// highlight-end +``` + +El store ahora usa el reducer para manejar acciones, que son dispatched o 'enviadas' al store con su método [dispatch](https://redux.js.org/tutorials/essentials/part-1-overview-concepts#dispatch)(envío). + +```js +store.dispatch({type: 'INCREMENT'}) +``` + +Puedes averiguar el estado del store utilizando el método [getState](https://redux.js.org/api/store#getstate). + +Por ejemplo, el siguiente código: + +```js +const store = createStore(counterReducer) +console.log(store.getState()) +store.dispatch({ type: 'INCREMENT' }) +store.dispatch({ type: 'INCREMENT' }) +store.dispatch({ type: 'INCREMENT' }) +console.log(store.getState()) +store.dispatch({ type: 'ZERO' }) +store.dispatch({ type: 'DECREMENT' }) +console.log(store.getState()) +``` + +imprimiría lo siguiente en la consola + +``` +0 +3 +-1 +``` + +porque al principio el estado del store es 0. Después de tres acciones INCREMENT el estado es 3. Al final, después de las acciones ZERO y DECREMENT, el estado es -1. + +El tercer método importante que tiene el store es [subscribe](https://redux.js.org/api/store#subscribelistener), que se utiliza para crear funciones callback que el store llama cuando cambia su estado. + +Si, por ejemplo, añadiéramos la siguiente función para suscribirnos, todos los cambios en el store se imprimirían en la consola. + +```js +store.subscribe(() => { + const storeNow = store.getState() + console.log(storeNow) +}) +``` + +entonces el código + +```js +const store = createStore(counterReducer) + +store.subscribe(() => { + const storeNow = store.getState() + console.log(storeNow) +}) + +store.dispatch({ type: 'INCREMENT' }) +store.dispatch({ type: 'INCREMENT' }) +store.dispatch({ type: 'INCREMENT' }) +store.dispatch({ type: 'ZERO' }) +store.dispatch({ type: 'DECREMENT' }) +``` + +causaría que se imprima lo siguiente: + +``` +1 +2 +3 +0 +-1 +``` + +El código de nuestra aplicación de contador es el siguiente. Todo el código se ha escrito en el mismo archivo, por lo que store está directamente disponible para el código React. Más adelante conoceremos mejores formas de estructurar el código React/Redux. + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' + +import { createStore } from 'redux' + +const counterReducer = (state = 0, action) => { + switch (action.type) { + case 'INCREMENT': + return state + 1 + case 'DECREMENT': + return state - 1 + case 'ZERO': + return 0 + default: + return state + } +} + +const store = createStore(counterReducer) + +const App = () => { + return ( +
    +
    + {store.getState()} +
    + + + +
    + ) +} + +const root = ReactDOM.createRoot(document.getElementById('root')) + +const renderApp = () => { + root.render() +} + +renderApp() +store.subscribe(renderApp) +``` + +Hay algunas cosas notables en el código. +App muestra el valor del contador solicitándolo al store con el método _store.getState()_. Los controladores de acciones de los botones envían (dispatch) las acciones correctas al store. + +Cuando se cambia el estado del store, React no puede volver a re-renderizar automáticamente la aplicación. Por lo tanto, hemos registrado una función _renderApp_ , que renderiza toda la aplicación, para escuchar cambios en el store con el método _store.subscribe_. Ten en cuenta que tenemos que invocar inmediatamente al método _renderApp_. Sin la invocación, el primer renderizado de la aplicación nunca se produciría. + +### Una nota sobre el uso de createStore + +Los más atentos notarán que el nombre de la función createStore está tachado. Si pasas el mouse sobre el nombre, aparecerá una explicación + +![mensaje de error de vscode: createStore esta obsoleto, usa configureStore en su lugar](../../images/6/30new.png) + +La explicación completa es la siguiente: + +>Recomendamos utilizar el método configureStore del paquete @reduxjs/toolkit, que reemplaza a createStore. +> +>Redux Toolkit es nuestro enfoque recomendado para escribir la lógica de Redux hoy, incluida la configuración de store, reducers, la obtención de datos y más. +> +>Para obtener más detalles, lea esta página de documentación de Redux: +> +>configureStore de Redux Toolkit es una versión mejorada de createStore que simplifica la configuración y ayuda a evitar errores comunes. +> +>No deberías usar el paquete principal de redux por sí solo hoy en día, excepto con fines de aprendizaje. El método createStore del paquete core de redux no se eliminará, pero alentamos a todos los usuarios a migrar al uso de Redux Toolkit para todo el código de Redux. + +Entonces, en lugar de la función createStore, se recomienda usar la función un poco más "avanzada" configureStore, y también la usaremos cuando nos hayamos hecho cargo de la funcionalidad básica de Redux. + +Nota adicional: createStore se define como "obsoleto", lo que generalmente significa que la función se eliminará en alguna versión más nueva de la librería. La explicación anterior y esta [discusión](https://stackoverflow.com/questions/71944111/redux-createstore-is-deprecated-cannot-get-state-from-getstate-in-redux-ac) revelan que createStore no se eliminará y se le ha dado el estado obsoleto, quizás por motivos ligeramente incorrectos. Por lo tanto, la función no está obsoleta, pero hoy en día existe una forma nueva y preferible de hacer casi lo mismo. + +### Redux-notas + +Nuestro objetivo es modificar nuestra aplicación de notas para utilizar Redux para la gestión del estado. Sin embargo, primero cubramos algunos conceptos clave a través de una aplicación de notas simplificada. + +La primera versión de nuestra aplicación es la siguiente + +```js +const noteReducer = (state = [], action) => { + if (action.type === 'NEW_NOTE') { + state.push(action.payload) + return state + } + + return state +} + +const store = createStore(noteReducer) + +store.dispatch({ + type: 'NEW_NOTE', + payload: { + content: 'the app state is in redux store', + important: true, + id: 1 + } +}) + +store.dispatch({ + type: 'NEW_NOTE', + payload: { + content: 'state changes are made with actions', + important: false, + id: 2 + } +}) + +const App = () => { + return( +
    +
      + {store.getState().map(note=> +
    • + {note.content} {note.important ? 'important' : ''} +
    • + )} +
    +
    + ) +} +``` + +Hasta el momento la aplicación no tiene la funcionalidad para agregar nuevas notas, aunque es posible hacerlo enviando acciones NEW\_NOTE. + +Ahora las acciones tienen un tipo y un campo payload (carga), que contiene la nota a agregar: + +```js +{ + type: 'NEW_NOTE', + payload: { + content: 'state changes are made with actions', + important: false, + id: 2 + } +} +``` + +La elección del nombre del campo es arbitraria. La convención es que las acciones tengan exactamente dos campos, type diciendo el tipo y payload conteniendo los datos incluidos en la acción. + +### Funciones puras, inmutables + +La versión inicial del reducer es muy sencilla: + +```js +const noteReducer = (state = [], action) => { + if (action.type === 'NEW_NOTE') { + state.push(action.payload) + return state + } + + return state +} +``` + +El estado ahora es un Array. Las acciones de tipo NEW\_NOTE hacen que se agregue una nueva nota al estado con el método [push](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array/push). + +La aplicación parece estar funcionando, pero el reducer que hemos declarado es malo. Rompe el [supuesto básico](https://redux.js.org/tutorials/essentials/part-1-overview-concepts#reducers) de que los reducers deben ser [funciones puras](https://es.wikipedia.org/wiki/Programaci%C3%B3n_funcional#Funciones_puras). + +Las funciones puras son aquellas que no causan ningún efecto secundario y siempre deben devolver la misma respuesta cuando se llaman con los mismos parámetros. + +Agregamos una nueva nota al estado con el método _state.push(action.payload)_ que cambia el estado del objeto-estado. Esto no está permitido. El problema se resuelve fácilmente utilizando el método [concat](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/Array/concat), que crea un nuevo array, que contiene todos los elementos del array anterior y el nuevo elemento: + +```js +const noteReducer = (state = [], action) => { + if (action.type === 'NEW_NOTE') { + // highlight-start + return state.concat(action.payload) + // highlight-end + } + + return state +} +``` + +El estado de un reducer debe estar compuesto por objetos [inmutables](https://es.wikipedia.org/wiki/Objeto_inmutable). Si hay un cambio en el estado, el objeto antiguo no se cambia, sino que se reemplaza por un objeto nuevo modificado. Esto es exactamente lo que hicimos con el nuevo reducer: el array anterior se reemplaza por el nuevo. + +Ampliemos nuestro reducer para que pueda manejar el cambio de importancia de una nota: + +```js +{ + type: 'TOGGLE_IMPORTANCE', + payload: { + id: 2 + } +} +``` + +Dado que todavía no tenemos ningún código que utilice esta funcionalidad, estamos expandiendo el reducer en la forma 'test driven' (guiada por pruebas). Comencemos creando una prueba para manejar la acción NEW\_NOTE. + +Tenemos que configurar primero la biblioteca de pruebas [Jest](https://jestjs.io/) para el proyecto. Vamos a instalar las siguientes dependencias: + +```js +npm install --save-dev jest @babel/preset-env @babel/preset-react eslint-plugin-jest +``` + +A continuación crearemos el archivo .babelrc, con el siguiente contenido: + +```json +{ + "presets": [ + "@babel/preset-env", + ["@babel/preset-react", { "runtime": "automatic" }] + ] +} +``` + +Expandamos package.json con un script para ejecutar las pruebas: + +```json +{ + // ... + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview", + "test": "jest" // highlight-line + }, + // ... +} +``` + +Y finalmente, .eslint.cjs necesita ser modificado de la siguiente manera: + +```js +module.exports = { + root: true, + env: { + browser: true, + es2020: true, + "jest/globals": true // highlight-line + }, + // ... +} +``` + +Para hacer las pruebas más fáciles, primero trasladaremos el código del reducer a su propio módulo, al archivo src/reducers/noteReducer.js. También agregaremos la librería [deep-freeze](https://www.npmjs.com/package/deep-freeze), que se puede usar para garantizar que el reducer se haya definido correctamente como una función inmutable. +Instalemos la librería como una dependencia de desarrollo: + +```js +npm install --save-dev deep-freeze +``` + +La prueba, que definimos en el archivo src/reducers/noteReducer.test.js, tiene el siguiente contenido: + +```js +import noteReducer from './noteReducer' +import deepFreeze from 'deep-freeze' + +describe('noteReducer', () => { + test('returns new state with action NEW_NOTE', () => { + const state = [] + const action = { + type: 'NEW_NOTE', + payload: { + content: 'the app state is in redux store', + important: true, + id: 1 + } + } + + deepFreeze(state) + const newState = noteReducer(state, action) + + expect(newState).toHaveLength(1) + expect(newState).toContainEqual(action.payload) + }) +}) +``` + +El comando deepFreeze(state) asegura que el reducer no cambie el estado del store que se le dio como parámetro. Si el reducer usa el comando _push_ para manipular el estado, la prueba no pasará + +![terminal mostrando test fallando y error acerca de no usar array.push](../../images/6/2.png) + +Ahora crearemos una prueba para la acción TOGGLE\_IMPORTANCE: + +```js +test('returns new state with action TOGGLE_IMPORTANCE', () => { + const state = [ + { + content: 'the app state is in redux store', + important: true, + id: 1 + }, + { + content: 'state changes are made with actions', + important: false, + id: 2 + }] + + const action = { + type: 'TOGGLE_IMPORTANCE', + payload: { + id: 2 + } + } + + deepFreeze(state) + const newState = noteReducer(state, action) + + expect(newState).toHaveLength(2) + + expect(newState).toContainEqual(state[0]) + + expect(newState).toContainEqual({ + content: 'state changes are made with actions', + important: true, + id: 2 + }) +}) +``` + +Entonces la siguiente acción + +```js +{ + type: 'TOGGLE_IMPORTANCE', + payload: { + id: 2 + } +} +``` + +tiene que cambiar la importancia de la nota con el id 2. + +El reducer se expande de la siguiente manera + +```js +const noteReducer = (state = [], action) => { + switch(action.type) { + case 'NEW_NOTE': + return state.concat(action.payload) + case 'TOGGLE_IMPORTANCE': { + const id = action.payload.id + const noteToChange = state.find(n => n.id === id) + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + return state.map(note => + note.id !== id ? note : changedNote + ) + } + default: + return state + } +} +``` + +Creamos una copia de la nota cuya importancia ha cambiado con la sintaxis [de la parte 2](/es/part2/alterando_datos_en_el_servidor#cambiar-la-importancia-de-las-notas), y reemplazamos el estado con un nuevo estado que contiene todas las notas que no han cambiado y la copia de la nota cambiada changedNote. + +Recapitulemos lo que sucede en el código. Primero, buscamos un objeto de nota específico, cuya importancia queremos cambiar: + +```js +const noteToChange = state.find(n => n.id === id) +``` + +luego creamos un nuevo objeto, que es una copia de la nota original, solo el valor del campo important se ha cambiado a lo opuesto de lo que era: + +```js +const changedNote = { + ...noteToChange, + important: !noteToChange.important +} +``` + +Entonces se devuelve un nuevo estado. Lo creamos tomando todas las notas del estado anterior, excepto la nota deseada, que reemplazamos con su copia ligeramente alterada: + +```js +state.map(note => + note.id !== id ? note : changedNote +) +``` + +### Array spread syntax + +Debido a que ahora tenemos pruebas bastante buenas para el reducer, podemos refactorizar el código de forma segura. + +Agregar una nueva nota crea el estado devuelto por la función de Arrays _concat_. Echemos un vistazo a cómo podemos lograr lo mismo usando la sintaxis [array spread](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Operators/Spread_syntax) de JavaScript: + +```js +const noteReducer = (state = [], action) => { + switch(action.type) { + case 'NEW_NOTE': + // highlight-start + return [...state, action.payload] + // highlight-end + case 'TOGGLE_IMPORTANCE': + // ... + default: + return state + } +} +``` + +La sintaxis spread funciona de la siguiente manera. Si declaramos + +```js +const numbers = [1, 2, 3] +``` + +...numbers divide el array en elementos individuales, que se pueden colocar en otro array. + +```js +[...numbers, 4, 5] +``` + +y el resultado es un array [1, 2, 3, 4, 5]. + +Si hubiéramos colocado el array en otro array sin el spread + +```js +[numbers, 4, 5] +``` + +el resultado habría sido [ [1, 2, 3], 4, 5]. + +Cuando tomamos elementos de un array mediante la [desestructuración](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment), se usa una sintaxis similar para juntar el resto de los elementos: + +```js +const numbers = [1, 2, 3, 4, 5, 6] + +const [first, second, ...rest] = numbers + +console.log(first) // imprime 1 +console.log(second) // imprime 2 +console.log(rest) // imprime [3, 4, 5, 6] +``` + +
    + +
    + +### Ejercicios 6.1.-6.2. + +Hagamos una versión simplificada del ejercicio unicafe de la parte 1. Manejemos la administración del estado con Redux. + +Puedes tomar el código de este repositorio https://github.com/fullstack-hy2020/unicafe-redux para la base de tu proyecto. + +Comienza eliminando la configuración git del repositorio clonado e instalando dependencias + +```bash +cd unicafe-redux // go to the directory of cloned repository +rm -rf .git +npm install +``` + +#### 6.1: Unicafe Revisitado, paso 1 + +Antes de implementar la funcionalidad de la UI(interfaz de usuario), implementemos la funcionalidad requerida por el store. + +Tenemos que guardar el número de cada tipo de feedback en el store, por lo que la forma del estado en el store es: + +```js +{ + good: 5, + ok: 4, + bad: 2 +} +``` + +El proyecto tiene la siguiente base para un reducer: + +```js +const initialState = { + good: 0, + ok: 0, + bad: 0 +} + +const counterReducer = (state = initialState, action) => { + console.log(action) + switch (action.type) { + case 'GOOD': + return state + case 'OK': + return state + case 'BAD': + return state + case 'ZERO': + return state + default: return state + } + +} + +export default counterReducer +``` + +y una base para sus pruebas + +```js +import deepFreeze from 'deep-freeze' +import counterReducer from './reducer' + +describe('unicafe reducer', () => { + const initialState = { + good: 0, + ok: 0, + bad: 0 + } + + test('should return a proper initial state when called with undefined state', () => { + const state = {} + const action = { + type: 'DO_NOTHING' + } + + const newState = counterReducer(undefined, action) + expect(newState).toEqual(initialState) + }) + + test('good is incremented', () => { + const action = { + type: 'GOOD' + } + const state = initialState + + deepFreeze(state) + const newState = counterReducer(state, action) + expect(newState).toEqual({ + good: 1, + ok: 0, + bad: 0 + }) + }) +}) +``` + +**Implementa el reducer y sus pruebas.** + +En las pruebas, asegúrate de que el reducer sea una función inmutable con la librería deep-freeze. +Asegúrate de que la primera prueba proporcionada pase, porque Redux espera que el reducer devuelva el estado original cuando se llama con un primer parámetro - que representa el estado previo - con el valor undefined. + +Comienza expandiendo el reducer para que pasen ambas pruebas. Luego agrega el resto de las pruebas y finalmente la funcionalidad que están probando. + +Un buen modelo para el reducer es el ejemplo anterior de [redux-notas](/es/part6/flux_architecture_y_redux#redux-notas). + +#### 6.2: Unicafe Revisitado, paso 2 + +Ahora implementa la funcionalidad real de la aplicación. + +Tu aplicación puede tener una apariencia modesta, nada más se necesitan 3 botones y el número de calificaciones para cada tipo: + +![botones good bad y ok](../../images/6/50new.png) + +
    + +
    + +### Formulario no controlado + +Agreguemos la funcionalidad para agregar nuevas notas y cambiar su importancia: + +```js +// highlight-start +const generateId = () => + Number((Math.random() * 1000000).toFixed(0)) +// highlight-end + +const App = () => { + // highlight-start + const addNote = (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + store.dispatch({ + type: 'NEW_NOTE', + payload: { + content, + important: false, + id: generateId() + } + }) + } + // highlight-end + + // highlight-start + const toggleImportance = (id) => { + store.dispatch({ + type: 'TOGGLE_IMPORTANCE', + payload: { id } + }) + } + // highlight-end + + return ( +
    + // highlight-start +
    + + +
    + // highlight-end +
      + {store.getState().map(note => +
    • toggleImportance(note.id)} // highlight-line + > + {note.content} {note.important ? 'important' : ''} +
    • + )} +
    +
    + ) +} +``` + +La implementación de ambas funcionalidades es sencilla. Cabe señalar que no hemos vinculado el estado de los campos del formulario al estado del componente App como lo hicimos anteriormente. React llama a este tipo de formulario [no controlado](https://es.react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable). + +>Los formularios no controlados tienen ciertas limitaciones (por ejemplo, no son posibles los mensajes de error dinámicos o la desactivación del botón de envío en función de input). Sin embargo, son adecuados para nuestras necesidades actuales. + +Puedes leer más sobre formularios no controlados [aquí](https://goshakkk.name/controlled-vs-uncontrolled-inputs-react/). + +El método para agregar nuevas notas es simple, simplemente envía la acción para agregar notas: + +```js +addNote = (event) => { + event.preventDefault() + const content = event.target.note.value // highlight-line + event.target.note.value = '' + store.dispatch({ + type: 'NEW_NOTE', + payload: { + content, + important: false, + id: generateId() + } + }) +} +``` + +Podemos obtener el contenido de la nueva nota directamente desde el campo del formulario. Debido a que el campo tiene un nombre, podemos acceder al contenido a través del objeto del evento event.target.note.value. + +```js +
    + // highlight-line + +
    +``` + +La importancia de una nota se puede cambiar haciendo clic en su nombre. El controlador de eventos es muy simple: + +```js +toggleImportance = (id) => { + store.dispatch({ + type: 'TOGGLE_IMPORTANCE', + payload: { id } + }) +} +``` + +### Action creators + +Comenzamos a notar que, incluso en aplicaciones tan simples como la nuestra, usar Redux puede simplificar el código de la interfaz. Sin embargo, podemos hacerlo mucho mejor. + +En realidad, no es necesario que los componentes de React conozcan los tipos y formas de acción de Redux. +Separemos la creación de acciones en sus propias funciones: + +```js +const createNote = (content) => { + return { + type: 'NEW_NOTE', + payload: { + content, + important: false, + id: generateId() + } + } +} + +const toggleImportanceOf = (id) => { + return { + type: 'TOGGLE_IMPORTANCE', + payload: { id } + } +} +``` + +Las funciones que crean acciones se denominan [action creators](https://redux.js.org/tutorials/essentials/part-1-overview-concepts#action-creators) (creadores de acciones). + +El componente App ya no tiene que saber nada sobre la representación interna de las acciones, solo obtiene la acción correcta llamando a la función creadora: + +```js +const App = () => { + const addNote = (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + store.dispatch(createNote(content)) // highlight-line + + } + + const toggleImportance = (id) => { + store.dispatch(toggleImportanceOf(id))// highlight-line + } + + // ... +} +``` + +### Reenviando Redux-Store a varios componentes + +Aparte del reducer, nuestra aplicación está en un solo archivo. Esto, por supuesto, no es sensato, y deberíamos separar App en su propio módulo. + +Ahora la pregunta es, ¿cómo puede App acceder al store después de moverlo? Y en términos más generales, cuando un componente está compuesto por muchos componentes más pequeños, debe haber una forma para que todos los componentes accedan al store. +Hay varias formas de compartir el store redux con los componentes. Primero veremos la forma más nueva, y posiblemente la más fácil, usando la api de [hooks](https://react-redux.js.org/api/hooks) de la librería [react-redux](https://react-redux.js.org/). + +Primero instalamos react-redux + +```bash +npm install react-redux +``` + +A continuación movemos el componente _App_ en su propio archivo _App.jsx_. Veamos cómo afecta esto al resto de los archivos de la aplicación. + +_main.jsx_ se convierte en: + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' +import { createStore } from 'redux' +import { Provider } from 'react-redux' // highlight-line + +import App from './App' +import noteReducer from './reducers/noteReducer' + +const store = createStore(noteReducer) + +ReactDOM.createRoot(document.getElementById('root')).render( + // highlight-line + + // highlight-line +) +``` + +Ten en cuenta que la aplicación ahora se define como un elemento secundario de un componente [Provider](https://react-redux.js.org/api/provider) (proveedor) proporcionado por la librería react-redux. +El store de la aplicación se entrega al Provider como su atributo store. + +La definición de los action creators se ha movido al archivo reducers/noteReducer.js donde se define el reducer. El archivo se ve así: + +```js +const noteReducer = (state = [], action) => { + // ... +} + +const generateId = () => + Number((Math.random() * 1000000).toFixed(0)) + +export const createNote = (content) => { // highlight-line + return { + type: 'NEW_NOTE', + payload: { + content, + important: false, + id: generateId() + } + } +} + +export const toggleImportanceOf = (id) => { // highlight-line + return { + type: 'TOGGLE_IMPORTANCE', + payload: { id } + } +} + +export default noteReducer +``` + +Si la aplicación tiene muchos componentes que necesitan el store, el componente App debe pasar store como props a todos esos componentes. + +El módulo ahora tiene varios comandos de [export](https://developer.mozilla.org/es/docs/Web/JavaScript/Reference/Statements/export). + +La función del reducer todavía se devuelve con el comando de export default, por lo que el reducer se puede importar de la forma habitual: + +```js +import noteReducer from './reducers/noteReducer' +``` + +Un módulo solo puede tener un default export, pero varias exportaciones "normales" + +```js +export const createNote = (content) => { + // ... +} + +export const toggleImportanceOf = (id) => { + // ... +} +``` + +Las funciones exportadas normalmente (no como los default) se pueden importar con la sintaxis de llaves: + +```js +import { createNote } from '../../reducers/noteReducer' +``` + +Código para el componente App + +```js +import { createNote, toggleImportanceOf } from './reducers/noteReducer' // highlight-line +import { useSelector, useDispatch } from 'react-redux' // highlight-line + +const App = () => { + const dispatch = useDispatch() // highlight-line + const notes = useSelector(state => state) // highlight-line + + const addNote = (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + dispatch(createNote(content)) // highlight-line + } + + const toggleImportance = (id) => { + dispatch(toggleImportanceOf(id)) // highlight-line + } + + return ( +
    +
    + + +
    +
      + {notes.map(note => // highlight-line +
    • toggleImportance(note.id)} + > + {note.content} {note.important ? 'important' : ''} +
    • + )} +
    +
    + ) +} + +export default App +``` + +Hay algunas cosas a tener en cuenta en el código. Anteriormente, el código despachaba acciones invocando al método dispatch de redux-store: + +```js +store.dispatch({ + type: 'TOGGLE_IMPORTANCE', + payload: { id } +}) +``` + +Ahora lo hace con la función dispatch del hook [useDispatch](https://react-redux.js.org/api/hooks#usedispatch). + +```js +import { useSelector, useDispatch } from 'react-redux' // highlight-line + +const App = () => { + const dispatch = useDispatch() // highlight-line + // ... + + const toggleImportance = (id) => { + dispatch(toggleImportanceOf(id)) // highlight-line + } + + // ... +} +``` + +El hook useDispatch proporciona acceso a cualquier componente de React a la función dispatch de redux-store definida en main.jsx. Esto permite que todos los componentes realicen cambios en el estado de Redux store. + +El componente puede acceder a las notas almacenadas en el store con el hook [useSelector](https://react-redux.js.org/api/hooks#useselector) de la librería react-redux. + +```js +import { useSelector, useDispatch } from 'react-redux' // highlight-line + +const App = () => { + // ... + const notes = useSelector(state => state) // highlight-line + // ... +} +``` + +useSelector recibe una función como parámetro. La función busca o selecciona datos del store de Redux. +Aquí necesitamos todas las notas, por lo que nuestra función de selector devuelve el estado completo: + + +```js +state => state +``` + +que es una abreviatura de + +```js +(state) => { + return state +} +``` + +Por lo general, las funciones de selector son un poco más interesantes y solo devuelven partes seleccionadas del contenido del store redux. Por ejemplo, podríamos devolver solo notas marcadas como importantes: + +```js +const importantNotes = useSelector(state => state.filter(note => note.important)) +``` + +La versión actual de la aplicación se puede encontrar en [GitHub](https://github.com/fullstack-hy2020/redux-notes/tree/part6-0), en la rama part6-0. + +### Más componentes + +Separemos la creación de una nueva nota en su propio componente. + +```js +import { useDispatch } from 'react-redux' // highlight-line +import { createNote } from '../reducers/noteReducer' // highlight-line + +const NewNote = () => { + const dispatch = useDispatch() // highlight-line + + const addNote = (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + dispatch(createNote(content)) // highlight-line + } + + return ( +
    + + +
    + ) +} + +export default NewNote +``` + +A diferencia del código de React que hicimos sin Redux, el controlador de eventos para cambiar el estado de la aplicación (que ahora vive en Redux) se ha movido de App a un componente hijo. La lógica para cambiar el estado en Redux todavía está claramente separada de toda la parte de React de la aplicación. + +También separaremos la lista de notas y mostraremos una sola nota en sus propios componentes (que se colocarán en el archivo Notes.jsx): + +```js +import { useDispatch, useSelector } from 'react-redux' // highlight-line +import { toggleImportanceOf } from '../reducers/noteReducer' // highlight-line + +const Note = ({ note, handleClick }) => { + return( +
  • + {note.content} + {note.important ? 'important' : ''} +
  • + ) +} + +const Notes = () => { + const dispatch = useDispatch() // highlight-line + const notes = useSelector(state => state) // highlight-line + + return( +
      + {notes.map(note => + + dispatch(toggleImportanceOf(note.id)) + } + /> + )} +
    + ) +} + +export default Notes +``` + +La lógica para cambiar la importancia de una nota ahora está en el componente que administra la lista de notas. + +No queda mucho código en App: + +```js +const App = () => { + + return ( +
    + + +
    + ) +} +``` + +Note, responsable de representar una sola nota, es muy simple y no es consciente de que el controlador de eventos que obtiene como props despacha una acción. Este tipo de componentes se denominan [presentacionales](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) en la terminología de React. + +Notes, por otro lado, es un componente [contenedor](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0), ya que contiene cierta lógica de aplicación: define lo que hacen los controladores de eventos de los componentes Note y coordina la configuración de los componentes presentacionales, es decir, los Notes. + +El código de la aplicación Redux se puede encontrar en [GitHub](https://github.com/fullstack-hy2020/redux-notes/tree/part6-1), en la rama part6-1. + +
    + +
    + +### Ejercicios 6.3.-6.8. + +Hagamos una nueva versión de la aplicación de votación de anécdotas de la parte 1. Toma el proyecto de este repositorio https://github.com/fullstack-hy2020/redux-anecdotes como base de tu solución. + +Si clonas el proyecto en un repositorio de git existente, elimina la configuración de git de la aplicación clonada: + +```bash +cd redux-anecdotes // go to the cloned repository +rm -rf .git +``` + +La aplicación se puede iniciar como de costumbre, pero primero debes instalar las dependencias: + +```bash +npm install +npm run dev +``` + +Después de completar estos ejercicios, tu aplicación debería verse así: + +![navegador mostrando anécdotas y botones para votarlas](../../images/6/3.png) + +#### 6.3: Anécdotas, paso 1 + +Implementa la funcionalidad para votar anécdotas. La cantidad de votos debe guardarse en una store de Redux. + +#### 6.4: Anécdotas, paso 2 + +Implementa la funcionalidad para agregar nuevas anécdotas. + +Puedes mantener el formulario no controlado, como hicimos [antes](/es/part6/flux_architecture_y_redux#formulario-no-controlado). + +#### 6.5: Anécdotas, paso 3 + +Asegúrate de que las anécdotas estén ordenadas por número de votos. + +#### 6.6: Anécdotas, paso 4 + +Si aún no lo haz hecho, separa la creación de objetos de acción en funciones [action creator](https://read.reduxbook.com/markdown/part1/04-action-creators.html) y colócalos en el archivo src/reducers/anecdoteReducer.js, así que haz lo que hemos estado haciendo desde el capítulo [action creators](/es/part6/flux_architecture_y_redux#action-creators). + +#### 6.7: Anécdotas, paso 5 + +Separa la creación de nuevas anécdotas en su propio componente llamado AnecdoteForm. Mueve toda la lógica para crear una nueva anécdota en este nuevo componente. + +#### 6.8: Anécdotas, paso 6 + +Separa el renderizado de la lista de anécdotas en su propio componente llamado AnecdoteList. Mueve toda la lógica relacionada con la votación de una anécdota a este nuevo componente. + +Ahora, el componente App debería verse así: + +```js +import AnecdoteForm from './components/AnecdoteForm' +import AnecdoteList from './components/AnecdoteList' + +const App = () => { + return ( +
    +

    Anecdotes

    + + +
    + ) +} + +export default App +``` + +
    diff --git a/src/content/6/es/part6b.md b/src/content/6/es/part6b.md new file mode 100644 index 00000000000..547d2f9a396 --- /dev/null +++ b/src/content/6/es/part6b.md @@ -0,0 +1,792 @@ +--- +mainImage: ../../../images/part-6.svg +part: 6 +letter: b +lang: es +--- + +
    + +Continuemos nuestro trabajo con la [versión Redux](/es/part6/flux_architecture_y_redux#redux-notas) simplificada de nuestra aplicación de notas. + +Para facilitar nuestro desarrollo, cambiemos nuestro reducer para que el store se inicialice con un estado que contenga un par de notas: + +```js +const initialState = [ + { + content: 'reducer defines how redux store works', + important: true, + id: 1, + }, + { + content: 'state of store can contain any data', + important: false, + id: 2, + }, +] + +const noteReducer = (state = initialState, action) => { + // ... +} + +// ... +export default noteReducer +``` + +### Store con estado complejo + +Implementemos el filtrado de las notas que se muestran al usuario. La interfaz de usuario para los filtros se implementará con [botones de radio](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/radio): + +![botones de radio con opciones important/not y listado](../../images/6/01e.png) + +Comencemos con una implementación muy simple y directa: + +```js +import NewNote from './components/NewNote' +import Notes from './components/Notes' + +const App = () => { +//highlight-start + const filterSelected = (value) => { + console.log(value) + } +//highlight-end + + return ( +
    + + //highlight-start +
    + all filterSelected('ALL')} /> + important filterSelected('IMPORTANT')} /> + nonimportant filterSelected('NONIMPORTANT')} /> +
    + //highlight-end + +
    + ) +} +``` + +Dado que el atributo name de todos los botones de radio es el mismo, estos forman un button group (grupo de botones) en el que solo se puede seleccionar una opción. + +Los botones tienen un controlador de cambios que actualmente solo imprime el string asociado con el botón en el que se hizo clic en la consola. + +En la siguiente sección, vamos a implementar el filtrado almacenando las notas y el valor del filtro en el store de redux. Cuando terminemos, nos gustaría que el estado del store se viera así: + +```js +{ + notes: [ + { content: 'reducer defines how redux store works', important: true, id: 1}, + { content: 'state of store can contain any data', important: false, id: 2} + ], + filter: 'IMPORTANT' +} +``` + +Solo el array de notas se almacenaba en el estado de la implementación anterior de nuestra aplicación. En la nueva implementación, el objeto de estado tiene dos propiedades, notes que contienen el array de notas y filter que contiene un string que indica qué notas deben mostrarse al usuario. + +### Reducers combinados + +Podríamos modificar nuestro reducer actual para hacer frente a la nueva forma del estado. Sin embargo, una mejor solución en esta situación es definir un nuevo reducer separado para el estado del filtro: + +```js +const filterReducer = (state = 'ALL', action) => { + switch (action.type) { + case 'SET_FILTER': + return action.payload + default: + return state + } +} +``` + +Las acciones para cambiar el estado del filtro se ven así: + +```js +{ + type: 'SET_FILTER', + payload: 'IMPORTANT' +} +``` + +Creemos también una nueva función de _action creator_. Escribiremos su código en un nuevo módulo src/reducers/filterReducer.js: + +```js +const filterReducer = (state = 'ALL', action) => { + // ... +} + +export const filterChange = filter => { + return { + type: 'SET_FILTER', + payload: filter, + } +} + +export default filterReducer +``` + +Podemos crear el reducer que nuestra aplicación realmente utilizara al combinar los dos reducers existentes con la función [combineReducers](https://redux.js.org/api/combinereducers). + +Definamos el reducer combinado en el archivo main.jsx: + +```js +import ReactDOM from 'react-dom/client' +import { createStore, combineReducers } from 'redux' // highlight-line +import { Provider } from 'react-redux' +import App from './App' + +import noteReducer from './reducers/noteReducer' +import filterReducer from './reducers/filterReducer' // highlight-line + + // highlight-start +const reducer = combineReducers({ + notes: noteReducer, + filter: filterReducer +}) + // highlight-end + +const store = createStore(reducer) // highlight-line + +console.log(store.getState()) + +/* +ReactDOM.createRoot(document.getElementById('root')).render( + + + +)*/ + +ReactDOM.createRoot(document.getElementById('root')).render( + +
    + +) +``` + +Dado que nuestra aplicación se rompe por completo en este punto, renderizamos un elemento div vacío en lugar del componente App. + +El estado del store se imprime en la consola: + +![consola de desarrollo mostrando el array de notas](../../images/6/4e.png) + +Como podemos ver en el resultado, ¡el store tiene la forma exacta que queríamos! + +Echemos un vistazo más de cerca a cómo se crea el reducer combinado: + +```js +const reducer = combineReducers({ + notes: noteReducer, + filter: filterReducer, +}) +``` + +El estado del store definido por este reducer es un objeto con dos propiedades: notes y filter. El valor de la propiedad notes es definido por noteReducer, que no tiene que lidiar con las otras propiedades del estado. Asimismo, la propiedad filter es administrada por filterReducer. + +Antes de realizar más cambios en el código, echemos un vistazo a cómo las diferentes acciones cambian el estado del store definido por el reducer combinado. Agreguemos lo siguiente al archivo main.jsx: + +```js +import { createNote } from './reducers/noteReducer' +import { filterChange } from './reducers/filterReducer' +//... +store.subscribe(() => console.log(store.getState())) +store.dispatch(filterChange('IMPORTANT')) +store.dispatch(createNote('combineReducers forms one reducer from many simple reducers')) +``` + +Al simular la creación de una nota y cambiar el estado del filtro de esta manera, el estado del store se muestra en la consola después de cada cambio que se realiza en el store: + +![consola mostrando filtro de notas y nueva nota](../../images/6/5e.png) + +En este punto es bueno darse cuenta de un pequeño pero importante detalle. Si agregamos un console log al comienzo de ambos reducers (noteReducer y filterReducer): + +```js +const filterReducer = (state = 'ALL', action) => { + console.log('ACTION: ', action) + // ... +} +``` + +```js +const noteReducer = (state = initialState, action) => { + console.log('ACTION: ', action) + // ... +} +``` + +Según el resultado de la consola, uno podría tener la impresión de que cada acción se duplica: + +![consola mostrando acciones duplicadas en los reducers note y filter](../../images/6/6.png) + +¿Hay algún bug en nuestro código? No. El reducer combinado funciona de tal manera que cada acción es controlada en cada parte del reducer combinado, o en otras palabras, cada reducer "escucha" a todas las acciones despachadas y hace algo con ellas si así se lo hemos instruido. Normalmente, solo un reducer está interesado en una acción determinada, pero hay situaciones en las que varios reducers cambian sus respectivas partes del estado en función de la misma acción. + +### Terminando los filtros + +Terminemos la aplicación para que utilice el reducer combinado. Comenzamos cambiando la renderización de la aplicación y conectando el store a la aplicación en el archivo main.jsx: + +```js +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) +``` + +A continuación, solucionemos un error causado por el código que espera que la store de aplicaciones sea un array de notas: + +![error en el navegador, TypeError: notes.map no es una función](../../images/6/7ea.png) + +Es una solución fácil. Debido a que las notas están en el campo notes del store, solo tenemos que hacer un pequeño cambio en la función de selector: + +```js +const Notes = () => { + const dispatch = useDispatch() + const notes = useSelector(state => state.notes) // highlight-line + + return( +
      + {notes.map(note => + + dispatch(toggleImportanceOf(note.id)) + } + /> + )} +
    + ) +} +``` + +Anteriormente, la función de selector devolvía el estado completo del store: + +```js +const notes = useSelector(state => state) +``` + +Y ahora devuelve solo su campo notes + +```js +const notes = useSelector(state => state.notes) +``` + +Extraigamos el filtro de visibilidad en su propio componente src/components/VisibilityFilter.jsx: + +```js +import { filterChange } from '../reducers/filterReducer' +import { useDispatch } from 'react-redux' + +const VisibilityFilter = (props) => { + const dispatch = useDispatch() + + return ( +
    + all + dispatch(filterChange('ALL'))} + /> + important + dispatch(filterChange('IMPORTANT'))} + /> + nonimportant + dispatch(filterChange('NONIMPORTANT'))} + /> +
    + ) +} + +export default VisibilityFilter +``` + +Con el nuevo componente, App se puede simplificar de la siguiente manera: + +```js +import Notes from './components/Notes' +import NewNote from './components/NewNote' +import VisibilityFilter from './components/VisibilityFilter' + +const App = () => { + return ( +
    + + + +
    + ) +} + +export default App +``` + +La implementación es bastante sencilla. Al hacer clic en los diferentes radio buttons, cambia el estado de la propiedad filter del store. + +Cambiemos el componente Notes para incorporar el filtro: + +```js +const Notes = () => { + const dispatch = useDispatch() + // highlight-start + const notes = useSelector(state => { + if ( state.filter === 'ALL' ) { + return state.notes + } + return state.filter === 'IMPORTANT' + ? state.notes.filter(note => note.important) + : state.notes.filter(note => !note.important) + }) + // highlight-end + + return( +
      + {notes.map(note => + + dispatch(toggleImportanceOf(note.id)) + } + /> + )} +
    + ) +``` + +Solo realizamos cambios en la función de selector, que solía ser + +```js +useSelector(state => state.notes) +``` + +Simplifiquemos el selector desestructurando los campos del estado que recibe como parámetro: + +```js +const notes = useSelector(({ filter, notes }) => { + if ( filter === 'ALL' ) { + return notes + } + return filter === 'IMPORTANT' + ? notes.filter(note => note.important) + : notes.filter(note => !note.important) +}) +``` + +Hay un pequeño defecto cosmético en nuestra aplicación. Aunque el filtro está configurado en ALL de forma predeterminada, el radio button asociado no está seleccionado. Naturalmente, este problema se puede solucionar, pero como se trata de un error desagradable pero, en última instancia, inofensivo, dejaremos la solución para más adelante. + +La versión actual de la aplicación se puede encontrar en [GitHub](https://github.com/fullstack-hy2020/redux-notes/tree/part6-2), en la rama part6-2. + +
    + +
    + +### Ejercicio 6.9 + +#### 6.9 Mejores Anécdotas, paso 7 + +Implementa el filtrado para las anécdotas que se muestran al usuario. + +![navegador mostrando filtrado de anécdotas](../../images/6/9ea.png) + +Almacena el estado del filtro en el store de Redux. Se recomienda crear un nuevo reducer, action creators y un reducer combinado para el store utilizando la función combineReducers. + +Crea un nuevo componente Filter para mostrar los filtros. Puedes utilizar el siguiente código como punto de partida: + +```js +const Filter = () => { + const handleChange = (event) => { + // input-field value is in variable event.target.value + } + const style = { + marginBottom: 10 + } + + return ( +
    + filter +
    + ) +} + +export default Filter +``` + +
    + +
    + +### Redux Toolkit + +Como hemos visto hasta ahora, la implementación de la gestión del estado y la configuración de Redux requiere bastante esfuerzo. Esto se manifiesta, por ejemplo, en el código relacionado con el reducer y el action creator, que tiene un código un tanto repetitivo. [Redux Toolkit](https://redux-toolkit.js.org/) es una librería que resuelve estos problemas comunes relacionados con Redux. La librería, por ejemplo, simplifica enormemente la configuración del store de Redux y ofrece una gran variedad de herramientas para facilitar la gestión del estado. + +Comencemos a usar Redux Toolkit en nuestra aplicación refactorizando el código existente. Primero, necesitaremos instalar la librería: + +```bash +npm install @reduxjs/toolkit +``` + +A continuación, abre el archivo main.jsx que actualmente crea la store de Redux. En lugar de la función createStore de Redux, creemos el Store usando la función [configureStore](https://redux-toolkit.js.org/api/configureStore) de Redux Toolkit: + +```js +import ReactDOM from 'react-dom/client' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' // highlight-line +import App from './App' + +import noteReducer from './reducers/noteReducer' +import filterReducer from './reducers/filterReducer' + + // highlight-start +const store = configureStore({ + reducer: { + notes: noteReducer, + filter: filterReducer + } +}) +// highlight-end + +console.log(store.getState()) + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) +``` + +Ya nos deshicimos de algunas líneas de código, ya no necesitamos la función combineReducers para crear el reducer del store. Pronto veremos que la función configureStore tiene muchos beneficios adicionales, como la integración sin esfuerzo de herramientas de desarrollo y muchas librerías de uso común sin necesidad de configuración adicional. + +Pasemos a refactorizar los reducers, lo que trae consigo los beneficios de Redux Toolkit. Con Redux Toolkit, podemos crear fácilmente reducers y action creators relacionados utilizando la función [createSlice](https://redux-toolkit.js.org/api/createSlice). Podemos usar la función createSlice para refactorizar el reducer y los action creators en el archivo reducers/noteReducer.js de la siguiente manera: + +```js +import { createSlice } from '@reduxjs/toolkit' // highlight-line + +const initialState = [ + { + content: 'reducer defines how redux store works', + important: true, + id: 1, + }, + { + content: 'state of store can contain any data', + important: false, + id: 2, + }, +] + +const generateId = () => + Number((Math.random() * 1000000).toFixed(0)) + +// highlight-start +const noteSlice = createSlice({ + name: 'notes', + initialState, + reducers: { + createNote(state, action) { + const content = action.payload + + state.push({ + content, + important: false, + id: generateId(), + }) + }, + toggleImportanceOf(state, action) { + const id = action.payload + + const noteToChange = state.find(n => n.id === id) + + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + + return state.map(note => + note.id !== id ? note : changedNote + ) + } + }, +}) +// highlight-end +``` + +El parámetro name de la función createSlice define el prefijo que se utiliza en los valores de tipo de la acción. Por ejemplo, la acción createNote definida más adelante tendrá el valor de tipo notes/createNote. Es una buena práctica dar al parámetro un valor que sea único entre los reducers. De esta forma no habrá colisiones inesperadas entre los valores de tipo de acción de la aplicación. +El parámetro initialState define el estado inicial del reducer. +El parámetro reducers toma al propio reducer como un objeto, cuyas funciones manejan los cambios de estado causados por ciertas acciones. Ten en cuenta que action.payload en la función contiene el argumento proporcionado al llamar al creador de la acción: + +```js +dispatch(createNote('Redux Toolkit is awesome!')) +``` + +Esta llamada a dispatch equivale a enviar el siguiente objeto: + +```js +dispatch({ type: 'notes/createNote', payload: 'Redux Toolkit is awesome!' }) +``` + +Si has prestado atención, es posible que hayas notado que dentro de la acción createNote, parece suceder algo que viola el principio de inmutabilidad de los reducers mencionado anteriormente: + +```js +createNote(state, action) { + const content = action.payload + + state.push({ + content, + important: false, + id: generateId(), + }) +} +``` + +Estamos mutando el array del argumento state al llamar al método push en lugar de devolver una nueva instancia del array. ¿De qué se trata todo esto? + +Redux Toolkit utiliza la librería [Immer](https://immerjs.github.io/immer/) con reducers creados por la función createSlice, lo que hace posible mutar el argumento state dentro del reducer. Immer usa el estado mutado para producir un nuevo estado inmutable y, por lo tanto, los cambios de estado permanecen inmutables. Ten en cuenta que state se puede cambiar sin "mutarlo", como hemos hecho con la acción toggleImportanceOf. En este caso, la función devuelve el nuevo estado directamente. Sin embargo, mutar el estado a menudo será útil, especialmente cuando se necesita actualizar un estado complejo. + +La función createSlice devuelve un objeto que contiene al reducer así como a los action creators definidos por el parámetro reducers. Se puede acceder al reducer mediante la propiedad noteSlice.reducer, mientras que a los action creators mediante la propiedad noteSlice.actions. Podemos producir las exportaciones del archivo de la siguiente manera: + +```js +const noteSlice = createSlice(/* ... */) + +// highlight-start +export const { createNote, toggleImportanceOf } = noteSlice.actions + +export default noteSlice.reducer +// highlight-end +``` + +Las importaciones en otros archivos funcionarán igual que antes: + +```js +import noteReducer, { createNote, toggleImportanceOf } from './reducers/noteReducer' +``` + +Necesitamos modificar los nombres de los tipos de las acciones en las pruebas debido a las convenciones de ReduxToolkit: + +```js +import noteReducer from './noteReducer' +import deepFreeze from 'deep-freeze' + +describe('noteReducer', () => { + test('returns new state with action notes/createNote', () => { + const state = [] + const action = { + type: 'notes/createNote', // highlight-line + payload: 'the app state is in redux store', // highlight-line + } + + deepFreeze(state) + const newState = noteReducer(state, action) + + expect(newState).toHaveLength(1) + expect(newState.map(s => s.content)).toContainEqual(action.payload) + }) + + test('returns new state with action notes/toggleImportanceOf', () => { + const state = [ + { + content: 'the app state is in redux store', + important: true, + id: 1 + }, + { + content: 'state changes are made with actions', + important: false, + id: 2 + }] + + const action = { + type: 'notes/toggleImportanceOf', // highlight-line + payload: 2 + } + + deepFreeze(state) + const newState = noteReducer(state, action) + + expect(newState).toHaveLength(2) + + expect(newState).toContainEqual(state[0]) + + expect(newState).toContainEqual({ + content: 'state changes are made with actions', + important: true, + id: 2 + }) + }) +}) +``` + +### Redux Toolkit y console.log + +Como hemos aprendido, console.log es una herramienta extremadamente poderosa, por lo general siempre nos salva de problemas. + +Intentemos imprimir el estado del store de Redux en la consola en medio del reducer creado con la función createSlice: + +```js +const noteSlice = createSlice({ + name: 'notes', + initialState, + reducers: { + // ... + toggleImportanceOf(state, action) { + const id = action.payload + + const noteToChange = state.find(n => n.id === id) + + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + + console.log(state) // highlight-line + + return state.map(note => + note.id !== id ? note : changedNote + ) + } + }, +}) +``` + +Lo siguiente se imprime en la consola + +![consola mostrando Handler y Target como null pero isRevoked como true](../../images/6/40new.png) + +Lo que vemos es interesante pero no muy útil. Esto tiene que ver con la librería Immer que mencionamos anteriormente y es utilizada por Redux Toolkit internamente para guardar el estado de la Tienda. + +El estado se puede convertir a un formato legible por humanos utilizando la función [current](https://redux-toolkit.js.org/api/other-exports#current) de la librería immer. + +Actualicemos las importaciones para incluir a la función "current" de la librería immer: + +```js +import { createSlice, current } from '@reduxjs/toolkit' // highlight-line +``` + +Luego actualicemos el llamado a la función console.log: + +```js +console.log(current(state)) // highlight-line +``` + +Ahora lo que imprime la consola es legible para humanos + +![consola mostrando array de 2 notas](../../images/6/41new.png) + +### Redux DevTools + +[Redux DevTools](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd) es una extension de Chrome, que ofrece útiles herramientas de desarrollo para Redux. Se puede usar, por ejemplo, para inspeccionar el estado del store de Redux y enviar acciones (dispatch) a través de la consola del navegador. Cuando el store se crea usando la función configureStore de Redux Toolkit, no se necesita ninguna configuración adicional para que Redux DevTools funcione. + +Una vez instalada la extension, al hacer clic en la pestaña de Redux en las herramientas de desarrollo del navegador, Redux DevTools debería abrirse: + +![redux addon en herramientas de desarrollo](../../images/6/42new.png) + +Puedes inspeccionar cómo el envío de una determinada acción cambia el estado haciendo clic en la acción: + +![devtools inspeccionando el árbol de state en redux](../../images/6/43new.png) + +También es posible enviar acciones (dispatch) a la store utilizando las herramientas de desarrollo: + +![devtools enviando createNote con payload](../../images/6/44new.png) + +El código actual de la aplicación se puede encontrar en [GitHub](https://github.com/fullstack-hy2020/redux-notes/tree/part6-3), en la rama part6-3. + +
    + +
    + +### Ejercicios 6.10.-6.13. + +Continuemos trabajando en la aplicación de anécdotas que comenzamos en el ejercicio 6.3, usando Redux Toolkit. + +#### 6.10 Mejores Anécdotas, paso 8 + +Instala Redux Toolkit en el proyecto. Mueve la creación del store de Redux a su propio archivo store.js y utiliza la función configureStore para crear el store. + +Cambia la definición del filter reducer y sus action creators para usar la función createSlice de Redux Toolkit. + +También, comienza a utilizar Redux DevTools para depurar el estado de la aplicación fácilmente. + +#### 6.11 Mejores Anécdotas, paso 9 + +Cambia también la definición de anecdote reducer y sus action creators para usar la función createSlice de Redux Toolkit. + +Nota de implementación: cuando utilices Redux Toolkit para devolver el estado inicial de las anécdotas, será inmutable, por lo que tendrás que copiarlo para ordenarlas, o te encontraras con el error "TypeError: Cannot assign to read only property". Puedes usar la sintaxis spread para hacer una copia del array. En vez de: + +```js + +anecdotes.sort() + +``` + +Escribe: + +```js + +[...anecdotes].sort() + +``` + +#### 6.12 Mejores Anécdotas, paso 10 + +La aplicación tiene el esqueleto del componente Notification listo para utilizarlo: + +```js +const Notification = () => { + const style = { + border: 'solid', + padding: 10, + borderWidth: 1 + } + return ( +
    + render here notification... +
    + ) +} + +export default Notification +``` + +Extiende el componente para que muestre el mensaje almacenado en el store de redux, haciendo que el componente tome la siguiente forma: + +```js +import { useSelector } from 'react-redux' // highlight-line + +const Notification = () => { + const notification = useSelector(/* something here */) // highlight-line + const style = { + border: 'solid', + padding: 10, + borderWidth: 1 + } + return ( +
    + {notification} // highlight-line +
    + ) +} +``` + +Tendrás que realizar cambios en el reducer existente de la aplicación. Crea un reducer separado para la nueva funcionalidad usando la función createSlice de Redux Toolkit. + +La aplicación no tiene que utilizar el componente Notification completamente en este punto de los ejercicios. Es suficiente con que la aplicación muestre el valor inicial establecido para el mensaje en el notificationReducer. + +#### 6.13 Mejores Anécdotas, paso 11 + +Extiende la aplicación para que utilice el componente Notification para mostrar un mensaje durante cinco segundos cuando el usuario vote por una anécdota o cree una nueva anécdota: + +![navegador mostrando el mensaje de haber votado](../../images/6/8ea.png) + +Se recomienda crear [action creators](https://redux-toolkit.js.org/api/createSlice#reducers) independientes para configurar y eliminar notificaciones. + +
    diff --git a/src/content/6/es/part6c.md b/src/content/6/es/part6c.md new file mode 100644 index 00000000000..ec4382d58d3 --- /dev/null +++ b/src/content/6/es/part6c.md @@ -0,0 +1,593 @@ +--- +mainImage: ../../../images/part-6.svg +part: 6 +letter: c +lang: es +--- + +
    + +Expandamos la aplicación, de modo que las notas se almacenen en el backend. Usaremos [json-server](/es/part2/obteniendo_datos_del_servidor), de la parte 2. + +El estado inicial de la base de datos se almacena en el archivo db.json, que se coloca en la raíz del proyecto: + +```json +{ + "notes": [ + { + "content": "the app state is in redux store", + "important": true, + "id": 1 + }, + { + "content": "state changes are made with actions", + "important": false, + "id": 2 + } + ] +} +``` + +Instalaremos json-server en nuestro proyecto... + +```js +npm install json-server --save-dev +``` + +y agregaremos la siguiente línea a la parte de scripts del archivo package.json + +```js +"scripts": { + "server": "json-server -p3001 --watch db.json", + // ... +} +``` + +Ahora iniciemos json-server con el comando _npm run server_. + +### Obteniendo datos del backend + +A continuación, crearemos un método en el archivo services/notes.js, que usa axios para obtener datos del backend + +```js +import axios from 'axios' + +const baseUrl = 'http://localhost:3001/notes' + +const getAll = async () => { + const response = await axios.get(baseUrl) + return response.data +} + +export default { getAll } +``` + +Agregaremos axios al proyecto + +```bash +npm install axios +``` + +Cambiaremos la inicialización del estado en noteReducer, de modo que por defecto no haya notas: + +```js +const noteSlice = createSlice({ + name: 'notes', + initialState: [], // highlight-line + // ... +}) +``` + +También agreguemos una nueva acción appendNote para añadir un objeto de una nota: + +```js +const noteSlice = createSlice({ + name: 'notes', + initialState: [], + reducers: { + createNote(state, action) { + const content = action.payload + + state.push({ + content, + important: false, + id: generateId(), + }) + }, + toggleImportanceOf(state, action) { + const id = action.payload + + const noteToChange = state.find(n => n.id === id) + + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + + return state.map(note => + note.id !== id ? note : changedNote + ) + }, + // highlight-start + appendNote(state, action) { + state.push(action.payload) + } + // highlight-end + }, +}) + +export const { createNote, toggleImportanceOf, appendNote } = noteSlice.actions // highlight-line + +export default noteSlice.reducer +``` + +Una manera rápida para inicializar el estado de las notas basado en los datos recibidos del backend es extraer las notas en el archivo main.jsx y enviar (dispatch) una acción usando el action creator appendNote para cada objeto de nota: + +```js +// ... +import noteService from './services/notes' // highlight-line +import noteReducer, { appendNote } from './reducers/noteReducer' // highlight-line + +const store = configureStore({ + reducer: { + notes: noteReducer, + filter: filterReducer, + } +}) + +// highlight-start +noteService.getAll().then(notes => + notes.forEach(note => { + store.dispatch(appendNote(note)) + }) +) +// highlight-end + +// ... +``` + +Enviar (dispatching) múltiples acciones no parece muy práctico. Agreguemos un action creator setNotes que se pueda usar para reemplazar directamente al array de notas. Obtendremos al action creator de la función createSlice implementando la acción setNotes: + +```js +// ... + +const noteSlice = createSlice({ + name: 'notes', + initialState: [], + reducers: { + createNote(state, action) { + const content = action.payload + + state.push({ + content, + important: false, + id: generateId(), + }) + }, + toggleImportanceOf(state, action) { + const id = action.payload + + const noteToChange = state.find(n => n.id === id) + + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + + return state.map(note => + note.id !== id ? note : changedNote + ) + }, + appendNote(state, action) { + state.push(action.payload) + }, + // highlight-start + setNotes(state, action) { + return action.payload + } + // highlight-end + }, +}) + +export const { createNote, toggleImportanceOf, appendNote, setNotes } = noteSlice.actions // highlight-line + +export default noteSlice.reducer +``` + +Ahora, el código en el archivo main.jsx se ve mucho mejor: + +```js +// ... +import noteService from './services/notes' +import noteReducer, { setNotes } from './reducers/noteReducer' // highlight-line + +const store = configureStore({ + reducer: { + notes: noteReducer, + filter: filterReducer, + } +}) + +noteService.getAll().then(notes => + store.dispatch(setNotes(notes)) // highlight-line +) +``` + +> **NB:** ¿Por qué no usamos await en lugar de promesas y controladores de eventos? +> +>Await solo funciona dentro de funciones async, y el código en main.jsx no está dentro de una función, por lo que debido a la naturaleza simple de la operación, esta vez nos abstendremos de usar async. + +Sin embargo, decidimos mover la inicialización de las notas al componente App y, como es habitual al obtener datos de un servidor, usaremos el effect hook. + +```js +import { useEffect } from 'react' // highlight-line +import NewNote from './components/NewNote' +import Notes from './components/Notes' +import VisibilityFilter from './components/VisibilityFilter' +import noteService from './services/notes' // highlight-line +import { setNotes } from './reducers/noteReducer' // highlight-line +import { useDispatch } from 'react-redux' // highlight-line + +const App = () => { + // highlight-start + const dispatch = useDispatch() + useEffect(() => { + noteService + .getAll().then(notes => dispatch(setNotes(notes))) + }, []) + // highlight-end + + return ( +
    + + + +
    + ) +} + +export default App +``` + +### Enviando datos al backend + +Podemos hacer lo mismo cuando se trata de crear una nueva nota. Expandamos el código comunicándonos con el servidor de la siguiente manera: + +```js +const baseUrl = 'http://localhost:3001/notes' + +const getAll = async () => { + const response = await axios.get(baseUrl) + return response.data +} + +// highlight-start +const createNew = async (content) => { + const object = { content, important: false } + const response = await axios.post(baseUrl, object) + return response.data +} +// highlight-end + +export default { + getAll, + createNew, // highlight-line +} +``` + +El método _addNote_ del componente NewNote cambia ligeramente: + +```js +import { useDispatch } from 'react-redux' +import { createNote } from '../reducers/noteReducer' +import noteService from '../services/notes' // highlight-line + +const NewNote = (props) => { + const dispatch = useDispatch() + + const addNote = async (event) => { // highlight-line + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + const newNote = await noteService.createNew(content) // highlight-line + dispatch(createNote(newNote)) // highlight-line + } + + return ( +
    + + +
    + ) +} + +export default NewNote +``` + +Debido a que el backend genera ids para las notas, cambiaremos el action creator createNote en el archivo noteReducer.js de la siguiente manera: + +```js +const noteSlice = createSlice({ + name: 'notes', + initialState: [], + reducers: { + createNote(state, action) { + state.push(action.payload) // highlight-line + }, + // .. + }, +}) +``` + +El cambio de importancia de las notas podría implementarse utilizando el mismo principio, haciendo una llamada asíncrona al servidor y luego enviando una acción apropiada. + +El estado actual del código para la aplicación se puede encontrar en [GitHub](https://github.com/fullstack-hy2020/redux-notes/tree/part6-3) en la rama part6-3. + +
    + +
    + +### Ejercicios 6.14.-6.15. + +#### 6.14 Anécdotas y el Backend, paso 1 + +Cuando la aplicación se inicie, obtén las anécdotas del backend implementado usando json-server. + +Como datos de backend iniciales, puedes usar, por ejemplo, [esto](https://github.com/fullstack-hy2020/misc/blob/master/anecdotes.json). + +#### 6.15 Anécdotas y el Backend, paso 2 + +Modifica la creación de nuevas anécdotas, de forma que las anécdotas se almacenen en el backend. + +
    + +
    + +### Acciones asíncronas y Redux Thunk + +Nuestro enfoque es bastante bueno, pero no es muy bueno que la comunicación con el servidor suceda dentro de las funciones de los componentes. Sería mejor si la comunicación pudiera abstraerse de los componentes para que no tengan que hacer nada más que llamar al action creator apropiado. Como ejemplo, App inicializaría el estado de la aplicación de la siguiente manera: + +```js +const App = () => { + const dispatch = useDispatch() + + useEffect(() => { + dispatch(initializeNotes()) + }, []) + + // ... +} +``` + +y NewNote crearía una nueva nota de la siguiente manera: + +```js +const NewNote = () => { + const dispatch = useDispatch() + + const addNote = async (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + dispatch(createNote(content)) + } + + // ... +} +``` + +En esta implementación, ambos componentes enviarían una acción sin necesidad de saber sobre la comunicación con el servidor que sucede detrás de escena. Estos tipos de acciones asíncronas se pueden implementar utilizando la librería [Redux Thunk](https://github.com/reduxjs/redux-thunk). El uso de la librería no requiere ninguna configuración adicional o incluso instalación cuando el store de Redux se ha creado utilizando la función configureStore del kit de herramientas de Redux (Redux Toolkit). + +Con Redux Thunk, es posible implementar action creators que devuelven una función en lugar de un objeto. La función recibe los métodos dispatch y getState del store de Redux como parámetros. Esto permite, por ejemplo, implementaciones de action creators asíncronos, que primero esperan la finalización de una cierta operación asíncrona y luego despachan alguna acción, que cambia el estado del store. + +Podemos implementar un action creator initializeNotes que inicializa las notas basadas en los datos recibidos del servidor de la siguiente manera: + +```js +// ... +import noteService from '../services/notes' // highlight-line + +const noteSlice = createSlice(/* ... */) + +export const { createNote, toggleImportanceOf, setNotes, appendNote } = noteSlice.actions + +// highlight-start +export const initializeNotes = () => { + return async dispatch => { + const notes = await noteService.getAll() + dispatch(setNotes(notes)) + } +} +// highlight-end + +export default noteSlice.reducer +``` + +En la función interna, es decir, la acción asíncrona, la operación primero obtiene todas las notas del servidor y luego despacha la acción setNotes, que las agrega al store. + +El componente App puede inicializar las notas de la siguiente manera: + +```js +// ... +import { initializeNotes } from './reducers/noteReducer' // highlight-line + +const App = () => { + const dispatch = useDispatch() + + // highlight-start + useEffect(() => { + dispatch(initializeNotes()) + }, []) + // highlight-end + + return ( +
    + + + +
    + ) +} +``` + +La solución es bastante elegante. La lógica de inicialización de las notas se ha separado completamente del componente React. + +Ahora, reemplacemos el action creator createNote creado por la función createSlice con un action creator asíncrono: + +```js +// ... +import noteService from '../services/notes' + +const noteSlice = createSlice({ + name: 'notes', + initialState: [], + reducers: { + toggleImportanceOf(state, action) { + const id = action.payload + + const noteToChange = state.find(n => n.id === id) + + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + + return state.map(note => + note.id !== id ? note : changedNote + ) + }, + appendNote(state, action) { + state.push(action.payload) + }, + setNotes(state, action) { + return action.payload + } + // createNote definition removed from here! + }, +}) + +export const { toggleImportanceOf, appendNote, setNotes } = noteSlice.actions // highlight-line + +export const initializeNotes = () => { + return async dispatch => { + const notes = await noteService.getAll() + dispatch(setNotes(notes)) + } +} + +// highlight-start +export const createNote = content => { + return async dispatch => { + const newNote = await noteService.createNew(content) + dispatch(appendNote(newNote)) + } +} +// highlight-end + +export default noteSlice.reducer +``` + +El principio aquí es el mismo: primero se ejecuta una operación asíncrona y luego se despacha la acción que cambia el estado del store. + +El componente NewNote cambia como se muestra a continuación: + +```js +// ... +import { createNote } from '../reducers/noteReducer' // highlight-line + +const NewNote = () => { + const dispatch = useDispatch() + + const addNote = async (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + dispatch(createNote(content)) //highlight-line + } + + return ( +
    + + +
    + ) +} +``` + +Finalmente, limpiemos un poco el archivo main.jsx moviendo el código relacionado con la creación del store de Redux a su propio archivo store.js: + +```js +import { configureStore } from '@reduxjs/toolkit' + +import noteReducer from './reducers/noteReducer' +import filterReducer from './reducers/filterReducer' + +const store = configureStore({ + reducer: { + notes: noteReducer, + filter: filterReducer + } +}) + +export default store +``` + +Luego de los cambios, el contenido del archivo main.jsx es el siguiente: + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' +import { Provider } from 'react-redux' +import store from './store' // highlight-line +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) +``` + +El estado actual del código de la aplicación se puede encontrar en [GitHub](https://github.com/fullstack-hy2020/redux-notes/tree/part6-5) en la rama part6-5. + +Redux Toolkit ofrece una gran cantidad de herramientas para simplificar la administración de estado asíncrono. Las herramientas adecuadas para este caso de uso son, por ejemplo, la función [createAsyncThunk](https://redux-toolkit.js.org/api/createAsyncThunk) y la API [RTK Query](https://redux-toolkit.js.org/rtk-query/overview). + +
    + +
    + +### Ejercicios 6.16.-6.19. + +#### 6.16 Anécdotas y el Backend, paso 3 + +Modifica la inicialización de la store de Redux para que suceda utilizando action creators asíncronos, los cuales son posibles gracias a la librería Redux Thunk. + +#### 6.17 Anécdotas y el Backend, paso 4 + +También modifica la creación de una nueva anécdota para que suceda usando action creators asíncronos, hecho posible por la librería Redux Thunk. + +#### 6.18 Anécdotas y el Backend, paso 5 + +La votación aún no guarda los cambios en el backend. Arregla la situación con la ayuda de la librería Redux Thunk. + +#### 6.19 Anécdotas y el Backend, paso 6 + +La creación de notificaciones sigue siendo un poco tediosa, ya que hay que realizar dos acciones y utilizar la función _setTimeout_: + +```js +dispatch(setNotification(`new anecdote '${content}'`)) +setTimeout(() => { + dispatch(clearNotification()) +}, 5000) +``` + +Crea un action creator, que te permita proveer la notificación de la siguiente manera: + +```js +dispatch(setNotification(`you voted '${anecdote.content}'`, 10)) +``` + +El primer parámetro es el texto que sera renderizado y el segundo parámetro es el tiempo durante el cual se mostrara la notificación en segundos. + +Implementa el uso de esta notificación mejorada en tu aplicación. + +
    diff --git a/src/content/6/es/part6d.md b/src/content/6/es/part6d.md new file mode 100644 index 00000000000..2feabff4876 --- /dev/null +++ b/src/content/6/es/part6d.md @@ -0,0 +1,826 @@ +--- +mainImage: ../../../images/part-6.svg +part: 6 +letter: d +lang: es +--- + +
    + +Al final de esta parte, analizaremos algunas formas diferentes de administrar el estado de una aplicación. + +Continuemos con la aplicación de notas. Nos centraremos en la comunicación con el servidor. Comencemos la aplicación desde cero. La primera versión es la siguiente: + +```js +const App = () => { + const addNote = async (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + console.log(content) + } + + const toggleImportance = (note) => { + console.log('toggle importance of', note.id) + } + + const notes = [] + + return( +
    +

    Notes app

    +
    + + +
    + {notes.map(note => +
  • toggleImportance(note)}> + {note.content} + {note.important ? 'important' : ''} +
  • + )} +
    + ) +} + +export default App +``` + +El código inicial está en GitHub en este [repositorio](https://github.com/fullstack-hy2020/query-notes/tree/part6-0), en la rama part6-0 . + +**Nota**: Por defecto, clonar el repositorio solo te dará la rama principal. Para obtener el código inicial de la rama part6-0, utiliza el siguiente comando: + +``` +git clone --branch part6-0 https://github.com/fullstack-hy2020/query-notes.git +``` + +### Administrando datos en el servidor con la librería React Query + +Ahora usaremos la librería [React Query](https://tanstack.com/query/latest) para almacenar y administrar los datos obtenidos del servidor. La ultima version también es llamada TanStack Query, pero seguiremos usando su nombre tradicional. + +Instala la librería con el comando + +```bash +npm install @tanstack/react-query +``` + +Se necesitan agregar algunas cosas en el archivo main.jsx para pasar las funciones de la librería a toda la aplicación: + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' // highlight-line + +import App from './App' + +const queryClient = new QueryClient() // highlight-line + +ReactDOM.createRoot(document.getElementById('root')).render( + // highlight-line + + // highlight-line +) +``` + +Ahora podemos traer las notas en el componente App. El código se expande de la siguiente manera: + +```js +import { useQuery } from '@tanstack/react-query' // highlight-line +import axios from 'axios' // highlight-line + +const App = () => { + // ... + + // highlight-start + const result = useQuery({ + queryKey: ['notes'], + queryFn: () => axios.get('http://localhost:3001/notes').then(res => res.data) + }) + + console.log(JSON.parse(JSON.stringify(result))) + // highlight-end + + // highlight-start + if ( result.isLoading ) { + return
    loading data...
    + } + // highlight-end + + const notes = result.data // highlight-line + + return ( + // ... + ) +} +``` + +La obtención de datos del servidor aún se realiza de una forma familiar con el método get de Axios. Sin embargo, la llamada al método de Axios ahora está envuelta en una [query](https://tanstack.com/query/latest/docs/react/guides/queries) (consulta) formada con la función [useQuery](https://tanstack.com/query/latest/docs/react/reference/useQuery). El primer parámetro de la llamada a la función es un string notes que actúa como la [key](https://tanstack.com/query/latest/docs/react/guides/query-keys) (clave) para la query definida, es decir, la lista de notas. + +El valor devuelto por la función useQuery es un objeto que indica el estado de la query. La salida a la consola ilustra la situación: + +![consola del navegador mostrando status success](../../images/6/60new.png) + +Es decir, la primera vez que se renderiza el componente, la query todavía está en estado loading, es decir, la solicitud HTTP asociada está pendiente. En esta etapa, solo se procesa lo siguiente: + +```html +
    loading data...
    +``` + +Sin embargo, la solicitud HTTP se completa tan rápido que ni siquiera Max Verstappen podría ver el texto. Cuando se completa la solicitud, el componente se renderiza de nuevo. La query está en el estado success en la segunda renderización, y el campo data del objeto de la query contiene los datos devueltos por la solicitud, es decir, la lista de notas que se muestran en la pantalla. + +Entonces, la aplicación recupera datos del servidor y los renderiza en la pantalla sin usar los Hooks de React useState y useEffect utilizados en los capítulos 2-5. Los datos en el servidor ahora están completamente bajo la administración de la librería React Query, ¡y la aplicación no necesita el estado definido con el Hook de React useState en absoluto! + +Movamos la función que realiza la solicitud HTTP a su propio archivo requests.js + +```js +import axios from 'axios' + +export const getNotes = () => + axios.get('http://localhost:3001/notes').then(res => res.data) +``` + +El componente App ahora se ha simplificado un poco: + +```js +import { useQuery } from '@tanstack/react-query' +import { getNotes } from './requests' // highlight-line + +const App = () => { + // ... + + const result = useQuery({ + queryKey: ['notes'], + queryFn: getNotes // highlight-line + }) + + // ... +} +``` + +El código actual de la aplicación está en [GitHub](https://github.com/fullstack-hy2020/query-notes/tree/part6-1) en la rama part6-1. + +### Sincronizando datos con el servidor usando React Query + +Los datos ya se han recuperado correctamente del servidor. A continuación, nos aseguraremos de que los datos agregados y modificados se almacenen en el servidor. Comencemos agregando nuevas notas. + +Hagamos una función createNote en el archivo requests.js para guardar nuevas notas: + +```js +import axios from 'axios' + +const baseUrl = 'http://localhost:3001/notes' + +export const getNotes = () => + axios.get(baseUrl).then(res => res.data) + +export const createNote = newNote => // highlight-line + axios.post(baseUrl, newNote).then(res => res.data) // highlight-line +``` + +El componente App cambiará de la siguiente manera: + +```js +import { useQuery, useMutation } from '@tanstack/react-query' // highlight-line +import { getNotes, createNote } from './requests' // highlight-line + +const App = () => { + const newNoteMutation = useMutation({ mutationFn: createNote }) // highlight-line + + const addNote = async (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + newNoteMutation.mutate({ content, important: true }) // highlight-line + } + + // + +} +``` + +Para crear una nueva nota, se define una [mutación](https://tanstack.com/query/latest/docs/react/guides/mutations) usando la función [useMutation](https://tanstack.com/query/latest/docs/react/reference/useMutation): + +```js +const newNoteMutation = useMutation({ mutationFn: createNote }) +``` + +El parámetro es la función que agregamos al archivo requests.js, que usa Axios para enviar una nueva nota al servidor. + +El controlador de eventos addNote realiza la mutación llamando a la función mutate del objeto de mutación y pasando la nueva nota como parámetro: + +```js +newNoteMutation.mutate({ content, important: true }) +``` + +Nuestra solución es buena. Excepto que no funciona. La nueva nota se guarda en el servidor, pero no se actualiza en la pantalla. + +Para renderizar una nueva nota, debemos decirle a React Query que el resultado antiguo de la query cuya clave es el string notes debe ser [invalidado](https://tanstack.com/query/latest/docs/react/guides/invalidations-from-mutations). + +Afortunadamente, la invalidación es fácil, se puede hacer definiendo la función de callback onSuccess apropiada para la mutación: + +```js +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' // highlight-line +import { getNotes, createNote } from './requests' + +const App = () => { + const queryClient = useQueryClient() // highlight-line + + const newNoteMutation = useMutation({ + mutationFn: createNote, + onSuccess: () => { // highlight-line + queryClient.invalidateQueries({ queryKey: ['notes'] }) // highlight-line + }, + }) + + // ... +} +``` + +Ahora que la mutación se ha ejecutado con éxito, se realiza una llamada a la función + +```js +queryClient.invalidateQueries('notes') +``` + +Esto hace que React Query actualice automáticamente la query con la clave notes, es decir que obtiene nuevamente las notas del servidor. Como resultado, la aplicación renderiza el estado actualizado en el servidor, por lo que la nota agregada también se renderiza. + +Implementemos también el cambio en la importancia de las notas. Se agrega una función para actualizar notas al archivo requests.js: + +```js +export const updateNote = updatedNote => + axios.put(`${baseUrl}/${updatedNote.id}`, updatedNote).then(res => res.data) +``` + +Actualizar la nota también se hace mediante una mutación. El componente App se expande de la siguiente manera: + +```js +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { getNotes, createNote, updateNote } from './requests' // highlight-line + +const App = () => { + // ... + + const updateNoteMutation = useMutation({ + mutationFn: updateNote, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notes'] }) + }, + }) + + // highlight-start + const toggleImportance = (note) => { + updateNoteMutation.mutate({...note, important: !note.important }) + } + // highlight-end + + // ... +} +``` + +De nuevo, se creó una mutación que invalidó la query notes para que la nota actualizada se renderice correctamente. Usar mutaciones es fácil, el método mutate recibe una nota como parámetro, cuya importancia se cambia a la negación del valor antiguo. + +El código actual de la aplicación está en [GitHub](https://github.com/fullstack-hy2020/query-notes/tree/part6-2) en la rama part6-2. + +### Optimizando el rendimiento + +La aplicación funciona bien y el código es relativamente simple. La facilidad para realizar cambios en la lista de notas es particularmente sorprendente. Cuando cambiamos la importancia de una nota, invalidar la query notes es suficiente para que los datos de la aplicación se actualicen: + +```js + const updateNoteMutation = useMutation({ + mutationFn: updateNote, + onSuccess: () => { + queryClient.invalidateQueries('notes') // highlight-line + }, + }) +``` + +La consecuencia de esto, por supuesto, es que después de la solicitud PUT que causa el cambio de nota, la aplicación realiza una nueva solicitud GET para recuperar los datos de la query desde el servidor: + +![pestaña network con foco sobre las solicitudes 3 y notes](../../images/6/61new.png) + +Si la cantidad de datos obtenidos por la aplicación no es grande, realmente no importa. Después de todo, desde el punto de vista de la funcionalidad del lado del navegador, hacer una solicitud HTTP GET adicional realmente no importa, pero en algunas situaciones podría generar una carga en el servidor. + +Si fuera necesario, es posible [optimizar el rendimiento manualmente](https://tanstack.com/query/latest/docs/react/guides/updates-from-mutation-responses), actualizando el estado de la query mantenido por React Query. + +El cambio para la mutación que agrega una nueva nota es el siguiente: + +```js +const App = () => { + const queryClient = useQueryClient() + + const newNoteMutation = useMutation({ + mutationFn: createNote, + onSuccess: (newNote) => { + const notes = queryClient.getQueryData(['notes']) // highlight-line + queryClient.setQueryData(['notes'], notes.concat(newNote)) // highlight-line + } + }) + // ... +} +``` + +Es decir, en el callback de onSuccess, el objeto queryClient primero lee el estado existente en notes de la query y lo actualiza agregando una nueva nota, que se obtiene como parámetro de la función de callback. El valor del parámetro es el valor devuelto por la función createNote, definida en el archivo requests.js de la siguiente manera: + +```js +export const createNote = newNote => + axios.post(baseUrl, newNote).then(res => res.data) +``` + +Seria relativamente fácil hacer un cambio similar a una mutación que cambia la importancia de la nota, pero lo dejamos como un ejercicio opcional. + +Si observamos de cerca la pestaña de red del navegador, nos damos cuenta de que React Query recupera todas las notas tan pronto como movemos el cursor al campo de entrada: + +![aplicación de notas con herramientas de desarrollo resaltando el campo de texto de entrada y flecha sobre red en la solicitud de notas con respuesta 200](../../images/6/62new.png) + +¿Que es lo que está pasando? Al leer la [documentación](https://tanstack.com/query/latest/docs/react/reference/useQuery), nos damos cuenta de que la funcionalidad predeterminada de las queries de React Query es que estas (cuyo estado es stale) se actualicen con el evento window focus, es decir, cuando cambia el elemento activo de la interfaz de usuario de la aplicación. Si queremos, podemos desactivar la funcionalidad creando una consulta de la siguiente manera: + +```js +const App = () => { + // ... + const result = useQuery({ + queryKey: ['notes'], + queryFn: getNotes, + refetchOnWindowFocus: false // highlight-line + }) + + // ... +} +``` + +Si colocas un console.log en el código, podrás ver desde la consola del navegador cuántas veces React Query hace que la aplicación se vuelva a renderizar. La regla general es que el renderizado ocurre cada vez que es necesario, es decir, cuando cambia el estado de la query. Puedes leer más al respecto [aquí](https://tkdodo.eu/blog/react-query-render-optimizations). + +El código de la aplicación está en [GitHub](https://github.com/fullstack-hy2020/query-notes/tree/part6-3) en la rama part6-3. + +React Query es una librería versátil que, basados en lo que ya hemos visto, simplifica la aplicación. ¿Hace React Query que soluciones de gestión de estado más complejas como Redux sean innecesarias? No. React Query puede reemplazar parcialmente el estado de la aplicación en algunos casos, pero como lo indica la [documentación:](https://tanstack.com/query/latest/docs/react/guides/does-this-replace-client-state): + +- React Query es una librería de estado del servidor, responsable de la gestión de operaciones asíncronas entre el servidor y el cliente +- Redux, etc. son librerías de estado del cliente que se pueden usar para almacenar datos asíncronos, aunque de manera menos eficiente cuando se comparan con una herramienta como React Query + +Entonces, React Query es una librería que mantiene el estado del servidor en el frontend, es decir, actúa como una caché para lo que se almacena en el servidor. React Query simplifica el procesamiento de datos en el servidor y, en algunos casos, puede eliminar la necesidad de que los datos en el servidor se guarden en el estado del frontend. + +La mayoría de las aplicaciones de React no necesitan solo una forma de almacenar temporalmente los datos servidos, sino también alguna solución para cómo se maneja el resto del estado del frontend (por ejemplo, el estado de los formularios o las notificaciones). + +
    + +
    + +### Ejercicios 6.20.-6.22. + +Ahora hagamos una nueva versión de la aplicación de anécdotas que use a la librería React Query. Usa [este proyecto](https://github.com/fullstack-hy2020/query-anecdotes) como punto de partida. El proyecto tiene un JSON Server instalado, la operación del cual se ha modificado ligeramente (revisa el archivo _server.js_ para más detalles). Inicia el servidor con npm run server. + +#### Ejercicio 6.20 + +Implementa la obtención de anécdotas del servidor usando React Query. + +La aplicación debe funcionar de tal manera que si hay problemas para comunicarse con el servidor, solo se mostrará una página de error: + +![navegador diciendo que anecdote service no esta disponible debido a problemas con el servidor en localhost](../../images/6/65new.png) + +Puedes encontrar [aquí](https://tanstack.com/query/latest/docs/react/guides/queries) información sobre cómo detectar posibles errores. + +Puedes simular un problema con el servidor apagando el JSON Server. Ten en cuenta que en una situación problemática, la consulta primero está en el estado isLoading durante un tiempo, porque si una solicitud falla, React Query intenta la solicitud varias veces antes de que indique que la solicitud no es exitosa. Opcionalmente, puedes especificar que no se realicen reintentos: + +```js +const result = useQuery( + { + queryKey: ['anecdotes'], + queryFn: getAnecdotes, + retry: false + } +) +``` + +o que la solicitud se vuelva a intentar solo una vez más: + +```js +const result = useQuery( + { + queryKey: ['anecdotes'], + queryFn: getAnecdotes, + retry: 1 + } +) +``` + +#### Ejercicio 6.21 + +Implementa la adición de nuevas anécdotas al servidor usando React Query. La aplicación debe renderizar una nueva anécdota por defecto. Ten en cuenta que el contenido de la anécdota debe tener al menos 5 caracteres de longitud, de lo contrario el servidor rechazará la solicitud POST. No tienes que preocuparte por el control de errores ahora. + +#### Ejercicio 6.22 + +Implementa la votación de anécdotas usando nuevamente React Query. La aplicación debe renderizar automáticamente el número aumentado de votos para la anécdota votada. + +
    + +
    + +### useReducer + +Entonces, incluso si la aplicación usa React Query, generalmente se necesita alguna solución para manejar el resto del estado del frontend (por ejemplo, el estado de los formularios). Con bastante frecuencia, el estado creado con useState es una solución suficiente. Usar Redux es, por supuesto, posible, pero hay otras alternativas. + +Veamos una aplicación de contador sencilla. La aplicación muestra el valor del contador y ofrece tres botones para actualizar su estado: + +![botones + - y 0, con el valor 7 arriba](../../images/6/63new.png) + +Ahora implementaremos la gestión del estado del contador usando un mecanismo de gestión de estado similar a Redux proporcionado por el hook de React [useReducer](https://es.react.dev/reference/react/useReducer). El código se ve así: + +```js +import { useReducer } from 'react' + +const counterReducer = (state, action) => { + switch (action.type) { + case "INC": + return state + 1 + case "DEC": + return state - 1 + case "ZERO": + return 0 + default: + return state + } +} + +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) + + return ( +
    +
    {counter}
    +
    + + + +
    +
    + ) +} + +export default App +``` + +El hook [useReducer](https://es.react.dev/reference/react/useReducer) proporciona un mecanismo para crear un estado para la aplicación. El parámetro para crear un estado es la función del reducer que maneja los cambios de estado y el valor inicial del estado: + +```js +const [counter, counterDispatch] = useReducer(counterReducer, 0) +``` + +La función del reducer que maneja los cambios de estado es similar a los reducers de Redux, es decir, la función obtiene como parámetros el estado actual y la acción que cambia el estado. La función devuelve el nuevo estado actualizado en función del tipo y el posible contenido de la acción: + +```js +const counterReducer = (state, action) => { + switch (action.type) { + case "INC": + return state + 1 + case "DEC": + return state - 1 + case "ZERO": + return 0 + default: + return state + } +} +``` + +En nuestro ejemplo, las acciones no tienen nada más que un tipo. Si el tipo de acción es INC, aumenta el valor del contador en uno, etc. Como los reducers de Redux, las acciones también pueden contener datos arbitrarios, que generalmente se colocan en el campo payload de la acción. + +La función useReducer devuelve un array que contiene un elemento para acceder al valor actual del estado (primer elemento del array) y una función dispatch (segundo elemento del array) para cambiar el estado: + +```js +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) // highlight-line + + return ( +
    +
    {counter}
    // highlight-line +
    + // highlight-line + + +
    +
    + ) +} +``` + +Como se puede ver, el cambio de estado se realiza exactamente como en Redux, la función de dispatch recibe la acción apropiada para cambiar el estado como parámetro: + +```js +counterDispatch({ type: "INC" }) +``` + +El código actual de la aplicación se encuentra en el repositorio [https://github.com/fullstack-hy2020/hook-counter](https://github.com/fullstack-hy2020/hook-counter/tree/part6-1) en la rama part6-1. + +### Usando context para pasar el estado a los componentes + +Si queremos dividir la aplicación en varios componentes, el valor del contador y la función de dispatch utilizada para gestionarlo también deben pasarse a los otros componentes. Una solución sería pasar estos como props de la manera habitual: + +```js +const Display = ({ counter }) => { + return
    {counter}
    +} + +const Button = ({ dispatch, type, label }) => { + return ( + + ) +} + +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) + + return ( +
    + // highlight-line +
    + // highlight-start +
    +
    + ) +} +``` + +La solución funciona, pero no es óptima. Si la estructura de los componentes se complica, el despachador debe transmitirse usando props a través de muchos componentes para llegar a los componentes que lo necesitan, aunque los componentes intermedios en el árbol de componentes no necesiten al despachador. Este fenómeno se llama prop drilling. + +La [API de Contexto](https://es.react.dev/learn/passing-data-deeply-with-context) integrada en React proporciona una solución para nosotros. El contexto de React es un tipo de estado global de la aplicación, al que se puede dar acceso directo a cualquier componente de la aplicación. + +Vamos a crear ahora un contexto en la aplicación que almacene la gestión de estado del contador. + +El contexto se crea con el hook [createContext](https://es.react.dev/reference/react/createContext) de React. Vamos a crear un contexto en el archivo CounterContext.jsx: + +```js +import { createContext } from 'react' + +const CounterContext = createContext() + +export default CounterContext +``` + +El componente App ahora puede proveer un contexto a sus componentes hijos de la siguiente manera: + +```js +import CounterContext from './CounterContext' // highlight-line + +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) + + return ( + // highlight-line + +
    +
    +
    // highlight-line + ) +} +``` + +Como se puede ver, el proveedor de contexto se realiza envolviendo los componentes hijo dentro del componente CounterContext.Provider y estableciendo un valor adecuado para el contexto. + +El valor del contexto ahora se establece en un array que contiene el valor del contador y la función dispatch. + +Otros componentes ahora acceden al contexto utilizando el hook [useContext](https://es.react.dev/reference/react/useContext): + +```js +import { useContext } from 'react' // highlight-line +import CounterContext from './CounterContext' + +const Display = () => { + const [counter] = useContext(CounterContext) // highlight-line + return
    + {counter} +
    +} + +const Button = ({ type, label }) => { + const [counter, dispatch] = useContext(CounterContext) // highlight-line + return ( + + ) +} +``` + +El código actual de la aplicación se encuentra en [GitHub](https://github.com/fullstack-hy2020/hook-counter/tree/part6-2) en la rama part6-2. + +### Definiendo el contexto del contador en un archivo separado + +Nuestra aplicación tiene una característica molesta, que la funcionalidad de la gestión del estado del contador está parcialmente definida en el componente App. Ahora vamos a mover todo lo relacionado con el contador al archivo CounterContext.jsx: + +```js +import { createContext, useReducer } from 'react' + +const counterReducer = (state, action) => { + switch (action.type) { + case "INC": + return state + 1 + case "DEC": + return state - 1 + case "ZERO": + return 0 + default: + return state + } +} + +const CounterContext = createContext() + +export const CounterContextProvider = (props) => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) + + return ( + + {props.children} + + ) +} + +export default CounterContext +``` + +El archivo ahora exporta, además del objeto CounterContext correspondiente al contexto, el componente CounterContextProvider, que es prácticamente un proveedor de contexto cuyo valor es un contador y un despachador utilizado para su gestión de estado. + +Habilitemos el proveedor de contexto haciendo un cambio en main.jsx: + +```js +import ReactDOM from 'react-dom/client' +import App from './App' +import { CounterContextProvider } from './CounterContext' // highlight-line + +ReactDOM.createRoot(document.getElementById('root')).render( + // highlight-line + + // highlight-line +) +``` + +Ahora el contexto que define el valor y la funcionalidad del contador está disponibles para todos los componentes de la aplicación. + +El componente App se simplifica a la siguiente forma: + +```js +import Display from './components/Display' +import Button from './components/Button' + +const App = () => { + return ( +
    + +
    +
    +
    + ) +} + +export default App +``` + +El contexto es usado de la misma forma en el componente Button, como se ve a continuación: + +```js +import { useContext } from 'react' +import CounterContext from '../CounterContext' + +const Button = ({ type, label }) => { + const [counter, dispatch] = useContext(CounterContext) + return ( + + ) +} + +export default Button +``` + +El componente Button solo necesita la función dispatch del contador, pero también obtiene el valor del contador del contexto utilizando la función useContext: + +```js + const [counter, dispatch] = useContext(CounterContext) +``` + +Esto no es un gran problema, pero es posible hacer que el código sea un poco más agradable y expresivo definiendo un par de funciones auxiliares en el archivo CounterContext: + +```js +import { createContext, useReducer, useContext } from 'react' // highlight-line + +const CounterContext = createContext() + +// ... + +export const useCounterValue = () => { + const counterAndDispatch = useContext(CounterContext) + return counterAndDispatch[0] +} + +export const useCounterDispatch = () => { + const counterAndDispatch = useContext(CounterContext) + return counterAndDispatch[1] +} + +// ... +``` + +Con la ayuda de estas funciones auxiliares, es posible que los componentes que usan el contexto obtengan la parte del contexto que necesitan. El componente Display cambia de la siguiente manera: + +```js +import { useCounterValue } from '../CounterContext' // highlight-line + +const Display = () => { + const counter = useCounterValue() // highlight-line + return
    + {counter} +
    +} + + +export default Display +``` + +El componente Button se vuelve: + +```js +import { useCounterDispatch } from '../CounterContext' // highlight-line + +const Button = ({ type, label }) => { + const dispatch = useCounterDispatch() // highlight-line + return ( + + ) +} + +export default Button +``` + +La solución es bastante elegante. Todo el estado de la aplicación, es decir, el valor del contador y el código para gestionarlo, ahora está aislado en el archivo CounterContext, que proporciona a los componentes funciones auxiliares bien nombradas y fáciles de usar para gestionar el estado. + +El código de la aplicación final se encuentra en [GitHub](https://github.com/fullstack-hy2020/hook-counter/tree/part6-3) en la rama part6-3. + +Como detalle técnico, debe tenerse en cuenta que las funciones auxiliares useCounterValue y useCounterDispatch se definen como [hooks personalizados](https://es.react.dev/learn/reusing-logic-with-custom-hooks), porque llamar a la función de hook useContext es [posible](https://es.react.dev/warnings/invalid-hook-call-warning#breaking-rules-of-hooks) solo desde componentes de React o hooks personalizados. Los hooks personalizados, por otro lado, son funciones JavaScript cuyo nombre debe comenzar con la palabra _use_. Volveremos a los hooks personalizados en un poco más de detalle en la [parte 7](/es/part7/hooks_personalizados) del curso. + +
    + +
    + +### Ejercicios 6.23.-6.24. + +#### Ejercicio 6.23. + +La aplicación tiene un componente Notification para mostrar notificaciones al usuario. + +Implementa la gestión del estado de las notificaciones para la aplicación utilizando el hook useReducer y el contexto. La notificación debe informar al usuario cuando se crea una nueva anécdota o cuando se vota por ella: + +![navegador mostrando notificación para anécdota añadida](../../images/6/66new.png) + +La notificación se muestra durante cinco segundos. + +#### Ejercicio 6.24. + +Como se indicó en el ejercicio 6.21, el servidor requiere que el contenido de la anécdota a agregar tenga al menos 5 caracteres de longitud. Ahora implementa el manejo de errores para la inserción. En la práctica, es suficiente mostrar una notificación al usuario en caso de una solicitud POST fallida: + +![navegador mostrando notificación de error por tratar de crear una anécdota muy corta](../../images/6/67new.png) + +La condición de error debe manejarse en la función de callback registrada para ello, consulta [aquí](https://tanstack.com/query/latest/docs/framework/react/reference/useMutation) cómo registrar una función. + +Este fue el último ejercicio para esta parte del curso y es hora de enviar tu código a GitHub y marcar todos tus ejercicios completados en el [sistema de envío de ejercicios](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    + +
    + +#### ¿Qué solución de gestión de estado elegir? + +En los capítulos 1-5, toda la gestión de estado de la aplicación se realizó utilizando el hook de React useState. Las llamadas asíncronas al backend requerían el uso del hook useEffect en algunas situaciones. En principio, no se necesita nada más. + +Un problema sutil con una solución basada en un estado creado con el hook React useState es que si alguna parte del estado de la aplicación se necesita en varios componentes de la aplicación, el estado y las funciones para manipularlo deben pasarse a través de las props a todos los componentes que manejan el estado. A veces, las props deben pasar por varios componentes, y algunos de los componentes a lo largo del camino ni siquiera están interesados en ese estado. Este fenómeno algo desagradable se llama prop drilling. + +A lo largo de los años, se han desarrollado varias soluciones alternativas para la gestión de estado de aplicaciones React, que se pueden usar para aliviar situaciones problemáticas (por ejemplo, prop drilling). Sin embargo, ninguna solución ha sido "final", todas tienen sus propias ventajas y desventajas, y se están desarrollando nuevas soluciones todo el tiempo. + +La situación puede confundir a un principiante e incluso a un desarrollador web experimentado. ¿Qué solución se debe usar? + +Para una aplicación simple, useState es sin duda un buen punto de partida. Si la aplicación está comunicándose con el servidor, la comunicación se puede manejar de la misma manera que en los capítulos 1-5, utilizando el estado de la aplicación misma. Sin embargo, recientemente se ha vuelto más común mover la comunicación y la gestión asociada del estado al menos parcialmente bajo el control de React Query (o alguna otra biblioteca similar). Si estás preocupado por useState y el prop drilling que conlleva, usar context puede ser una buena opción. También hay situaciones en las que puede tener sentido manejar parte del estado con useState y parte con contextos. + +La solución de gestión de estado más completa y robusta es Redux, que es una forma de implementar la llamada arquitectura [Flux](https://facebookarchive.github.io/flux/docs/in-depth-overview/). Redux es ligeramente más antigua que las soluciones presentadas en esta sección. La rigidez de Redux ha sido la motivación para muchas nuevas soluciones de gestión de estado, como el useReducer de React. Algunas de las críticas a la rigidez de Redux ya se han vuelto obsoletas gracias al [Redux Toolkit](https://redux-toolkit.js.org/). + +A lo largo de los años, también se han desarrollado otras librería de gestión de estado que son similares a Redux, como el recién llegado [Recoil](https://recoiljs.org/) y el ligeramente más antiguo [MobX](https://mobx.js.org/). Sin embargo, según [Tendencias de Npm](https://npmtrends.com/mobx-vs-recoil-vs-redux), Redux todavía domina claramente, y de hecho parece estar aumentando su ventaja: + +![gráfica mostrando a redux creciendo en popularidad en los últimos 5 años](../../images/6/64new.png) + +Redux puede no usarse en su totalidad en una aplicación. Por ejemplo, puede tener sentido gestionar el estado de un formulario fuera de Redux, especialmente en situaciones en las que el estado de un formulario no afecta al resto de la aplicación. También es perfectamente posible usar Redux y React Query juntos en la misma aplicación. + +La pregunta de qué solución de gestión de estado se debe usar no es para nada sencilla. No es posible dar una sola respuesta correcta. También es probable que la solución de gestión de estado seleccionada pueda resultar ser sub-óptima a medida que la aplicación crece, hasta tal punto que la solución tenga que cambiar incluso si ya se está usando la aplicación en producción. + +
    diff --git a/src/content/6/fi/osa6.md b/src/content/6/fi/osa6.md index 07cd320cbd5..66d6a3e0864 100644 --- a/src/content/6/fi/osa6.md +++ b/src/content/6/fi/osa6.md @@ -6,6 +6,13 @@ lang: fi
    -Olemme toistaiseksi sijoittaneet ohjelman tilan ja siitä huolehtivan logiikan suoraan React-komponentteihin. Kun sovellukset kasvavat, kannattaa sovelluksen tila siirtää React-komponenttien ulkopuolelle. Tässä osassa tutustumme Redux-kirjastoon, joka on tämän hetken eniten käytetty React-sovellusten tilanhallintarkatkaisu. +Olemme toistaiseksi sijoittaneet ohjelman tilan ja siitä huolehtivan logiikan suoraan React-komponentteihin. Kun sovellukset kasvavat, kannattaa sovelluksen tila siirtää React-komponenttien ulkopuolelle. Tässä osassa tutustumme Redux-kirjastoon, joka on tämän hetken eniten käytetty React-sovellusten tilanhallintaratkaisu. + +Tututustumme Reactin suoraan tukemaan Reduxin kevytversioon, eli Reactin kontekstiin ja useRedux-hookiin sekä palvelimen tilan hallintaa helpottavaan React Query ‑kirjastoon. + +Osa päivitetty 12.10.2025 +- Node päivitetty versioon 22.18.0 +- Jest korvattu Vitestillä +- Axios korvattu Fetch API:lla
    diff --git a/src/content/6/fi/osa6a.md b/src/content/6/fi/osa6a.md index f9f759bcb99..50f71699870 100644 --- a/src/content/6/fi/osa6a.md +++ b/src/content/6/fi/osa6a.md @@ -7,40 +7,39 @@ lang: fi
    -Olemme noudattaneet sovelluksen tilan hallinnassa Reactin suosittelemaa käytäntöä määritellä useiden komponenttien tarvitsema tila ja sitä käsittelevät metodit [sovelluksen juurikomponentissa](https://reactjs.org/docs/lifting-state-up.html). Tilaa ja sitä käsitteleviä funktioita on välitetty propsien avulla niitä tarvitseville komponenteille. Tämä toimii johonkin pisteeseen saakka, mutta sovelluksen kasvaessa, muuttuu tilan hallinta haasteelliseksi. +Olemme noudattaneet sovelluksen tilan hallinnassa Reactin suosittelemaa käytäntöä määritellä useiden komponenttien tarvitsema tila ja sitä käsittelevät funktiot sovelluksen komponenttirakenteen [ylimmissä](https://reactjs.org/docs/lifting-state-up.html) kompontenteissa. Usein suurin osa tilaa ja sitä käsittelevistä funktioista on määritelty suoraan sovelluksen juurikomponentissa ja välitetty propsien avulla niitä tarvitseville komponenteille. Tämä toimii johonkin pisteeseen saakka, mutta sovelluksen kasvaessa tilan hallinta muuttuu haasteelliseksi. ### Flux-arkkitehtuuri -Facebook kehitti tilan hallinnan ongelmia helpottamaan [Flux](https://facebook.github.io/flux/docs/in-depth-overview)-arkkitehtuurin. Fluxissa sovelluksen tilan hallinta erotetaan kokonaan Reactin komponenttien ulkopuolisiin varastoihin eli storeihin. Storessa olevaa tilaa ei muuteta suoraan, vaan tapahtumien eli actionien avulla. +Facebook kehitti jo Reactin historian varhaisvaiheissa tilan hallinnan ongelmia helpottamaan [Flux](https://facebookarchive.github.io/flux/docs/in-depth-overview)-arkkitehtuurin. Fluxissa sovelluksen tilan hallinta erotetaan kokonaan Reactin komponenttien ulkopuolisiin varastoihin eli storeihin. Storessa olevaa tilaa ei muuteta suoraan, vaan tapahtumien eli actionien avulla. Kun action muuttaa storen tilaa, renderöidään näkymät uudelleen: -![](https://facebook.github.io/flux/img/overview/flux-simple-f8-diagram-1300w.png) +![Action -> Dispatcher -> Store -> View](../../images/6/flux1.png) -Jos sovelluksen käyttö, esim. napin painaminen aiheuttaa tarpeen tilan muutokseen, tehdään tilanmuutos actionin avulla. Tämä taas aiheuttaa uuden näytön renderöitymisen: +Jos sovelluksen käyttö (esim. napin painaminen) aiheuttaa tarpeen tilan muutokseen, tehdään muutos actionin avulla. Tämä taas aiheuttaa uuden näytön renderöitymisen: -![](https://facebook.github.io/flux/img/overview/flux-simple-f8-diagram-with-client-action-1300w.png) +![Action -> Dispatcher -> Store -> View -> Action -> Dispatcher -> View](../../images/6/flux2.png) Flux tarjoaa siis standardin tavan sille miten ja missä sovelluksen tila pidetään sekä tavalle tehdä tilaan muutoksia. ### Redux -Facebookilla on olemassa valmis toteutus Fluxille, käytämme kuitenkin saman periaatteen mukaan toimivaa, mutta hieman yksinkertaisempaa [Redux](https://redux.js.org)-kirjastoa, jota myös Facebookilla käytetään nykyään alkuperäisen Flux-toteutuksen sijaan. +Facebookilla on olemassa valmis toteutus Fluxille, mutta käytämme kuitenkin saman periaatteen mukaan toimivaa mutta hieman yksinkertaisempaa [Redux](https://redux.js.org)-kirjastoa, jota myös Facebookilla käytetään nykyään alkuperäisen Flux-toteutuksen sijaan. Tutustutaan Reduxiin tekemällä jälleen kerran laskurin toteuttava sovellus: -![](../../images/6/1.png) +![Renderöity kokonaisluku sekä kolme nappia: plus, minus ja zero](../../images/6/1.png) - -Tehdään uusi create-react-app-sovellus ja asennetaan siihen redux komennolla +Tehdään uusi Vite‑sovellus ja asennetaan siihen Redux: ```bash -npm install redux --save +npm install redux ``` Fluxin tapaan Reduxissa sovelluksen tila talletetaan [storeen](https://redux.js.org/basics/store). -Koko sovelluksen tila talletetaan yhteen storen tallettamaan Javascript-objektiin. Koska sovelluksemme ei tarvitse mitään muuta tilaa kuin laskurin arvon, talletetaan se storeen suoraan. Jos sovelluksen tila olisi monipuolisempi, talletettaisiin "eri asiat" storessa olevaan olioon erillisinä kenttinä. +Koko sovelluksen tila talletetaan yhteen storen tallettamaan JavaScript-objektiin. Koska sovelluksemme ei tarvitse mitään muuta tilaa kuin laskurin arvon, talletetaan se storeen sellaisenaan. Jos sovelluksen tila olisi monimutkaisempi, talletettaisiin "eri asiat" storessa olevaan olioon erillisinä kenttinä. Storen tilaa muutetaan [actionien](https://redux.js.org/basics/actions) avulla. Actionit ovat olioita, joilla on vähintään actionin tyypin määrittelevä kenttä type. Sovelluksessamme tarvitsemme esimerkiksi seuraavaa actionia: @@ -52,9 +51,9 @@ Storen tilaa muutetaan [actionien](https://redux.js.org/basics/actions) avulla. Jos actioneihin liittyy dataa, määritellään niille tarpeen vaatiessa muitakin kenttiä. Laskurisovelluksemme on kuitenkin niin yksinkertainen, että actioneille riittää pelkkä tyyppikenttä. -Actionien vaikutus sovelluksen tilaan määritellään [reducerin](https://redux.js.org/basics/reducers) avulla. Käytännössä reducer on funktio, joka saa parametrikseen olemassaolevan staten tilan sekä actionin ja palauttaa staten uuden tilan. +Actionien vaikutus sovelluksen tilaan määritellään [reducerin](https://redux.js.org/basics/reducers) avulla. Käytännössä reducer on funktio, joka saa parametrikseen staten nykyisen tilan sekä actionin ja palauttaa staten uuden tilan. -Määritellään nyt sovelluksellemme reduceri: +Määritellään nyt sovelluksellemme reducer tiedostoon main.jsx. Tiedosto näyttää aluksi seuraavalta: ```js const counterReducer = (state, action) => { @@ -70,7 +69,7 @@ const counterReducer = (state, action) => { } ``` -Ensimmäinen parametri on siis storessa oleva tila. Reducer palauttaa uuden tilan actionin tyypin mukaan. +Ensimmäinen parametri on siis storessa oleva tila. Reducer palauttaa uuden tilan actionin tyypin mukaan. Eli esim. actionin tyypin ollessa INCREMENT tila saa arvokseen vanhan arvon plus yksi. Jos actionin tyyppi on ZERO tilan uusi arvo on nolla. Muutetaan koodia vielä hiukan. Reducereissa on tapana käyttää if:ien sijaan [switch](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/switch)-komentoa. Määritellään myös parametrille state [oletusarvoksi](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters) 0. Näin reducer toimii vaikka storen tilaa ei olisi vielä alustettu. @@ -85,7 +84,7 @@ const counterReducer = (state = 0, action) => { case 'ZERO': return 0 default: // jos ei mikään ylläolevista tullaan tänne - return state + return state } } ``` @@ -93,15 +92,26 @@ const counterReducer = (state = 0, action) => { Reduceria ei ole tarkoitus kutsua koskaan suoraan sovelluksen koodista. Reducer ainoastaan annetaan parametrina storen luovalle _createStore_-funktiolle: ```js -import { createStore } from 'redux' +import { createStore } from 'redux' // highlight-line const counterReducer = (state = 0, action) => { - // ... + switch (action.type) { + case 'INCREMENT': + return state + 1 + case 'DECREMENT': + return state - 1 + case 'ZERO': + return 0 + default: + return state + } } -const store = createStore(counterReducer) +const store = createStore(counterReducer) // highlight-line ``` +Koodieditori saattaa huomauttaa, että _createStore_ on vanhentunut. Ei välitetä siitä toistaiseksi, alempana on tarkempi selitys asiasta. + Store käyttää nyt reduceria käsitelläkseen actioneja, jotka dispatchataan eli "lähetetään" storelle sen [dispatch](https://redux.js.org/api/store#dispatchaction)-metodilla: ```js @@ -113,7 +123,11 @@ Storen tilan saa selville metodilla [getState](https://redux.js.org/api/store/#g Esim. seuraava koodi ```js +// ... + const store = createStore(counterReducer) + +// highlight-start console.log(store.getState()) store.dispatch({type: 'INCREMENT'}) store.dispatch({type: 'INCREMENT'}) @@ -122,21 +136,22 @@ console.log(store.getState()) store.dispatch({type: 'ZERO'}) store.dispatch({type: 'DECREMENT'}) console.log(store.getState()) +// highlight-end ``` tulostaisi konsoliin -
    +```
     0
     3
     -1
    -
    +``` sillä ensin storen tila on 0. Kolmen INCREMENT-actionin jälkeen tila on 3, ja lopulta actionien ZERO ja DECREMENT jälkeen -1. -Kolmas tärkeä metodi storella on [subscribe](https://redux.js.org/api/store#subscribelistener), jonka avulla voidaan määritellä takaisinkutsufunktioita, joita store kutsuu sen tilan muuttumisen yhteydessä. +Kolmas storen tärkeä metodi on [subscribe](https://redux.js.org/api/store#subscribelistener), jonka avulla voidaan määritellä takaisinkutsufunktioita, joita store kutsuu sen tilan muuttumisen yhteydessä. -Jos esim. lisäisimme seuraavan funktion subscribe:lla, tulostuisi jokainen storen muutos konsoliin. +Esimerkkinä voisimme tulostaa jokaisen storen muutoksen konsoliin näin: ```js store.subscribe(() => { @@ -145,39 +160,44 @@ store.subscribe(() => { }) ``` -eli koodi +Tällöin koodi ```js +// ... + const store = createStore(counterReducer) +// highlight-start store.subscribe(() => { const storeNow = store.getState() console.log(storeNow) }) +// highlight-end +// highlight-start store.dispatch({ type: 'INCREMENT' }) store.dispatch({ type: 'INCREMENT' }) store.dispatch({ type: 'INCREMENT' }) store.dispatch({ type: 'ZERO' }) store.dispatch({ type: 'DECREMENT' }) +// highlight-end ``` -aiheuttaisi tulostuksen +tulostaisi -
    +```
     1
     2
     3
     0
     -1
    -
    +``` -Laskurisovelluksemme koodi on seuraavassa. Kaikki koodi on kirjoitettu samaan tiedostoon, joten store on suoraan React-koodin käytettävissä. Tutustumme React/Redux-koodin parempiin strukturointitapoihin myöhemmin. +Laskurisovelluksemme koodi on seuraavassa. Kaikki koodi on kirjoitettu samaan tiedostoon, joten store on suoraan React-koodin käytettävissä. Tutustumme React/Redux-koodin parempiin strukturointitapoihin myöhemmin. Tiedoston main.jsx sisältö näyttää seuraavalta: ```js -import React from 'react' -import ReactDOM from 'react-dom' +import ReactDOM from 'react-dom/client' import { createStore } from 'redux' const counterReducer = (state = 0, action) => { @@ -198,30 +218,24 @@ const store = createStore(counterReducer) const App = () => { return (
    -
    - {store.getState()} -
    - - -
    ) } +const root = ReactDOM.createRoot(document.getElementById('root')) + const renderApp = () => { - ReactDOM.render(, document.getElementById('root')) + root.render() } renderApp() @@ -230,29 +244,55 @@ store.subscribe(renderApp) Koodissa on pari huomionarvoista seikkaa. App renderöi laskurin arvon kysymällä sitä storesta metodilla _store.getState()_. Nappien tapahtumankäsittelijät dispatchaavat suoraan oikean tyyppiset actionit storelle. -Kun storessa olevan tilan arvo muuttuu, ei React osaa automaattisesti renderöidä sovellusta uudelleen. Olemmekin rekisteröineet koko sovelluksen renderöinnin suorittavan funktion _renderApp_ kuuntelemaan storen muutoksia metodilla _store.subscribe_. Huomaa, että joudumme kutsumaan heti alussa metodia _renderApp_, ilman kutsua sovelluksen ensimmäistä renderöintiä ei koskaan tapahdu. +Kun storessa olevan tilan arvo muuttuu, ei React osaa automaattisesti renderöidä sovellusta uudelleen. Olemmekin rekisteröineet koko sovelluksen renderöinnin suorittavan funktion _renderApp_ kuuntelemaan storen muutoksia metodilla _store.subscribe_. Huomaa, että joudumme kutsumaan heti alussa metodia _renderApp_, sillä ilman kutsua sovelluksen ensimmäistä renderöintiä ei tapahdu ollenkaan. + +### Huomautus funktion createStore käytöstä + +Tarkkasilmäisimmät huomaavat, että funktion createStore nimen päällä on viiva. Jos hiiren vie nimen päälle, tulee asialle selitystä + +![](../../images/6/30new.png) + +Selitys on kokonaisuudessaan seuraava + +>We recommend using the configureStore method of the @reduxjs/toolkit package, which replaces createStore. +> +>Redux Toolkit is our recommended approach for writing Redux logic today, including store setup, reducers, data fetching, and more. +> +>For more details, please read this Redux docs page: https://redux.js.org/introduction/why-rtk-is-redux-today +> +>configureStore from Redux Toolkit is an improved version of createStore that simplifies setup and helps avoid common bugs. +> +>You should not be using the redux core package by itself today, except for learning purposes. The createStore method from the core redux package will not be removed, but we encourage all users to migrate to using Redux Toolkit for all Redux code. + +Funktion createStore sijaan siis suositellaan käytettäväksi hieman "kehittyneempää" funktiota configureStore, ja mekin tulemme ottamaan sen käyttöömme kun olemme ottaneet Reduxin perustoiminnallisuuden haltuun. + +Sivuhuomio: createStore on määritelty olevan "deprecated", joka yleensä tarkoittaa sitä, että ominaisuus tulee poistumaan kirjaston jossain uudemmassa versiossa. Yllä oleva selitys ja [tämäkin](https://stackoverflow.com/questions/71944111/redux-createstore-is-deprecated-cannot-get-state-from-getstate-in-redux-ac) keskustelu paljastavat, että createStore ei tule poistumaan, ja sille onkin annettu ehkä hieman virheellisin perustein status deprecated. Funktio ei siis ole vanhentunut, mutta nykyään on olemassa suositeltavampi, uusi tapa tehdä suunnilleen sama asia. ### Redux-muistiinpanot Tavoitteenamme on muuttaa muistiinpanosovellus käyttämään tilanhallintaan Reduxia. Katsotaan kuitenkin ensin eräitä konsepteja hieman yksinkertaistetun muistiinpanosovelluksen kautta. -Sovelluksen ensimmäinen versio seuraavassa +Sovelluksen ensimmäinen versio tiedostossa main.jsx on seuraava: ```js +import ReactDOM from 'react-dom/client' +import { createStore } from 'redux' + const noteReducer = (state = [], action) => { - if (action.type === 'NEW_NOTE') { - state.push(action.data) - return state + switch (action.type) { + case 'NEW_NOTE': + state.push(action.payload) + return state + default: + return state } - - return state } const store = createStore(noteReducer) store.dispatch({ type: 'NEW_NOTE', - data: { + payload: { content: 'the app state is in redux store', important: true, id: 1 @@ -261,7 +301,7 @@ store.dispatch({ store.dispatch({ type: 'NEW_NOTE', - data: { + payload: { content: 'state changes are made with actions', important: false, id: 2 @@ -269,28 +309,37 @@ store.dispatch({ }) const App = () => { - return( + return (
      - {store.getState().map(note=> + {store.getState().map(note => (
    • {note.content} {note.important ? 'important' : ''}
    • - )} -
    + ))} +
    ) } + +const root = ReactDOM.createRoot(document.getElementById('root')) + +const renderApp = () => { + root.render() +} + +renderApp() +store.subscribe(renderApp) ``` -Toistaiseksi sovelluksessa ei siis ole toiminnallisuutta uusien muistiinpanojen lisäämiseen, voimme kuitenkin tehdä sen dispatchaamalla NEW\_NOTE-tyyppisiä actioneja koodista. +Toistaiseksi sovelluksessa ei siis ole toiminnallisuutta uusien muistiinpanojen lisäämiseen, mutta voimme toteuttaa sen dispatchaamalla NEW\_NOTE-tyyppisiä actioneja koodista. -Actioneissa on nyt tyypin lisäksi kenttä data, joka sisältää lisättävän muistiinpanon: +Actioneissa on nyt tyypin lisäksi kenttä payload, joka sisältää lisättävän muistiinpanon: ```js { type: 'NEW_NOTE', - data: { + payload: { content: 'state changes are made with actions', important: false, id: 2 @@ -298,72 +347,132 @@ Actioneissa on nyt tyypin lisäksi kenttä data, joka sisältää lisätt } ``` -### puhtaat funktiot, immutable +Kentän nimen valinta ei ole sattumanvarainen. Yleinen konventio on, että actioneilla on juurikin kaksi kenttää, tyypin kertova type ja actionin mukana olevan tiedon sisältävä payload. + +### Puhtaat funktiot ja muuttumattomat (immutable) oliot Reducerimme alustava versio on yksinkertainen: ```js const noteReducer = (state = [], action) => { - if (action.type === 'NEW_NOTE') { - state.push(action.data) - return state + switch (action.type) { + case 'NEW_NOTE': + state.push(action.payload) + return state + default: + return state } - - return state } ``` Tila on nyt taulukko. NEW\_NOTE-tyyppisen actionin seurauksena tilaan lisätään uusi muistiinpano metodilla [push](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push). -Sovellus näyttää toimivan, mutta määrittelemämme reduceri on huono, se rikkoo Reduxin reducerien [perusolettamusta](https://github.com/reactjs/redux/blob/master/docs/basics/Reducers.md#handling-actions) siitä, että reducerien tulee olla [puhtaita funktioita](https://en.wikipedia.org/wiki/Pure_function). +Sovellus näyttää toimivan, mutta määrittelemämme reduceri on huono, sillä se rikkoo Reduxin reducerien [perusolettamusta](https://redux.js.org/tutorials/essentials/part-1-overview-concepts#reducers) siitä, että reducerien tulee olla [puhtaita funktioita](https://en.wikipedia.org/wiki/Pure_function). -Puhtaat funktiot ovat sellaisia, että ne eivät aiheuta mitään sivuvaikutuksia ja niiden tulee aina palauttaa sama vastaus samoilla parametreilla kutsuttaessa. +Puhtaat funktiot ovat sellaisia, että ne eivät aiheuta mitään sivuvaikutuksia ja ne palauttavat aina saman vastauksen samoilla parametreilla kutsuttaessa. -Lisäsimme tilaan uuden muistiinpanon metodilla _state.push(action.data)_ joka muuttaa state-olion tilaa. Tämä ei ole sallittua. Ongelma korjautuu helposti käyttämällä metodia [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat), joka luo uuden taulukon, jonka sisältönä on vanhan taulukon alkiot sekä lisättävä alkio: +Lisäsimme tilaan uuden muistiinpanon metodilla _state.push(action.payload)_, joka muuttaa state-olion tilaa. Tämä ei ole sallittua. Ongelman voi korjata helposti käyttämällä metodia [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat), joka luo uuden taulukon, jonka sisältönä on vanhan taulukon alkiot sekä lisättävä alkio: ```js const noteReducer = (state = [], action) => { - if (action.type === 'NEW_NOTE') { - return state.concat(action.data) + switch (action.type) { + case 'NEW_NOTE': + return state.concat(action.payload) // highlight-line + default: + return state } - - return state } ``` -Reducen tilan tulee koostua muuttumattomista eli [immutable](https://en.wikipedia.org/wiki/Immutable_object) olioista. Jos tilaan tulee muutos, ei vanhaa oliota muuteta, vaan se korvataan uudella muuttuneella oliolla. Juuri näin toimimme uudistuneessa reducerissa, vanha taulukko korvaantuu uudella. +Reducerin tilan tulee koostua muuttumattomista eli [immutable](https://en.wikipedia.org/wiki/Immutable_object)-olioista. Jos tilaan tulee muutos, ei vanhaa oliota muuteta, vaan se korvataan uudella muuttuneella oliolla. Juuri näin toimimme uudistuneessa reducerissa, eli vanha taulukko korvaantuu uudella. Laajennetaan reduceria siten, että se osaa käsitellä muistiinpanon tärkeyteen liittyvän muutoksen: ```js { type: 'TOGGLE_IMPORTANCE', - data: { + payload: { id: 2 } } ``` -Koska meillä ei ole vielä koodia joka käyttää ominaisuutta, laajennetaan reduceria testivetoisesti. Aloitetaan tekemällä testi actionin NEW\_NOTE käsittelylle. +Koska meillä ei ole vielä koodia joka käyttää ominaisuutta, laajennetaan reduceria testivetoisesti. + +### Testiympäristön konfigurointi + +Konfiguroidaan sovellukseen [Vitest](https://vitest.dev/). Asennetaan se sovelluksen kehityksenaikaiseksi riippuvuudeksi: + +```js +npm install --save-dev vitest +``` + +Lisätään tiedostoon package.json testit suorittava skripti: + +```json +{ + // ... + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview", + "test": "vitest" // highlight-line + }, + // ... +} +``` + +Jotta testaus olisi helpompaa, siirretään reducerin koodi ensin omaan moduuliinsa tiedostoon src/reducers/noteReducer.js: + +```js +const noteReducer = (state = [], action) => { + switch (action.type) { + case 'NEW_NOTE': + return state.concat(action.payload) + default: + return state + } +} + +export default noteReducer +``` + +Tiedosto main.jsx muuttuu seuraavasti: -Jotta testaus olisi helpompaa, siirretään reducerin koodi ensin omaan moduuliinsa tiedostoon src/reducers/noteReducer.js. Otetaan käyttöön myös kirjasto [deep-freeze](https://github.com/substack/deep-freeze), jonka avulla voimme varmistaa, että reducer on määritelty oikeaoppisesti puhtaana funktiona. Asennetaan kirjasto kehitysaikaiseksi riippuvuudeksi +```js +import ReactDOM from 'react-dom/client' +import { createStore } from 'redux' +import noteReducer from './reducers/noteReducer' // highlight-line + +const store = createStore(noteReducer) + +// ... +``` + + Otetaan lisäksi käyttöön kirjasto [deep-freeze](https://www.npmjs.com/package/deep-freeze), jonka avulla voimme varmistaa, että reducer on määritelty oikeaoppisesti puhtaana funktiona. Asennetaan kirjasto kehitysaikaiseksi riippuvuudeksi: ```js npm install --save-dev deep-freeze ``` -Testi, joka määritellään tiedostoon src/reducers/noteReducer.test.js on sisällöltään seuraava: +Olemme nyt valmiita kirjoittamaan testejä. + +### Testit noteReducerille + +Aloitetaan tekemällä testi actionin NEW\_NOTE käsittelylle. Määritellään testi tiedostoon src/reducers/noteReducer.test.js: ```js -import noteReducer from './noteReducer' import deepFreeze from 'deep-freeze' +import { describe, expect, test } from 'vitest' +import noteReducer from './noteReducer' describe('noteReducer', () => { test('returns new state with action NEW_NOTE', () => { const state = [] const action = { type: 'NEW_NOTE', - data: { + payload: { content: 'the app state is in redux store', important: true, id: 1 @@ -374,16 +483,16 @@ describe('noteReducer', () => { const newState = noteReducer(state, action) expect(newState).toHaveLength(1) - expect(newState).toContainEqual(action.data) + expect(newState).toContainEqual(action.payload) }) }) ``` -Testi siis varmistaa, että reducerin palauttama uusi tila on taulukko, joka sisältää yhden elementin, joka on sama kun actionin kentän data sisältävä olio. +Suoritetaan testi komennolla _npm test_. Testi siis varmistaa, että reducerin palauttama uusi tila on taulukko, joka sisältää yhden elementin, joka on sama kun actionin kentän payload sisältävä olio. -Komento deepFreeze(state) varmistaa, että reducer ei muuta parametrina olevaa storen tilaa. Jos reduceri käyttää state:n manipulointiin komentoa _push_, testi ei mene läpi +Komento deepFreeze(state) varmistaa, että reducer ei muuta parametrina olevaa storen tilaa. Jos reducer käyttäisi tilan manipulointiin komentoa _push_, testi ei menisi läpi: -![](../../images/6/2.png) +![Testi aiheuttaa virheilmoituksen TypeError: Can not add property 0, object is not extensible. Syynä komento state.push(action.payload)](../../images/6/2.png) Tehdään sitten testi actionin TOGGLE\_IMPORTANCE käsittelylle: @@ -399,11 +508,12 @@ test('returns new state with action TOGGLE_IMPORTANCE', () => { content: 'state changes are made with actions', important: false, id: 2 - }] + } + ] const action = { type: 'TOGGLE_IMPORTANCE', - data: { + payload: { id: 2 } } @@ -428,37 +538,39 @@ Eli seuraavan actionin ```js { type: 'TOGGLE_IMPORTANCE', - data: { + payload: { id: 2 + } } ``` -tulee muuttaa id:n 2 omaavan muistiinpanon tärkeyttä. +tulee muuttaa tärkeys muistiinpanolle, jonka id on 2. -Reduceri laajenee seuraavasti +Reducer laajenee seuraavasti: ```js const noteReducer = (state = [], action) => { switch(action.type) { case 'NEW_NOTE': - return state.concat(action.data) - case 'TOGGLE_IMPORTANCE': - const id = action.data.id + return state.concat(action.payload) + // highlight-start + case 'TOGGLE_IMPORTANCE': { + const id = action.payload.id const noteToChange = state.find(n => n.id === id) - const changedNote = { - ...noteToChange, - important: !noteToChange.important + const changedNote = { + ...noteToChange, + important: !noteToChange.important } - return state.map(note => - note.id !== id ? note : changedNote - ) + return state.map(note => (note.id !== id ? note : changedNote)) + } + // highlight-end default: return state } } ``` -Luomme tärkeyttä muuttaneesta muistiinpanosta kopion osasta 2 [tutulla syntaksilla](/osa2/palvelimella_olevan_datan_muokkaaminen#muistiinpanon-tarkeyden-muutos) ja korvaamme tilan uudella tilalla, mihin otetaan muuttumattomat muistiinpanot ja muutettavasta sen muutettu kopio changedNote. +Luomme tärkeyttä muuttaneesta muistiinpanosta kopion osasta 2 [tutulla syntaksilla](/osa2/palvelimella_olevan_datan_muokkaaminen#muistiinpanon-tarkeyden-muutos) ja korvaamme tilan uudella tilalla, johon otetaan muuttumattomat muistiinpanot ja muutettavasta sen muutettu kopio changedNote. Kerrataan vielä mitä koodissa tapahtuu. Ensin etsitään olio, jonka tärkeys on tarkoitus muuttaa: @@ -466,7 +578,7 @@ Kerrataan vielä mitä koodissa tapahtuu. Ensin etsitään olio, jonka tärkeys const noteToChange = state.find(n => n.id === id) ``` -luodaan sitten uusi olio, joka on muuten kopio muuttuvasta oliosta mutta kentän important arvo on muutettu päinvastaiseksi: +Luodaan sitten uusi olio, joka on muuten kopio muuttuvasta oliosta mutta kentän important arvo on muutettu päinvastaiseksi: ```js const changedNote = { @@ -475,27 +587,26 @@ const changedNote = { } ``` -Palautetaan uusi tila, joka saadaan ottamalla kaikki vanhan tilan muistiinpanot paitsi uusi juuri luotu olio tärkeydeltään muuttavasta muistiinpanosta: +Lopuksi palautetaan uusi tila. Se saadaan valitsemalla kaikki vanhan tilan muistiinpanot pois lukien etsittävää id:tä vastaava muistiinpano, jonka tilalle valitaan juuri muokattu muistiinpano: ```js -state.map(note => - note.id !== id ? note : changedNote -) +state.map(note => (note.id !== id ? note : changedNote)) ``` -### array spread -syntaksi +### Array spread ‑syntaksi -Koska reducerilla on nyt suhteellisen hyvät testit, voimme refaktoroida koodia turvallisesti. +Koska reducerille on nyt suhteellisen hyvät testit, voimme refaktoroida koodia turvallisesti. -Uuden muistiinpanon lisäys luo palautettavan tilan taulukon _concat_-funktiolla. Katsotaan nyt miten voimme toteuttaa saman hyödyntämällä Javascriptin [array spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_operator) -syntaksia: +Uuden muistiinpanon lisäys luo palautettavan tilan taulukon _concat_-funktiolla. Katsotaan nyt miten voimme toteuttaa saman hyödyntämällä JavaScriptin [array spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_operator) ‑syntaksia: ```js const noteReducer = (state = [], action) => { switch(action.type) { case 'NEW_NOTE': - return [...state, action.data] - case 'TOGGLE_IMPORTANCE': + return [...state, action.payload] // highlight-line + case 'TOGGLE_IMPORTANCE': { // ... + } default: return state } @@ -514,7 +625,7 @@ niin ...luvut hajottaa taulukon yksittäisiksi alkioiksi, eli voimm [...luvut, 4, 5] ``` -ja lopputuloksena on taulukko, jonka sisältö on `[1, 2, 3, 4, 5]`. +ja lopputuloksena on taulukko, jonka sisältö on [1, 2, 3, 4, 5]. Jos olisimme sijoittaneet taulukon toisen sisälle ilman spreadia, eli @@ -522,7 +633,7 @@ Jos olisimme sijoittaneet taulukon toisen sisälle ilman spreadia, eli [luvut, 4, 5] ``` -lopputulos olisi ollut `[ [1, 2, 3], 4, 5]`. +lopputulos olisi ollut [[1, 2, 3], 4, 5]. Samannäköinen syntaksi toimii taulukosta [destrukturoimalla](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) alkioita otettaessa siten, että se kerää loput alkiot: @@ -542,11 +653,11 @@ console.log(loput) // tulostuu [3, 4, 5, 6] ### Tehtävät 6.1.-6.2. -Tehdään hieman yksinkertaistettu versio osan 1 unicafe-tehtävästä. Hoidetaan sovelluksen tilan käsittely Reduxin avulla. +Tehdään hieman yksinkertaistettu versio osan 1 Unicafe-tehtävästä. Hoidetaan sovelluksen tilan käsittely Reduxin avulla. -Voit ottaa sovelluksesi pohjaksi repositoriossa https://github.com/fullstack-hy2020/unicafe-redux olevan projektin. +Voit ottaa sovelluksesi pohjaksi repositoriossa https://github.com/fullstack-hy2020/unicafe-redux olevan projektin. -Aloita poistamalla kloonatun sovelluksen git-konfiguraatio ja asentamalla riippuvuudet +Aloita poistamalla kloonatun sovelluksen Git-konfiguraatio ja asentamalla riippuvuudet: ```bash cd unicafe-redux // mene kloonatun repositorion hakemistoon @@ -554,7 +665,7 @@ rm -rf .git npm install ``` -#### 6.1: unicafe revisited, step1 +#### 6.1: Unicafe revisited, step1 Ennen sivulla näkyvää toiminnallisuutta toteutetaan storen edellyttämä toiminnallisuus. @@ -586,19 +697,21 @@ const counterReducer = (state = initialState, action) => { return state case 'BAD': return state - case 'ZERO': + case 'RESET': + return state + default: return state } - return state } export default counterReducer ``` -ja sen testien runko +Testien runko on: ```js import deepFreeze from 'deep-freeze' +import { describe, expect, test } from 'vitest' import counterReducer from './reducer' describe('unicafe reducer', () => { @@ -609,7 +722,6 @@ describe('unicafe reducer', () => { } test('should return a proper initial state when called with undefined state', () => { - const state = {} const action = { type: 'DO_NOTHING' } @@ -637,96 +749,113 @@ describe('unicafe reducer', () => { **Toteuta reducer ja tee sille testit.** -Varmista testeissä deep-freeze-kirjaston avulla, että kyseessä on puhdas funktio. Huomaa, että valmiin ensimmäisen testin on syytä mennä läpi koska redux olettaa, että reduceri palauttaa järkevän alkutilan kun sitä kutsutaan siten että ensimmäinen parametri, eli aiempaa tilaa edustava state on undefined. +Valmiina olevan ensimmäisen testin pitäisi mennä suoraan läpi ilman muutoksia. Redux olettaa, että reducer palauttaa järkevän alkutilan kun sitä kutsutaan siten että ensimmäinen parametri eli aiempaa tilaa edustava state on undefined. -Aloita laajentamalla reduceria siten, että molemmat testeistä menevät läpi. Lisää tämän jälkeen loput testit ja niiden toteuttava toiminnallisuus. +Aloita laajentamalla reduceria siten, että molemmat testeistä menevät läpi. Lisää tämän jälkeen loput testit reducerin eri actioneille ja toteuta niitä vastaava toiminnallisuus reduceriin. -Reducerin toteutuksessa kannattaa ottaa mallia ylläolevasta [redux-muistiinpanot](/osa6/flux_arkkitehtuuri_ja_redux#puhtaat-funktiot-immutable)-esimerkistä. +Varmista testeissä deep-freeze-kirjaston avulla, että kyseessä on puhdas funktio. Reducerin toteutuksessa kannattaa ottaa mallia yllä olevasta [Redux-muistiinpanot](/osa6/flux_arkkitehtuuri_ja_redux#puhtaat-funktiot-immutable)-esimerkistä. -#### 6.2: unicafe revisited, step2 +#### 6.2: Unicafe revisited, step2 Toteuta sitten sovellukseen koko sen varsinainen toiminnallisuus. +Sovelluksesi saa olla ulkoasultaan vaatimaton, muuta ei tarvita kuin napit ja tieto kunkin tyyppisen arvostelun lukumäärästä: + +![](../../images/6/50new.png) +
    -### ei-kontrolloitu lomake +### Ei-kontrolloitu lomake Lisätään sovellukseen mahdollisuus uusien muistiinpanojen tekemiseen sekä tärkeyden muuttamiseen: ```js -const generateId = () => - Number((Math.random() * 1000000).toFixed(0)) +// ... + +const generateId = () => Number((Math.random() * 1000000).toFixed(0)) // highlight-line const App = () => { - const addNote = (event) => { + // highlight-start + const addNote = event => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' store.dispatch({ type: 'NEW_NOTE', - data: { + payload: { content, important: false, id: generateId() } }) } + // highlight-end - const toggleImportance = (id) => { + // highlight-start + const toggleImportance = id => { store.dispatch({ type: 'TOGGLE_IMPORTANCE', - data: { id } + payload: { id } }) } - + // highlight-end return (
    + // highlight-start
    + // highlight-end
      - {store.getState().map(note => -
    • toggleImportance(note.id)} - > + {store.getState().map(note => ( +
    • toggleImportance(note.id)}> // highlight-line {note.content} {note.important ? 'important' : ''}
    • - )} + ))}
    ) } + +// ... ``` -Molemmat toiminnallisuudet on toteutettu suoraviivaisesti. Huomionarvoista uuden muistiinpanon lisäämisessä on nyt se, että toisin kuin aiemmat Reactilla toteutetut lomakkeet, emme ole nyt sitoneet lomakkeen kentän arvoa komponentin App tilaan. React kutsuu tälläisiä lomakkeita [ei-kontrolloiduiksi](https://reactjs.org/docs/uncontrolled-components.html). +Molemmat toiminnallisuudet on toteutettu suoraviivaisesti. Huomionarvoista uuden muistiinpanon lisäämisessä on nyt se, että toisin kuin aiemmat Reactilla toteutetut lomakkeet, emme ole nyt sitoneet lomakkeen kentän arvoa komponentin App tilaan. React kutsuu tällaisia lomakkeita [ei-kontrolloiduiksi](https://reactjs.org/docs/uncontrolled-components.html). -> Ei-kontrolloiduilla lomakkeilla on tiettyjä rajoitteita (ne eivät esim. mahdollista lennossa annettavia validointiviestejä, lomakkeen lähetysnapin disabloimista sisällön perusteella ym...), meidän käyttötapaukseemme ne kuitenkin tällä kertaa sopivat. +> Ei-kontrolloiduilla lomakkeilla on tiettyjä rajoitteita. Ne eivät mahdollista esim. lennossa annettavia validointiviestejä, lomakkeen lähetysnapin disabloimista sisällön perusteella yms. Meidän käyttötapaukseemme ne kuitenkin tällä kertaa sopivat. Voit halutessasi lukea aiheesta enemmän [täältä](https://goshakkk.name/controlled-vs-uncontrolled-inputs-react/). -Muistiinpanon lisäämisen käsittelevä metodi on yksinkertainen, se ainoastaan dispatchaa muistiinpanon lisäävän actionin: +Muistiinpanon lisäämisen käsittelevä metodi on yksinkertainen. Se dispatchaa muistiinpanon lisäävän actionin: ```js -addNote = (event) => { +addNote = event => { event.preventDefault() - const content = event.target.note.value // highlight-line + const content = event.target.note.value event.target.note.value = '' + // highlight-start store.dispatch({ type: 'NEW_NOTE', - data: { + payload: { content, important: false, id: generateId() } }) + // highlight-end } ``` -Uuden muistiinpanon sisältö saadaan suoraan lomakkeen syötekentästä, johon kentän nimeämisen ansiosta päästään käsiksi tapahtumaolion kautta event.target.note.value. Kannattaa huomata, että syötekentällä on oltava nimi, jotta sen arvoon on mahdollista päästä käsiksi: +Uuden muistiinpanon sisältö saadaan suoraan lomakkeen syötekentästä, johon päästään käsiksi tapahtumaolion kautta: + +```js +const content = event.target.note.value +``` + +Kannattaa huomata, että syötekentällä on oltava nimi, jotta sen arvoon on mahdollista päästä käsiksi: ```js
    @@ -735,28 +864,28 @@ Uuden muistiinpanon sisältö saadaan suoraan lomakkeen syötekentästä, johon
    ``` -Tärkeyden muuttaminen tapahtuu klikkaamalla muistiinpanon nimeä. Käsittelijä on erittäin yksinkertainen: +Tärkeys muutetaan klikkaamalla muistiinpanon nimeä. Käsittelijä on erittäin yksinkertainen: ```js -toggleImportance = (id) => { +toggleImportance = id => { store.dispatch({ type: 'TOGGLE_IMPORTANCE', - data: { id } + payload: { id } }) } ``` -### action creatorit +### Action creatorit Alamme huomata, että jo näinkin yksinkertaisessa sovelluksessa Reduxin käyttö yksinkertaistaa sovelluksen ulkoasusta vastaavaa koodia. Pystymme kuitenkin vielä paljon parempaan. -React-komponenttien on oikeastaan tarpeetonta tuntea reduxin actionien tyyppejä ja esitysmuotoja. Eristetään actioneiden luominen omiin funktioihinsa: +React-komponenttien on oikeastaan tarpeetonta tuntea Reduxin actionien tyyppejä ja esitysmuotoja. Eristetään actioneiden luominen omiin funktioihinsa: ```js -const createNote = (content) => { +const createNote = content => { return { type: 'NEW_NOTE', - data: { + payload: { content, important: false, id: generateId() @@ -764,30 +893,28 @@ const createNote = (content) => { } } -const toggleImportanceOf = (id) => { +const toggleImportanceOf = id => { return { type: 'TOGGLE_IMPORTANCE', - data: { id } + payload: { id } } } ``` -Actioneja luovia funktioita kutsutaan [action creatoreiksi](https://redux.js.org/advanced/async-actions#synchronous-action-creators). - - -Komponentin App ei tarvitse enää tietää mitään actionien sisäisestä esitystavasta, se saa sopivan actionin kutsumalla creator-funktiota: +Actioneja luovia funktioita kutsutaan [action creatoreiksi](https://read.reduxbook.com/markdown/part1/04-action-creators.html). +Komponentin App ei tarvitse enää tietää mitään actionien sisäisestä esitystavasta, vaan se saa sopivan actionin kutsumalla creator-funktiota: ```js const App = () => { - const addNote = (event) => { + const addNote = event => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' store.dispatch(createNote(content)) // highlight-line } - const toggleImportance = (id) => { + const toggleImportance = id => { store.dispatch(toggleImportanceOf(id))// highlight-line } @@ -797,54 +924,77 @@ const App = () => { ### Redux-storen välittäminen eri komponenteille -Koko sovellus on toistaiseksi kirjoitettu yhteen tiedostoon ja sen ansiosta joka puolelta sovellusta on päästy käsiksi redux-storeen. Entä jos haluamme jakaa sovelluksen useisiin, omiin komponentteihin sijoitettuihin tiedostoihin? +Koko sovellus on toistaiseksi kirjoitettu reduceria lukuunottamatta yhteen tiedostoon, minkä ansiosta joka puolelta sovellusta on päästy käsiksi Redux-storeen. Entä jos haluamme jakaa sovelluksen useisiin, omiin tiedostoihinsa sijoitettuihin komponentteihin? -Tapoja välittää redux-store sovelluksen komponenteille on useita, tutustutaan ensin ehkä uusimpaan ja helpoimpaan tapaan [react-redux](https://react-redux.js.org/)-kirjaston tarjoamaan [hooks](https://react-redux.js.org/api/hooks)-rajapintaan. +Tapoja välittää Redux-store sovelluksen komponenteille on useita. Tutustutaan ensin ehkä uusimpaan ja helpoimpaan tapaan eli [React Redux](https://react-redux.js.org/)-kirjaston tarjoamaan [hooks](https://react-redux.js.org/api/hooks)-rajapintaan. -Asennetaan react-redux +Asennetaan react-redux: -```js -npm install --save react-redux +```bash +npm install react-redux ``` -Eriytetään komponentti _App_ omaan tiedostoon _App.js_. Tarkastellaan ensin mitä sovelluksen muiden tiedostojen sisällöksi tulee. - -Tiedosto _index.js_ näyttää seuraavalta +Jäsennellään samalla sovelluksen koodi järkevämmin useisiin eri tiedostoihin. Tiedosto _main.jsx_ näyttää muutosten jälkeen seuraavalta: ```js -import React from 'react' -import ReactDOM from 'react-dom' +import ReactDOM from 'react-dom/client' import { createStore } from 'redux' -import { Provider } from 'react-redux' // highlight-line +import { Provider } from 'react-redux' + import App from './App' import noteReducer from './reducers/noteReducer' const store = createStore(noteReducer) -ReactDOM.render( - // highlight-line +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) +``` + +Uutta tässä on se, että sovellus on määritelty React Redux ‑kirjaston tarjoaman [Provider](https://react-redux.js.org/api/provider)-komponentin lapsena ja että sovelluksen käyttämä store on annettu Provider-komponentin attribuutiksi store: + +```js +const store = createStore(noteReducer) + +ReactDOM.createRoot(document.getElementById('root')).render( + // highlight-line - , // highlight-line - document.getElementById('root') + // highlight-line ) ``` -Uutta tässä on se, että sovellus on määritelty react redux -kirjaston tarjoaman [Provider](https://github.com/reactjs/react-redux/blob/master/docs/api.md#provider-store)-komponentin lapsena ja että sovelluksen käyttämä store on annettu Provider-komponentin attribuutiksi store. +Tämän ansiosta store on kaikkien ohjelman komponenttien saavutettavissa, kuten tulemme pian näkemään. -Action creator -funktioiden määrittely on siirretty reducerin kanssa samaan tiedostoon reducers/noteReducer.js joka näyttää seuraavalta +Action creator ‑funktioiden määrittely on siirretty reducerin kanssa samaan tiedostoon src/reducers/noteReducer.js, joka näyttää seuraavalta: ```js const noteReducer = (state = [], action) => { - // ... + switch (action.type) { + case 'NEW_NOTE': + return [...state, action.payload] + case 'TOGGLE_IMPORTANCE': { + const id = action.payload.id + const noteToChange = state.find(n => n.id === id) + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + return state.map(note => (note.id !== id ? note : changedNote)) + } + default: + return state + } } const generateId = () => Number((Math.random() * 1000000).toFixed(0)) -export const createNote = (content) => { // highlight-line +export const createNote = (content) => { return { type: 'NEW_NOTE', - data: { + payload: { content, important: false, id: generateId() @@ -852,28 +1002,26 @@ export const createNote = (content) => { // highlight-line } } -export const toggleImportanceOf = (id) => { // highlight-line +export const toggleImportanceOf = (id) => { return { type: 'TOGGLE_IMPORTANCE', - data: { id } + payload: { id } } } export default noteReducer ``` -Moduulissa on nyt useita [export](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export)-komentoja. - -Reducer-funktio palautetaan edelleen komennolla export default. Tämän ansiosta reducer importataan tuttuun tapaan: +Moduulissa on nyt useita [export](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export)-komentoja. Reducer-funktio palautetaan edelleen komennolla export default. Tämän ansiosta reducer importataan tuttuun tapaan: ```js import noteReducer from './reducers/noteReducer' ``` -Moduulilla voi olla vain yksi default export, mutta useita "normaaleja" exporteja +Moduulilla voi olla vain yksi default export, mutta useita "normaaleja" exporteja: ```js -export const noteCreation = (content) => { +export const createNote = (content) => { // ... } @@ -885,32 +1033,29 @@ export const toggleImportanceOf = (id) => { Normaalisti (eli ei defaultina) exportattujen funktioiden käyttöönotto tapahtuu aaltosulkusyntaksilla: ```js -import { noteCreation } from './../reducers/noteReducer' +import { createNote } from './../reducers/noteReducer' ``` -Komponentin App koodi +Eriytetään seuraavaksi komponentti _App_ tiedostoon _src/App.jsx_. Tiedoston sisältö on seuraava: ```js -import React from 'react' -import { - createNote, toggleImportanceOf -} from './reducers/noteReducer' -import { useSelector, useDispatch } from 'react-redux' // highlight-line +import { createNote, toggleImportanceOf } from './reducers/noteReducer' +import { useSelector, useDispatch } from 'react-redux' const App = () => { - const dispatch = useDispatch() // highlight-line - const notes = useSelector(state => state) // highlight-line + const dispatch = useDispatch() + const notes = useSelector(state => state) const addNote = (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' - dispatch(createNote(content)) // highlight-line + dispatch(createNote(content)) } const toggleImportance = (id) => { - dispatch(toggleImportanceOf(id)) // highlight-line + dispatch(toggleImportanceOf(id)) } return ( @@ -920,7 +1065,7 @@ const App = () => {
      - {notes.map(note => // highlight-line + {notes.map(note =>
    • toggleImportance(note.id)} @@ -936,12 +1081,12 @@ const App = () => { export default App ``` -Komponentin koodissa on muutama mielenkiintoinen seikka. Aiemmin koodi hoiti actionien dispatchaamisen kutsumalla redux-storen metodia dispatch: +Komponentin koodissa on muutama mielenkiintoinen seikka. Aiemmin koodi hoiti actionien dispatchaamisen kutsumalla Redux-storen metodia dispatch: ```js store.dispatch({ type: 'TOGGLE_IMPORTANCE', - data: { id } + payload: { id } }) ``` @@ -962,9 +1107,9 @@ const App = () => { } ``` -React-redux-kirjaston tarjoama useDispatch-hook siis tarjoaa mille tahansa React-komponentille pääsyn tiedostossa index.js määritellyn redux-storen dispatch-funktioon, jonka avulla komponentti pääsee tekemään muutoksia redux-storen tilaan. +React Redux ‑kirjaston tarjoama useDispatch-hook siis tarjoaa mille tahansa React-komponentille pääsyn tiedostossa main.jsx määritellyn Redux-storen dispatch-funktioon, jonka avulla komponentti pääsee tekemään muutoksia Redux-storen tilaan. -Storeen talletettuihin muistiinpanoihin komponentti pääsee käsiksi react-redux-kirjaston [useSelector](https://react-redux.js.org/api/hooks#useselector)-hookin kautta: +Storeen talletettuihin muistiinpanoihin komponentti pääsee käsiksi React Redux ‑kirjaston [useSelector](https://react-redux.js.org/api/hooks#useselector)-hookin kautta: ```js @@ -977,7 +1122,7 @@ const App = () => { } ``` -useSelector saa parametrikseen funktion, joka hakee tai valitsee (engl. select) tarvittavan datan redux-storesta. Tarvitsemme nyt kaikki muistiinpanot, eli selektorifunktiomme palauttaa koko staten, eli on muotoa +useSelector saa parametrikseen funktion, joka hakee tai valitsee (engl. select) tarvittavan datan Redux-storesta. Tarvitsemme nyt kaikki muistiinpanot, eli selektorifunktiomme palauttaa koko staten, eli on muotoa: ```js @@ -992,29 +1137,30 @@ joka siis tarkoittaa samaa kuin } ``` -Yleensä selektorifunktiot ovat mielenkiintoisempia, ja valitsevat vain osan redux-storen sisällöstä. Voisimme esimerkiksi hakea storesta ainoastaan tärkeät muistiinpanot seuraavasti +Yleensä selektorifunktiot ovat mielenkiintoisempia ja valitsevat vain osan Redux-storen sisällöstä. Voisimme esimerkiksi hakea storesta ainoastaan tärkeät muistiinpanot seuraavasti: ```js const importantNotes = useSelector(state => state.filter(note => note.important)) ``` +Redux-sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/redux-notes/tree/part6-0), branchissa part6-0. + ### Lisää komponentteja -Eriytetään uuden muistiinpanon luominen omaksi komponentiksi. +Eriytetään uuden muistiinpanon luomisesta vastaava lomake omaksi komponentikseen tiedostoon src/components/NoteForm.jsx: ```js -import React from 'react' -import { useDispatch } from 'react-redux' // highlight-line -import { createNote } from '../reducers/noteReducer' // highlight-line +import { useDispatch } from 'react-redux' +import { createNote } from '../reducers/noteReducer' -const NewNote = (props) => { - const dispatch = useDispatch() // highlight-line +const NoteForm = () => { + const dispatch = useDispatch() const addNote = (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' - dispatch(createNote(content)) // highlight-line + dispatch(createNote(content)) } return ( @@ -1025,42 +1171,39 @@ const NewNote = (props) => { ) } -export default NewNote +export default NoteForm ``` -Toisin kuin aiemmin ilman Reduxia tekemässämme React-koodissa, sovelluksen tilaa (joka on nyt siis reduxissa) muuttava tapahtumankäsittelijä on siirretty pois App-komponentista, alikomponentin vastuulle. Itse tilaa muuttava logiikka on kuitenkin siististi reduxissa eristettynä koko sovelluksen React-osuudesta. +Toisin kuin aiemmin ilman Reduxia tekemässämme React-koodissa, sovelluksen tilaa (joka on nyt siis Reduxissa) muuttava tapahtumankäsittelijä on siirretty pois App-komponentista, alikomponentin vastuulle. Itse tilaa muuttava logiikka on kuitenkin siististi Reduxissa eristettynä koko sovelluksen React-osuudesta. -Eriytetään vielä muistiinpanojen lista ja yksittäisen muistiinpanon esittäminen omiksi komponenteikseen (jotka molemmat sijoitetaan tiedostoon Notes.js): +Eriytetään vielä muistiinpanojen lista ja yksittäisen muistiinpanon esittäminen omiksi komponenteikseen. Sijoitetaan molemmat tiedostoon src/components/Notes.jsx: ```js -import React from 'react' -import { useDispatch, useSelector } from 'react-redux' // highlight-line -import { toggleImportanceOf } from '../reducers/noteReducer' // highlight-line +import { useDispatch, useSelector } from 'react-redux' +import { toggleImportanceOf } from '../reducers/noteReducer' const Note = ({ note, handleClick }) => { - return( + return (
    • - {note.content} + {note.content} {note.important ? 'important' : ''}
    • ) } const Notes = () => { - const dispatch = useDispatch() // highlight-line - const notes = useSelector(state => state) // highlight-line + const dispatch = useDispatch() + const notes = useSelector(state => state) - return( + return (
        - {notes.map(note => + {notes.map(note => ( - dispatch(toggleImportanceOf(note.id)) - } + handleClick={() => dispatch(toggleImportanceOf(note.id))} /> - )} + ))}
      ) } @@ -1070,27 +1213,31 @@ export default Notes Muistiinpanon tärkeyttä muuttava logiikka on nyt muistiinpanojen listaa hallinnoivalla komponentilla. -Komponenttiin App ei jää enää paljoa koodia: +Tiedostoon App.jsx jää vain vähän koodia: ```js -const App = () => { +import NoteForm from './components/NoteForm' +import Notes from './components/Notes' +const App = () => { return (
      - - + +
      ) } + +export default App ``` Yksittäisen muistiinpanon renderöinnistä huolehtiva Note on erittäin yksinkertainen, eikä ole tietoinen siitä, että sen propsina saama tapahtumankäsittelijä dispatchaa actionin. Tällaisia komponentteja kutsutaan Reactin terminologiassa [presentational](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0)-komponenteiksi. -Notes taas on sellainen mitä kutsutaan [container](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0)-komponenteiksi, se sisältää sovelluslogiikkaa, eli määrittelee mitä Note-komponenttien tapahtumankäsittelijät tekevät ja koordinoi presentational-komponenttien, eli Notejen konfigurointia. +Notes taas on sellainen komponentti, jota kutsutaan [container](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0)-komponentiksi. Se sisältää sovelluslogiikkaa eli määrittelee mitä Note-komponenttien tapahtumankäsittelijät tekevät ja koordinoi presentational-komponenttien eli Notejen konfigurointia. Palaamme presentational/container-jakoon tarkemmin myöhemmin tässä osassa. -Redux-sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https://github.com/fullstack-hy2020/redux-notes/tree/part6-1), branchissa part6-1. +Redux-sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/redux-notes/tree/part6-1), branchissa part6-1.
    @@ -1098,29 +1245,29 @@ Redux-sovelluksen tämänhetkinen koodi on kokonaisuudessaan [githubissa](https: ### Tehtävät 6.3.-6.8. -Toteutetaan nyt versio toisesta ensimmäisen osan anekdoottien äänestyssovelluksesta. Ota ratkaisusi pohjaksi repositoriossa https://github.com/fullstack-hy2020/redux-anecdotes oleva projekti. +Toteutetaan nyt uusi versio ensimmäisen osan anekdoottien äänestyssovelluksesta. Ota ratkaisusi pohjaksi repositoriossa https://github.com/fullstack-hy2020/redux-anecdotes oleva projekti. -Jos kloonaat projektin olemassaolevan git-repositorion sisälle, poista kloonatun sovelluksen git-konfiguraatio: +Jos kloonaat projektin olemassaolevan Git-repositorion sisälle, poista kloonatun sovelluksen Git-konfiguraatio: ```bash cd redux-anecdotes // mene kloonatun repositorion hakemistoon rm -rf .git ``` -Sovellus käynnistyy normaaliin tapaan, mutta joudut ensin asentamaan sovelluksen riippuvuudet: +Sovellus käynnistyy normaaliin tapaan, mutta joudut ensin asentamaan sen riippuvuudet: ```bash npm install -npm start +npm run dev ``` -Kun teet seuraavat tehtävät, tulisi sovelluksen näyttää seuraavalta +Kun teet seuraavat tehtävät, tulisi sovelluksen näyttää seuraavalta: -![](../../images/6/3.png) +![Sovellus renderöi anekdootit. Jokaisen anekdootin yhteydessä myös tieto sen saamien äänien määrästä sekä nappi "vote" anekdootin äänestämiselle](../../images/6/3.png) #### 6.3: anekdootit, step1 -Toteuta mahdollisuus anekdoottien äänestämiseen. Äänien määrä tulee tallettaa redux-storeen. +Toteuta mahdollisuus anekdoottien äänestämiseen. Äänien määrä tulee tallettaa Redux-storeen. #### 6.4: anekdootit, step2 @@ -1128,13 +1275,13 @@ Tee sovellukseen mahdollisuus uusien anekdoottien lisäämiselle. Voit pitää lisäyslomakkeen aiemman esimerkin tapaan [ei-kontrolloituna](/osa6/flux_arkkitehtuuri_ja_redux#ei-kontrolloitu-lomake). -#### 6.5*: anekdootit, step3 +#### 6.5: anekdootit, step3 Huolehdi siitä, että anekdootit pysyvät äänten mukaisessa suuruusjärjestyksessä. #### 6.6: anekdootit, step4 -Jos et jo sitä tehnyt, eriytä action-olioiden luominen [action creator](https://redux.js.org/basics/actions#action-creators) -funktioihin ja sijoita ne tiedostoon src/reducers/anecdoteReducer.js. Eli toimi samalla tavalla kuin materiaali esimerkissä kohdasta [action creator](/osa6/flux_arkkitehtuuri_ja_redux#action-creatorit) alkaen on toimittu. +Jos et jo sitä tehnyt, eriytä action-olioiden luominen [action creator](https://redux.js.org/basics/actions#action-creators) ‑funktioihin ja sijoita ne tiedostoon src/reducers/anecdoteReducer.js. Toimi siis kuten materiaalin esimerkissä on toimittu kohdasta [action creator](/osa6/flux_arkkitehtuuri_ja_redux#action-creatorit) alkaen. #### 6.7: anekdootit, step5 @@ -1147,7 +1294,6 @@ Eriytä anekdoottilistan näyttäminen omaksi komponentikseen nimeltään Ane Tämän tehtävän jälkeen komponentin App pitäisi näyttää seuraavalta: ```js -import React from 'react' import AnecdoteForm from './components/AnecdoteForm' import AnecdoteList from './components/AnecdoteList' diff --git a/src/content/6/fi/osa6b.md b/src/content/6/fi/osa6b.md index e741eb247c1..aa5b2c32d7e 100644 --- a/src/content/6/fi/osa6b.md +++ b/src/content/6/fi/osa6b.md @@ -7,11 +7,12 @@ lang: fi
    -Jatketaan muistiinpanosovelluksen yksinkertaistetun [redux-version](/osa6/flux_arkkitehtuuri_ja_redux#redux-muistiinpanot) laajentamista. +Jatketaan muistiinpanosovelluksen yksinkertaistetun [Redux-version](/osa6/flux_arkkitehtuuri_ja_redux#redux-muistiinpanot) laajentamista. Sovelluskehitystä helpottaaksemme laajennetaan reduceria siten, että storelle määritellään alkutila, jossa on pari muistiinpanoa: ```js +// highlight-start const initialState = [ { content: 'reducer defines how redux store works', @@ -24,26 +25,27 @@ const initialState = [ id: 2, }, ] +//highlight-end -const noteReducer = (state = initialState, action) => { +const noteReducer = (state = initialState, action) => { // highlight-line // ... } // ... + export default noteReducer ``` ### Monimutkaisempi tila storessa -Toteutetaan sovellukseen näytettävien muistiinpanojen filtteröinti, jonka avulla näytettäviä muistiinpanoja voidaan rajata. Filtterin toteutus tapahtuu [radiobuttoneiden](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/radio) avulla: +Toteutetaan sovellukseen näytettävien muistiinpanojen filtteröinti, jonka avulla näytettäviä muistiinpanoja voidaan rajata. Filtterin toteutus tapahtuu [radiopainikkeiden](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/radio) avulla: -![](../../images/6/01e.png) +![Sivun alussa lomake muistiinpanon lisäämiseen (syötekenttä ja nappi add). Tämän jälkeen radiopainikevalinta mitkä muistiinpanot näytetään, vaihtoehdot all, important ja noimportant. Näiden alle renderöidän kaikki muistiinpanot ja niiden yhteyteen teksti important jos muistiinpano merkattu tärkeäksi. ](../../images/6/01f.png) Aloitetaan todella suoraviivaisella toteutuksella: ```js -import React from 'react' -import NewNote from './components/NewNote' +import NoteForm from './components/NoteForm' import Notes from './components/Notes' const App = () => { @@ -55,15 +57,27 @@ const App = () => { return (
    - - //highlight-start + + //highlight-start
    - all filterSelected('ALL')} /> - important filterSelected('IMPORTANT')} /> - nonimportant filterSelected('NONIMPORTANT')} /> + filterSelected('ALL')} + /> + all + filterSelected('IMPORTANT')} + /> + important + filterSelected('NONIMPORTANT')} + /> + nonimportant
    //highlight-end @@ -88,92 +102,79 @@ Päätämme toteuttaa filtteröinnin siten, että talletamme muistiinpanojen lis } ``` -Tällä hetkellähän tilassa on ainoastaan muistiinpanot sisältävä taulukko. Uudessa ratkaisussa tilalla on siis kaksi avainta, notes jonka arvona muistiinpanot ovat sekä filter, jonka arvona on merkkijono joka kertoo mitkä muistiinpanoista tulisi näyttää ruudulla. +Tällä hetkellähän tilassa on ainoastaan muistiinpanot sisältävä taulukko. Uudessa ratkaisussa tilalla on siis kaksi avainta eli notes, jonka arvona muistiinpanot ovat sekä filter, jonka arvona on merkkijono joka kertoo, mitkä muistiinpanoista tulisi näyttää ruudulla. ### Yhdistetyt reducerit -Voisimme periaatteessa muokata jo olemassaolevaa reduceria ottamaan huomioon muuttuneen tilanteen. Parempi ratkaisu on kuitenkin määritellä tässä tilanteessa uusi, filtterin arvosta huolehtiva reduceri: +Voisimme periaatteessa muokata jo olemassaolevaa reduceria ottamaan huomioon muuttuneen tilanteen. Parempi ratkaisu on kuitenkin määritellä tässä tilanteessa uusi, filtterin arvosta huolehtiva reduceri. Määritellään samalla myös sopiva _action creator_ ‑funktio. Sijoitetaan koodi moduuliin src/reducers/filterReducer.js: ```js const filterReducer = (state = 'ALL', action) => { switch (action.type) { case 'SET_FILTER': - return action.filter + return action.payload default: return state } } -``` - -Filtterin arvon asettavat actionit ovat siis muotoa - -```js -{ - type: 'SET_FILTER', - filter: 'IMPORTANT' -} -``` - -Määritellään samalla myös sopiva _action creator_ -funktio. Sijoitetaan koodi moduuliin src/reducers/filterReducer.js: - -```js -const filterReducer = (state = 'ALL', action) => { - // ... -} export const filterChange = filter => { return { type: 'SET_FILTER', - filter, + payload: filter } } export default filterReducer ``` +Filtterin arvon asettavat actionit ovat siis muotoa: + +```js +{ + type: 'SET_FILTER', + payload: 'IMPORTANT' +} +``` + Saamme nyt muodostettua varsinaisen reducerin yhdistämällä kaksi olemassaolevaa reduceria funktion [combineReducers](https://redux.js.org/api/combinereducers) avulla. -Määritellään yhdistetty reduceri tiedostossa index.js: +Määritellään yhdistetty reducer tiedostossa main.jsx. Tiedoston päivitetty sisältö on seuraavanlainen: ```js -import React from 'react' -import ReactDOM from 'react-dom' -import { createStore, combineReducers } from 'redux' // highlight-line -import { Provider } from 'react-redux' -import App from './App' +import ReactDOM from 'react-dom/client' +import { Provider } from 'react-redux' +import { createStore, combineReducers } from 'redux' +import App from './App' +import filterReducer from './reducers/filterReducer' import noteReducer from './reducers/noteReducer' -import filterReducer from './reducers/filterReducer' // highlight-line - // highlight-start const reducer = combineReducers({ notes: noteReducer, filter: filterReducer }) - // highlight-end const store = createStore(reducer) console.log(store.getState()) -ReactDOM.render( +ReactDOM.createRoot(document.getElementById('root')).render( - - , -
    , - document.getElementById('root') +
    + ) ``` Koska sovelluksemme hajoaa tässä vaiheessa täysin, komponentin App sijasta renderöidään tyhjä div-elementti. -Konsoliin tulostuu storen tila: +_console.log_-komennon ansiosta konsoliin tulostuu storen tila: -![](../../images/6/4e.png) +![Konsolista selviää että store on olio jolla kentät filter (teksti, arvona "ALL") ja notes (taulukollinen muistiinpanoja).](../../images/6/4e.png) -eli store on juuri siinä muodossa missä haluammekin sen olevan! +Store on siis juuri siinä muodossa jossa haluammekin sen olevan! -Tarkastellaan vielä yhdistetyn reducerin luomista +Tarkastellaan vielä yhdistetyn reducerin luomista: ```js const reducer = combineReducers({ @@ -182,54 +183,86 @@ const reducer = combineReducers({ }) ``` -Näin tehdyn reducerin määrittelemän storen tila on olio, jossa on kaksi kenttää, notes ja filter. Tilan kentän notes arvon määrittelee noteReducer, jonka ei tarvitse välittää mitään tilan muista kentistä. Vastaavasti filter kentän käsittely tapahtuu filterReducer:in avulla. +Näin tehdyn reducerin määrittelemän storen tila on olio, jossa on kaksi kenttää: notes ja filter. Tilan kentän notes arvon määrittelee noteReducer, jonka ei tarvitse välittää mitään tilan muista kentistä. Vastaavasti filter kentän käsittely tapahtuu filterReducer:in avulla. -Ennen muun koodin muutoksia, kokeillaan vielä konsolista, miten actionit muuttavat yhdistetyn reducerin muodostamaa staten tilaa. Lisätään seuraavat tiedostoon index.js: +Ennen muun koodin muutoksia kokeillaan vielä konsolista, miten actionit muuttavat yhdistetyn reducerin muodostamaa staten tilaa. Lisätään seuraavat rivit väliaikaisesti tiedostoon main.jsx: ```js +// ... + +const store = createStore(reducer) + +console.log(store.getState()) + +// highlight-start import { createNote } from './reducers/noteReducer' import { filterChange } from './reducers/filterReducer' -//... +// highlight-end + +// highlight-start store.subscribe(() => console.log(store.getState())) store.dispatch(filterChange('IMPORTANT')) -store.dispatch(createNote('combineReducers forms one reduces from many simple reducers')) +store.dispatch(createNote('combineReducers forms one reducer from many simple reducers')) +// highlight-end + +ReactDOM.createRoot(document.getElementById('root')).render( + +
    + +) ``` -Kun simuloimme näin filtterin tilan muutosta ja muistiinpanon luomista Konsoliin tulostuu storen tila jokaisen muutoksen jälkeen: +Kun simuloimme näin filtterin tilan muutosta ja muistiinpanon luomista, konsoliin tulostuu storen tila jokaisen muutoksen jälkeen: -![](../../images/6/5e.png) +![Storen filter-arvoksi muuttuu ensin IMPORTANT, tämän jäleen storen notesiin tulee uusi muistiinpano](../../images/6/5e.png) -Jo tässä vaiheessa kannattaa laittaa mieleen eräs tärkeä detalji. Jos lisäämme molempien reducerien alkuun konsoliin tulostuksen: +Jo tässä vaiheessa kannattaa laittaa mieleen eräs tärkeä detalji. Jos molempien reducerien alkuun lisätään konsoliin tulostus ```js const filterReducer = (state = 'ALL', action) => { - console.log('ACTION: ', action) + console.log('ACTION: ', action) // highlight-line // ... } ``` -Näyttää konsolin perusteella siltä, että jokainen action kahdentuu: +niin nyt konsolin perusteella näyttää siltä, että jokainen action kahdentuu: -![](../../images/6/6.png) +![Konsolin tulostus paljastaa että sekä noteReducer että filterReducer käsittelevät jokaisen actionin](../../images/6/6.png) -Onko koodissa bugi? Ei. Yhdistetty reducer toimii siten, että jokainen action käsitellään kaikissa yhdistetyn reducerin osissa. Usein tietystä actionista on kiinnostunut vain yksi reduceri, on kuitenkin tilanteita, joissa useampi reduceri muuttaa hallitsemaansa staten tilaa jonkin actionin seurauksena. +Onko koodissa bugi? Ei. Yhdistetty reducer toimii siten, että jokainen action käsitellään kaikissa yhdistetyn reducerin osissa. Usein tietystä actionista on kiinnostunut vain yksi reducer, mutta on kuitenkin tilanteita, joissa useampi reducer muuttaa hallitsemaansa staten tilaa jonkin actionin seurauksena. ### Filtteröinnin viimeistely -Viimeistellään nyt sovellus käyttämään yhdistettyä reduceria, eli palautetaan tiedostossa index.js suoritettava renderöinti muotoon +Viimeistellään nyt sovellus käyttämään yhdistettyä reduceria. Poistetaan tiedostosta main.jsx ylimääräiset kokeilut ja palautetaan _App_ renderöitäväksi komponentiksi. Tiedoston päivitetty sisältö on seuraava: ```js -ReactDOM.render( +import ReactDOM from 'react-dom/client' +import { Provider } from 'react-redux' +import { createStore, combineReducers } from 'redux' + +import App from './App' +import filterReducer from './reducers/filterReducer' +import noteReducer from './reducers/noteReducer' + +const reducer = combineReducers({ + notes: noteReducer, + filter: filterReducer +}) + +const store = createStore(reducer) + +console.log(store.getState()) + +ReactDOM.createRoot(document.getElementById('root')).render( - , - document.getElementById('root') + ) ``` Korjataan sitten bugi, joka johtuu siitä, että koodi olettaa storen tilan olevan mustiinpanot tallettava taulukko: -![](../../images/6/7ea.png) +![komennon notes.map(note => ...) suoritus aiheuttaa virheen TypeError notes.map is not a function)](../../images/6/7v.png) Korjaus on helppo. Koska muistiinpanot ovat nyt storen kentässä notes, riittää pieni muutos selektorifunktioon: @@ -238,7 +271,7 @@ const Notes = () => { const dispatch = useDispatch() const notes = useSelector(state => state.notes) // highlight-line - return( + return (
      {notes.map(note => state) ``` -Nyt siis palautetaan tilasta ainoastaan sen kenttä notes +Nyt siis palautetaan tilasta ainoastaan sen kenttä notes: ```js const notes = useSelector(state => state.notes) ``` -Eriytetään näkyvyyden säätelyfiltteri omaksi, tiedostoon sijoitettavaksi src/components/VisibilityFilter.js komponentiksi: +Eriytetään näkyvyyden säätelyfiltteri omaksi, tiedostoon src/components/VisibilityFilter.jsx sijoitettavaksi komponentiksi: ```js -import React from 'react' -import { filterChange } from '../reducers/filterReducer' import { useDispatch } from 'react-redux' +import { filterChange } from '../reducers/filterReducer' -const VisibilityFilter = (props) => { +const VisibilityFilter = () => { const dispatch = useDispatch() return (
      - all - dispatch(filterChange('ALL'))} /> - important + all dispatch(filterChange('IMPORTANT'))} /> - nonimportant + important dispatch(filterChange('NONIMPORTANT'))} /> + nonimportant
      ) } @@ -303,20 +335,19 @@ const VisibilityFilter = (props) => { export default VisibilityFilter ``` -Toteutus on suoraviivainen, radiobuttonin klikkaaminen muuttaa storen kentän filter tilaa. +Toteutus on suoraviivainen - radiopainikkeen klikkaaminen muuttaa storen kentän filter tilaa. Komponentti App yksinkertaisuu nyt seuraavasti: ```js -import React from 'react' +import NoteForm from './components/NoteForm' import Notes from './components/Notes' -import NewNote from './components/NewNote' import VisibilityFilter from './components/VisibilityFilter' const App = () => { return (
      - +
      @@ -326,35 +357,34 @@ const App = () => { export default App ``` -Muutetaan vielä komponentin Notes ottamaan huomioon filtteri +Muutetaan vielä komponenttia Notes ottamaan huomioon filtteri: ```js const Notes = () => { const dispatch = useDispatch() // highlight-start const notes = useSelector(state => { - if ( state.filter === 'ALL' ) { + if (state.filter === 'ALL') { return state.notes } - return state.filter === 'IMPORTANT' + return state.filter === 'IMPORTANT' ? state.notes.filter(note => note.important) : state.notes.filter(note => !note.important) }) // highlight-end - return( + return (
        - {notes.map(note => + {notes.map(note => ( - dispatch(toggleImportanceOf(note.id)) - } + handleClick={() => dispatch(toggleImportanceOf(note.id))} /> - )} + ))}
      ) +} ``` Muutos kohdistuu siis ainoastaan selektorifunktioon, joka oli aiemmin muotoa @@ -376,155 +406,419 @@ const notes = useSelector(({ filter, notes }) => { }) ``` -Sovelluksessa on vielä pieni kauneusvirhe, vaikka oletusarvosesti filtterin arvo on ALL, eli näytetään kaikki muistiinpanot, ei vastaava radiobutton ole valittuna. Ongelma on luonnollisestikin mahdollista korjata, mutta koska kyseessä on ikävä, mutta harmiton feature, jätämme korjauksen myöhemmäksi. +Sovelluksessa on vielä pieni kauneusvirhe, sillä vaikka oletusarvosesti filtterin arvo on ALL eli näytetään kaikki muistiinpanot, ei vastaava radiopainike ole valittuna. Ongelma on luonnollisestikin mahdollista korjata, mutta koska kyseessä on ikävä, mutta harmiton feature, jätämme korjauksen myöhemmäksi. -### Redux DevTools +Redux-sovelluksen tämänhetkinen koodi on kokonaisuudessaan [GitHubissa](https://github.com/fullstack-hy2020/redux-notes/tree/part6-2), branchissa part6-2. -Chromeen on asennettavissa [Redux DevTools](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=fi), jonka avulla Redux-storen tilaa ja sitä muuttavia actioneja on mahdollisuus seurata selaimen konsolista. +
    -Selaimen lisäosan lisäksi debugatessa tarvitaan kirjastoa [redux-devtools-extension](https://www.npmjs.com/package/redux-devtools-extension). Asennetaan se komennolla +
    + +### Tehtävä 6.9 + +Jatketaan tehtävässä 6.3 aloitetun Reduxia käyttävän anekdoottisovelluksen parissa. + +#### 6.9 anekdootit, step7 + +Toteuta sovellukseen näytettävien anekdoottien filtteröiminen: + +![Yläosaan lisätään tekstikenttä, johon kirjoittamalla voidaan rajoittaa näytettävät anekdootit niihin joihin sisältyy "filtterikenttään" kirjoitettu merkkijono](../../images/6/9ea.png) + +Säilytä filtterin tila Redux-storessa. Käytännössä kannattaa tehdä uusi reducer ja action creatorit, ja luoda storea varten yhdistetty reduceri funktion combineReducers avulla. + +Tee filtterin ruudulla näyttämistä varten komponentti Filter. Voit ottaa sen pohjaksi seuraavan koodin: ```js -npm install --save-dev redux-devtools-extension +const Filter = () => { + const handleChange = (event) => { + // input-kentän arvo muuttujassa event.target.value + } + const style = { + marginBottom: 10 + } + + return ( +
    + filter +
    + ) +} + +export default Filter +``` + +
    + +
    + +### Redux Toolkit ja storen konfiguraation refaktorointi + +Kuten olemme jo tähän asti huomanneet, Reduxin konfigurointi ja tilanhallinnan toteutus vaativat melko paljon vaivannäköä. Tämä ilmenee esimerkiksi reducereiden ja action creatorien koodista, jossa on jonkin verran toisteisuutta. [Redux Toolkit](https://redux-toolkit.js.org/) on kirjasto, joka soveltuu näiden yleisten Reduxin käyttöön liittyvien ongelmien ratkaisemiseen. Kirjaston käyttö mm. yksinkertaistaa huomattavasti Redux-storen luontia ja tarjoaa suuren määrän tilanhallintaa helpottavia työkaluja. + +Otetaan Redux Toolkit käyttöön sovelluksessamme refaktoroimalla nykyistä koodia. Aloitetaan kirjaston asennuksella: + +``` +npm install @reduxjs/toolkit ``` -Storen luomistapaa täytyy hieman muuttaa, että kirjasto saadaan käyttöön: +Avataan sen jälkeen main.jsx-tiedosto, jossa nykyinen Redux-store luodaan. Käytetään storen luonnissa Reduxin createStore-funktion sijaan Redux Toolkitin [configureStore](https://redux-toolkit.js.org/api/configureStore)-funktiota: ```js -// ... -import { createStore, combineReducers } from 'redux' -import { composeWithDevTools } from 'redux-devtools-extension' // highlight-line +import ReactDOM from 'react-dom/client' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' // highlight-line -import noteReducer from './reducers/noteReducer' +import App from './App' import filterReducer from './reducers/filterReducer' +import noteReducer from './reducers/noteReducer' -const reducer = combineReducers({ - notes: noteReducer, - filter: filterReducer + // highlight-start +const store = configureStore({ + reducer: { + notes: noteReducer, + filter: filterReducer + } }) +// highlight-end -const store = createStore( - reducer, - // highlight-start - composeWithDevTools() - // highlight-end +console.log(store.getState()) + +ReactDOM.createRoot(document.getElementById('root')).render( + + + ) +``` + +Pääsimme eroon jo muutamasta koodirivistä, kun reducerin muodostamiseen ei enää tarvita combineReducers-funktiota. Tulemme pian näkemään, että configureStore-funktion käytöstä on myös monia muita hyötyjä, kuten kehitystyökalujen ja usein käytettyjen kirjastojen vaivaton käyttöönotto ilman erillistä konfiguraatiota. + +Siistitään vielä main.jsx-tiedostoa siirtämällä Redux-storen luontiin liittyvä koodi erilliseen tiedostoon. Luodaan uusi tiedosto src/store.js: + +```js +import { configureStore } from '@reduxjs/toolkit' + +import noteReducer from './reducers/noteReducer' +import filterReducer from './reducers/filterReducer' + +const store = configureStore({ + reducer: { + notes: noteReducer, + filter: filterReducer + } +}) export default store ``` -Kun nyt avaat konsolin, välilehti redux näyttää seuraavalta: - -![](../../images/6/11ea.png) +Muutosten jälkeen main.jsx-tiedosto näyttää seuraavalta: -Kunkin actionin storen tilaan aiheuttamaa muutosta on helppo tarkastella +```js +import ReactDOM from 'react-dom/client' +import { Provider } from 'react-redux' -![](../../images/6/12ea.png) +import App from './App' +import store from './store' -Konsolin avulla on myös mahdollista dispatchata actioneja storeen +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) +``` -![](../../images/6/13ea.png) +### Redux Toolkit ja reducereiden refaktorointi +Siirrytään seuraavaksi reducereiden refaktorointiin, jossa Redux Toolkitin edut tulevat parhaiten esiin. Redux Toolkitin avulla reducerin ja siihen liittyvät action creatorit voi luoda kätevästi [createSlice](https://redux-toolkit.js.org/api/createSlice)-funktion avulla. Voimme refaktoroida reducers/noteReducer.js-tiedostossa olevan reducerin ja action creatorit createSlice-funktion avulla seuraavasti: -Sovelluksen tämänhetkinen koodi on [githubissa](https://github.com/fullstack-hy2020/redux-notes/tree/part6-2) branchissa part6-2. +```js +import { createSlice } from '@reduxjs/toolkit' // highlight-line -
    +const initialState = [ + { + content: 'reducer defines how redux store works', + important: true, + id: 1, + }, + { + content: 'state of store can contain any data', + important: false, + id: 2, + }, +] -
    +const generateId = () => + Number((Math.random() * 1000000).toFixed(0)) + +// highlight-start +const noteSlice = createSlice({ + name: 'notes', + initialState, + reducers: { + createNote(state, action) { + const content = action.payload + + state.push({ + content, + important: false, + id: generateId(), + }) + }, + toggleImportanceOf(state, action) { + const id = action.payload + + const noteToChange = state.find(n => n.id === id) + + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + + return state.map(note => + note.id !== id ? note : changedNote + ) + } + }, +}) +// highlight-end -### Tehtävät 6.9.-6.12. +// highlight-start +export const { createNote, toggleImportanceOf } = noteSlice.actions +export default noteSlice.reducer +// highlight-end +``` -Jatketaan tehtävässä 6.3 aloitetun reduxia käyttävän anekdoottisovelluksen parissa. +createSlice-funktion name-parametri määrittelee etuliitteen, jota käytetään actioneiden type-arvoissa. Esimerkiksi myöhemmin määritelty createNote-action saa type-arvon notes/createNote. Parametrin arvona on hyvä käyttää muiden reducereiden kesken uniikkia nimeä, jotta sovelluksen actioneiden type-arvoissa ei tapahtuisi odottamattomia yhteentörmäyksiä. Parametri initialState määrittelee reducerin alustavan tilan. Parametri reducers määrittelee itse reducerin objektina, jonka funktiot käsittelevät tietyn actionin aiheuttamat tilamuutokset. Huomaa, että funktioissa action.payload sisältää action creatorin kutsussa annetun parametrin: -#### 6.9 anekdootit, step7 +```js +dispatch(createNote('Redux Toolkit is awesome!')) +``` -Ota sovelluksessasi käyttöön React dev tools. Siirrä Redux-storen määrittely omaan tiedostoon store.js. +Tämä dispatch-kutsu vastaa seuraavan objektin dispatchaamista: -#### 6.10 anekdootit, step8 +```js +dispatch({ type: 'notes/createNote', payload: 'Redux Toolkit is awesome!' }) +``` -Sovelluksessa on valmiina komponentin Notification runko: +Jos olit tarkkana, saatoit huomata, että actionin createNote kohdalla tapahtuu jotain, mikä vaikuttaa rikkovan aiemmin mainittua reducereiden immutabiliteetin periaatetta: ```js -import React from 'react' +createNote(state, action) { + const content = action.payload -const Notification = () => { - const style = { - border: 'solid', - padding: 10, - borderWidth: 1 - } - return ( -
    - render here notification... -
    - ) + state.push({ + content, + important: false, + id: generateId(), + }) } +``` -export default Notification +Mutatoimme state-argumentin taulukkoa kutsumalla push-metodia sen sijaan, että palauttaisimme uuden instanssin taulukosta. Mistä on kyse? + +Redux Toolkit hyödyntää createSlice-funktion avulla määritellyissä reducereissa [Immer](https://immerjs.github.io/immer/)-kirjastoa, joka mahdollistaa state-argumentin mutatoinnin reducerin sisällä. Immer muodostaa mutatoidun tilan perusteella uuden, immutablen tilan ja näin tilamuutosten immutabiliteetti säilyy. + +Huomaa, että tilaa voi muuttaa myös "mutatoimatta" kuten esimerkiksi toggleImportanceOf ‑actionin kohdalla on tehty. Tällöin funktio palauttaa uuden tilan. Mutatointi osoittautuu kuitenkin usein hyödylliseksi etenkin rakenteeltaan monimutkaisen tilan päivittämisessä. + +Funktio createSlice palauttaa objektin, joka sisältää sekä reducerin että reducers-parametrin actioneiden mukaiset action creatorit. Reducer on palautetussa objektissa noteSlice.reducer-kentässä kun taas action creatorit ovat noteSlice.actions-kentässä. Voimme muodostaa tiedoston exportit kätevästi: + +```js +const noteSlice = createSlice({ + // ... +}) + +// highlight-start +export const { createNote, toggleImportanceOf } = noteSlice.actions +export default noteSlice.reducer +// highlight-end ``` -Laajenna komponenttia siten, että se renderöi redux-storeen talletetun viestin, eli renderöitävä komponentti muuttuu muotoon: +Importit toimivat muissa tiedostoissa tavalliseen tapaan: ```js -import React from 'react' -import { useSelector } from 'react-redux' // highlight-line +import noteReducer, { createNote, toggleImportanceOf } from './reducers/noteReducer' +``` -const Notification = () => { - const notification = useSelector(/*s omething here */) // highlight-line - const style = { - border: 'solid', - padding: 10, - borderWidth: 1 +Joudumme hieman muuttamaan testejämme Redux Toolkitin nimeämiskäytäntöjen takia: + +```js +import deepFreeze from 'deep-freeze' +import { describe, expect, test } from 'vitest' +import noteReducer from './noteReducer' + +describe('noteReducer', () => { + test('returns new state with action notes/createNote', () => { // highlight-line + const state = [] + const action = { + type: 'notes/createNote', // highlight-line + payload: 'the app state is in redux store' // highlight-line + } + + deepFreeze(state) + const newState = noteReducer(state, action) + + expect(newState).toHaveLength(1) + expect(newState.map(note => note.content)).toContainEqual(action.payload) // highlight-line + }) +}) + +test('returns new state with action notes/toggleImportanceOf', () => { // highlight-line + const state = [ + { + content: 'the app state is in redux store', + important: true, + id: 1 + }, + { + content: 'state changes are made with actions', + important: false, + id: 2 + } + ] + + const action = { + type: 'notes/toggleImportanceOf', // highlight-line + payload: 2 // highlight-line } - return ( -
    - {notification} // highlight-line -
    - ) -} + + deepFreeze(state) + const newState = noteReducer(state, action) + + expect(newState).toHaveLength(2) + + expect(newState).toContainEqual(state[0]) + + expect(newState).toContainEqual({ + content: 'state changes are made with actions', + important: true, + id: 2 + }) +}) ``` -Joudut siis muuttamaan/laajentamaan sovelluksen olemassaolevaa reduceria. Tee toiminnallisuutta varten oma reduceri ja siirry käyttämään sovelluksessa yhdistettyä reduceria tämän osan materiaalin tapaan. +Sovelluksen tämänhetkinen koodi on [GitHubissa](https://github.com/fullstack-hy2020/redux-notes/tree/part6-3) branchissa part6-3. -Tässä vaiheessa sovelluksen ei vielä tarvitse osata käyttää Notification komponenttia järkevällä tavalla, riittää että sovellus toimii ja näyttää notificationReducerin alkuarvoksi asettaman viestin. +### Redux Toolkit ja console.log -#### 6.11 paremmat anekdootit, step9 +Kuten olemme oppineet, on _console.log_ äärimmäisen voimakas työkalu, se pelastaa meidät yleensä aina pulasta. -Laajenna sovellusta siten, että se näyttää Notification-komponentin avulla viiden sekunnin ajan, kun sovelluksessa äänestetään tai luodaan uusia anekdootteja: +Yritetään kokeeksi tulostaa Redux-storen tila konsoliin kesken funktiolla _createSlice_ luodun reducerin: -![](../../images/6/8ea.png) +```js +const noteSlice = createSlice({ + name: 'notes', + initialState, + reducers: { + // ... + toggleImportanceOf(state, action) { + const id = action.payload + + const noteToChange = state.find(n => n.id === id) + + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + + console.log(state) // highlight-line + + return state.map(note => + note.id !== id ? note : changedNote + ) + } + }, +}) +``` -Notifikaation asettamista ja poistamista varten kannattaa toteuttaa [action creatorit](https://redux.js.org/basics/actions#action-creators). +Kun nyt muistiinpanon tärkeyttä muuttaa klikkaamalla sen nimeä, konsoliin tulostuu seuraava -#### 6.12* paremmat anekdootit, step10 +![](../../images/6/40new.png) -Toteuta sovellukseen näytettävien muistiinpanojen filtteröiminen +Tulostus on mielenkiintoinen mutta ei kovin hyödyllinen. Kyse tässä jo edellä mainitusta Redux toolkitin käyttämästä Immer-kirjastosta, mitä käytetään nyt sisäisesti storen tilan tallentamiseen. -![](../../images/6/9ea.png) +Tilan voi muuntaa ihmisluettavaan muotoon käyttämällä immer-kirjaston [current](https://redux-toolkit.js.org/api/other-exports#current)-funktiota. Funktion voi importata käyttöön komennolla: -Säilytä filtterin tila redux storessa, eli käytännössä kannattaa jälleen luoda uusi reduceri ja action creatorit. +```js +import { current } from '@reduxjs/toolkit' +``` -Tee filtterin ruudulla näyttämistä varten komponentti Filter. Voit ottaa sen pohjaksi seuraavan +ja tämän jälkeen tilan voi tulostaa konsoliin komennolla: ```js -import React from 'react' +console.log(current(state)) +``` -const Filter = () => { - const handleChange = (event) => { - // input-kentän arvo muuttujassa event.target.value - } +Konsolitulostus on nyt ihmisluettava: + +![](../../images/6/41new.png) + +### Redux DevTools + +Chromeen on asennettavissa [Redux DevTools](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=fi) ‑lisäosa, jonka avulla Redux-storen tilaa ja sitä muuttavia actioneja on mahdollisuus seurata selaimen konsolista. Redux Toolkitin configureStore-funktion avulla luodussa storessa Redux DevTools on käytössä automaattisesti ilman ylimääräistä konfigurointia. + +Kun lisäosa on asennettu Chromeen, konsolin Redux-välilehti pitäisi näyttää seuraavalta: + +![Redux DevToolsin oikea puoli "State" näyttää storen alkutilan](../../images/6/42new.png) + +Kunkin actionin storen tilaan aiheuttamaa muutosta on helppo tarkastella: + +![edux DevToolsin vasen puoli näyttää suoritetut actionit, muuttunut tila heijastuu oikealle puolelle](../../images/6/43new.png) + +Konsolin avulla on myös mahdollista dispatchata actioneja storeen: + +![Mahdollisuus actionien dispatchaamiseen avautuu alalaidan valinnoista](../../images/6/44new.png) + +
    + +
    + +### Tehtävät 6.10-6.13 + +#### 6.10 anekdootit, step8 + +Asenna projektiin Redux Toolkit. Siirrä tämän jälkeen Redux-storen määrittely omaan tiedostoon store.js ja hyödynnä sen luonnissa Redux Toolkitin configureStore-funktiota. + +Muuta filter reduserin ja action creatorien määrittely tapahtumaan Redux Toolkitin createSlice-funktion avulla. + +Ota myös käyttöön Redux DevTools sovelluksen tilan debuggaamisen helpottamiseksi. + +#### 6.11 anekdootit, step9 + +Muuta myös anekdoottireduserin ja action creatorien määrittely tapahtumaan Redux Toolkitin createSlice-funktion avulla. + +#### 6.12 anekdootit, step10 + +Sovelluksessa on valmiina komponentin Notification runko: + +```js +const Notification = () => { const style = { + border: 'solid', + padding: 10, + borderWidth: 1, marginBottom: 10 } return (
    - filter + render here notification...
    ) } -export default Filter +export default Notification ``` +Laajenna komponenttia siten, että se renderöi Redux-storeen talletetun viestin. Tee toiminnallisuutta varten oma reduceri ja hyödynnä jälleen Redux Toolkitin createSlice-funktiota. + +Tässä vaiheessa sovelluksen ei vielä tarvitse osata käyttää Notification-komponenttia järkevällä tavalla, vaan riittää että sovellus toimii ja näyttää notificationReducerin alkuarvoksi asettaman viestin. + +#### 6.13 anekdootit, step11 + +Laajenna sovellusta siten, että se näyttää Notification-komponentin avulla viiden sekunnin ajan, kun sovelluksessa äänestetään tai luodaan uusia anekdootteja: + +![Äänestyksen yhteydessä näytetään notifikaatio: you voted 'if it hurts, do it more often'](../../images/6/8eb.png) + +Notifikaation asettamista ja poistamista varten kannattaa toteuttaa [action creatorit](https://redux-toolkit.js.org/api/createSlice#reducers). + +
    diff --git a/src/content/6/fi/osa6c.md b/src/content/6/fi/osa6c.md index 159a393a1f1..618f970cd17 100644 --- a/src/content/6/fi/osa6c.md +++ b/src/content/6/fi/osa6c.md @@ -7,9 +7,11 @@ lang: fi
    -Laajennetaan sovellusta siten, että muistiinpanot talletetaan backendiin. Käytetään osasta 2 tuttua [json-serveriä](/osa2/palvelimella_olevan_datan_hakeminen). +### JSON Serverin käyttöönotto -Tallennetaan projektin juuren tiedostoon db.json tietokannan alkutila: +Laajennetaan sovellusta siten, että muistiinpanot talletetaan backendiin. Käytetään osasta 2 tuttua [JSON Serveriä](/osa2/palvelimella_olevan_datan_hakeminen). + +Tallennetaan projektin juureen tiedostoon db.json tietokannan alkutila: ```json { @@ -28,139 +30,180 @@ Tallennetaan projektin juuren tiedostoon db.json tietokannan alkutila: } ``` -Asennetaan projektiin json-server +Asennetaan projektiin JSON Server -```js -npm install json-server --save +```bash +npm install json-server --save-dev ``` ja lisätään tiedoston package.json osaan scripts rivi ```js "scripts": { - "server": "json-server -p3001 --watch db.json", + "server": "json-server -p 3001 db.json", // ... } ``` -Käynnistetään json-server komennolla _npm run server_. +Käynnistetään JSON Server komennolla _npm run server_. -Tehdään sitten tuttuun tapaan axiosia hyödyntävä backendistä dataa hakeva metodi tiedostoon services/notes.js +### Fetch API +Ohjelmistokehityksessä joudutaan usein pohtimaan, kannattaako jokin toiminnallisuus toteuttaa käyttämällä ulkoista kirjastoa vai onko parempi hyödyntää ympäristön tarjoamia natiiveja ratkaisuja. Molemmilla lähestymistavoilla on omat etunsa ja haasteensa. -```js -import axios from 'axios' +Käytimme HTTP-pyyntöjen tekemiseen kurssin aiemmissa osissa [Axios](https://axios-http.com/docs/intro)-kirjastoa. Tutustutaan nyt vaihtoehtoiseen tapaan tehdä HTTP-pyyntöjä natiivia [Fetch APIa](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) hyödyntäen. + +On tyypillistä, että ulkoinen kirjasto kuten Axios on toteutettu hyödyntäen muita ulkoisia kirjastoja. Esimerkiksi jos Axioksen asentaa projektiin komennolla _npm install axios_, konsoliin tulostuu: + +```bash +$ npm install axios + +added 23 packages, and audited 302 packages in 1s + +71 packages are looking for funding + run `npm fund` for details + +found 0 vulnerabilities +``` + +Komento asentaisi projektiin siis Axios-kirjaston lisäksi yli 20 muuta npm-pakettia, jotka Axios tarvitsisi toimiakseen. + +Fetch API tarjoaa samankaltaisen tavan tehdä HTTP-pyyntöjä kuin Axios, mutta Fetch APIn käyttäminen ei vaadi ulkoisten kirjastojen asentamista. Sovelluksen ylläpito helpottuu, kun päivitettäviä kirjastoja on vähemmän, ja myös tietoturva paranee, koska sovelluksen mahdollinen hyökkäyspinta-ala pienenee. Sovellusten tietoturvaa ja ylläpitoa sivutaan kurssin [osassa 7](https://fullstackopen.com/osa7/luokkakomponentit_sekalaista#react-node-sovellusten-tietoturva). +Pyyntöjen tekeminen tapahtuu käytännössä käyttämällä _fetch()_-funktiota. Käytettävässä syntaksissa on jonkin verran eroja verrattuna Axiokseen. Huomaamme myös pian, että Axios on huolehtinut joistakin asioista puolestamme ja helpottanut elämäämme. Käytämme nyt kuitenkin Fetch APIa, koska se on laajasti käytetty natiiviratkaisu, joka jokaisen Full Stack -kehittäjän on syytä tuntea. + +### Datan hakeminen palvelimelta + +Tehdään backendistä dataa hakeva metodi tiedostoon src/services/notes.js: + +```js const baseUrl = 'http://localhost:3001/notes' const getAll = async () => { - const response = await axios.get(baseUrl) - return response.data + const response = await fetch(baseUrl) + + if (!response.ok) { + throw new Error('Failed to fetch notes') + } + + const data = await response.json() + return data } export default { getAll } ``` -Asennetaan myös axios projektiin +Tutkitaan _getAll_-metodin toteutusta tarkemmin. Muistiinpanot haetaan backendistä nyt kutsumalla _fetch()_-funktiota, jolle on annettu argumentiksi backendin URL-osoite. Pyynnön tyyppiä ei ole erikseen määritelty, joten _fetch_ toteuttaa oletusarvoisen toiminnon eli GET-pyynnön. + +Kun vastaus on saapunut, tarkistetaan pyynnön onnistuminen vastauksen kentästä _response.ok_ ja heitetään tarvittaessa virhe: ```js -npm install axios --save +if (!response.ok) { + throw new Error('Failed to fetch notes') +} ``` -Muutetaan nodeReducer:issa tapahtuva muistiinpanojen tilan alustusta, siten että oletusarvoisesti muistiinpanoja ei ole: +Attribuutti _response.ok_ saa arvon _true_, jos pyyntö on onnistunut eli jos vastauksen statuskoodi on välillä 200-299. Kaikilla muilla statuskoodeilla, esimerkiksi 404 tai 500, se saa arvon _false_. -```js -const noteReducer = (state = [], action) => { - // ... -}; -``` +Huomaa, että _fetch_ ei automaattisesti heitä virhettä, vaikka vastauksen statuskoodi olisi esimerkiksi 404. Virheenkäsittely tulee toteuttaa manuaalisesti, kuten olemme nyt tehneet. -Nopea tapa saada storen tila alustettua palvelimella olevan datan perusteella on hakea muistiinpanot tiedostossa index.js ja dispatchata niille yksitellen action NEW\_NOTE: +Jos pyyntö on onnistunut, vastauksen sisältämä data muunnetaan JSON-muotoon: ```js -// ... -import noteService from './services/notes' // highlight-line +const data = await response.json() +``` -const reducer = combineReducers({ - notes: noteReducer, - filter: filterReducer, -}); +_fetch_ ei siis automaattisesti muunna vastauksen mukana mahdollisesti olevaa dataa JSON-muotoon, vaan muunnos tulee tehdä manuaalisesti. On myös hyvä huomata, että _response.json()_ on asynkroninen metodi, eli sen kanssa tulee käyttää await-avainsanaa. -const store = createStore(reducer); +Suoraviivaistetaan koodia vielä hieman palauttamalla suoraan metodin _response.json()_ palauttama data: -// highlight-start -noteService.getAll().then(notes => - notes.forEach(note => { - store.dispatch({ type: 'NEW_NOTE', data: note }) - }) -) -// highlight-end +```js +const getAll = async () => { + const response = await fetch(baseUrl) -// ... + if (!response.ok) { + throw new Error('Failed to fetch notes') + } + + return await response.json() // highlight-line +} ``` -Lisätään reduceriin tuki actionille INIT\_NOTES, jonka avulla alustus voidaan tehdä dispatchaamalla yksittäinen action. Luodaan myös sitä varten oma action creator -funktio _initializeNotes_: +### Storen alustaminen palvelimelta haetulla datalla -```js -// ... -const noteReducer = (state = [], action) => { - console.log('ACTION:', action) - switch (action.type) { - case 'NEW_NOTE': - return [...state, action.data] - case 'INIT_NOTES': // highlight-line - return action.data // highlight-line - // ... - } -} +Muutetaan nyt sovellustamme siten, että sovelluksen tila alustetaan palvelimelta haetuilla muistiinpanoilla. -export const initializeNotes = (notes) => { - return { - type: 'INIT_NOTES', - data: notes, - } -} +Muutetaan tiedostossa noteReducer.js tapahtuvaa muistiinpanojen tilan alustusta siten, että oletusarvoisesti muistiinpanoja ei ole: -// ... +```js +const noteSlice = createSlice({ + name: 'notes', + initialState: [], // highlight-line + // ... +}) ``` -index.js yksinkertaistuu: +Lisätään action creator setNotes, jonka avulla muistiinpanojen taulukon voi suoraan korvata. Saamme createSlice-funktion avulla luotua haluamamme action creatorin seuraavasti: ```js -import noteReducer, { initializeNotes } from './reducers/noteReducer' // ... -noteService.getAll().then(notes => - store.dispatch(initializeNotes(notes)) -) -``` +const noteSlice = createSlice({ + name: 'notes', + initialState: [], + reducers: { + createNote(state, action) { + const content = action.payload + state.push({ + content, + important: false, + id: generateId() + }) + }, + toggleImportanceOf(state, action) { + const id = action.payload + const noteToChange = state.find(n => n.id === id) + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + return state.map(note => (note.id !== id ? note : changedNote)) + }, + // highlight-start + setNotes(state, action) { + return action.payload + } + // highlight-end + } +}) -> **HUOM:** miksi emme käyttäneet koodissa promisejen ja _then_-metodilla rekisteröidyn tapahtumankäsittelijän sijaan awaitia? -> -> await toimii ainoastaan async-funktioiden sisällä, ja index.js:ssä oleva koodi ei ole funktiossa, joten päädyimme tilanteen yksinkertaisuuden takia tällä kertaa jättämään async:in käyttämättä. +export const { createNote, toggleImportanceOf, setNotes } = noteSlice.actions // highlight-line +export default noteSlice.reducer +``` -Päätetään kuitenkin siirtää muistiinpanojen alustus App-komponentiin, eli kuten yleensä dataa palvelimelta haettaessa, käytetään effect hookia: +Toteutetaan muistiinpanojen alustus App-komponentiin, eli kuten yleensä dataa palvelimelta haettaessa, käytetään useEffect-hookia: ```js -import React, {useEffect} from 'react' // highlight-line -import NewNote from './components/NowNote' +import { useEffect } from 'react' // highlight-line +import { useDispatch } from 'react-redux' // highlight-line + +import NoteForm from './components/NoteForm' import Notes from './components/Notes' import VisibilityFilter from './components/VisibilityFilter' -import noteService from './services/notes' -import { initializeNotes } from './reducers/noteReducer' // highlight-line -import { useDispatch } from 'react-redux' // highlight-line +import { setNotes } from './reducers/noteReducer' // highlight-line +import noteService from './services/notes' // highlight-line const App = () => { - const dispatch = useDispatch() + const dispatch = useDispatch() // highlight-line + // highlight-start useEffect(() => { - noteService - .getAll().then(notes => dispatch(initializeNotes(notes))) - }, []) + noteService.getAll().then(notes => dispatch(setNotes(notes))) + }, [dispatch]) // highlight-end return (
    - +
    @@ -170,83 +213,103 @@ const App = () => { export default App ``` -Hookin useEffect käyttö aiheuttaa eslint-varoituksen: +Muistiinpanot haetaan palvelimelta siis käyttäen määrittelemäämme _getAll()_-metodia ja tallennetaan sitten Redux-storeen dispatchaamalla _setNotes_ -action creatorin palauttama action. Toiminnot tehdään useEffect-hookissa eli ne suoritetaan App-komponentin ensimmäisen renderoinnin yhteydessä. + +Tutkitaan vielä tarkemmin erästä pientä yksityiskohtaa. Olemme lisänneet _dispatch_-muuttujan useEffect-hookin riippuvuustaulukkoon. Jos yritämme käyttää tyhjää riippuvuustaulukkoa, ESLint antaa seuraavan varoituksen: React Hook useEffect has a missing dependency: 'dispatch'. Mistä on kyse? + +Koodi toimisi loogisesti täysin samoin, vaikka käyttäisimme tyhjää riippuvuustaulukkoa, koska dispatch viittaa samaan funktioon koko ohjelman suorituksen ajan. On kuitenkin hyvän ohjelmointikäytännön mukaista lisätä _useEffect_-hookin riippuvuuksiksi kaikki sen käyttämät muuttujat ja funktiot, jotka on määritelty kyseisen komponentin sisällä. Näin voidaan välttää yllättäviä bugeja. + +### Datan lähettäminen palvelimelle -![](../../images/6/26ea.png) +Toteutetaan seuraavaksi toiminnallisuus uuden muistiinpanon lähettämiseksi palvelimelle. Pääsemme samalla harjoittelemaan, miten POST-pyyntö tehdään _fetch()_-metodia käyttäen. -Pääsemme varoituksesta eroon seuraavasti: +Laajennetaan tiedostossa src/services/notes.js olevaa palvelimen kanssa kommunikoivaa koodia seuraavasti: ```js -const App = () => { - const dispatch = useDispatch() - useEffect(() => { - noteService - .getAll().then(notes => dispatch(initializeNotes(notes))) - }, [dispatch]) // highlight-line +const baseUrl = 'http://localhost:3001/notes' - // ... +const getAll = async () => { + const response = await fetch(baseUrl) + + if (!response.ok) { + throw new Error('Failed to fetch notes') + } + + return await response.json() } -``` -Nyt komponentin _App_ sisällä määritelty muuttuja dispatch eli käytännössä redux-storen dispatch-funktio on lisätty useEffectille parametrina annettuun taulukkoon. **Jos** dispatch-muuttujan sisältö muuttuisi ohjelman suoritusaikana, suoritettaisiin efekti uudelleen, näin ei kuitenkaan ole, eli varoitus on tässä tilanteessa oikeastaan aiheeton. +// highlight-start +const createNew = async (content) => { + const response = await fetch(baseUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content, important: false }), + }) + + if (!response.ok) { + throw new Error('Failed to create note') + } + + return await response.json() +} +// highlight-end + +export default { getAll, createNew } // highlight-line +``` -Toinen tapa päästä eroon varoituksesta olisi disabloida se kyseisen rivin kohdalta: +Tutkitaan _createNew_-metodin toteutusta tarkemmin. _fetch()_-funktion ensimmäinen parametri määrittelee URL-osoitteen, johon pyyntö tehdään. Toinen parametri on olio, joka määrittelee muut pyynnön yksityiskohdat, kuten pyynnön tyypin, otsikot ja pyynnön mukana lähetettävän datan. Voimme selkeyttää koodia vielä hieman tallentamalla pyynnön yksityiskohdat määrittelevän olion erilliseen options-apumuuttujaan: ```js -const App = () => { - const dispatch = useDispatch() - useEffect(() => { - noteService - .getAll().then(notes => dispatch(initializeNotes(notes))) - // highlight-start - },[]) // eslint-disable-line react-hooks/exhaustive-deps +const createNew = async (content) => { + // highlight-start + const options = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content, important: false }), + } + + const response = await fetch(baseUrl, options) // highlight-end - // ... + if (!response.ok) { + throw new Error('Failed to create note') + } + + return await response.json() } ``` -Yleisesti ottaen eslint-virheiden disabloiminen ei ole hyvä idea, joten vaikka kyseisen eslint-säännön tarpeellisuus onkin aiheuttanut [kiistelyä](https://github.com/facebook/create-react-app/issues/6880), pitäydytään ylemmässä ratkaisussa. - -Lisää hookien riippuvuuksien määrittelyn tarpeesta [reactin dokumentaatiossa](https://reactjs.org/docs/hooks-faq.html#is-it-safe-to-omit-functions-from-the-list-of-dependencies). +Tutkitaan options-oliota tarkemmin: +- method määrittelee pyynnön tyypin, joka tässä tapauksessa on POST +- headers määrittelee pyynnön otsikot. Liitämme pyyntöön otsikon _'Content-Type': 'application/json'_, jotta palvelin tietää, että pyynnön mukana oleva data on JSON-muotoista, ja osaa käsitellä pyynnön oikein +- body sisältää pyynnön mukana lähetettävän datan. Kentään ei voi suoraan sijoittaa JavaScript-oliota, vaan se tulee ensin muuntaa JSON-merkkijonoksi kutsumalla funktiota _JSON.stringify()_ -Voimme toimia samoin myös uuden muistiinpanon luomisen suhteen. Laajennetaan palvelimen kanssa kommunikoivaa koodia: +Kuten GET-pyynnön kanssa, myös nyt vastauksen statuskoodi tutkitaan virheiden varalta: ```js -const baseUrl = 'http://localhost:3001/notes' - -const getAll = async () => { - const response = await axios.get(baseUrl) - return response.data +if (!response.ok) { + throw new Error('Failed to create note') } +``` -// highlight-start -const createNew = async (content) => { - const object = { content, important: false } - const response = await axios.post(baseUrl, object) - return response.data -} -// highlight-end +Jos pyyntö onnistuu, JSON Server palauttaa juuri luodun muistiinpanon, jolle se on generoinut myös yksilöllisen id:n. Vastauksen sisältämä data tulee kuitenkin vielä muuntaa JSON-muotoon metodilla _response.json()_: -export default { - getAll, - createNew, -} +```js +return await response.json() ``` -Komponentin NewNote metodi _addNote_ muuttuu hiukan: +Muutetaan sitten sovelluksemme NoteForm-komponenttia siten, että uusi muistiinpano lähetetään backendiin. Komponentin metodi _addNote_ muuttuu hiukan: ```js -import React from 'react' import { useDispatch } from 'react-redux' import { createNote } from '../reducers/noteReducer' import noteService from '../services/notes' // highlight-line -const NewNote = (props) => { +const NoteForm = (props) => { const dispatch = useDispatch() - const addNote = async (event) => { + const addNote = async (event) => { // highlight-line event.preventDefault() const content = event.target.note.value event.target.note.value = '' @@ -262,64 +325,68 @@ const NewNote = (props) => { ) } -export default NewNote +export default NoteForm ``` -Koska backend generoi muistiinpanoille id:t, muutetaan action creator _createNote_ muotoon +Kun uusi muistiinpano luodaan backendiin kutsumalla _createNew()_-metodia, saadaan paluuarvona muistiinpanoa kuvaava olio, jolle backend on generoinut id:n. Muutetaan siksi tiedostossa notesReducer.js määritelty action creator createNote seuraavaan muotoon: ```js -export const createNote = (data) => { - return { - type: 'NEW_NOTE', - data, - } -} +const noteSlice = createSlice({ + name: 'notes', + initialState: [], + reducers: { + createNote(state, action) { + state.push(action.payload) // highlight-line + }, + // .. + }, +}) ``` Muistiinpanojen tärkeyden muuttaminen olisi mahdollista toteuttaa samalla periaatteella, eli tehdä palvelimelle ensin asynkroninen metodikutsu ja sen jälkeen dispatchata sopiva action. -Sovelluksen tämänhetkinen koodi on [githubissa](https://github.com/fullstack-hy2020/redux-notes/tree/part6-3) branchissa part6-3. +Sovelluksen tämänhetkinen koodi on [GitHubissa](https://github.com/fullstack-hy2020/redux-notes/tree/part6-4) branchissa part6-4.
    -### Tehtävät 6.13.-6.14. +### Tehtävät 6.14.-6.15. -#### 6.13 anekdootit ja backend, step1 +#### 6.14 anekdootit ja backend, step1 -Hae sovelluksen käynnistyessä anekdootit json-serverillä toteutetusta backendistä. +Hae sovelluksen käynnistyessä anekdootit JSON Serverillä toteutetusta backendistä. Käytä HTTP-pyynnön tekemiseen Fetch APIa. Backendin alustavan sisällön saat esim. [täältä](https://github.com/fullstack-hy2020/misc/blob/master/anecdotes.json). -#### 6.14 anekdootit ja backend, step2 +#### 6.15 anekdootit ja backend, step2 -Muuta uusien anekdoottien luomista siten, että anekdootit talletetaan backendiin. +Muuta uusien anekdoottien luomista siten, että anekdootit talletetaan backendiin. Hyödynnä toteutuksessasi jälleen Fetch APIa.
    -### Asynkroniset actionit ja redux thunk +### Asynkroniset actionit ja Redux Thunk -Lähestymistapamme on ok, mutta siinä mielessä ikävä, että palvelimen kanssa kommunikointi tapahtuu komponenttien funktioissa. Olisi parempi, jos kommunikointi voitaisiin abstrahoida komponenteilta siten, että niiden ei tarvitsisi kuin kutsua sopivaa action creatoria, esim. App alustaisi sovelluksen tilan seuraavasti: +Lähestymistapamme on melko hyvä, mutta siinä mielessä ikävä, että palvelimen kanssa kommunikointi tapahtuu komponentit määrittelevien funktioiden koodissa. Olisi parempi, jos kommunikointi voitaisiin abstrahoida komponenteilta siten, että niiden ei tarvitsisi kuin kutsua sopivaa action creatoria. Esim. App voisi alustaa sovelluksen tilan seuraavasti: ```js const App = () => { const dispatch = useDispatch() useEffect(() => { - dispatch(initializeNotes())) - },[dispatch]) + dispatch(initializeNotes()) + }, [dispatch]) // ... } ``` -ja NoteForm loisi uuden muistiinpanon seuraavasti: +NoteForm puolestaan loisi uuden muistiinpanon seuraavasti: ```js -const NewNote = () => { +const NoteForm = () => { const dispatch = useDispatch() const addNote = async (event) => { @@ -333,158 +400,191 @@ const NewNote = () => { } ``` -Molemmat komponentit dispatchaisivat ainoastaan actionin, välittämättä siitä että taustalla tapahtuu todellisuudessa palvelimen kanssa tapahtuvaa kommunikointia. +Molemmat komponentit dispatchaisivat ainoastaan actionin välittämättä siitä, että taustalla tapahtuu todellisuudessa palvelimen kanssa tapahtuvaa kommunikointia. Tämän kaltaisten asynkronisten actioneiden käyttö onnistuu [Redux Thunk](https://github.com/reduxjs/redux-thunk)-kirjaston avulla. Kirjaston käyttö ei vaadi ylimääräistä konfiguraatiota eikä asennusta, kun Redux-store on luotu Redux Toolkitin configureStore-funktiolla. -Asennetaan nyt [redux-thunk](https://github.com/gaearon/redux-thunk)-kirjasto, joka mahdollistaa asynkronisten actionien luomisen. Asennus tapahtuu komennolla: +Redux Thunkin ansiosta on mahdollista määritellä action creatoreja, jotka palauttavat objektin sijaan funktion. Tämän ansiosta on mahdollista toteuttaa asynkronisia action creatoreja, jotka ensin odottavat jonkin asynkronisen toimenpiteen valmistumista ja vasta sen jälkeen dispatchaavat varsinaisen actionin. -```js -npm install --save redux-thunk -``` - -redux-thunk-kirjasto on ns. redux-middleware joka täytyy ottaa käyttöön storen alustuksen yhteydessä. Eriytetään samalla storen määrittely omaan tiedostoon src/store.js: +Jos action creator palauttaa funktion, Redux välittää palautetulle funktiolle automaattisesti Redux-storen dispatch- ja getState-metodit argumenteiksi. Sen ansiosta voimme määritellä muistiinpanojen alkutilan palvelimelta hakevan action creatorin initializeNotes tiedostossa noteReducer.js seuraavasti: ```js -import { createStore, combineReducers, applyMiddleware } from 'redux' -import thunk from 'redux-thunk' -import { composeWithDevTools } from 'redux-devtools-extension' - -import noteReducer from './reducers/noteReducer' -import filterReducer from './reducers/filterReducer' +import { createSlice } from '@reduxjs/toolkit' +import noteService from '../services/notes' // highlight-line -const reducer = combineReducers({ - notes: noteReducer, - filter: filterReducer, +const noteSlice = createSlice({ + name: 'notes', + initialState: [], + reducers: { + createNote(state, action) { + state.push(action.payload) + }, + toggleImportanceOf(state, action) { + const id = action.payload + const noteToChange = state.find((n) => n.id === id) + const changedNote = { + ...noteToChange, + important: !noteToChange.important, + } + return state.map((note) => (note.id !== id ? note : changedNote)) + }, + setNotes(state, action) { + return action.payload + }, + }, }) -const store = createStore( - reducer, - composeWithDevTools( - applyMiddleware(thunk) - ) -) - -export default store -``` - -Tiedosto src/index.js on muutoksen jälkeen seuraava - -```js -import React from 'react' -import ReactDOM from 'react-dom' -import { Provider } from 'react-redux' -import store from './store' // highlight-line -import App from './App' - -ReactDOM.render( - - - , - document.getElementById('root') -) -``` +const { setNotes } = noteSlice.actions // highlight-line -redux-thunkin ansiosta on mahdollista määritellä action creatoreja siten, että ne palauttavat funktion, jonka parametrina on redux-storen dispatch-metodi. Tämän ansiosta on mahdollista tehdä asynkronisia action creatoreja, jotka ensin odottavat jonkin toimenpiteen valmistumista ja vasta sen jälkeen dispatchaavat varsinaisen actionin. - -Voimme nyt määritellä muistiinpanojen alkutilan palvelimelta hakevan action creatorin initializeNotes seuraavasti: - -```js +// highlight-start export const initializeNotes = () => { - return async dispatch => { + return async (dispatch) => { const notes = await noteService.getAll() - dispatch({ - type: 'INIT_NOTES', - data: notes, - }) + dispatch(setNotes(notes)) } } +// highlight-end + +export const { createNote, toggleImportanceOf } = noteSlice.actions // highlight-line + +export default noteSlice.reducer ``` -Sisemmässä funktiossaan, eli asynkronisessa actionissa operaatio hakee ensin palvelimelta kaikki muistiinpanot ja sen jälkeen dispatchaa muistiinpanot storeen lisäävän actionin. +Sisemmässä funktiossaan eli asynkronisessa actionissa operaatio hakee ensin palvelimelta kaikki muistiinpanot ja sen jälkeen dispatchaa muistiinpanot storeen lisäävän actionin. Huomionarvioista on se, että Redux välittää _dispatch_-metodin viitteen automaattisesti funktion argumentiksi, eli action creator _initializeNotes_ ei tarvitse mitään parametreja. + +Action creatoria _setNotes_ ei enää exportata moduulin ulkopuolelle, koska muistiinpanojen alkutila on tarkoitus asettaa jatkossa käytämällä tekemäämme asynkronista action creatoria _initialNotes_. Hyödynnämme kuitenkin edelleen _setNotes_ -action creatoria moduulin sisällä. Komponentti App voidaan nyt määritellä seuraavasti: ```js +import { useEffect } from 'react' +import { useDispatch } from 'react-redux' + +import NoteForm from './components/NoteForm' +import Notes from './components/Notes' +import VisibilityFilter from './components/VisibilityFilter' +import { initializeNotes } from './reducers/noteReducer' // highlight-line + const App = () => { const dispatch = useDispatch() - // highlight-start useEffect(() => { - dispatch(initializeNotes()) - },[dispatch]) - // highlight-end + dispatch(initializeNotes()) // highlight-line + }, [dispatch]) return (
    - +
    ) } + +export default App ``` -Ratkaisu on elegantti, muistiinpanojen alustuslogiikka on eriytetty kokonaan React-komponenttien ulkopuolelle. +Ratkaisu on elegantti, sillä muistiinpanojen alustuslogiikka on eriytetty kokonaan React-komponenttien ulkopuolelle. -Uuden muistiinpanon lisäävä action creator _createNote_ on seuraavassa +Luodaan seuraavaksi _appendNote_-niminen asynkroninen action creator: ```js -export const createNote = content => { - return async dispatch => { +import { createSlice } from '@reduxjs/toolkit' +import noteService from '../services/notes' + +const noteSlice = createSlice({ + name: 'notes', + initialState: [], + reducers: { + createNote(state, action) { + state.push(action.payload) + }, + toggleImportanceOf(state, action) { + const id = action.payload + const noteToChange = state.find((n) => n.id === id) + const changedNote = { + ...noteToChange, + important: !noteToChange.important, + } + return state.map((note) => (note.id !== id ? note : changedNote)) + }, + setNotes(state, action) { + return action.payload + }, + }, +}) + +const { createNote, setNotes } = noteSlice.actions // highlight-line + +export const initializeNotes = () => { + return async (dispatch) => { + const notes = await noteService.getAll() + dispatch(setNotes(notes)) + } +} + +// highlight-start +export const appendNote = (content) => { + return async (dispatch) => { const newNote = await noteService.createNew(content) - dispatch({ - type: 'NEW_NOTE', - data: newNote, - }) + dispatch(noteSlice.actions.createNote(newNote)) } } +// highlight-end + +export const { toggleImportanceOf } = noteSlice.actions // highlight-line + +export default noteSlice.reducer ``` -Periaate on jälleen sama, ensin suoritetaan asynkroninen operaatio, ja sen valmistuttua dispatchataan storen tilaa muuttava action. +Periaate on jälleen sama. Ensin suoritetaan asynkroninen operaatio, ja sen valmistuttua dispatchataan storen tilaa muuttava action. _createNote_ -action creatoria ei enää exportata tiedoston ulkopuolelle, vaan sitä käytetään ainoastaan sisäisesti _appendNote_ -funktion toteutuksessa. -Komponentti NewNote muuttuu seuraavasti: +Komponentti NoteForm yksinkertaistuu seuraavasti: ```js -const NewNote = () => { +import { useDispatch } from 'react-redux' +import { appendNote } from '../reducers/noteReducer' // highlight-line + +const NoteForm = () => { const dispatch = useDispatch() - + const addNote = async (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' - dispatch(createNote(content)) //highlight-line + dispatch(appendNote(content)) // highlight-line } return (
    - +
    ) } ``` -Sovelluksen tämänhetkinen koodi on [githubissa](https://github.com/fullstack-hy2020/redux-notes/tree/part6-4) branchissa part6-4. +Sovelluksen tämänhetkinen koodi on [GitHubissa](https://github.com/fullstack-hy2020/redux-notes/tree/part6-5) branchissa part6-5. + +Redux Toolkit tarjoaa myös hieman kehittyneempiä työkaluja asynkronisen tilanhallinnan helpottamiseksi, esim mm. [createAsyncThunk](https://redux-toolkit.js.org/api/createAsyncThunk)-funktion ja [RTK Query](https://redux-toolkit.js.org/rtk-query/overview) ‑API:n. Yksinkertaisissa sovelluksissa näiden tuoma hyöty lienee kuitenkin vähäinen.
    -### Tehtävät 6.15.-6.18. - -#### 6.15 anekdootit ja backend, step3 +### Tehtävät 6.16.-6.19. -Muuta redux-storen alustus tapahtumaan redux-thunk-kirjaston avulla toteutettuun asynkroniseen actioniin. +#### 6.16 anekdootit ja backend, step3 -#### 6.16 anekdootit ja backend, step4 +Muuta Redux-storen alustus tapahtumaan Redux Thunk ‑kirjaston avulla toteutettuun asynkroniseen actioniin. -Muuta myös uuden anekdootin luominen tapahtumaan redux-thunk-kirjaston avulla toteutettuihin asynkronisiin actioneihin. +#### 6.17 anekdootit ja backend, step4 +Muuta myös uuden anekdootin luominen tapahtumaan Redux Thunk ‑kirjaston avulla toteutettuihin asynkronisiin actioneihin. -#### 6.17 anekdootit ja backend, step5 +#### 6.18 anekdootit ja backend, step5 -Äänestäminen ei vielä talleta muutoksia backendiin. Korjaa tilanne redux-thunk-kirjastoa hyödyntäen. +Äänestäminen ei vielä talleta muutoksia backendiin. Korjaa tilanne Redux Thunk ‑kirjastoa ja Fetch APIa hyödyntäen. -#### 6.18 anekdootit ja backend, step6 +#### 6.19 anekdootit ja backend, step6 Notifikaatioiden tekeminen on nyt hieman ikävää, sillä se edellyttää kahden actionin tekemistä ja _setTimeout_-funktion käyttöä: @@ -495,13 +595,13 @@ setTimeout(() => { }, 5000) ``` -Tee asynkroninen action creator, joka mahdollistaa notifikaation antamisen seuraavasti: +Toteuta action creator, joka mahdollistaa notifikaation antamisen seuraavasti: ```js dispatch(setNotification(`you voted '${anecdote.content}'`, 10)) ``` -eli ensimmäisenä parametrina on renderöitävä teksti ja toisena notifikaation näyttöaika sekunneissa. +Ensimmäisenä parametrina on renderöitävä teksti ja toisena notifikaation näyttöaika sekunneissa. Ota paranneltu notifikaatiotapa käyttöön sovelluksessasi. diff --git a/src/content/6/fi/osa6d.md b/src/content/6/fi/osa6d.md index 672aee74c92..269924b19c4 100644 --- a/src/content/6/fi/osa6d.md +++ b/src/content/6/fi/osa6d.md @@ -7,601 +7,913 @@ lang: fi
    -Olemme käyttäneet redux-storea react-redux-kirjaston [hook](https://react-redux.js.org/api/hooks)-apin, eli funktioiden [useSelector](https://react-redux.js.org/api/hooks#useselector) ja [useDispatch](https://react-redux.js.org/api/hooks#usedispatch) avulla. +Tarkastellaan osan lopussa vielä muutamaa erilaista tapaa sovelluksen tilan hallintaan. -Tarkastellaan tämän osan lopuksi toista, hieman vanhempaa ja jonkin verran monimutkaisempaa tapaa reduxin käyttöön, eli [react-redux](https://github.com/reactjs/react-redux) -kirjaston määrittelemää [connect](https://github.com/reactjs/react-redux/blob/master/docs/api.md#connectmapstatetoprops-mapdispatchtoprops-mergeprops-options)-funktiota. +Jatketaan muistiinpano-sovelluksen parissa. Otetaan fokukseen palvelimen kanssa tapahtuva kommunikointi. Aloitetaan sovellus puhtaalta pöydältä. Ensimmäinen versio on seuraava: -Uusissa sovelluksissa kannattaa ehdottomasti käyttää hook-apia, mutta connectin tuntemisesta on hyötyä vanhempia reduxia käyttäviä projekteja ylläpidettävissä. - -### Redux Storen välittäminen komponentille connect-funktiolla +```js +const App = () => { + const addNote = async (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + console.log(content) + } -Muutetaan sovelluksen komponenttia Notes, siten että korvataan hook-apin eli funktioiden _useDispatch_ ja _useSelector_ käyttö funktioilla _connect_. Komponentin seuraavat osat tulee siis muuttaa: + const toggleImportance = (note) => { + console.log('toggle importance of', note.id) + } -````js -import React from 'react' -import { useDispatch, useSelector } from 'react-redux' // highlight-line -import { toggleImportanceOf } from '../reducers/noteReducer' + const notes = [] -const Notes = () => { - // highlight-start - const dispatch = useDispatch() - const notes = useSelector(({filter, notes}) => { - if ( filter === 'ALL' ) { - return notes - } - return filter === 'IMPORTANT' - ? notes.filter(note => note.important) - : notes.filter(note => !note.important) - }) - // highlight-end - - return( -
      - {notes.map(note => - - dispatch(toggleImportanceOf(note.id)) // highlight-line - } - /> - )} -
    + return ( +
    +

    Notes app

    +
    + + +
    + {notes.map((note) => ( +
  • toggleImportance(note)}> + {note.content} + {note.important ? 'important' : ''} +
  • + ))} +
    ) } -export default Notes -```` +export default App +``` -Funktiota _connect_ käyttämällä "normaaleista" React-komponenteista saadaan muodostettua komponentteja, joiden propseihin on "mäpätty" eli yhdistetty haluttuja osia storen määrittelemästä tilasta. +Alkuvaiheen koodi on GitHubissa repositorion [https://github.com/fullstack-hy2020/query-notes](https://github.com/fullstack-hy2020/query-notes/tree/part6-0) branchissa part6-0. -Muodostetaan ensin komponentista Notes connectin avulla yhdistetty komponentti: +### Palvelimella olevan datan hallinta React Query ‑kirjaston avulla + +Hyödynnämme nyt [React Query](https://tanstack.com/query/latest) ‑kirjastoa palvelimelta haettavan datan säilyttämiseen ja hallinnointiin. Kirjaston uusimmasta versiosta käytetään myös nimitystä TanStack Query mutta pitäydymme vanhassa tutussa nimessä. + +Asennetaan kirjasto komennolla + +```bash +npm install @tanstack/react-query +``` + +Tiedostoon main.jsx tarvitaan muutama lisäys, jotta kirjaston funktiot saadaan välitettyä koko sovelluksen käyttöön: ```js -import React from 'react' -import { connect } from 'react-redux' // highlight-line -import { toggleImportanceOf } from '../reducers/noteReducer' +import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' // highlight-line -const Notes = () => { - // ... -} +import App from './App.jsx' -const ConnectedNotes = connect()(Notes) // highlight-line -export default ConnectedNotes // highlight-line +const queryClient = new QueryClient() // highlight-line + +createRoot(document.getElementById('root')).render( + // highlight-line + + // highlight-line +) ``` -Moduuli eksporttaa nyt alkuperäisen komponentin sijaan yhdistetyn komponentin, joka toimii toistaiseksi täsmälleen alkuperäisen komponentin kaltaisesti. +Käytetään aiemmista osista tuttuun tapaan [JSON Serveriä](https://github.com/typicode/json-server) simuloimaan backendin toimintaa. JSON Server on valmiiksi konfiguroituna esimerkkiprojektiin, ja projektin juuressa on tiedosto db.json, joka sisältää oletuksena kaksi muistiinpanoa. Voimme siis käynnistää serverin suoraan komennolla: -Komponentti tarvitsee storesta sekä muistiinpanojen listan, että filtterin arvon. Funktion _connect_ ensimmäisenä parametrina voidaan määritellä funktio [mapStateToProps](https://github.com/reactjs/react-redux/blob/master/docs/api.md#arguments), joka liittää joitakin storen tilan perusteella määriteltyjä asioita connectilla muodostetun yhdistetyn komponentin propseiksi. +```js +npm run server +``` -Jos määritellään: +Voimme nyt hakea muistiinpanot komponentissa App. Koodi laajenee seuraavasti: ```js -const Notes = (props) => { // highlight-line - const dispatch = useDispatch() +import { useQuery } from '@tanstack/react-query' // highlight-line -// highlight-start - const notesToShow = () => { - if ( props.filter === 'ALL') { - return props.notes +const App = () => { + const addNote = async (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + console.log(content) + } + + const toggleImportance = (note) => { + console.log('toggle importance of', note.id) + } + + // highlight-start + const result = useQuery({ + queryKey: ['notes'], + queryFn: async () => { + const response = await fetch('http://localhost:3001/notes') + if (!response.ok) { + throw new Error('Failed to fetch notes') + } + return await response.json() } - - return props.filter === 'IMPORTANT' - ? props.notes.filter(note => note.important) - : props.notes.filter(note => !note.important) + }) + + console.log(JSON.parse(JSON.stringify(result))) + + if (result.isLoading) { + return
    loading data...
    } + + const notes = result.data // highlight-end - return( -
      - {notesToShow().map(note => // highlight-line - - dispatch(toggleImportanceOf(note.id)) - } - /> - )} -
    + return ( + // ... ) } +``` -const mapStateToProps = (state) => { - return { - notes: state.notes, - filter: state.filter, - } -} +Datan hakeminen palvelimelta tapahtuu edellisen luvun tapaan Fetch APIn fetch-metodilla. Metodikutsu on kuitenkin nyt kääritty [useQuery](https://tanstack.com/query/latest/docs/react/reference/useQuery)-funktiolla muodostetuksi [kyselyksi](https://tanstack.com/query/latest/docs/react/guides/queries). useQuery-funktiokutsun parametrina on olio, jolla on kentät queryKey ja queryFn. Kentän queryKey arvona on taulukko, joka sisältää merkkijonon notes. Se toimii [avaimena](https://tanstack.com/query/latest/docs/react/guides/query-keys) määriteltyyn kyselyyn, eli muistiinpanojen listaan. -const ConnectedNotes = connect(mapStateToProps)(Notes) // highlight-line +Funktion useQuery paluuarvo on olio, joka kertoo kyselyn tilan. Konsoliin tehty tulostus havainnollistaa tilannetta: -export default ConnectedNotes -``` +![](../../images/6/60new.png) -on komponentin Notes sisällä mahdollista viitata storen tilaan, esim. muistiinpanoihin suoraan propsin kautta props.notes. Vastaavasti props.filter viittaa storessa olevaan filter-kentän tilaan. +Eli ensimmäistä kertaa komponenttia renderöitäessä kysely on vielä tilassa loading, eli siihen liittyvä HTTP-pyyntö on kesken. Tässä vaiheessa renderöidään ainoastaan: -Connect-komennolla ja mapStateToProps-määrittelyllä aikaan saatua tilannetta voidaan visualisoida seuraavasti: +``` +
    loading data...
    +``` -![](../../images/6/24c.png) +HTTP-pyyntö kuitenkin valmistuu niin nopeasti, että tekstiä eivät edes tarkkasilmäisimmät ehdi näkemään. Kun pyyntö valmistuu, renderöidään komponentti uudelleen. Kysely on toisella renderöinnillä tilassa success, ja kyselyolion kenttä data sisältää pyynnön palauttaman datan, eli muistiinpanojen listan, joka renderöidään ruudulle. -eli komponentin Notes sisältä on propsien props.notes ja props.filter kautta "suora pääsy" tarkastelemaan Redux storen sisällä olevaa tilaa. +Sovellus siis hakee datan palvelimelta ja renderöi sen ruudulle käyttämättä ollenkaan luvuissa 2-5 käytettyjä Reactin hookeja useState ja useEffect. Palvelimella oleva data on nyt kokonaisuudessaan React Query ‑kirjaston hallinnoinnin alaisuudessa, ja sovellus ei tarvitse ollenkaan Reactin useState-hookilla määriteltyä tilaa! -Komponentti _Notes_ ei oikeastaan tarvitse mihinkään tietoa siitä mikä filtteri on valittuna, eli filtteröintilogiikka voidaan siirtää kokonaan sen ulkopuolelle, ja palauttaa propsina _notes_ suoraan sopivalla tavalla filtteröidyt muistiinpanot: +Siirretään varsinaisen HTTP-pyynnön tekevä funktio omaan tiedostoonsa src/requests.js: ```js -const Notes = (props) => { // highlight-line - const dispatch = useDispatch() - - return( -
      - {props.notes.map(note => - - dispatch(toggleImportanceOf(note.id)) - } - /> - )} -
    - ) -} +const baseUrl = 'http://localhost:3001/notes' -// highlight-start -const mapStateToProps = (state) => { - if ( state.filter === 'ALL' ) { - return { - notes: state.notes - } - } - - return { - notes: (state.filter === 'IMPORTANT' - ? state.notes.filter(note => note.important) - : state.notes.filter(note => !note.important) - ) +export const getNotes = async () => { + const response = await fetch(baseUrl) + if (!response.ok) { + throw new Error('Failed to fetch notes') } + return await response.json() } -// highlight-end +``` -const ConnectedNotes = connect(mapStateToProps)(Notes) -export default ConnectedNotes - ``` +Komponentti App yksinkertaistuu nyt seuraavasti: -### mapDispatchToProps +```js +import { useQuery } from '@tanstack/react-query' +import { getNotes } from './requests' // highlight-line -Olemme nyt päässeet eroon hookista _useSelector_, mutta Notes käyttää edelleen hookia _useDispatch_ ja sen palauttavaa funktiota _dispatch_: +const App = () => { + // ... -```js -const Notes = (props) => { - const dispatch = useDispatch() // highlight-line + const result = useQuery({ + queryKey: ['notes'], + queryFn: getNotes // highlight-line + }) - return( -
      - {props.notes.map(note => - - dispatch(toggleImportanceOf(note.id)) // highlight-line - } - /> - )} -
    - ) + // ... } ``` -Connect-funktion toisena parametrina voidaan määritellä [mapDispatchToProps](https://github.com/reactjs/react-redux/blob/master/docs/api.md#arguments) eli joukko action creator -funktioita, jotka välitetään yhdistetylle komponentille propseina. Laajennetaan connectausta seuraavasti +Sovelluksen tämän hetken koodi on [GitHubissa](https://github.com/fullstack-hy2020/query-notes/tree/part6-1) branchissa part6-1. + +### Datan vieminen palvelimelle React Queryn avulla + +Data haetaan jo onnistuneesti palvelimelta. Huolehditaan seuraavaksi siitä, että lisätty ja muutettu data tallennetaan palvelimelle. Aloitetaan uusien muistiinpanojen lisäämisestä. + +Tehdään tiedostoon requests.js funktio createNote uusien muistiinpanojen talletusta varten: ```js -const mapStateToProps = (state) => { - return { - notes: state.notes, - filter: state.filter, +const baseUrl = 'http://localhost:3001/notes' + +export const getNotes = async () => { + const response = await fetch(baseUrl) + if (!response.ok) { + throw new Error('Failed to fetch notes') } + return await response.json() } // highlight-start -const mapDispatchToProps = { - toggleImportanceOf, +export const createNote = async (newNote) => { + const options = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newNote) + } + + const response = await fetch(baseUrl, options) + + if (!response.ok) { + throw new Error('Failed to create note') + } + + return await response.json() } // highlight-end - -const ConnectedNotes = connect( - mapStateToProps, - mapDispatchToProps // highlight-line -)(Notes) - -export default ConnectedNotes ``` -Nyt komponentti voi dispatchata suoraan action creatorin _toggleImportanceOf_ määrittelemän actionin kutsumalla propsien kautta saamaansa funktiota koodissa: +Komponentti App muuttuu seuraavasti ```js -const Notes = (props) => { - return( -
      - {props.notes.map(note => - props.toggleImportanceOf(note.id)} - /> - )} -
    - ) +import { useQuery, useMutation } from '@tanstack/react-query' // highlight-line +import { getNotes, createNote } from './requests' // highlight-line + +const App = () => { + //highlight-start + const newNoteMutation = useMutation({ + mutationFn: createNote, + }) + // highlight-end + + const addNote = async (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + newNoteMutation.mutate({ content, important: true }) // highlight-line + } + + // + } ``` -Eli se sijaan että kutsuttaisiin action creator-funktiota dispatch-funktion kanssa +Uuden muistiinpanon luomista varten määritellään siis [mutaatio](https://tanstack.com/query/latest/docs/react/guides/mutations) funktion [useMutation](https://tanstack.com/query/latest/docs/react/reference/useMutatio) avulla: ```js -dispatch(toggleImportanceOf(note.id)) +const newNoteMutation = useMutation({ + mutationFn: createNote, +}) ``` -_connect_-metodia käytettäessä actionin dispatchaamiseen riittää +Parametrina on tiedostoon requests.js lisäämämme funktio, joka lähettää Fetch APIn avulla uuden muistiinpanon palvelimelle. + +Tapahtumakäsittelijä addNote suorittaa mutaation kutsumalla mutaatio-olion funktiota mutate ja antamalla uuden muistiinpanon parametrina: ```js -props.toggleImportanceOf(note.id) +newNoteMutation.mutate({ content, important: true }) ``` -Storen _dispatch_-funktiota ei enää tarvitse kutsua, sillä _connect_ on muokannut action creatorin _toggleImportanceOf_ sellaiseen muotoon, joka sisältää dispatchauksen. +Ratkaisumme on hyvä. Paitsi se ei toimi. Uusi muistiinpano kyllä tallettuu palvelimelle, mutta se ei päivity näytölle. + +Jotta saamme renderöityä myös uuden muistiinpanon, meidän on kerrottava React Querylle, että kyselyn, jonka avaimena on merkkijono notes, vanha tulos tulee mitätöidä eli +[invalidoida](https://tanstack.com/query/latest/docs/react/guides/invalidations-from-mutations). -_mapDispatchToProps_ lienee aluksi hieman haastava ymmärtää, etenkin sen kohta käsiteltävä [vaihtoehtoinen käyttötapa](/osa6/connect#map-dispatch-to-propsin-vaihtoehtoinen-kayttotapa). +Invalidointi on onneksi helppoa, se voidaan tehdä kytkemällä mutaatioon sopiva onSuccess-takaisinkutsufunktio: + +```js +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' // highlight-line +import { getNotes, createNote } from './requests' -Connectin aikaansaamaa tilannetta voidaan havainnollistaa seuraavasti: +const App = () => { + const queryClient = useQueryClient() // highlight-line -![](../../images/6/25b.png) + const newNoteMutation = useMutation({ + mutationFn: createNote, + onSuccess: () => { // highlight-line + queryClient.invalidateQueries({ queryKey: ['notes'] }) // highlight-line + }, // highlight-line + }) -eli sen lisäksi että Notes pääsee storen tilaan propsin props.notes kautta, se viittaa props.toggleImportanceOf:lla funktioon, jonka avulla storeen saadaan dispatchattua TOGGLE\_IMPORTANCE-tyyppisiä actioneja. + // ... +} +``` -Connectia käyttämään refaktoroitu komponentti Notes on kokonaisuudessaan seuraava: +Kun mutaatio on nyt suoritettu onnistuneesti, suoritetaan funktiokutsu ```js -import React from 'react' -import { connect } from 'react-redux' -import { toggleImportanceOf } from '../reducers/noteReducer' +queryClient.invalidateQueries({ queryKey: ['notes'] }) +``` -const Notes = (props) => { - return( -
      - {props.notes.map(note => - props.toggleImportanceOf(note.id)} - /> - )} -
    - ) -} +Tämä taas saa aikaan sen, että React Query päivittää automaattisesti kyselyn, jonka avain on notes eli hakee muistiinpanot palvelimelta. Tämän seurauksena sovellus renderöi ajantasaisen palvelimella olevan tilan, eli myös lisätty muistiinpano renderöityy. -const mapStateToProps = (state) => { - if ( state.filter === 'ALL' ) { - return { - notes: state.notes - } +Toteutetaan vielä muistiinpanojen tärkeyden muutos. Lisätään tiedostoon requests.js muistiinpanojen päivityksen hoitava funktio: + +```js +export const updateNote = async (updatedNote) => { + const options = { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updatedNote) } - return { - notes: (state.filter === 'IMPORTANT' - ? state.notes.filter(note => note.important) - : state.notes.filter(note => !note.important) - ) + const response = await fetch(`${baseUrl}/${updatedNote.id}`, options) + + if (!response.ok) { + throw new Error('Failed to update note') } -} -const mapDispatchToProps = { - toggleImportanceOf + return await response.json() } - -// eksportoidaan suoraan connectin palauttama komponentti -export default connect( - mapStateToProps, - mapDispatchToProps -)(Notes) ``` -Otetaan _connect_ käyttöön myös uuden muistiinpanon luomisessa: +Myös muistiinpanon päivittäminen tapahtuu mutaation avulla. Komponentti App laajenee seuraavasti: ```js -import React from 'react' -import { connect } from 'react-redux' -import { createNote } from '../reducers/noteReducer' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { getNotes, createNote, updateNote } from './requests' // highlight-line + +const App = () => { + const queryClient = useQueryClient() + + const newNoteMutation = useMutation({ + mutationFn: createNote, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notes'] }) + } + }) + + // highlight-start + const updateNoteMutation = useMutation({ + mutationFn: updateNote, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notes'] }) + } + }) + // highlight-end -const NewNote = (props) => { // highlight-line - const addNote = async (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' - props.createNote(content) // highlight-line + newNoteMutation.mutate({ content, important: true }) } - return ( -
    - - -
    - ) + const toggleImportance = (note) => { + updateNoteMutation.mutate({...note, important: !note.important }) // highlight-line + } + + // ... } +``` -// highlight-start -export default connect( - null, - { createNote } -)(NewNote) -// highlight-end +Eli jälleen luotiin mutaatio, joka invalidoi kyselyn notes, jotta päivitetty muistiinpano saadaan renderöitymään oikein. Mutaation käyttö on helppoa, metodi mutate saa parametrikseen muistiinpanon, jonka tärkeys on vaihdettu vanhan arvon negaatioon. + +Sovelluksen tämän hetken koodi on [GitHubissa](https://github.com/fullstack-hy2020/query-notes/tree/part6-2) branchissa part6-2. + +### Suorituskyvyn optimointi + +Sovellus toimii hyvin, ja koodikin on suhteellisen yksinkertaista. Erityisesti yllättää muistiinpanojen listan muutoksen toteuttamisen helppous. Esim. kun muutamme muistiinpanon tärkeyttä, riittää kyselyn notes invalidointi siihen, että sovelluksen data päivittyy: + +```js +const updateNoteMutation = useMutation({ + mutationFn: updateNote, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notes'] }) // highlight-line + } +}) ``` -Koska komponentti ei tarvitse storen tilasta mitään, on funktion _connect_ ensimmäinen parametri null. +Tästä on toki seurauksena se, että sovellus tekee muistiinpanon muutoksen aiheuttavan PUT-pyynnön jälkeen uuden GET-pyynnön, jonka avulla se hakee palvelimelta kyselyn datan: -Sovelluksen koodi on [githubissa](https://github.com/fullstackopen-2019/redux-notes/tree/part6-5) branchissa part6-5. +![](../../images/6/61new.png) -### Huomio propsina välitettyyn action creatoriin viittaamisesta +Jos sovelluksen hakema datamäärä ei ole suuri, ei asialla ole juurikaan merkitystä. Selainpuolen toiminnallisuuden kannaltahan ylimääräisen HTTP GET ‑pyynnön tekeminen ei juurikaan haittaa, mutta joissain tilanteissa se saattaa rasittaa palvelinta. -Tarkastellaan vielä erästä mielenkiintoista seikkaa komponentista NewNote: +Tarvittaessa on myös mahdollista optimoida suorituskykyä [päivittämällä itse](https://tanstack.com/query/latest/docs/react/guides/updates-from-mutation-responses) React Queryn ylläpitämää kyselyn tilaa. + +Muutos uuden muistiinpanon lisäävän mutaation osalta on seuraavassa: ```js -import React from 'react' -import { connect } from 'react-redux' -import { createNote } from '../reducers/noteReducer' // highlight-line +const App = () => { + const queryClient = useQueryClient() + + const newNoteMutation = useMutation({ + mutationFn: createNote, + // highlight-start + onSuccess: (newNote) => { + const notes = queryClient.getQueryData('notes') + queryClient.setQueryData('notes', notes.concat(newNote)) + // highlight-end + } + }) -const NewNote = (props) => { - - const addNote = async (event) => { - event.preventDefault() - const content = event.target.note.value - event.target.note.value = '' - props.createNote(content) // highlight-line + // ... +} +``` + +Eli onSuccess-takaisinkutsussa ensin luetaan queryClient-olion avulla olemassaoleva kyselyn notes tila ja päivitetään sitä lisäämällä mukaan uusi muistiinpano, joka saadaan takaisunkutsufunktion parametrina. Parametrin arvo on funktion createNote palauttama arvo, jonka määriteltiin tiedostossa requests.js seuraavasti: + +```js +export const createNote = async (newNote) => { + const options = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newNote) } - return ( -
    - - -
    - ) -} + const response = await fetch(baseUrl, options) + + if (!response.ok) { + throw new Error('Failed to create note') + } -export default connect( - null, - { createNote } // highlight-line -)(NewNote) + return await response.json() +} ``` -Aloittelevalle connectin käyttäjälle aiheuttaa joskus ihmetystä se, että action creatorista createNote on komponentin sisällä käytettävissä kaksi eri versiota. +Samankaltainen muutos olisi suhteellisen helppoa tehdä myös muistiinpanon tärkeyden muuttavaan mutaatioon, jätämme sen kuitenkin vapaaehtoiseksi harjoitustehtäväksi. + +Kiinnitetään lopuksi huomio erikoiseen yksityiskohtaan. React Query hakee kaikki muistiinpanot uudestaan, jos siirrymme selaimessa toiselle välilehdelle ja sen jälkeen palaamme sovelluksen välilehdelle. Tämän voi havaita Developer Consolen network-välilehdeltä: -Funktioon tulee viitata propsien kautta, eli props.createNote, tällöin kyseessä on _connectin_ muokkaama, dispatchauksen sisältävä versio funktiosta. +![](../../images/6/62new-2025.png) -Moduulissa olevan import-lauseen +Mistä on kyse? Hieman [dokumentaatiota](https://tanstack.com/query/latest/docs/react/reference/useQuery) +tutkimalla huomataan, että React Queryn kyselyjen oletusarvoinen toiminnallisuus on se, että kyselyt (joiden tila on stale) päivitetään kun window focus vaihtuu. Voimme halutessamme kytkeä toiminnallisuuden pois luomalla kyselyn seuraavasti: ```js -import { createNote } from './../reducers/noteReducer' +const App = () => { + // ... + const result = useQuery({ + queryKey: ['notes'], + queryFn: getNotes, + refetchOnWindowFocus: false // highlight-line + }) + + // ... +} ``` -ansiosta komponentin sisältä on mahdollista viitata funktioon myös suoraan, eli _createNote_. Näin ei kuitenkaan tule tehdä, sillä silloin on kyseessä alkuperäinen action creator joka ei sisällä dispatchausta. +Konsoliin tehtävillä tulostuksilla voit tarkkailla sitä miten usein React Query aiheuttaa sovelluksen uudelleenrenderöinnin. Nyrkkisääntönä on se, että uudelleenrenderöinti tapahtuu vähintään aina kun sille on tarvetta, eli kun kyselyn tila muuttuu. Voit lukea lisää asiasta esim. [täältä](https://tkdodo.eu/blog/react-query-render-optimizations). -Jos tulostamme funktiot koodin sisällä (emme olekaan vielä käyttäneet kurssilla tätä erittäin hyödyllistä debug-kikkaa) +Sovelluksen lopullinen koodi on [GitHubissa](https://github.com/fullstack-hy2020/query-notes/tree/part6-3) branchissa part6-3. -```js -const NewNote = (props) => { - console.log(createNote) - console.log(props.createNote) +React Query on monipuolinen kirjasto joka jo nyt nähdyn perusteella yksinkertaistaa sovellusta. Tekeekö React Query monimutkaisemmat tilanhallintaratkaisut kuten esim. Reduxin tarpeettomaksi? Ei. React Query voi joissain tapauksissa korvata osin sovelluksen tilan, mutta kuten [dokumentaatio](https://tanstack.com/query/latest/docs/react/guides/does-this-replace-client-state) toteaa - const addNote = (event) => { - event.preventDefault() - const content = event.target.note.value - event.target.note.value = '' - props.createNote(content) +- React Query is a server-state library, responsible for managing asynchronous operations between your server and client +- Redux, etc. are client-state libraries that can be used to store asynchronous data, albeit inefficiently when compared to a tool like React Query + +React Query on siis kirjasto, joka ylläpitää frontendissä palvelimen tilaa, eli toimii ikäänkuin välimuistina sille, mitä palvelimelle on talletettu. React Query yksinkertaistaa palvelimella olevan datan käsittelyä, ja voi joissain tapauksissa eliminoida tarpeen sille, että palvelimella oleva data haettaisiin frontendin tilaan. Useimmat React-sovellukset tarvitsevat palvelimella olevan datan tilapäisen tallettamisen lisäksi myös jonkun ratkaisun sille, miten frontendin muu tila (esim. lomakkeiden tai notifikaatioiden tila) käsitellään. + +
    + +
    + +### Tehtävät 6.20.-6.22. + +Tehdään nyt anekdoottisovelluksesta uusi, React Query ‑kirjastoa hyödyntävä versio. Ota lähtökohdaksesi +[täällä](https://github.com/fullstack-hy2020/query-anecdotes) oleva projekti. Projektissa on valmiina asennettuna JSON Server, jonka toimintaa on hieman modifioitu. Käynnistä palvelin komennolla npm run server. + +Käytä pyyntöjen tekemiseen Fetch APIa. + +HUOM. Osa 6 on päivitetty 12.10.2025 käyttämään Fetch APIa, joka esitellään osassa 6c. Jos olet aloittanut osan läpikäymisen ennen tätä päivämäärää, voit halutessasi käyttää tehtävissä vielä Axiosta. + +#### Tehtävä 6.20 + +Toteuta anekdoottien hakeminen palvelimelta React Queryn avulla. + +Sovelluksen tulee toimia siten, että jos palvelimen kanssa kommunikoinnissa ilmenee ongelmia, tulee näkyviin ainoastaan virhesivu: + +![](../../images/6/65new.png) + +Löydät ohjeen virhetilanteen havaitsemiseen [täältä](https://tanstack.com/query/latest/docs/react/guides/queries). + +Voit simuloida palvelimen kanssa tapahtuvaa ongelmaa esim. sammuttamalla JSON Serverin. Huomaa, että kysely on ensin jonkin aikaa tilassa isLoading sillä epäonnistuessaan React Query yrittää pyyntöä muutaman kerran ennen kuin se toteaa, että pyyntö ei onnistu. Voit halutessasi määritellä, että uudelleenyrityksiä ei tehdä: + +```js +const result = useQuery( + { + queryKey: ['anecdotes'], + queryFn: getAnecdotes, + retry: false } +) +``` - // ... -} +tai, että pyyntöä yritetään uudelleen esim. vain kerran: + +```js +const result = useQuery( + { + queryKey: ['anecdotes'], + queryFn: getAnecdotes, + retry: 1 + } +) ``` -näemme eron: +#### Tehtävä 6.21 + +Toteuta uusien anekdoottien lisääminen palvelimelle React Queryn avulla. Sovelluksen tulee automaattisesti renderöidä lisätty anekdootti. Huomaa, että anekdootin sisällön pitää olla vähintään 5 merkkiä pitkä, muuten palvelin ei hyväksy POST pyyntöä. Virheiden käsittelystä ei tarvitse nyt välittää. + +#### Tehtävä 6.22 + +Toteuta anekdoottien äänestäminen hyödyntäen jälleen React Queryä. Sovelluksen tulee automaattisesti renderöidä äänestetyn anekdootin kasvatettu äänimäärä. -![](../../images/6/10.png) +
    + +
    + +### useReducer + +Vaikka sovellus siis käyttäisi React Queryä, tarvitaan siis yleensä jonkinlainen ratkaisu selaimen muun tilan (esimerkiksi lomakkeiden) hallintaan. Melko usein useState:n avulla muodostettu tila on riittävä ratkaisu. Reduxin käyttö on toki mahdollista mutta on olemassa myös muita vaihtoehtoja. -Ensimmäinen funktioista siis on normaali action creator, toinen taas connectin muotoilema funktio, joka sisältää storen metodin dispatch-kutsun. +Tarkastellaan yksinkertaista laskurisovellusta. Sovellus näyttää laskurin arvon, ja tarjoaa kolme nappia laskurin tilan päivittämiseen: -Connect on erittäin kätevä työkalu, mutta abstraktiuutensa takia se voi aluksi tuntua hankalalta. +![](../../images/6/63new.png) -### mapDispatchToPropsin vaihtoehtoinen käyttötapa +Toteutetaan laskurin tilan hallinta Reactin sisäänrakennetun [useReducer](https://react.dev/reference/react/useReducer)-hookin tarjoamalla Reduxin kaltaisella tilanhallintamekanismilla. -Määrittelimme siis connectin komponentille NewNote antamat actioneja dispatchaavan funktion seuraavasti: +Sovelluksen lähtötilanteen koodi on [GitHubissa](https://github.com/fullstack-hy2020/hook-counter/tree/part6-1) branchissa part6-1. Tiedosto App.jsx näyttää seuraavalta: ```js -const NewNote = (props) => { - // ... +import { useReducer } from 'react' + +const counterReducer = (state, action) => { + switch (action.type) { + case 'INC': + return state + 1 + case 'DEC': + return state - 1 + case 'ZERO': + return 0 + default: + return state + } } -export default connect( - null, - { createNote } -)(NewNote) +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) + + return ( +
    +
    {counter}
    +
    + + + +
    +
    + ) +} + +export default App ``` -Eli määrittelyn ansiosta komponentti dispatchaa uuden muistiinpanon lisäyksen suorittavan actionin suoraan komennolla props.createNote('uusi muistiinpano'). +useReducer siis tarjoaa mekanismin, jonka avulla sovellukselle voidaan luoda tila. Parametrina tilaa luotaessa annetaan tilan muutosten hallinnan hoitava reduserifunktio, sekä tilan alkuarvo: -Parametrin mapDispatchToProps kenttinä ei voi antaa mitä tahansa funktiota, vaan funktion on oltava action creator, eli Redux-actionin palauttava funktio. +```js +const [counter, counterDispatch] = useReducer(counterReducer, 0) +``` -Kannattaa huomata, että parametri mapDispatchToProps on nyt olio, sillä määrittely +Tilan muutokset hoitava reduserifunktio on täysin samanlainen Reduxin reducerien kanssa, eli funktio saa parametrikseen nykyisen tilan, sekä tilanmuutoksen tekemän actionin. Funktio palauttaa actionin tyypin ja mahdollisen sisällön perusteella päivitetyn uuden tilan: ```js -{ - createNote +const counterReducer = (state, action) => { + switch (action.type) { + case 'INC': + return state + 1 + case 'DEC': + return state - 1 + case 'ZERO': + return 0 + default: + return state + } } ``` -on lyhempi tapa määritellä olioliteraali +Esimerkissämme actioneilla ei ole muuta kuin tyyppi. Jos actionin tyyppi on INC, kasvattaa se tilan arvoa yhdellä jne. Reduxin reducerien tapaan actionin mukana voi myös olla mielivaltaista dataa, joka yleensä laitetaan actionin kenttään payload. + +Funktio useReducer palauttaa taulukon, jonka kautta päästään käsiksi tilan nykyiseen arvoon (taulukon ensimmäinen alkio), sekä dispatch-funktioon (taulukon toinen alkio), jonka avulla tilaa voidaan muuttaa: ```js -{ - createNote: createNote +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) // highlight-line + + return ( +
    +
    {counter}
    // highlight-line +
    + // highlight-line + + +
    +
    + ) } ``` -eli olio, jonka ainoan kentän createNote arvona on funktio createNote. +Tilan muutos tapahtuu siis täsmälleen kuten Reduxia käytettäessä, dispatch-funktiolle annetaan parametriksi sopiva tilaa muuttava action: + +```js +counterDispatch({ type: "INC" }) +``` + +### Tilan välittäminen propseina -Voimme määritellä saman myös "pitemmän kaavan" kautta, antamalla _connectin_ toisena parametrina seuraavanlaisen funktion: +Kun sovellus jaetaan useaan komponenttiin, laskurin arvo sekä sen hallintaan käytettävä dispatch-funktio on välitettävä jotenkin myös muille komponenteille. Eräs ratkaisu on välittää nämä tuttuun tapaan propseina. + +Määritellään sovellukselle erillinen Display-komponentti, jonka vastuulla on laskurin arvon näyttäminen. Tiedoston src/components/Display.jsx sisällöksi tulee: ```js -const NewNote = (props) => { - // ... +const Display = ({ counter }) => { + return
    {counter}
    } -// highlight-start -const mapDispatchToProps = (dispatch) => { - return { - createNote: (value) => { - dispatch(createNote(value)) - }, - } +export default Display +``` + +Määritellään lisäksi Button-komponentti, joka vastaa sovelluksen painikkeista: + +```js +const Button = ({ dispatch, type, label }) => { + return ( + + ) } -// highlight-end -export default connect( - null, - mapDispatchToProps -)(NewNote) +export default Button ``` -Tässä vaihtoehtoisessa tavassa mapDispatchToProps on funktio, jota _connect_ kutsuu antaen sille parametriksi storen _dispatch_-funktion. Funktion paluuarvona on olio, joka määrittelee joukon funktioita, jotka annetaan connectoitavalle komponentille propsiksi. Esimerkkimme määrittelee propsin createNote olevan funktion +Tiedosto App.jsx muuttuu seuraavasti: ```js -(value) => { - dispatch(createNote(value)) +import { useReducer } from 'react' + +import Button from './components/Button' // highlight-line +import Display from './components/Display' // highlight-line + +const counterReducer = (state, action) => { + switch (action.type) { + case 'INC': + return state + 1 + case 'DEC': + return state - 1 + case 'ZERO': + return 0 + default: + return state + } +} + +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) + + return ( +
    + // highlight-line +
    + // highlight-start +
    +
    + ) } ``` -eli action creatorilla luodun actionin dispatchaus. +Sovellus on nyt jaettu useampaan komponenttiin. Tilanhallinta on määritelty tiedostossa App.jsx, josta tilanhallintaan tarvittavat arvot ja funktiot välitetään lapsikomponenteille propseina. + +Ratkaisu toimii, mutta ei ole optimaalinen. Jos komponenttirakenne monimutkaistuu, tulee esim dispatcheria välittää propsien avulla monen komponentin kautta sitä tarvitseville komponenteille siitäkin huolimatta, että komponenttipuussa välissä olevat komponentit eivät dispatcheria tarvitsisikaan. Tästä ilmiöstä käytetään nimitystä prop drilling. -Komponentti siis viittaa funktioon propsin props.createNote kautta: +### Kontekstin käyttö tilan välittämiseen + +Reactin sisäänrakennettu [Context API](https://react.dev/learn/passing-data-deeply-with-context) tuo tilanteeseen ratkaisun. Reactin konteksti on eräänlainen sovelluksen globaali tila, johon on mahdollista antaa suora pääsy mille tahansa komponentille. + +Luodaan sovellukseen nyt konteksti, joka tallettaa laskurin tilanhallinnan. + +Konteksti luodaan Reactin hookilla [createContext](https://react.dev/reference/react/createContext). Luodaan konteksti tiedostoon src/CounterContext.jsx: ```js -const NewNote = (props) => { +import { createContext } from 'react' - const addNote = async (event) => { - event.preventDefault() - const content = event.target.note.value - event.target.note.value = '' - props.createNote(content) - } +const CounterContext = createContext() + +export default CounterContext +``` + +Komponentti App voi nyt tarjota kontekstin sen alikomponenteille seuraavasti: + +```js +import { useReducer } from 'react' + +import Button from './components/Button' +import Display from './components/Display' +import CounterContext from './CounterContext' // highlight-line + +// ... + +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) return ( -
    - - -
    + // highlight-line + // highlight-line +
    + // highlight-start +
    +
    // highlight-line ) } ``` -Konsepti on hiukan monimutkainen ja sen selittäminen sanallisesti on haastavaa. Useimmissa tapauksissa onneksi riittää mapDispatchToProps:in yksinkertaisempi muoto. On kuitenkin tilanteita, joissa monimutkaisempi muoto on tarpeen, esim. jos määriteltäessä propseiksi mäpättyjä dispatchattavia actioneja on [viitattava komponentin omiin propseihin](https://github.com/gaearon/redux-devtools/issues/250#issuecomment-186429931). +Kontekstin tarjoaminen siis tapahtuu käärimällä lapsikomponentit komponentin CounterContext.Provider sisälle ja asettamalla kontekstille sopiva arvo. -Egghead.io:sta löytyy Reduxin kehittäjän Dan Abramovin loistava tutoriaali [Getting started with Redux](https://egghead.io/courses/getting-started-with-redux), jonka katsomista voin suositella kaikille. Neljässä viimeisessä videossa käsitellään _connect_-metodia ja nimenomaan sen "hankalampaa" käyttötapaa. +Kontekstin arvoksi annetaan nyt olio, jolla on attribuutit counter ja counterDispatch. Kenttä counter sisältää laskimen arvon ja counterDispatch arvon muuttamiseen käytettävän dispatch-funktion. -### Presentational/Container revisited +Muut komponentit saavat nyt kontekstin käyttöön hookin [useContext](https://react.dev/reference/react/useContext) avulla. Display-komponentti muuttuu seuraavasti: -Connect-funktiota hyödyntävä versio komponentista Notes keskittyy lähes ainoastaan muistiinpanojen renderöimiseen, se on hyvin lähellä sitä minkä sanotaan olevan [presentational](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0)-komponentti, joita Dan Abramovin [sanoin](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) kuvaillaan seuraavasti: +```js +import { useContext } from 'react' // highlight-line +import CounterContext from './CounterContext' // highlight-line -- Are concerned with how things look. -- May contain both presentational and container components inside, and usually have some DOM markup and styles of their own. -- Often allow containment via props.children. -- Have no dependencies on the rest of the app, such Redux actions or stores. -- Don’t specify how the data is loaded or mutated. -- Receive data and callbacks exclusively via props. -- Rarely have their own state (when they do, it’s UI state rather than data). -- Are written as functional components unless they need state, lifecycle hooks, or performance optimizations. +const Display = () => { // highlight-line + const { counter } = useContext(CounterContext) // highlight-line -Connect-metodin avulla muodostettu _yhdistetty komponentti_ + return
    {counter}
    +} +``` + +Display-komponentti ei siis tarvitse enää propseja, vaan se saa laskurin arvon käyttöönsä kutsumalla useContext-hookia, jolle se antaa argumentiksi CounterContext-olion. + +Vastaavasti Button-komponentti muuttuu muotoon: ```js -const mapStateToProps = (state) => { - if ( state.filter === 'ALL' ) { - return { - notes: state.notes - } - } +import { useContext } from 'react' // highlight-line +import CounterContext from './CounterContext' // highlight-line + +const Button = ({ type, label }) => { // highlight-line + const { counterDispatch } = useContext(CounterContext) // highlight-line + + return ( + + ) +} +``` + +Komponentit saavat siis näin tietoonsa kontekstin tarjoajan siihen asettaman sisällön. Tässä tapauksessa kontekstina on olio, jolla on laskurin arvoa kuvaava kenttä counter sekä laskurin tilaa muuttavaa dispatch-funktiota kuvaava kenttä counterDispatch. + +Komponentit ottavat käyttöönsä tarvitsemansa attribuutit käyttäen hyödykseen JavaScriptin destrukturointisyntaksia: - return { - notes: (state.filter === 'IMPORTANT' - ? state.notes.filter(note => note.important) - : state.notes.filter(note => !note.important) - ) +```js +const { counter } = useContext(CounterContext) +``` + +Sovelluksen tämänhetkinen koodi on GitHubissa repositorion [https://github.com/fullstack-hy2020/hook-counter](https://github.com/fullstack-hy2020/hook-counter/tree/part6-2) branchissa part6-2. + +### Laskurikontekstin määrittely omassa tiedostossa + +Sovelluksessamme on vielä sellainen ikävä piirre, että laskurin tilanhallinnan toiminnallisuus on määritelty osin komponentissa App. Siirretään nyt kaikki laskuriin liittyvä tiedostoon CounterContext.jsx: + +```js +import { createContext, useReducer } from 'react' + +const counterReducer = (state, action) => { + switch (action.type) { + case 'INC': + return state + 1 + case 'DEC': + return state - 1 + case 'ZERO': + return 0 + default: + return state } } -const mapDispatchToProps = { - toggleImportanceOf, +const CounterContext = createContext() + +export const CounterContextProvider = (props) => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) + + return ( + + {props.children} + + ) } -export default connect( - mapStateToProps, - mapDispatchToProps -)(Notes) +export default CounterContext ``` -taas on selkeästi container-komponentti, joita Dan Abramov [luonnehtii](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) seuraavasti: +Tiedosto exporttaa nyt kontekstia vastaavan olion CounterContext lisäksi komponentin CounterContextProvider joka on käytännössä kontekstin tarjoaja (context provider), jonka arvona on laskuri ja sen tilanhallintaan käytettävä dispatcheri. -- Are concerned with how things work. -- May contain both presentational and container components inside but usually don’t have any DOM markup of their own except for some wrapping divs, and never have any styles. -- Provide the data and behavior to presentational or other container components. -- Call Redux actions and provide these as callbacks to the presentational components. -- Are often stateful, as they tend to serve as data sources. -- Are usually generated using higher order components such as connect from React Redux, rather than written by hand. +Otetaan kontekstin tarjoaja käyttöön tiedostossa main.jsx -Komponenttien presentational vs. container -jaottelu on eräs hyväksi havaittu tapa strukturoida React-sovelluksia. Jako voi olla toimiva tai sitten ei, kaikki riippuu kontekstista. +```js +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' -Abramov mainitsee jaon [eduiksi](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) muun muassa seuraavat +import App from './App' +import { CounterContextProvider } from './CounterContext' // highlight-line -- Better separation of concerns. You understand your app and your UI better by writing components this way. -- Better reusability. You can use the same presentational component with completely different state sources, and turn those into separate container components that can be further reused. -- Presentational components are essentially your app’s “palette”. You can put them on a single page and let the designer tweak all their variations without touching the app’s logic. You can run screenshot regression tests on that page. +createRoot(document.getElementById('root')).render( + + // highlight-line + + // highlight-line + +) + +``` + +Nyt laskurin arvon ja toiminnallisuuden määrittelevä konteksti on kaikkien sovelluksen komponenttien käytettävissä. + +Komponentti App yksinkertaistuu seuraavaan muotoon: + +```js +import Button from './components/Button' +import Display from './components/Display' -Abramov mainitsee termin [high order component](https://reactjs.org/docs/higher-order-components.html). Esim. Notes on normaali komponentti, React-reduxin _connect_ metodi taas on high order komponentti, eli käytännössä funktio, joka haluaa parametrikseen komponentin muuttuakseen "normaaliksi" komponentiksi. +const App = () => { + return ( +
    + +
    +
    +
    + ) +} -High order componentit eli HOC:t ovat yleinen tapa määritellä geneeristä toiminnallisuutta, joka sitten erikoistetaan esim. renderöitymisen määrittelyn suhteen parametrina annettavan komponentin avulla. Kyseessä on funktionaalisen ohjelmoinnin etäisesti olio-ohjelmoinnin perintää muistuttava käsite. +export default App +``` -HOC:it ovat oikeastaan käsitteen [High Order Function](https://en.wikipedia.org/wiki/Higher-order_function) (HOF) yleistys. HOF:eja ovat sellaiset funkiot, jotka joko ottavat parametrikseen funktioita tai palauttavat funkioita. Olemme oikeastaan käyttäneet HOF:eja läpi kurssin, esim. lähes kaikki taulukoiden käsittelyyn tarkoitetut metodit, kuten _map, filter ja find_ ovat HOF:eja. +Kontekstia käytetään edelleen samalla tavalla, eikä muihin kompoentteihin tarvita muutoksia. Esimerkiksi komponentti Button on siis määritelty seuraavasti: -Reactin hook-apin ilmestymisen jälkeen HOC:ien suosio on kääntynyt laskuun, ja melkein kaikki kirjastot, joiden käyttö on aiemmin perustunut HOC:eihin, ovat saaneet hook-perustaisen apin. Useimmiten, kuten myös reduxin kohdalla, hook-perustaiset apit ovat HOC-apeja huomattavasti yksinkertaisempia. +```js +import { useContext } from 'react' +import CounterContext from '../CounterContext' -### Redux ja komponenttien tila +const Button = ({ type, label }) => { + const { counterDispatch } = useContext(CounterContext) -Kurssi on ehtinyt pitkälle, ja olemme vihdoin päässeet siihen pisteeseen missä käytämme Reactia "oikein", eli React keskittyy pelkästään näkymien muodostamiseen ja sovelluksen tila sekä sovelluslogiikka on eristetty kokonaan React-komponenttien ulkopuolelle, Reduxiin ja action reducereihin. + return ( + + ) +} -Entä _useState_-hookilla saatava komponenttien oma tila, onko sillä roolia jos sovellus käyttää Reduxia tai muuta komponenttien ulkoista tilanhallintaratkaisua? Jos sovelluksessa on monimutkaisempia lomakkeita, saattaa niiden lokaali tila olla edelleen järkevä toteuttaa funktiolla _useState_ saatavan tilan avulla. Lomakkeidenkin tilan voi toki tallettaa myös reduxiin, mutta jos lomakkeen tila on oleellinen ainoastaan lomakkeen täyttövaiheessa (esim. syötteen muodon validoinnin kannalta), voi olla viisaampi jättää tilan hallinta suoraan lomakkeesta huolehtivan komponentin vastuulle. +export default Button +``` -Kannattaako reduxia käyttää aina? Tuskinpa. Reduxin kehittäjä Dan Abramov pohdiskelee asiaa artikkelissaan [You Might Not Need Redux](https://medium.com/@dan_abramov/you-might-not-need-redux-be46360cf367) +Ratkaisu on varsin tyylikäs. Koko sovelluksen tila eli laskurin arvo ja sen hallintaan tarkoitettu koodi on nyt eristetty tiedostoon CounterContext. Komponentit saavat käyttöönsä juuri tarvitsemansa osan kontekstia useContext-hookia ja JavaScriptin destrukturointi-syntaksia käyttäen. -Reduxin kaltainen tilankäsittely on mahdollista toteuttaa nykyään myös ilman reduxia, käyttämällä Reactin [context](https://reactjs.org/docs/context.html)-apia ja [useReducer](https://reactjs.org/docs/hooks-reference.html#usereducer)-hookia, lisää asiasta esim -[täällä](https://www.simplethread.com/cant-replace-redux-with-hooks/) ja [täällä](https://hswolff.com/blog/how-to-usecontext-with-usereducer/). Tutustumme tähän tapaan myös kurssin [yhdeksännessä osassa](/en/part9). +Sovelluksen lopullinen koodi on GitHubissa repositorion [https://github.com/fullstack-hy2020/hook-counter](https://github.com/fullstack-hy2020/hook-counter/tree/part6-3) branchissa part6-3.
    +
    -### Tehtävät 6.19.-6.21. +### Tehtävät 6.23.-6.24. + +#### Tehtävä 6.23. + +Sovelluksessa on valmiina komponentti Notification käyttäjälle tehtävien notifikaatioiden näyttämistä varten. + +Toteuta sovelluksen notifikaation tilan hallinta useReducer-hookin ja contextin avulla. Notifikaatio kertoo kun uusi anekdootti luodaan tai anekdoottia äänestetään: + +![](../../images/6/66new.png) + +Notifikaatio näytetään viiden sekunnin ajan. + +#### Tehtävä 6.24. + +Kuten tehtävässä 6.20 todettiin, palvelin vaatii, että lisättävän anekdootin sisällön pituus on vähintään 5 merkkiä. Toteuta nyt lisäämisen yhteyteen virheenkäsittely. Käytännössä riittää, että näytät epäonnistuneen lisäyksen yhteydessä käyttäjälle notifikaation: + +![](../../images/6/67new.png) + +Virhetilanne kannattaa käsitellä sille rekisteröidyssä takaisinkutsufunktiossa, ks [täältä](https://tanstack.com/query/latest/docs/react/reference/useMutation) miten rekisteröit funktion. + +Tämä oli osan viimeinen tehtävä ja on aika pushata koodi GitHubiin sekä merkata tehdyt tehtävät [palautussovellukseen](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    + +
    + +### Tilanhallintaratkaisun valinta + +Luvuissa 1-5 kaikki sovelluksen tilanhallinta hoidettiin Reactin hookin useState avulla. Backendiin tehtävät asynkroniset kutsut edellyttivät joissain tilanteissa hookin useEffect käyttöä. Mitään muuta ei periaatteessa tarvita. + +Hienoisena ongelmana useState-hookilla luotuun tilaan perustuvassa ratkaisussa on se, että jos jotain osaa sovelluksen tilasta tarvitaan useissa sovelluksen komponenteissa, tulee tila ja sen muuttamiseksi tarvittavat funktiot välittää propsien avulla kaikille tilaa käsitteleville komponenteille. Joskus propseja on välitettävä usean komponentin läpi, ja voi olla, että matkan varrella olevat komponentit eivät edes ole tilasta millään tavalla kiinnostuneita. Tästä hieman ikävästä ilmiöstä käytetään nimitystä prop drilling. -#### 6.19 anekdootit ja connect, step1 +Aikojen saatossa React-sovellusten tilanhallintaan on kehitelty muutamiakin vaihtoehtoisia ratkaisuja, joiden avulla ongelmallisia tilanteinta (esim. prop drilling) saadaan helpotettua. Mikään ratkaisu ei kuitenkaan ole ollut "lopullinen", kaikilla on omat hyvät ja huonot puolensa, ja uusia ratkaisuja kehitellään koko ajan. -Muuta notifikaatioiden näyttämisestä huolehtiva komponentti käyttämään _useSelector_-hookin sijaan _connect_-funktiota. +Aloittelijaa ja kokenuttakin web-kehittäjää tilanne saattaa hämmentää. Mitä ratkaisua tulisi käytää? -#### 6.20 anekdootit ja connect, step2 +Yksinkertaisessa sovelluksessa useState on varmasti hyvä lähtökohta. Jos sovellus kommunikoi palvelimen kanssa, voi kommunikoinnin hoitaa lukujen 1-5 tapaan itse sovelluksen tilaa hyödyntäen. Viime aikoina on kuitenkin yleistynyt se, että kommunikointi ja siihen liittyvä tilanhallinta siirretään ainakin osin React Queryn (tai jonkun muun samantapaisen kirjaston) hallinnoitavaksi. Jos useState ja sen myötä aiheutuva prop drilling arveluttaa, voi kontekstin käyttö olla hyvä vaihtoehto. On myös tilanteita, joissa osa tilasta voi olla järkevää hoitaa useStaten ja osa kontekstien avulla. -Tee sama komponentille AnecdoteForm. +Kaikkien kattavimman ja järeimmän tilanhallintaratkaisun tarjoaa Redux, joka on eräs tapa toteuttaa ns. [Flux](https://facebookarchive.github.io/flux/)-arkkitehtuuri. Redux on hieman vanhempi kuin tässä aliosassa esitetyt ratkaisut. Reduxin jähmeys onkin ollut motivaationa monille uusille tilanhallintaratkaisuille kuten tässä osassa esittelemällemme Reactin useReducer:ille. Osa Reduxin jäykkyyteen kohdistuvasta kritiikistä tosin on jo vanhentunut [Redux Toolkit](https://redux-toolkit.js.org/):in ansiosta. -#### 6.21 anekdootit, loppuhuipennus +Vuosien saatossa on myös kehitelty muita Reduxia vastaavia tilantahallintakirjastoja, kuten esim. uudempi tulokas [Recoil](https://recoiljs.org/) ja hieman iäkkäämpi [MobX](https://mobx.js.org/). [Npm trendsien](https://npmtrends.com/mobx-vs-recoil-vs-redux) perusteella Redux kuitenkin dominoi edelleen selvästi, ja näyttää itse asiassa vaan kasvattavan etumatkaansa: -Sovellukseen on (todennäköisesti) jäänyt eräs hieman ikävä bugi. Jos vote-näppäintä painellaan useasti peräkkäin, notifikaatio näkyy ruudulla hieman miten sattuu. Esimerkiksi jos äänestetään kaksi kertaa kolmen sekunnin välein, näkyy jälkimmäinen notifikaatio ruudulla ainoastaan kahden sekunnin verran (olettaen että notifikaation näyttöaika on 5 sekuntia). Tämä johtuu siitä, että ensimmäisen äänestyksen notifikaation tyhjennys tyhjentääkin myöhemmän äänestyksen notifikaation. +![](../../images/6/64new.png) -Korjaa bugi, siten että usean peräkkäisen äänestyksen viimeistä notifikaatiota näytetään aina viiden sekunnin ajan. Korjaus tapahtuu siten, että uuden notifikaation tullessa edellisen notifikaation nollaus tarvittaessa perutaan, ks. funktion setTimeout [dokumentaatio](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout). +Myöskään Reduxia ei ole pakko käyttää sovelluksessa kokonaisvaltaisesti. Saattaa olla mielekästä hoitaa esim. sovellusten lomakkeiden datan tallentaminen Reduxin ulkopuolella, erityisesti niissä tilanteissa, missä lomakkeen tila ei vaikuta muuhun sovellukseen. Myös Reduxin ja React Queryn yhteiskäyttö samassa sovelluksessa on täysin mahdollista. -Tämä oli osan viimeinen tehtävä ja on aika pushata koodi githubiin sekä merkata tehdyt tehtävät [palautussovellukseen](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). +Kysymys siitä mitä tilanhallintaratkaisua tulisi käyttää ei ole ollenkaan suoraviivainen. Yhtä oikeaa vastausta on mahdotonta antaa, ja on myös todennäköistä, että valittu tilanhallintaratkaisu saattaa sovelluksen kasvaessa osoittautua siinä määrin epäoptimaaliseksi, että tilanhallinnan ratkaisuja täytyy vaihtaa vaikka sovellus olisi jo ehditty viedä tuotantokäyttöön.
    diff --git a/src/content/6/fr/part6.md b/src/content/6/fr/part6.md new file mode 100644 index 00000000000..d389511fc3d --- /dev/null +++ b/src/content/6/fr/part6.md @@ -0,0 +1,17 @@ +--- +mainImage: ../../../images/part-6.svg +part: 6 +lang: fr +--- + +
    + +Jusqu'à présent, nous avons placé l'état de l'application et la logique d'état directement à l'intérieur des composants React. Lorsque les applications deviennent plus grandes, la gestion de l'état devrait être déplacée en dehors des composants React. Dans cette partie, nous allons introduire la bibliothèque Redux, qui est actuellement la solution la plus populaire pour gérer l'état des applications React. + +Nous apprendrons la version allégée de Redux directement prise en charge par React, à savoir le contexte React et le hook useRedux, ainsi que la bibliothèque React Query qui simplifie la gestion de l'état du serveur. + +Partie mise à jour le 23 août 2023 +- Create React App remplacé par Vite +- React Query mis à jour à la version 4 + +
    \ No newline at end of file diff --git a/src/content/6/fr/part6a.md b/src/content/6/fr/part6a.md new file mode 100644 index 00000000000..e36c1d958d1 --- /dev/null +++ b/src/content/6/fr/part6a.md @@ -0,0 +1,1266 @@ +--- +mainImage: ../../../images/part-6.svg +part: 6 +letter: a +lang: fr +--- + +
    + +Jusqu'à présent, nous avons suivi les conventions de gestion d'état recommandées par React. Nous avons placé l'état et les fonctions pour le gérer dans un [niveau supérieur](https://react.dev/learn/sharing-state-between-components) de la structure des composants de l'application. Assez souvent, la majeure partie de l'état de l'application et les fonctions modifiant l'état résident directement dans le composant racine. L'état et ses méthodes de gestion ont ensuite été passés à d'autres composants avec les props. Cela fonctionne jusqu'à un certain point, mais lorsque les applications grandissent, la gestion de l'état devient un défi. + +### Architecture Flux + +Il y a déjà quelques années, Facebook a développé l'architecture [Flux](https://facebookarchive.github.io/flux/docs/in-depth-overview) pour faciliter la gestion de l'état des applications React. Dans Flux, l'état est séparé des composants React et placé dans ses propres stores. +L'état dans le store n'est pas modifié directement, mais avec différentes actions. + +Lorsqu'une action change l'état du store, les vues sont rerendues: + +![diagramme action->dispatcher->store->vue](../../images/6/flux1.png) + +Si une action dans l'application, par exemple appuyer sur un bouton, nécessite de changer l'état, le changement est réalisé avec une action. +Cela provoque à nouveau le rerendu de la vue: + +![même diagramme que ci-dessus mais avec l'action bouclant en arrière](../../images/6/flux2.png) + +Flux offre une manière standard de comment et où l'état de l'application est conservé et comment il est modifié. + +### Redux + +Facebook a une implémentation pour Flux, mais nous utiliserons la bibliothèque [Redux](https://redux.js.org). Elle fonctionne sur le même principe mais est un peu plus simple. Facebook utilise maintenant également Redux au lieu de leur Flux original. + +Nous allons apprendre à connaître Redux en implémentant à nouveau une application de compteur: + +![application de compteur dans le navigateur](../../images/6/1.png) + +Créez une nouvelle application Vite et installez redux avec la commande + +```bash +npm install redux +``` + +Comme dans Flux, dans Redux, l'état est également stocké dans un [store](https://redux.js.org/basics/store). + +L'ensemble de l'état de l'application est stocké dans un objet JavaScript dans le store. Étant donné que notre application n'a besoin que de la valeur du compteur, nous la sauvegarderons directement dans le store. Si l'état était plus compliqué, différentes choses dans l'état seraient sauvegardées comme champs séparés de l'objet. + +L'état du store est modifié avec des [actions](https://redux.js.org/basics/actions). Les actions sont des objets, qui ont au moins un champ déterminant le type de l'action. +Notre application a besoin par exemple de l'action suivante: + +```js +{ + type: 'INCREMENT' +} +``` + +Si l'action implique des données, d'autres champs peuvent être déclarés selon les besoins. Cependant, notre application de comptage est si simple que les actions sont suffisantes avec juste le champ de type. + +L'impact de l'action sur l'état de l'application est défini à l'aide d'un [reducer](https://redux.js.org/basics/reducers). En pratique, un reducer est une fonction à laquelle sont donnés l'état actuel et une action comme paramètres. Elle retourne un nouvel état. + +Définissons maintenant un reducer pour notre application: + +```js +const counterReducer = (state, action) => { + if (action.type === 'INCREMENT') { + return state + 1 + } else if (action.type === 'DECREMENT') { + return state - 1 + } else if (action.type === 'ZERO') { + return 0 + } + + return state +} +``` + +Le premier paramètre est l'état dans le store. Le reducer retourne un nouvel état basé sur le type de _l'action_. Ainsi, par exemple, lorsque le type de l'Action est INCREMENT, l'état obtient l'ancienne valeur plus un. Si le type de l'Action est ZERO, la nouvelle valeur de l'état est zéro. + +Changeons un peu le code. Nous avons utilisé des instructions if-else pour répondre à une action et changer l'état. Cependant, l'instruction [switch](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/switch) est l'approche la plus courante pour écrire un reducer. + +Définissons également une [valeur par défaut](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters) de 0 pour le paramètre état. Maintenant, le reducer fonctionne même si l'état du store n'a pas encore été initialisé. + +```js +// highlight-start +const counterReducer = (state = 0, action) => { + // highlight-end + switch (action.type) { + case 'INCREMENT': + return state + 1 + case 'DECREMENT': + return state - 1 + case 'ZERO': + return 0 + default: // if none of the above matches, code comes here + return state + } +} +``` + +Reducer n'est jamais supposé être appelé directement depuis le code de l'application. Reducer est uniquement donné en paramètre à la fonction _createStore_ qui crée le store: + +```js +// highlight-start +import { createStore } from 'redux' +// highlight-end + +const counterReducer = (state = 0, action) => { + // ... +} + +// highlight-start +const store = createStore(counterReducer) +// highlight-end +``` + +Le store utilise maintenant le reducer pour gérer les actions, qui sont dispatchées ou 'envoyées' au store avec sa méthode [dispatch](https://redux.js.org/api/store#dispatchaction). + +```js +store.dispatch({ type: 'INCREMENT' }) +``` + +Vous pouvez connaître l'état du store en utilisant la méthode [getState](https://redux.js.org/api/store#getstate). + +Par exemple, le code suivant: + +```js +const store = createStore(counterReducer) +console.log(store.getState()) +store.dispatch({ type: 'INCREMENT' }) +store.dispatch({ type: 'INCREMENT' }) +store.dispatch({ type: 'INCREMENT' }) +console.log(store.getState()) +store.dispatch({ type: 'ZERO' }) +store.dispatch({ type: 'DECREMENT' }) +console.log(store.getState()) +``` + +imprimerait ce qui suit dans la console + +``` +0 +3 +-1 +``` + +car au début, l'état du store est de 0. Après trois actions INCREMENT, l'état est de 3. À la fin, après les actions ZERO et DECREMENT, l'état est de -1. + +La troisième méthode importante que le store possède est [subscribe](https://redux.js.org/api/store#subscribelistener), qui est utilisée pour créer des fonctions de rappel que le store appelle chaque fois qu'une action est dispatchée au store. + +Si, par exemple, nous ajoutions la fonction suivante à subscribe, chaque changement dans le store serait imprimé dans la console. + +```js +store.subscribe(() => { + const storeNow = store.getState() + console.log(storeNow) +}) +``` + +donc le code + +```js +const store = createStore(counterReducer) + +store.subscribe(() => { + const storeNow = store.getState() + console.log(storeNow) +}) + +store.dispatch({ type: 'INCREMENT' }) +store.dispatch({ type: 'INCREMENT' }) +store.dispatch({ type: 'INCREMENT' }) +store.dispatch({ type: 'ZERO' }) +store.dispatch({ type: 'DECREMENT' }) +``` + +provoquerait l'impression suivante + +``` +1 +2 +3 +0 +-1 +``` + +Le code de notre application compteur est le suivant. Tout le code a été écrit dans le même fichier (_main.jsx_), donc le store est directement disponible pour le code React. Nous découvrirons plus tard de meilleures façons de structurer le code React/Redux. + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' + +import { createStore } from 'redux' + +const counterReducer = (state = 0, action) => { + switch (action.type) { + case 'INCREMENT': + return state + 1 + case 'DECREMENT': + return state - 1 + case 'ZERO': + return 0 + default: + return state + } +} + +const store = createStore(counterReducer) + +const App = () => { + return ( +
    +
    + {store.getState()} +
    + + + +
    + ) +} + +const root = ReactDOM.createRoot(document.getElementById('root')) + +const renderApp = () => { + root.render() +} + +renderApp() +store.subscribe(renderApp) +``` + +Il y a quelques points notables dans le code. +App rend la valeur du compteur en la demandant au store avec la méthode _store.getState()_. Les gestionnaires d'action des boutons dispatch les bonnes actions au store. + +Lorsque l'état dans le store est changé, React n'est pas capable de rerendre automatiquement l'application. Ainsi, nous avons enregistré une fonction _renderApp_, qui rend toute l'application, pour écouter les changements dans le store avec la méthode _store.subscribe_. Notez que nous devons immédiatement appeler la méthode _renderApp_. Sans cet appel, le premier rendu de l'application ne se produirait jamais. + +### Une note sur l'utilisation de createStore + +Les plus observateurs remarqueront que le nom de la fonction createStore est barré. Si vous déplacez la souris sur le nom, une explication apparaîtra + +![erreur vscode montrant createStore déprécié et recommandant d'utiliser configureStore](../../images/6/30new.png) + +L'explication complète est la suivante + +>Nous recommandons d'utiliser la méthode configureStore du package @reduxjs/toolkit, qui remplace createStore. +> +>Redux Toolkit est notre approche recommandée pour écrire la logique Redux aujourd'hui, y compris la configuration du store, les reducers, la récupération de données et plus encore. +> +>Pour plus de détails, veuillez lire cette page de documentation Redux: +> +>configureStore de Redux Toolkit est une version améliorée de createStore qui simplifie la configuration et aide à éviter les bugs courants. +> +>Vous ne devriez pas utiliser le package redux core seul aujourd'hui, sauf à des fins d'apprentissage. La méthode createStore du package redux core ne sera pas retirée, mais nous encourageons tous les utilisateurs à migrer vers l'utilisation de Redux Toolkit pour tout le code Redux. + +Donc, au lieu de la fonction createStore, il est recommandé d'utiliser la fonction un peu plus "avancée" configureStore, et nous l'utiliserons également lorsque nous aurons atteint la fonctionnalité de base de Redux. + +Note à part: createStore est défini comme "déprécié", ce qui signifie généralement que la fonctionnalité sera retirée dans une version plus récente de la bibliothèque. L'explication ci-dessus et la discussion de [celle-ci](https://stackoverflow.com/questions/71944111/redux-createstore-is-deprecated-cannot-get-state-from-getstate-in-redux-ac) révèlent que createStore ne sera pas retiré, et il a été donné le statut déprécié, peut-être pour des raisons légèrement incorrectes. Donc, la fonction n'est pas obsolète, mais aujourd'hui, il y a une nouvelle manière plus préférable de faire presque la même chose. + +### Notes sur Redux + +Nous visons à modifier notre application de notes pour utiliser Redux pour la gestion de l'état. Cependant, couvrons d'abord quelques concepts clés à travers une application de notes simplifiée. + +La première version de notre application est la suivante + +```js +const noteReducer = (state = [], action) => { + if (action.type === 'NEW_NOTE') { + state.push(action.payload) + return state + } + + return state +} + +const store = createStore(noteReducer) + +store.dispatch({ + type: 'NEW_NOTE', + payload: { + content: 'the app state is in redux store', + important: true, + id: 1 + } +}) + +store.dispatch({ + type: 'NEW_NOTE', + payload: { + content: 'state changes are made with actions', + important: false, + id: 2 + } +}) + +const App = () => { + return( +
    +
      + {store.getState().map(note=> +
    • + {note.content} {note.important ? 'important' : ''} +
    • + )} +
    +
    + ) +} +``` + +Jusqu'à présent, l'application n'a pas la fonctionnalité pour ajouter de nouvelles notes, bien qu'il soit possible de le faire en dispatchant des actions NEW\_NOTE. + +Maintenant, les actions ont un type et un champ payload, qui contient la note à ajouter: + +```js +{ + type: 'NEW_NOTE', + payload: { + content: 'state changes are made with actions', + important: false, + id: 2 + } +} +``` + +Le choix du nom du champ n'est pas aléatoire. La convention générale est que les actions ont exactement deux champs, type indiquant le type et payload contenant les données incluses avec l'Action. + +### Fonctions pures, immuables + +La version initiale du reducer est très simple: + +```js +const noteReducer = (state = [], action) => { + if (action.type === 'NEW_NOTE') { + state.push(action.payload) + return state + } + + return state +} +``` + +L'état est maintenant un tableau. Les actions de type NEW\_NOTE entraînent l'ajout d'une nouvelle note à l'état avec la méthode [push](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push). + +L'application semble fonctionner, mais le reducer que nous avons déclaré est mauvais. Il casse [l'hypothèse de base](https://redux.js.org/tutorials/essentials/part-1-overview-concepts#reducers) de Redux selon laquelle les reducer doivent être des [fonctions pures](https://en.wikipedia.org/wiki/Pure_function). + +Les fonctions pures sont telles qu'elles ne provoquent aucun effet de bord et doivent toujours renvoyer la même réponse lorsqu'elles sont appelées avec les mêmes paramètres. + +Nous avons ajouté une nouvelle note à l'état avec la méthode _state.push(action.payload)_ qui change l'état de l'objet état. Cela n'est pas autorisé. Le problème est facilement résolu en utilisant la méthode [concat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat), qui crée un nouveau tableau, contenant tous les éléments de l'ancien tableau et le nouvel élément: + +```js +const noteReducer = (state = [], action) => { + if (action.type === 'NEW_NOTE') { + // highlight-start + return state.concat(action.payload) + // highlight-end + } + + return state +} +``` + +Un état de reducer doit être composé d'objets [immuables](https://en.wikipedia.org/wiki/Immutable_object). Si un changement se produit dans l'état, l'ancien objet n'est pas modifié, mais il est remplacé par un nouvel objet modifié. C'est exactement ce que nous avons fait avec le nouveau reducer: l'ancien tableau est remplacé par le nouveau. + +Étendons notre reducer pour qu'il puisse gérer le changement d'importance d'une note: + +```js +{ + type: 'TOGGLE_IMPORTANCE', + payload: { + id: 2 + } +} +``` + +Puisque nous n'avons pas encore de code qui utilise cette fonctionnalité, nous étendons le reducer de manière 'test-driven'. Commençons par créer un test pour gérer l'action NEW\_NOTE. + +Nous devons d'abord configurer la bibliothèque de tests [Jest](https://jestjs.io/) pour le projet. Installons les dépendances suivantes: + +```js +npm install --save-dev jest @babel/preset-env @babel/preset-react eslint-plugin-jest +``` + +Ensuite, nous allons créer le fichier .babelrc, avec le contenu suivant: + +```json +{ + "presets": [ + "@babel/preset-env", + ["@babel/preset-react", { "runtime": "automatic" }] + ] +} +``` + +Étendons le fichier package.json avec un script pour exécuter les tests: + +```json +{ + // ... + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview", + "test": "jest" // highlight-line + }, + // ... +} +``` + +Et enfin, le fichier .eslintrc.cjs doit être modifié comme suit: + +```js +module.exports = { + root: true, + env: { + browser: true, + es2020: true, + "jest/globals": true // highlight-line + }, + // ... +} +``` + +Pour faciliter les tests, nous allons d'abord déplacer le code du reducer dans son propre module, dans le fichier src/reducers/noteReducer.js. Nous allons également ajouter la bibliothèque [deep-freeze](https://www.npmjs.com/package/deep-freeze), qui peut être utilisée pour s'assurer que le reducer a été correctement défini comme une fonction immuable. +Installons la bibliothèque en tant que dépendance de développement + +```js +npm install --save-dev deep-freeze +``` + +Le test, que nous définissons dans le fichier src/reducers/noteReducer.test.js, contient le contenu suivant: + +```js +import noteReducer from './noteReducer' +import deepFreeze from 'deep-freeze' + +describe('noteReducer', () => { + test('returns new state with action NEW_NOTE', () => { + const state = [] + const action = { + type: 'NEW_NOTE', + payload: { + content: 'the app state is in redux store', + important: true, + id: 1 + } + } + + deepFreeze(state) + const newState = noteReducer(state, action) + + expect(newState).toHaveLength(1) + expect(newState).toContainEqual(action.payload) + }) +}) +``` + +La commande deepFreeze(state) garantit que le reducer ne modifie pas l'état du store qui lui est donné en paramètre. Si le reducer utilise la commande push pour manipuler l'état, le test ne passera pas + +![terminal montrant l'échec du test et une erreur concernant l'utilisation de array.push](../../images/6/2.png) + +Maintenant, nous allons créer un test pour l'action TOGGLE\_IMPORTANCE: + +```js +test('returns new state with action TOGGLE_IMPORTANCE', () => { + const state = [ + { + content: 'the app state is in redux store', + important: true, + id: 1 + }, + { + content: 'state changes are made with actions', + important: false, + id: 2 + }] + + const action = { + type: 'TOGGLE_IMPORTANCE', + payload: { + id: 2 + } + } + + deepFreeze(state) + const newState = noteReducer(state, action) + + expect(newState).toHaveLength(2) + + expect(newState).toContainEqual(state[0]) + + expect(newState).toContainEqual({ + content: 'state changes are made with actions', + important: true, + id: 2 + }) +}) +``` + +Donc l'action suivante + +```js +{ + type: 'TOGGLE_IMPORTANCE', + payload: { + id: 2 + } +} +``` + +doit changer l'importance de la note avec l'id 2. + +Le reducer est étendu comme suit + +```js +const noteReducer = (state = [], action) => { + switch(action.type) { + case 'NEW_NOTE': + return state.concat(action.payload) + case 'TOGGLE_IMPORTANCE': { + const id = action.payload.id + const noteToChange = state.find(n => n.id === id) + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + return state.map(note => + note.id !== id ? note : changedNote + ) + } + default: + return state + } +} +``` + +Nous créons une copie de la note dont l'importance a changé avec la syntaxe [familier de la partie 2](/fr/part2/modification_des_donnees_cote_serveur), et remplaçons l'état par un nouvel état contenant toutes les notes qui n'ont pas changé et la copie de la note modifiée changedNote. + +Récapitulons ce qui se passe dans le code. D'abord, nous recherchons un objet note spécifique, dont nous voulons changer l'importance: + +```js +const noteToChange = state.find(n => n.id === id) +``` + +ensuite, nous créons un nouvel objet, qui est une copie de la note originale, seul la valeur du champ important a été changée pour l'opposé de ce qu'elle était: + +```js +const changedNote = { + ...noteToChange, + important: !noteToChange.important +} +``` + +Un nouvel état est ensuite retourné. Nous le créons en prenant toutes les notes de l'ancien état à l'exception de la note désirée, que nous remplaçons par sa copie légèrement modifiée: + +```js +state.map(note => + note.id !== id ? note : changedNote +) +``` + +### Syntaxe de décomposition des tableaux + +Puisque nous avons maintenant de bons tests pour le reducer, nous pouvons refactoriser le code en toute sécurité. + +L'ajout d'une nouvelle note crée l'état qu'il retourne avec la fonction _concat_ de Array. Examinons comment nous pouvons obtenir le même résultat en utilisant la syntaxe de [décomposition des tableaux](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_operator) de JavaScript: + +```js +const noteReducer = (state = [], action) => { + switch(action.type) { + case 'NEW_NOTE': + // highlight-start + return [...state, action.payload] + // highlight-end + case 'TOGGLE_IMPORTANCE': + // ... + default: + return state + } +} +``` + +La syntaxe de décomposition fonctionne comme suit. Si nous déclarons + +```js +const numbers = [1, 2, 3] +``` + +...numbers décompose le tableau en éléments individuels, qui peuvent être placés dans un autre tableau. + +```js +[...numbers, 4, 5] +``` + +et le résultat est un tableau [1, 2, 3, 4, 5]. + +Si nous avions placé le tableau dans un autre tableau sans la décomposition + +```js +[numbers, 4, 5] +``` + +le résultat aurait été [ [1, 2, 3], 4, 5]. + +Lorsque nous prenons des éléments d'un tableau par [déstructuration](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment), une syntaxe d'apparence similaire est utilisée pour rassembler le reste des éléments: + +```js +const numbers = [1, 2, 3, 4, 5, 6] + +const [first, second, ...rest] = numbers + +console.log(first) // prints 1 +console.log(second) // prints 2 +console.log(rest) // prints [3, 4, 5, 6] +``` + +
    + +
    + +### Exercices 6.1.-6.2. + +Faisons une version simplifiée de l'exercice unicafe de la partie 1. Gérons la gestion de l'état avec Redux. + +Vous pouvez prendre le projet de ce dépôt comme base pour votre projet. + +Commencez par supprimer la configuration git du dépôt cloné, et par installer les dépendances + +```bash +cd unicafe-redux // go to the directory of cloned repository +rm -rf .git +npm install +``` + +### 6.1 : unicafe revisité, étape 1 + +Avant d'implémenter la fonctionnalité de l'UI, implémentons la fonctionnalité requise par le store. + +Nous devons sauvegarder le nombre de chaque type de retour dans le store, donc la forme de l'état dans le store est: + +```js +{ + good: 5, + ok: 4, + bad: 2 +} +``` + +Le projet a la base suivante pour un reducer: + +```js +const initialState = { + good: 0, + ok: 0, + bad: 0 +} + +const counterReducer = (state = initialState, action) => { + console.log(action) + switch (action.type) { + case 'GOOD': + return state + case 'OK': + return state + case 'BAD': + return state + case 'ZERO': + return state + default: return state + } + +} + +export default counterReducer +``` + +et une base pour ses tests + +```js +import deepFreeze from 'deep-freeze' +import counterReducer from './reducer' + +describe('unicafe reducer', () => { + const initialState = { + good: 0, + ok: 0, + bad: 0 + } + + test('should return a proper initial state when called with undefined state', () => { + const state = {} + const action = { + type: 'DO_NOTHING' + } + + const newState = counterReducer(undefined, action) + expect(newState).toEqual(initialState) + }) + + test('good is incremented', () => { + const action = { + type: 'GOOD' + } + const state = initialState + + deepFreeze(state) + const newState = counterReducer(state, action) + expect(newState).toEqual({ + good: 1, + ok: 0, + bad: 0 + }) + }) +}) +``` + +**Implémentez le reducer et ses tests.** + +Dans les tests, assurez-vous que le reducer est une fonction immuable avec la bibliothèque deep-freeze. +Assurez-vous que le premier test fourni passe, car Redux s'attend à ce que le reducer retourne un état initial sensé lorsqu'il est appelé de sorte que le premier paramètre state, qui représente l'état précédent, soit undefined. + +Commencez par étendre le reducer pour que les deux tests passent. Ensuite, ajoutez le reste des tests, et enfin la fonctionnalité qu'ils testent. + +Un bon modèle pour le reducer est l'exemple [redux-notes](/en/part6/flux_architecture_and_redux#pure-functions-immutable) ci-dessus. + +#### 6.2 : unicafe revisité, étape 2 + +Implémentez maintenant la fonctionnalité réelle de l'application. + +Votre application peut avoir une apparence modeste, rien d'autre n'est nécessaire à part des boutons et le nombre d'avis pour chaque type: + +![navigateur montrant les boutons bon, mauvais, ok](../../images/6/50new.png) + +
    + +
    + +### Formulaire non contrôlé + +Ajoutons la fonctionnalité pour ajouter de nouvelles notes et changer leur importance: + +```js +// highlight-start +const generateId = () => + Number((Math.random() * 1000000).toFixed(0)) +// highlight-end + +const App = () => { + // highlight-start + const addNote = (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + store.dispatch({ + type: 'NEW_NOTE', + payload: { + content, + important: false, + id: generateId() + } + }) + } + // highlight-end + + // highlight-start + const toggleImportance = (id) => { + store.dispatch({ + type: 'TOGGLE_IMPORTANCE', + payload: { id } + }) + } + // highlight-end + + return ( +
    + // highlight-start +
    + + +
    + // highlight-end +
      + {store.getState().map(note => +
    • toggleImportance(note.id)} // highlight-line + > + {note.content} {note.important ? 'important' : ''} +
    • + )} +
    +
    + ) +} +``` + +L'implémentation des deux fonctionnalités est simple. Il est notable que nous n'avons pas lié l'état des champs du formulaire à l'état du composant App comme nous l'avons précédemment fait. React appelle ce type de formulaire [non contrôlé](https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable). + +>Les formulaires non contrôlés ont certaines limitations (par exemple, les messages d'erreur dynamiques ou la désactivation du bouton d'envoi en fonction de l'entrée ne sont pas possibles). Cependant, ils conviennent à nos besoins actuels. + +Vous pouvez en savoir plus sur les formulaires non contrôlés [ici](https://goshakkk.name/controlled-vs-uncontrolled-inputs-react/). + +La méthode de gestion pour ajouter de nouvelles notes est simple, elle dispatche juste l'action pour ajouter des notes: + +```js +addNote = (event) => { + event.preventDefault() + const content = event.target.note.value // highlight-line + event.target.note.value = '' + store.dispatch({ + type: 'NEW_NOTE', + payload: { + content, + important: false, + id: generateId() + } + }) +} +``` + +Nous pouvons obtenir le contenu de la nouvelle note directement à partir du champ du formulaire. Comme le champ a un nom, nous pouvons accéder au contenu via l'objet événement event.target.note.value. + +```js +
    + // highlight-line + +
    +``` + +L'importance d'une note peut être changée en cliquant sur son nom. Le gestionnaire d'événement est très simple: + +```js +toggleImportance = (id) => { + store.dispatch({ + type: 'TOGGLE_IMPORTANCE', + payload: { id } + }) +} +``` + +### Créateurs d'actions + +Nous commençons à remarquer que, même dans des applications aussi simples que la nôtre, l'utilisation de Redux peut simplifier le code frontend. Cependant, nous pouvons faire beaucoup mieux. + +Les composants React n'ont pas besoin de connaître les types d'actions et les formulaires Redux. +Séparons la création d'actions en fonctions distinctes: + +```js +const createNote = (content) => { + return { + type: 'NEW_NOTE', + payload: { + content, + important: false, + id: generateId() + } + } +} + +const toggleImportanceOf = (id) => { + return { + type: 'TOGGLE_IMPORTANCE', + payload: { id } + } +} +``` + +Les fonctions qui créent des actions sont appelées [créateurs d'actions](https://redux.js.org/tutorials/fundamentals/part-7-standard-patterns#action-creators). + +Le composant App n'a plus besoin de connaître quoi que ce soit sur la représentation interne des actions, il obtient simplement la bonne action en appelant la fonction créatrice: + +```js +const App = () => { + const addNote = (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + store.dispatch(createNote(content)) // highlight-line + + } + + const toggleImportance = (id) => { + store.dispatch(toggleImportanceOf(id))// highlight-line + } + + // ... +} +``` + +### Transmettre le Store Redux à divers composants + +À part le reducer, notre application est dans un seul fichier. Cela n'est bien sûr pas sensé, et nous devrions séparer App dans son propre module. + +La question est maintenant, comment App peut-il accéder au store après le déménagement? Et plus largement, lorsqu'un composant est composé de nombreux petits composants, il doit y avoir un moyen pour tous les composants d'accéder au store. +Il existe plusieurs façons de partager le store Redux avec les composants. D'abord, nous examinerons la manière la plus récente, et peut-être la plus facile, qui est d'utiliser l'API [hooks](https://react-redux.js.org/api/hooks) de la bibliothèque [react-redux](https://react-redux.js.org/). + +D'abord, nous installons react-redux + +```bash +npm install react-redux +``` + +Ensuite, nous déplaçons le composant _App_ dans son propre fichier _App.jsx_. Voyons comment cela affecte le reste des fichiers de l'application. + +_main.jsx_ devient: + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' +import { createStore } from 'redux' +import { Provider } from 'react-redux' // highlight-line + +import App from './App' +import noteReducer from './reducers/noteReducer' + +const store = createStore(noteReducer) + +ReactDOM.createRoot(document.getElementById('root')).render( + // highlight-line + + // highlight-line +) +``` + +Notez que l'application est maintenant définie comme un enfant d'un composant [Provider](https://react-redux.js.org/api/provider) fourni par la bibliothèque react-redux. +Le store de l'application est donné au Provider en tant qu'attribut store. + +La définition des créateurs d'actions a été déplacée dans le fichier reducers/noteReducer.js où le reducer est défini. Ce fichier ressemble à ceci: + +```js +const noteReducer = (state = [], action) => { + // ... +} + +const generateId = () => + Number((Math.random() * 1000000).toFixed(0)) + +export const createNote = (content) => { // highlight-line + return { + type: 'NEW_NOTE', + payload: { + content, + important: false, + id: generateId() + } + } +} + +export const toggleImportanceOf = (id) => { // highlight-line + return { + type: 'TOGGLE_IMPORTANCE', + payload: { id } + } +} + +export default noteReducer +``` + +Si l'application a de nombreux composants qui ont besoin du store, le composant App doit passer store comme props à tous ces composants. + +Le module a maintenant plusieurs commandes [export](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export). + +La fonction reducer est toujours retournée avec la commande export default, donc le reducer peut être importé de la manière habituelle: + +```js +import noteReducer from './reducers/noteReducer' +``` + +Un module peut avoir seulement un export par défaut, mais plusieurs exports "normaux" + +```js +export const createNote = (content) => { + // ... +} + +export const toggleImportanceOf = (id) => { + // ... +} +``` + +Les fonctions exportées normalement (pas par défaut) peuvent être importées avec la syntaxe des accolades: + +```js +import { createNote } from './../reducers/noteReducer' +``` + +Code pour le composant App + +```js +import { createNote, toggleImportanceOf } from './reducers/noteReducer' // highlight-line +import { useSelector, useDispatch } from 'react-redux' // highlight-line + +const App = () => { + const dispatch = useDispatch() // highlight-line + const notes = useSelector(state => state) // highlight-line + + const addNote = (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + dispatch(createNote(content)) // highlight-line + } + + const toggleImportance = (id) => { + dispatch(toggleImportanceOf(id)) // highlight-line + } + + return ( +
    +
    + + +
    +
      + {notes.map(note => // highlight-line +
    • toggleImportance(note.id)} + > + {note.content} {note.important ? 'important' : ''} +
    • + )} +
    +
    + ) +} + +export default App +``` + +Il y a quelques points à noter dans le code. Auparavant, le code dispatchait des actions en appelant la méthode dispatch du store Redux: + +```js +store.dispatch({ + type: 'TOGGLE_IMPORTANCE', + payload: { id } +}) +``` + +Maintenant, cela se fait avec la fonction dispatch provenant du hook [useDispatch](https://react-redux.js.org/api/hooks#usedispatch). + +```js +import { useSelector, useDispatch } from 'react-redux' // highlight-line + +const App = () => { + const dispatch = useDispatch() // highlight-line + // ... + + const toggleImportance = (id) => { + dispatch(toggleImportanceOf(id)) // highlight-line + } + + // ... +} +``` + +Le hook useDispatch donne à n'importe quel composant React l'accès à la fonction dispatch du store Redux défini dans main.jsx. Cela permet à tous les composants de modifier l'état du store Redux. + +Le composant peut accéder aux notes stockées dans le store avec le hook [useSelector](https://react-redux.js.org/api/hooks#useselector) de la bibliothèque react-redux. + +```js +import { useSelector, useDispatch } from 'react-redux' // highlight-line + +const App = () => { + // ... + const notes = useSelector(state => state) // highlight-line + // ... +} +``` + +useSelector reçoit une fonction en paramètre. La fonction recherche ou sélectionne des données dans le store Redux. +Ici, nous avons besoin de toutes les notes, donc notre fonction sélecteur retourne l'ensemble de l'état: + +```js +state => state +``` + +which is a shorthand for: + +```js +(state) => { + return state +} +``` + +Habituellement, les fonctions sélecteurs sont un peu plus intéressantes et ne retournent que des parties sélectionnées du contenu du store Redux. +Nous pourrions par exemple ne retourner que les notes marquées comme importantes: + +```js +const importantNotes = useSelector(state => state.filter(note => note.important)) +``` + +La version actuelle de l'application peut être trouvée sur [GitHub](https://github.com/fullstack-hy2020/redux-notes/tree/part6-0), branche part6-0. + +### Plus de composants + +Séparons la création d'une nouvelle note en un composant. + +```js +import { useDispatch } from 'react-redux' // highlight-line +import { createNote } from '../reducers/noteReducer' // highlight-line + +const NewNote = () => { + const dispatch = useDispatch() // highlight-line + + const addNote = (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + dispatch(createNote(content)) // highlight-line + } + + return ( +
    + + +
    + ) +} + +export default NewNote +``` + +Contrairement au code React que nous avons fait sans Redux, le gestionnaire d'événement pour changer l'état de l'application (qui vit maintenant dans Redux) a été déplacé de App vers un composant enfant. La logique de changement d'état dans Redux est toujours soigneusement séparée de toute la partie React de l'application. + +Nous allons également séparer la liste des notes et l'affichage d'une seule note en leurs propres composants (qui seront tous deux placés dans le fichier Notes.jsx): + +```js +import { useDispatch, useSelector } from 'react-redux' // highlight-line +import { toggleImportanceOf } from '../reducers/noteReducer' // highlight-line + +const Note = ({ note, handleClick }) => { + return( +
  • + {note.content} + {note.important ? 'important' : ''} +
  • + ) +} + +const Notes = () => { + const dispatch = useDispatch() // highlight-line + const notes = useSelector(state => state) // highlight-line + + return( +
      + {notes.map(note => + + dispatch(toggleImportanceOf(note.id)) + } + /> + )} +
    + ) +} + +export default Notes +``` + +La logique de changement de l'importance d'une note se trouve maintenant dans le composant qui gère la liste des notes. + +Il ne reste pas beaucoup de code dans App: + +```js +const App = () => { + + return ( +
    + + +
    + ) +} +``` + +Note, responsable du rendu d'une note unique, est très simple et n'est pas conscient que le gestionnaire d'événement qu'il reçoit en props dispatche une action. Ce type de composants est appelé [présentationnel](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) dans la terminologie React. + +Notes, d'autre part, est un composant [conteneur](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0), car il contient une certaine logique d'application : il définit ce que font les gestionnaires d'événements des composants Note et coordonne la configuration des composants présentationnels, c'est-à-dire, les Notes. + +Le code de l'application Redux peut être trouvé sur [GitHub](https://github.com/fullstack-hy2020/redux-notes/tree/part6-1), branche part6-1. + +
    + +
    + +### Exercices 6.3.-6.8. + +Réalisons une nouvelle version de l'application de vote d'anecdotes de la partie 1. Prenez le projet de ce dépôt comme base pour votre solution. + +Si vous clonez le projet dans un dépôt git existant, supprimez la configuration git de l'application clonée : + +```bash +cd redux-anecdotes // go to the cloned repository +rm -rf .git +``` + +L'application peut être démarrée comme d'habitude, mais vous devez d'abord installer les dépendances: + +```bash +npm install +npm run dev +``` + +Après avoir terminé ces exercices, votre application devrait ressembler à ceci : + +![navigateur montrant des anecdotes et des boutons de vote](../../images/6/3.png) + +#### 6.3 : anecdotes, étape 1 + +Implémentez la fonctionnalité de vote pour les anecdotes. Le nombre de votes doit être sauvegardé dans un store Redux. + +#### 6.4 : anecdotes, étape 2 + +Implémentez la fonctionnalité pour ajouter de nouvelles anecdotes. + +Vous pouvez garder le formulaire non contrôlé comme nous l'avons fait [précédemment](/en/part6/flux_architecture_and_redux#uncontrolled-form). + +#### 6.5 : anecdotes, étape 3 + +Assurez-vous que les anecdotes sont ordonnées par le nombre de votes. + +#### 6.6 : anecdotes, étape 4 + +Si vous ne l'avez pas déjà fait, séparez la création d'objets d'action en fonctions de [créateurs d'action](https://read.reduxbook.com/markdown/part1/04-action-creators.html) et placez-les dans le fichier src/reducers/anecdoteReducer.js, donc faites ce que nous avons fait depuis le chapitre [créateurs d'actions](/en/part6/flux_architecture_and_redux#action-creators). + +#### 6.7 : anecdotes, étape 5 + +Séparez la création de nouvelles anecdotes dans un composant appelé AnecdoteForm. Déplacez toute la logique de création d'une nouvelle anecdote dans ce nouveau composant. + +#### 6.8 : anecdotes, étape 6 + +Séparez le rendu de la liste des anecdotes dans un composant appelé AnecdoteList. Déplacez toute la logique liée au vote pour une anecdote dans ce nouveau composant. + +Maintenant, le composant App devrait ressembler à cela: + +```js +import AnecdoteForm from './components/AnecdoteForm' +import AnecdoteList from './components/AnecdoteList' + +const App = () => { + return ( +
    +

    Anecdotes

    + + +
    + ) +} + +export default App +``` + +
    \ No newline at end of file diff --git a/src/content/6/fr/part6b.md b/src/content/6/fr/part6b.md new file mode 100644 index 00000000000..dc37fcb512b --- /dev/null +++ b/src/content/6/fr/part6b.md @@ -0,0 +1,761 @@ +--- +mainImage: ../../../images/part-6.svg +part: 6 +letter: b +lang: fr +--- + +
    + +Continuons notre travail avec la [version simplifiée Redux](/en/part6/flux_architecture_and_redux#redux-notes) de notre application de notes. + +Pour faciliter notre développement, modifions notre reducer afin que le store soit initialisé avec un état contenant quelques notes: + +```js +const initialState = [ + { + content: 'reducer defines how redux store works', + important: true, + id: 1, + }, + { + content: 'state of store can contain any data', + important: false, + id: 2, + }, +] + +const noteReducer = (state = initialState, action) => { + // ... +} + +// ... +export default noteReducer +``` + +### Store avec un état complexe + +Implémentons un filtrage pour les notes affichées à l'utilisateur. L'interface utilisateur pour les filtres sera mise en oeuvre avec des [boutons radio](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/radio): + +![navigateur avec boutons radio important/pas important et liste](../../images/6/01e.png) + +Commençons par une mise en oeuvre très simple et directe: + +```js +import NewNote from './components/NewNote' +import Notes from './components/Notes' + +const App = () => { +//highlight-start + const filterSelected = (value) => { + console.log(value) + } +//highlight-end + + return ( +
    + + //highlight-start +
    + all filterSelected('ALL')} /> + important filterSelected('IMPORTANT')} /> + nonimportant filterSelected('NONIMPORTANT')} /> +
    + //highlight-end + +
    + ) +} +``` + +Puisque l'attribut name de tous les boutons radio est le même, ils forment un groupe de boutons où une seule option peut être sélectionnée. + +Les boutons ont un gestionnaire de changement qui imprime actuellement seulement la chaîne associée au bouton cliqué dans la console. + +Nous décidons d'implémenter la fonctionnalité de filtre en stockant la valeur du filtre dans le store Redux en plus des notes elles-mêmes. L'état du store devrait ressembler à ceci après avoir effectué ces changements: + +```js +{ + notes: [ + { content: 'reducer defines how redux store works', important: true, id: 1}, + { content: 'state of store can contain any data', important: false, id: 2} + ], + filter: 'IMPORTANT' +} +``` + +Dans l'implémentation actuelle de notre application, seul le tableau de notes est stocké dans l'état. Dans la nouvelle implémentation, l'objet d'état a deux propriétés, notes qui contient le tableau de notes et filter qui contient une chaîne indiquant quelles notes doivent être affichées à l'utilisateur. + +### Reducers combinés + +Nous pourrions modifier notre reducer actuel pour gérer la nouvelle forme de l'état. Cependant, une meilleure solution dans cette situation est de définir un nouveau reducer séparé pour l'état du filtre: + +```js +const filterReducer = (state = 'ALL', action) => { + switch (action.type) { + case 'SET_FILTER': + return action.payload + default: + return state + } +} +``` + +Les actions pour changer l'état du filtre ressemblent à ceci: + +```js +{ + type: 'SET_FILTER', + payload: 'IMPORTANT' +} +``` + +Créons également une nouvelle fonction de _créateur d'action_. Nous écrirons le code pour le créateur d'action dans un nouveau module src/reducers/filterReducer.js: + +```js +const filterReducer = (state = 'ALL', action) => { + // ... +} + +export const filterChange = filter => { + return { + type: 'SET_FILTER', + payload: filter, + } +} + +export default filterReducer +``` + +Nous pouvons créer le reducer actuel pour notre application en combinant les deux reducers existants avec la fonction [combineReducers](https://redux.js.org/api/combinereducers). + +Définissons le reducer combiné dans le fichier main.jsx: + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' +import { createStore, combineReducers } from 'redux' // highlight-line +import { Provider } from 'react-redux' +import App from './App' + +import noteReducer from './reducers/noteReducer' +import filterReducer from './reducers/filterReducer' // highlight-line + + // highlight-start +const reducer = combineReducers({ + notes: noteReducer, + filter: filterReducer +}) + // highlight-end + +const store = createStore(reducer) // highlight-line + +console.log(store.getState()) + +/* +ReactDOM.createRoot(document.getElementById('root')).render( + + + +)*/ + +ReactDOM.createRoot(document.getElementById('root')).render( + +
    + +) +``` + +Puisque notre application se casse complètement à ce point, nous rendons un élément div vide au lieu du composant App. + +L'état du store est imprimé dans la console: + +[console devtools montrant les données du tableau de notes](../../images/6/4e.png) + +Comme nous pouvons le voir dans la sortie, le store a exactement la forme que nous voulions! + +Examinons de plus près comment le reducer combiné est créé: + +```js +const reducer = combineReducers({ + notes: noteReducer, + filter: filterReducer, +}) +``` + +L'état du store défini par le reducer ci-dessus est un objet avec deux propriétés: notes et filter. La valeur de la propriété notes est définie par le noteReducer, qui n'a pas à gérer les autres propriétés de l'état. De même, la propriété filter est gérée par le filterReducer. + +Avant de faire plus de changements dans le code, examinons comment différentes actions changent l'état du store défini par le reducer combiné. Ajoutons ce qui suit au fichier main.jsx: + +```js +import { createNote } from './reducers/noteReducer' +import { filterChange } from './reducers/filterReducer' +//... +store.subscribe(() => console.log(store.getState())) +store.dispatch(filterChange('IMPORTANT')) +store.dispatch(createNote('combineReducers forms one reducer from many simple reducers')) +``` + +En simulant la création d'une note et en changeant l'état du filtre de cette manière, l'état du store est enregistré dans la console après chaque changement effectué dans le store: + +![sortie console devtools montrant le filtre de notes et la nouvelle note](../../images/6/5e.png) + +À ce stade, il est bon de prendre conscience d'un petit mais important détail. Si nous ajoutons une instruction de log console au début des deux reducers: + +```js +const filterReducer = (state = 'ALL', action) => { + console.log('ACTION: ', action) + // ... +} +``` + +D'après la sortie console, on pourrait avoir l'impression que chaque action est dupliquée: + +![sortie console devtools montrant des actions dupliquées dans les reducers de note et de filtre](../../images/6/6.png) + +Y a-t-il un bug dans notre code ? Non. Le reducer combiné fonctionne de telle manière que chaque action est gérée dans chaque partie du reducer combiné. Typiquement, un seul reducer est intéressé par une action donnée, mais il y a des situations où plusieurs reducers changent leurs parties respectives de l'état basées sur la même action. + +### Finaliser les filtres + +Terminons l'application de sorte qu'elle utilise le reducer combiné. Nous commençons par changer le rendu de l'application et en connectant le store à l'application dans le fichier main.jsx: + +```js +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) +``` + +Ensuite, corrigeons un bug causé par le code qui s'attend à ce que le store de l'application soit un tableau de notes: + +![TypeError dans le navigateur: notes.map n'est pas une fonction](../../images/6/7v.png) + +C'est une correction facile. Étant donné que les notes se trouvent dans le champ notes du store, nous devons juste apporter un petit changement à la fonction de sélection: + +```js +const Notes = () => { + const dispatch = useDispatch() + const notes = useSelector(state => state.notes) // highlight-line + + return( +
      + {notes.map(note => + + dispatch(toggleImportanceOf(note.id)) + } + /> + )} +
    + ) +} +``` + +Auparavant, la fonction sélecteur retournait l'intégralité de l'état du store: + +```js +const notes = useSelector(state => state) +``` + +Et maintenant, elle retourne seulement son champ notes + +```js +const notes = useSelector(state => state.notes) +``` + +Extrayons le filtre de visibilité dans son propre composant src/components/VisibilityFilter.jsx: + +```js +import { filterChange } from '../reducers/filterReducer' +import { useDispatch } from 'react-redux' + +const VisibilityFilter = (props) => { + const dispatch = useDispatch() + + return ( +
    + all + dispatch(filterChange('ALL'))} + /> + important + dispatch(filterChange('IMPORTANT'))} + /> + nonimportant + dispatch(filterChange('NONIMPORTANT'))} + /> +
    + ) +} + +export default VisibilityFilter +``` + +Avec le nouveau composant App peut être simplifié comme suit: + +```js +import Notes from './components/Notes' +import NewNote from './components/NewNote' +import VisibilityFilter from './components/VisibilityFilter' + +const App = () => { + return ( +
    + + + +
    + ) +} + +export default App +``` + +L'implémentation est plutôt simple. Cliquer sur les différents boutons radio change l'état de la propriété filter du store. + +Changeons le composant Notes pour incorporer le filtre: + +```js +const Notes = () => { + const dispatch = useDispatch() + // highlight-start + const notes = useSelector(state => { + if ( state.filter === 'ALL' ) { + return state.notes + } + return state.filter === 'IMPORTANT' + ? state.notes.filter(note => note.important) + : state.notes.filter(note => !note.important) + }) + // highlight-end + + return( +
      + {notes.map(note => + + dispatch(toggleImportanceOf(note.id)) + } + /> + )} +
    + ) +``` + +Nous ne faisons des modifications qu'à la fonction sélectrice, qui était auparavant + +```js +useSelector(state => state.notes) +``` + +Let's simplify the selector by destructuring the fields from the state it receives as a parameter: + +```js +const notes = useSelector(({ filter, notes }) => { + if ( filter === 'ALL' ) { + return notes + } + return filter === 'IMPORTANT' + ? notes.filter(note => note.important) + : notes.filter(note => !note.important) +}) +``` + +Il y a un léger défaut cosmétique dans notre application. Même si le filtre est réglé sur ALL par défaut, le bouton radio associé n'est pas sélectionné. Naturellement, ce problème peut être résolu, mais puisqu'il s'agit d'un bug désagréable mais finalement inoffensif, nous allons reporter la correction à plus tard. + +La version actuelle de l'application peut être trouvée sur [GitHub](https://github.com/fullstack-hy2020/redux-notes/tree/part6-2), branche part6-2. + +
    + +
    + +### Exercice 6.9 + +#### 6.9 Meilleures anecdotes, étape 7 + +Implémentez un filtrage pour les anecdotes qui sont affichées à l'utilisateur. + +![navigateur montrant le filtrage des anecdotes](../../images/6/9ea.png) + +Stockez l'état du filtre dans le store redux. Il est recommandé de créer un nouveau reducer, des créateurs d'actions, et un reducer combiné pour le store en utilisant la fonction combineReducers. + +Créez un nouveau composant Filter pour afficher le filtre. Vous pouvez utiliser le code suivant comme modèle pour le composant: + +```js +const Filter = () => { + const handleChange = (event) => { + // input-field value is in variable event.target.value + } + const style = { + marginBottom: 10 + } + + return ( +
    + filter +
    + ) +} + +export default Filter +``` + +
    + +
    + +### Redux Toolkit + +Comme nous l'avons vu jusqu'à présent, la configuration de Redux et la mise en oeuvre de la gestion de l'état nécessitent pas mal d'efforts. Cela se manifeste, par exemple, dans le code lié aux reducers et aux créateurs d'actions, qui comprend un code modèle (boilerplate) quelque peu répétitif. [Redux Toolkit](https://redux-toolkit.js.org/) est une bibliothèque qui résout ces problèmes communs liés à Redux. La bibliothèque simplifie grandement, par exemple, la configuration du store Redux et offre une grande variété d'outils pour faciliter la gestion de l'état. + +Commençons à utiliser Redux Toolkit dans notre application en refactorisant le code existant. Tout d'abord, nous devrons installer la bibliothèque: + +```bash +npm install @reduxjs/toolkit +``` + +Ensuite, ouvrez le fichier main.jsx qui crée actuellement le store Redux. Au lieu de la fonction createStore de Redux, créons le store en utilisant la fonction [configureStore](https://redux-toolkit.js.org/api/configureStore) de Redux Toolkit: + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' // highlight-line +import App from './App' + +import noteReducer from './reducers/noteReducer' +import filterReducer from './reducers/filterReducer' + + // highlight-start +const store = configureStore({ + reducer: { + notes: noteReducer, + filter: filterReducer + } +}) +// highlight-end + +console.log(store.getState()) + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) +``` + +Nous avons déjà éliminé quelques lignes de code maintenant que nous n'avons pas besoin de la fonction combineReducers pour créer le reducer pour le store. Nous verrons bientôt que la fonction configureStore offre de nombreux avantages supplémentaires, tels que l'intégration sans effort d'outils de développement et de nombreuses bibliothèques couramment utilisées sans avoir besoin de configuration supplémentaire. + +Passons à la refonte des reducers, ce qui met en avant les avantages de Redux Toolkit. Avec Redux Toolkit, nous pouvons facilement créer des reducers et des créateurs d'actions associés en utilisant la fonction [createSlice](https://redux-toolkit.js.org/api/createSlice) . Nous pouvons utiliser la fonction createSlice pour refondre le reducer et les créateurs d'actions dans le fichier reducers/noteReducer.js de la manière suivante: + +```js +import { createSlice } from '@reduxjs/toolkit' // highlight-line + +const initialState = [ + { + content: 'reducer defines how redux store works', + important: true, + id: 1, + }, + { + content: 'state of store can contain any data', + important: false, + id: 2, + }, +] + +const generateId = () => + Number((Math.random() * 1000000).toFixed(0)) + +// highlight-start +const noteSlice = createSlice({ + name: 'notes', + initialState, + reducers: { + createNote(state, action) { + const content = action.payload + + state.push({ + content, + important: false, + id: generateId(), + }) + }, + toggleImportanceOf(state, action) { + const id = action.payload + + const noteToChange = state.find(n => n.id === id) + + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + + return state.map(note => + note.id !== id ? note : changedNote + ) + } + }, +}) +// highlight-end +``` + +La fonction createSlice utilise le paramètre name pour définir le préfixe utilisé dans les valeurs de type des actions. Par exemple, l'action createNote définie plus tard aura une valeur de type notes/createNote. Il est de bonne pratique de donner à ce paramètre une valeur qui est unique parmi les reducers. De cette façon, il n'y aura pas de collisions inattendues entre les valeurs de type d'action de l'application. Le paramètre initialState définit l'état initial du reducer. Le paramètre reducers prend le reducer lui-même comme un objet, dont les fonctions gèrent les changements d'état provoqués par certaines actions. Notez que l'action.payload dans la fonction contient l'argument fourni en appelant le créateur d'action: + +```js +dispatch(createNote('Redux Toolkit is awesome!')) +``` + +Cet appel de dispatch réagit à la diffusion de l'objet suivant: + +```js +dispatch({ type: 'notes/createNote', payload: 'Redux Toolkit is awesome!' }) +``` + +Si vous avez suivi attentivement, vous avez peut-être remarqué que, à l'intérieur de l'action createNote, il semble se produire quelque chose qui enfreint le principe d'immutabilité des reducers mentionné précédemment: + +```js +createNote(state, action) { + const content = action.payload + + state.push({ + content, + important: false, + id: generateId(), + }) +} +``` + +Nous modifions le tableau de l'argument state en appelant la méthode push au lieu de retourner une nouvelle instance du tableau. De quoi s'agit-il? + +Redux Toolkit utilise la bibliothèque [Immer](https://immerjs.github.io/immer/) avec les reducers créés par la fonction createSlice, ce qui rend possible la mutation de l'argument state à l'intérieur du reducer. Immer utilise l'état muté pour produire un nouvel état immuable et ainsi les changements d'état restent immuables. Notez que l'state peut être changé sans être "muté", comme nous l'avons fait avec l'action toggleImportanceOf. Dans ce cas, la fonction retourne le nouvel état. Néanmoins, muter l'état sera souvent pratique, en particulier lorsqu'un état complexe doit être mis à jour. + +La fonction createSlice retourne un objet contenant le reducer ainsi que les créateurs d'actions définis par le paramètre reducers. Le reducer peut être accédé par la propriété noteSlice.reducer, tandis que les créateurs d'actions par la propriété noteSlice.actions. Nous pouvons produire les exports du fichier de la manière suivante: + +```js +const noteSlice = createSlice(/* ... */) + +// highlight-start +export const { createNote, toggleImportanceOf } = noteSlice.actions + +export default noteSlice.reducer +// highlight-end +``` + +Les importations dans d'autres fichiers fonctionneront exactement comme avant: + +```js +import noteReducer, { createNote, toggleImportanceOf } from './reducers/noteReducer' +``` + +Nous devons modifier les noms des types d'action dans les tests en raison des conventions de Redux Toolkit: + +```js +import noteReducer from './noteReducer' +import deepFreeze from 'deep-freeze' + +describe('noteReducer', () => { + test('returns new state with action notes/createNote', () => { + const state = [] + const action = { + type: 'notes/createNote', // highlight-line + payload: 'the app state is in redux store', // highlight-line + } + + deepFreeze(state) + const newState = noteReducer(state, action) + + expect(newState).toHaveLength(1) + expect(newState.map(s => s.content)).toContainEqual(action.payload) + }) + + test('returns new state with action notes/toggleImportanceOf', () => { + const state = [ + { + content: 'the app state is in redux store', + important: true, + id: 1 + }, + { + content: 'state changes are made with actions', + important: false, + id: 2 + }] + + const action = { + type: 'notes/toggleImportanceOf', // highlight-line + payload: 2 + } + + deepFreeze(state) + const newState = noteReducer(state, action) + + expect(newState).toHaveLength(2) + + expect(newState).toContainEqual(state[0]) + + expect(newState).toContainEqual({ + content: 'state changes are made with actions', + important: true, + id: 2 + }) + }) +}) +``` + +### Redux Toolkit et console.log + +Comme nous l'avons appris, console.log est un outil extrêmement puissant; il nous sauve souvent des ennuis. + +Essayons d'imprimer l'état du Store Redux dans la console au milieu du reducer créé avec la fonction createSlice: + +```js +const noteSlice = createSlice({ + name: 'notes', + initialState, + reducers: { + // ... + toggleImportanceOf(state, action) { + const id = action.payload + + const noteToChange = state.find(n => n.id === id) + + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + + console.log(state) // highlight-line + + return state.map(note => + note.id !== id ? note : changedNote + ) + } + }, +}) +``` + +Ce qui suit est imprimé dans la console + +![devtools console montrant Handler, Target comme null mais IsRevoked comme vrai](../../images/6/40new.png) + +La sortie est intéressante mais pas très utile. Cela concerne la bibliothèque Immer mentionnée précédemment utilisée par Redux Toolkit, qui est maintenant utilisée en interne pour sauvegarder l'état du Store. + +Le statut peut être converti en un format lisible par l'homme, par exemple en le convertissant en chaîne de caractères puis de nouveau en objet JavaScript comme suit: + +```js +console.log(JSON.parse(JSON.stringify(state))) // highlight-line +``` + +La sortie de la console est maintenant lisible par l'humain + +![dev tools montrant un tableau de 2 notes](../../images/6/41new.png) + +### Redux DevTools + +[Redux DevTools](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd) est une extension Chrome qui offre des outils de développement utiles pour Redux. Elle peut être utilisée, par exemple, pour inspecter l'état du store Redux et dispatcher des actions via la console du navigateur. Lorsque le store est créé en utilisant la fonction configureStore de Redux Toolkit, aucune configuration supplémentaire n'est nécessaire pour que Redux DevTools fonctionne. + +Une fois l'extension installée, cliquer sur l'onglet Redux dans la console du navigateur devrait ouvrir les outils de développement: + +[navigateur avec l'addon redux dans devtools](../../images/6/42new.png) + +Vous pouvez inspecter comment le dispatching d'une action spécifique change l'état en cliquant sur l'action: + +![devtools inspectant l'arbre des notes dans redux](../../images/6/43new.png) + +Il est également possible de dispatcher des actions vers le store en utilisant les outils de développement: + +![devtools redux dispatchant createNote avec payload](../../images/6/44new.png) + +Vous pouvez trouver le code de notre application actuelle dans son intégralité dans la branche part6-3 de ce [dépôt GitHub](https://github.com/fullstack-hy2020/redux-notes/tree/part6-3). + +
    + +
    + +### Exercices 6.10.-6.13. + +Continuons à travailler sur l'application d'anecdotes utilisant Redux que nous avons commencée à l'exercice 6.3. + +#### 6.10 Meilleures anecdotes, étape 8 + +Installez Redux Toolkit pour le projet. Déplacez la création du store Redux dans le fichier store.js et utilisez la fonction configureStore de Redux Toolkit pour créer le store. + +Changez la définition du reducer de filtre et des créateurs d'actions pour utiliser la fonction createSlice de Redux Toolkit. + +Commencez également à utiliser Redux DevTools pour déboguer plus facilement l'état de l'application. + +#### 6.11 Meilleures anecdotes, étape 9 + +Changez également la définition du reducer d'anecdotes et des créateurs d'actions pour utiliser la fonction createSlice de Redux Toolkit. + +#### 6.12 Meilleures anecdotes, étape 10 + +L'application a un corps prêt à l'emploi pour le composant Notification: + +```js +const Notification = () => { + const style = { + border: 'solid', + padding: 10, + borderWidth: 1 + } + return ( +
    + render here notification... +
    + ) +} + +export default Notification +``` + +Étendez le composant pour qu'il affiche le message stocké dans le store Redux, de manière à ce que le composant prenne la forme suivante: + +```js +import { useSelector } from 'react-redux' // highlight-line + +const Notification = () => { + const notification = useSelector(/* something here */) // highlight-line + const style = { + border: 'solid', + padding: 10, + borderWidth: 1 + } + return ( +
    + {notification} // highlight-line +
    + ) +} +``` + +Vous devrez apporter des modifications au reducer existant de l'application. Créez un reducer séparé pour la nouvelle fonctionnalité en utilisant la fonction createSlice de Redux Toolkit. + +À ce stade des exercices, il n'est pas nécessaire que l'application utilise le composant Notification de manière intelligente. Il suffit que l'application affiche la valeur initiale définie pour le message dans le notificationReducer. + +#### 6.13 Better anecdotes, step11 + +Étendez l'application pour qu'elle utilise le composant Notification afin d'afficher un message pendant cinq secondes lorsque l'utilisateur vote pour une anecdote ou crée une nouvelle anecdote: + +![navigateur affichant le message de vote](../../images/6/8ea.png) + +Il est recommandé de créer des [créateurs d'actions](https://redux-toolkit.js.org/api/createSlice#reducers) séparés pour définir et supprimer les notifications. + +
    \ No newline at end of file diff --git a/src/content/6/fr/part6c.md b/src/content/6/fr/part6c.md new file mode 100644 index 00000000000..4b042d0fc7e --- /dev/null +++ b/src/content/6/fr/part6c.md @@ -0,0 +1,593 @@ +--- +mainImage: ../../../images/part-6.svg +part: 6 +letter: c +lang: fr +--- + +
    + +Étendons l'application afin que les notes soient stockées dans le backend. Nous utiliserons [json-server](/fr/part2/obtenir_des_donnees_du_serveur), déjà connu de la partie 2. + +L'état initial de la base de données est stocké dans le fichier db.json, qui est placé à la racine du projet: + +```json +{ + "notes": [ + { + "content": "the app state is in redux store", + "important": true, + "id": 1 + }, + { + "content": "state changes are made with actions", + "important": false, + "id": 2 + } + ] +} +``` + +Nous installerons json-server pour le projet: + +```js +npm install json-server --save-dev +``` + +et ajoutez la ligne suivante à la partie scripts du fichier package.json + +```js +"scripts": { + "server": "json-server -p3001 --watch db.json", + // ... +} +``` + +Maintenant, lançons json-server avec la commande _npm run server_. + +### Récupération des données depuis le backend + +Ensuite, nous allons créer une méthode dans le fichier services/notes.js, qui utilise axios pour récupérer les données depuis le backend + +```js +import axios from 'axios' + +const baseUrl = 'http://localhost:3001/notes' + +const getAll = async () => { + const response = await axios.get(baseUrl) + return response.data +} + +export default { getAll } +``` + +Nous allons ajouter axios au projet + +```bash +npm install axios +``` + +Nous allons modifier l'initialisation de l'état dans noteReducer, de sorte qu'il n'y ait par défaut aucune note: + +```js +const noteSlice = createSlice({ + name: 'notes', + initialState: [], // highlight-line + // ... +}) +``` + +Ajoutons également une nouvelle action appendNote pour ajouter un objet note: + +```js +const noteSlice = createSlice({ + name: 'notes', + initialState: [], + reducers: { + createNote(state, action) { + const content = action.payload + + state.push({ + content, + important: false, + id: generateId(), + }) + }, + toggleImportanceOf(state, action) { + const id = action.payload + + const noteToChange = state.find(n => n.id === id) + + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + + return state.map(note => + note.id !== id ? note : changedNote + ) + }, + // highlight-start + appendNote(state, action) { + state.push(action.payload) + } + // highlight-end + }, +}) + +export const { createNote, toggleImportanceOf, appendNote } = noteSlice.actions // highlight-line + +export default noteSlice.reducer +``` + +Une manière rapide d'initialiser l'état des notes en fonction des données reçues du serveur consiste à récupérer les notes dans le fichier main.jsx et à dispatcher une action en utilisant le créateur d'action appendNote pour chaque objet note individuel: + +```js +// ... +import noteService from './services/notes' // highlight-line +import noteReducer, { appendNote } from './reducers/noteReducer' // highlight-line + +const store = configureStore({ + reducer: { + notes: noteReducer, + filter: filterReducer, + } +}) + +// highlight-start +noteService.getAll().then(notes => + notes.forEach(note => { + store.dispatch(appendNote(note)) + }) +) +// highlight-end + +// ... +``` + +Dispatcher plusieurs actions peut sembler peu pratique. Ajoutons un créateur d'action setNotes qui peut être utilisé pour remplacer directement le tableau des notes. Nous obtiendrons le créateur d'action de la fonction createSlice en implémentant l'action setNotes: + +```js +// ... + +const noteSlice = createSlice({ + name: 'notes', + initialState: [], + reducers: { + createNote(state, action) { + const content = action.payload + + state.push({ + content, + important: false, + id: generateId(), + }) + }, + toggleImportanceOf(state, action) { + const id = action.payload + + const noteToChange = state.find(n => n.id === id) + + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + + return state.map(note => + note.id !== id ? note : changedNote + ) + }, + appendNote(state, action) { + state.push(action.payload) + }, + // highlight-start + setNotes(state, action) { + return action.payload + } + // highlight-end + }, +}) + +export const { createNote, toggleImportanceOf, appendNote, setNotes } = noteSlice.actions // highlight-line + +export default noteSlice.reducer +``` + +Maintenant, le code dans le fichier main.jsx est beaucoup plus propre: + +```js +// ... +import noteService from './services/notes' +import noteReducer, { setNotes } from './reducers/noteReducer' // highlight-line + +const store = configureStore({ + reducer: { + notes: noteReducer, + filter: filterReducer, + } +}) + +noteService.getAll().then(notes => + store.dispatch(setNotes(notes)) // highlight-line +) +``` + +> **NB:** Pourquoi n'avons-nous pas utilisé await à la place des promesses et des gestionnaires d'événements (enregistrés sur les méthodes _then_)? +> +> Await ne fonctionne qu'à l'intérieur des fonctions async, et le code dans main.jsx n'est pas à l'intérieur d'une fonction, donc en raison de la nature simple de l'opération, nous nous abstiendrons d'utiliser async cette fois-ci. + +Cependant, nous décidons de déplacer l'initialisation des notes dans le composant App, et, comme d'habitude, lors de la récupération de données depuis un serveur, nous utiliserons le hook d'effet. + +```js +import { useEffect } from 'react' // highlight-line +import NewNote from './components/NewNote' +import Notes from './components/Notes' +import VisibilityFilter from './components/VisibilityFilter' +import noteService from './services/notes' // highlight-line +import { setNotes } from './reducers/noteReducer' // highlight-line +import { useDispatch } from 'react-redux' // highlight-line + +const App = () => { + // highlight-start + const dispatch = useDispatch() + useEffect(() => { + noteService + .getAll().then(notes => dispatch(setNotes(notes))) + }, []) + // highlight-end + + return ( +
    + + + +
    + ) +} + +export default App +``` + +### Envoyer des données vers le backend + +nous pouvons procéder de la même manière que pour la création d'une nouvelle note. Étendons le code communiquant avec le serveur de la manière suivante: + +```js +const baseUrl = 'http://localhost:3001/notes' + +const getAll = async () => { + const response = await axios.get(baseUrl) + return response.data +} + +// highlight-start +const createNew = async (content) => { + const object = { content, important: false } + const response = await axios.post(baseUrl, object) + return response.data +} +// highlight-end + +export default { + getAll, + createNew, // highlight-line +} +``` + +La méthode _addNote_ du composant NewNote change légèrement: + +```js +import { useDispatch } from 'react-redux' +import { createNote } from '../reducers/noteReducer' +import noteService from '../services/notes' // highlight-line + +const NewNote = (props) => { + const dispatch = useDispatch() + + const addNote = async (event) => { // highlight-line + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + const newNote = await noteService.createNew(content) // highlight-line + dispatch(createNote(newNote)) // highlight-line + } + + return ( +
    + + +
    + ) +} + +export default NewNote +``` + +Puisque le backend génère les identifiants pour les notes, nous allons modifier le créateur d'action createNote dans le fichier noteReducer.js en conséquence: + +```js +const noteSlice = createSlice({ + name: 'notes', + initialState: [], + reducers: { + createNote(state, action) { + state.push(action.payload) // highlight-line + }, + // .. + }, +}) +``` + +Modifier l'importance des notes pourrait être implémenté en utilisant le même principe, en effectuant un appel de méthode asynchrone au serveur puis en dispatchant une action appropriée. + +L'état actuel du code pour l'application peut être trouvé sur [GitHub](https://github.com/fullstack-hy2020/redux-notes/tree/part6-3) dans la branche part6-3. + +
    + +
    + +### Exercices 6.14.-6.15. + +#### 6.14 Anecdotes et le backend, étape1 + +Au lancement de l'application, récupérez les anecdotes depuis le backend implémenté avec json-server. + +Comme données initiales du backend, vous pouvez utiliser, par exemple, [celles-ci](https://github.com/fullstack-hy2020/misc/blob/master/anecdotes.json). + +#### 6.15 Anecdotes et le backend, étape2 + +Modifiez la création de nouvelles anecdotes, de sorte que les anecdotes soient stockées dans le backend. + +
    + +
    + +### Actions asynchrones et Redux thunk + +Notre approche est assez bonne, mais il n'est pas idéal que la communication avec le serveur se fasse à l'intérieur des fonctions des composants. Il serait préférable que cette communication soit abstraite des composants, de sorte qu'ils n'aient rien d'autre à faire que d'appeler le créateur d'action approprié. Par exemple, App initialiserait l'état de l'application comme suit: + +```js +const App = () => { + const dispatch = useDispatch() + + useEffect(() => { + dispatch(initializeNotes()) + }, []) + + // ... +} +``` + +et NewNote créerait une nouvelle note comme suit: + +```js +const NewNote = () => { + const dispatch = useDispatch() + + const addNote = async (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + dispatch(createNote(content)) + } + + // ... +} +``` + +Dans cette implémentation, les deux composants enverraient une action sans avoir besoin de connaître la communication avec le serveur qui se passe en coulisse. Ce genre d'actions asynchrones peut être implémenté en utilisant la bibliothèque [Redux Thunk](https://github.com/reduxjs/redux-thunk). L'utilisation de la bibliothèque ne nécessite aucune configuration supplémentaire ni même d'installation lorsque le magasin Redux est créé en utilisant la fonction configureStore de Redux Toolkit. + +Avec Redux Thunk, il est possible d'implémenter des créateurs d'action qui retournent une fonction au lieu d'un objet. La fonction reçoit les méthodes dispatch et getState du magasin Redux comme paramètres. Cela permet, par exemple, des implémentations de créateurs d'action asynchrones, qui attendent d'abord la complétion d'une certaine opération asynchrone et après cela envoient une action, qui change l'état du magasin. + +Nous pouvons définir un créateur d'action initializeNotes qui initialise les notes basées sur les données reçues du serveur: + +```js +// ... +import noteService from '../services/notes' // highlight-line + +const noteSlice = createSlice(/* ... */) + +export const { createNote, toggleImportanceOf, setNotes, appendNote } = noteSlice.actions + +// highlight-start +export const initializeNotes = () => { + return async dispatch => { + const notes = await noteService.getAll() + dispatch(setNotes(notes)) + } +} +// highlight-end + +export default noteSlice.reducer +``` + +Dans la fonction interne, c'est-à-dire l'action asynchrone, l'opération commence d'abord par récupérer toutes les notes du serveur, puis envoie l'action setNotes, qui les ajoute au magasin. + +Le composant App peut maintenant être défini comme suit: + +```js +// ... +import { initializeNotes } from './reducers/noteReducer' // highlight-line + +const App = () => { + const dispatch = useDispatch() + + // highlight-start + useEffect(() => { + dispatch(initializeNotes()) + }, []) + // highlight-end + + return ( +
    + + + +
    + ) +} +``` + +La solution est élégante. La logique d'initialisation des notes a été complètement séparée du composant React. + +Ensuite, remplaçons le créateur d'action createNote créé par la fonction createSlice par un créateur d'action asynchrone: + +```js +// ... +import noteService from '../services/notes' + +const noteSlice = createSlice({ + name: 'notes', + initialState: [], + reducers: { + toggleImportanceOf(state, action) { + const id = action.payload + + const noteToChange = state.find(n => n.id === id) + + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + + return state.map(note => + note.id !== id ? note : changedNote + ) + }, + appendNote(state, action) { + state.push(action.payload) + }, + setNotes(state, action) { + return action.payload + } + // createNote definition removed from here! + }, +}) + +export const { toggleImportanceOf, appendNote, setNotes } = noteSlice.actions // highlight-line + +export const initializeNotes = () => { + return async dispatch => { + const notes = await noteService.getAll() + dispatch(setNotes(notes)) + } +} + +// highlight-start +export const createNote = content => { + return async dispatch => { + const newNote = await noteService.createNew(content) + dispatch(appendNote(newNote)) + } +} +// highlight-end + +export default noteSlice.reducer +``` + +Le principe est le même ici: d'abord, une opération asynchrone est exécutée, après quoi l'action qui change l'état du store est dispatchée. + +Le composant NewNote change comme suit: + +```js +// ... +import { createNote } from '../reducers/noteReducer' // highlight-line + +const NewNote = () => { + const dispatch = useDispatch() + + const addNote = async (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + dispatch(createNote(content)) //highlight-line + } + + return ( +
    + + +
    + ) +} +``` + +Pour finir, nettoyons un peu le fichier main.jsx en déplaçant le code relatif à la création du store Redux dans son propre fichier, store.js: + +```js +import { configureStore } from '@reduxjs/toolkit' + +import noteReducer from './reducers/noteReducer' +import filterReducer from './reducers/filterReducer' + +const store = configureStore({ + reducer: { + notes: noteReducer, + filter: filterReducer + } +}) + +export default store +``` + +Après les modifications, le contenu du fichier main.jsx est le suivant: + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' +import { Provider } from 'react-redux' +import store from './store' // highlight-line +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) +``` + +L'état actuel du code pour l'application peut être trouvé sur [GitHub](https://github.com/fullstack-hy2020/redux-notes/tree/part6-5) dans la branche part6-5. + +Redux Toolkit offre une multitude d'outils pour simplifier la gestion de l'état asynchrone. Des outils adaptés à ce cas d'usage incluent par exemple la fonction [createAsyncThunk](https://redux-toolkit.js.org/api/createAsyncThunk) et l'API [RTK Query](https://redux-toolkit.js.org/rtk-query/overview). + +
    + +
    + +### Exercices 6.16.-6.19. + +#### 6.16 Anecdotes et le backend, étape 3 + +Modifiez l'initialisation du Redux store pour qu'elle se fasse à l'aide de créateurs d'actions asynchrones, rendus possibles par la bibliothèque Redux Thunk. + +#### 6.17 Anecdotes et le backend, étape 4 + +Modifiez également la création d'une nouvelle anecdote pour qu'elle se fasse à l'aide de créateurs d'actions asynchrones, rendus possibles par la bibliothèque Redux Thunk. + +#### 6.18 Anecdotes et le backend, étape 5 + +Le vote ne sauvegarde pas encore les changements dans le backend. Corrigez la situation avec l'aide de la bibliothèque Redux Thunk. + +#### 6.19 Anecdotes et le backend, étape 6 + +La création de notifications est encore un peu fastidieuse puisqu'il faut faire deux actions et utiliser la fonction _setTimeout_: + +```js +dispatch(setNotification(`new anecdote '${content}'`)) +setTimeout(() => { + dispatch(clearNotification()) +}, 5000) +``` + +Créez un créateur d'actions, qui permet de fournir la notification comme suit: + +```js +dispatch(setNotification(`you voted '${anecdote.content}'`, 10)) +``` + +Le premier paramètre est le texte à afficher et le deuxième paramètre est le temps d'affichage de la notification donné en secondes. + +Implémentez l'utilisation de cette notification améliorée dans votre application. + +
    \ No newline at end of file diff --git a/src/content/6/fr/part6d.md b/src/content/6/fr/part6d.md new file mode 100644 index 00000000000..c542ccb822a --- /dev/null +++ b/src/content/6/fr/part6d.md @@ -0,0 +1,824 @@ +--- +mainImage: ../../../images/part-6.svg +part: 6 +letter: d +lang: fr +--- + +
    + +À la fin de cette partie, nous examinerons quelques autres méthodes de gestion de l'état d'une application. + +Continuons avec l'application de notes. Nous nous concentrerons sur la communication avec le serveur. Commençons l'application à partir de zéro. La première version est la suivante: + +```js +const App = () => { + const addNote = async (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + console.log(content) + } + + const toggleImportance = (note) => { + console.log('toggle importance of', note.id) + } + + const notes = [] + + return( +
    +

    Notes app

    +
    + + +
    + {notes.map(note => +
  • toggleImportance(note)}> + {note.content} + {note.important ? 'important' : ''} +
  • + )} +
    + ) +} + +export default App +``` + +Le code initial est sur GitHub dans le [dépôt](https://github.com/fullstack-hy2020/query-notes/tree/part6-0) dans la branche part6-0. + +**Note**: Par défaut, le clonage du dépôt vous donnera uniquement la branche principale. Pour obtenir le code initial de la branche part6-0, utilisez la commande suivante: +``` +git clone --branch part6-0 https://github.com/fullstack-hy2020/query-notes.git +``` + +### Gestion des données sur le serveur avec la bibliothèque React Query + +Nous allons maintenant utiliser la bibliothèque [React Query](https://tanstack.com/query/latest) pour stocker et gérer les données récupérées depuis le serveur. La dernière version de la bibliothèque est également appelée TanStack Query, mais nous restons avec le nom familier. + +Installez la bibliothèque avec la commande + +```bash +npm install @tanstack/react-query +``` + +Quelques ajouts dans le fichier main.jsx sont nécessaires pour passer les fonctions de la bibliothèque à l'ensemble de l'application: + +```js +import React from 'react' +import ReactDOM from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' // highlight-line + +import App from './App' + +const queryClient = new QueryClient() // highlight-line + +ReactDOM.createRoot(document.getElementById('root')).render( + // highlight-line + + // highlight-line +) +``` + +Nous pouvons maintenant récupérer les notes dans le composant App. Le code s'étend comme suit: + +```js +import { useQuery } from '@tanstack/react-query' // highlight-line +import axios from 'axios' // highlight-line + +const App = () => { + // ... + + // highlight-start + const result = useQuery({ + queryKey: ['notes'], + queryFn: () => axios.get('http://localhost:3001/notes').then(res => res.data) + }) + + console.log(JSON.parse(JSON.stringify(result))) + // highlight-end + + // highlight-start + if ( result.isLoading ) { + return
    loading data...
    + } + // highlight-end + + const notes = result.data // highlight-line + + return ( + // ... + ) +} +``` + +Récupérer les données depuis le serveur se fait encore de manière familière avec la méthode get d'Axios. Cependant, l'appel de méthode Axios est maintenant encapsulé dans une [requête](https://tanstack.com/query/latest/docs/react/guides/queries) formée avec la fonction [useQuery](https://tanstack.com/query/latest/docs/react/reference/useQuery). Le premier paramètre de l'appel de fonction est une chaîne notes qui agit comme une [clé](https://tanstack.com/query/latest/docs/react/guides/query-keys) pour la requête définie, c'est-à-dire la liste des notes. + +La valeur de retour de la fonction useQuery est un objet qui indique le statut de la requête. La sortie vers la console illustre la situation: + +![browser devtools showing success status](../../images/6/60new.png) + +C'est-à-dire que la première fois que le composant est rendu, la requête est encore dans l'état loading, c'est-à-dire que la requête HTTP associée est en attente. À ce stade, seul ce qui suit est rendu: + +```html +
    loading data...
    +``` + +Cependant, la requête HTTP est complétée si rapidement que même les plus astucieux ne pourront pas voir le texte. Lorsque la requête est terminée, le composant est rendu à nouveau. La requête est dans l'état success lors du deuxième rendu, et le champ data de l'objet de la requête contient les données renvoyées par la requête, c'est-à-dire la liste des notes qui est rendue à l'écran. + +Ainsi, l'application récupère les données depuis le serveur et les affiche à l'écran sans utiliser du tout les hooks React useState et useEffect utilisés dans les chapitres 2 à 5. Les données sur le serveur sont maintenant entièrement sous l'administration de la bibliothèque React Query, et l'application n'a pas du tout besoin de l'état défini avec le hook useState de React ! + +Déplaçons la fonction effectuant la requête HTTP réelle dans son propre fichier requests.js. + +```js +import axios from 'axios' + +export const getNotes = () => + axios.get('http://localhost:3001/notes').then(res => res.data) +``` + +Le composant App est maintenant légèrement simplifié + +```js +import { useQuery } from '@tanstack/react-query' +import { getNotes } from './requests' // highlight-line + +const App = () => { + // ... + + const result = useQuery({ + queryKey: ['notes'], + queryFn: getNotes // highlight-line + }) + + // ... +} +``` + +Le code actuel de l'application se trouve sur [GitHub](https://github.com/fullstack-hy2020/query-notes/tree/part6-1) dans la branche part6-1. + +### Synchronisation des données avec le serveur à l'aide de React Query + +Les données sont déjà récupérées avec succès depuis le serveur. Ensuite, nous nous assurerons que les données ajoutées et modifiées sont stockées sur le serveur. Commençons par ajouter de nouvelles notes. + +Faisons une fonction createNote dans le fichier requests.js pour sauvegarder les nouvelles notes: + +```js +import axios from 'axios' + +const baseUrl = 'http://localhost:3001/notes' + +export const getNotes = () => + axios.get(baseUrl).then(res => res.data) + +export const createNote = newNote => // highlight-line + axios.post(baseUrl, newNote).then(res => res.data) // highlight-line +``` + +Le composant App changera comme suit + +```js +import { useQuery, useMutation } from '@tanstack/react-query' // highlight-line +import { getNotes, createNote } from './requests' // highlight-line + +const App = () => { + const newNoteMutation = useMutation({ mutationFn: createNote }) // highlight-line + + const addNote = async (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + newNoteMutation.mutate({ content, important: true }) // highlight-line + } + + // + +} +``` + +Pour créer une nouvelle note, une [mutation](https://tanstack.com/query/latest/docs/react/guides/mutations) est définie en utilisant la fonction [useMutation](https://tanstack.com/query/latest/docs/react/reference/useMutation): + +```js +const newNoteMutation = useMutation({ mutationFn: createNote }) +``` + +Le paramètre est la fonction que nous avons ajoutée au fichier requests.js, qui utilise Axios pour envoyer une nouvelle note au serveur. + +Le gestionnaire d'événement addNote effectue la mutation en appelant la fonction de l'objet de mutation mutate et en passant la nouvelle note comme paramètre: + +```js +newNoteMutation.mutate({ content, important: true }) +``` + +Notre solution est bonne. Sauf qu'elle ne fonctionne pas. La nouvelle note est enregistrée sur le serveur, mais elle n'est pas mise à jour à l'écran. + +Pour afficher également la nouvelle note, nous devons informer React Query que l'ancien résultat de la requête dont la clé est la chaîne notes doit être [invalidé](https://tanstack.com/query/latest/docs/react/guides/invalidations-from-mutations). + +Heureusement, l'invalidation est facile, elle peut être réalisée en définissant la fonction de rappel onSuccess appropriée pour la mutation: + +```js +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' // highlight-line +import { getNotes, createNote } from './requests' + +const App = () => { + const queryClient = useQueryClient() // highlight-line + + const newNoteMutation = useMutation({ + mutationFn: createNote, + onSuccess: () => { // highlight-line + queryClient.invalidateQueries({ queryKey: ['notes'] }) // highlight-line + }, + }) + + // ... +} +``` + +Maintenant que la mutation a été exécutée avec succès, un appel de fonction est effectué pour + +```js +queryClient.invalidateQueries('notes') +``` + +Cela entraîne à son tour React Query à mettre automatiquement à jour une requête avec la clé notes, c'est-à-dire récupérer les notes depuis le serveur. En conséquence, l'application rend l'état actuel sur le serveur, c'est-à-dire que la note ajoutée est également rendue. + +Implémentons également le changement dans l'importance des notes. Une fonction pour la mise à jour des notes est ajoutée au fichier requests.js: + +```js +export const updateNote = updatedNote => + axios.put(`${baseUrl}/${updatedNote.id}`, updatedNote).then(res => res.data) +``` + +La mise à jour de la note est également effectuée par mutation. Le composant App s'étend comme suit: + +```js +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { getNotes, createNote, updateNote } from './requests' // highlight-line + +const App = () => { + // ... + + const updateNoteMutation = useMutation({ + mutationFn: updateNote, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notes'] }) + }, + }) + + // highlight-start + const toggleImportance = (note) => { + updateNoteMutation.mutate({...note, important: !note.important }) + } + // highlight-end + + // ... +} +``` + +Donc, encore une fois, une mutation a été créée qui a invalidé la requête notes afin que la note mise à jour soit correctement rendue. Utiliser une mutation est facile, la méthode mutate reçoit une note en paramètre, dont l'importance a été changée à la négation de l'ancienne valeur. + +Le code actuel de l'application se trouve sur [GitHub](https://github.com/fullstack-hy2020/query-notes/tree/part6-2) dans la branche part6-2. + +### Optimiser la performance + +L'application fonctionne bien, et le code est relativement simple. La facilité de faire des modifications à la liste des notes est particulièrement surprenante. Par exemple, lorsque nous changeons l'importance d'une note, invalider la requête notes suffit pour que les données de l'application soient mises à jour: + +```js + const updateNoteMutation = useMutation({ + mutationFn: updateNote, + onSuccess: () => { + queryClient.invalidateQueries('notes') // highlight-line + }, + }) +``` + +La conséquence de cela, bien sûr, est qu'après la requête PUT qui provoque le changement de la note, l'application fait une nouvelle requête GET pour récupérer les données de la requête du serveur : + +![devtools network tab with highlight over 3 and notes requests](../../images/6/61new.png) + +Si la quantité de données récupérées par l'application n'est pas grande, cela n'a pas vraiment d'importance. Après tout, du point de vue de la fonctionnalité côté navigateur, faire une requête HTTP GET supplémentaire n'a pas vraiment d'importance, mais dans certaines situations, cela pourrait mettre à rude épreuve le serveur. + +Si nécessaire, il est également possible d'optimiser les performances en [mettant à jour manuellement](https://tanstack.com/query/latest/docs/react/guides/updates-from-mutation-responses) l'état de la requête maintenu par React Query. + +Le changement pour la mutation ajoutant une nouvelle note est le suivant: + +```js +const App = () => { + const queryClient = useQueryClient() + + const newNoteMutation = useMutation({ + mutationFn: createNote, + onSuccess: (newNote) => { + const notes = queryClient.getQueryData(['notes']) // highlight-line + queryClient.setQueryData(['notes'], notes.concat(newNote)) // highlight-line + } + }) + // ... +} +``` + +C'est-à-dire, dans le callback onSuccess, l'objet queryClient lit d'abord l'état existant de la requête notes et le met à jour en ajoutant une nouvelle note, qui est obtenue en tant que paramètre de la fonction de callback. La valeur du paramètre est la valeur retournée par la fonction createNote, définie dans le fichier requests.js comme suit: + +```js +export const createNote = newNote => + axios.post(baseUrl, newNote).then(res => res.data) +``` + +Il serait relativement facile de faire une modification similaire pour une mutation qui change l'importance de la note, mais nous laissons cela comme un exercice optionnel. + +Si nous suivons attentivement l'onglet réseau du navigateur, nous remarquons que React Query récupère toutes les notes dès que nous déplaçons le curseur vers le champ de saisie: + +![outil de développement notes app avec champ de texte en surbrillance et flèche sur le réseau au-dessus de la requête des notes comme 200](../../images/6/62new.png) + +Qu'est-ce qui se passe? En lisant la [documentation](https://tanstack.com/query/latest/docs/react/reference/useQuery), nous remarquons que la fonctionnalité par défaut des requêtes de React Query est que les requêtes (dont le statut est stale) sont mises à jour lorsque le window focus, c'est-à-dire l'élément actif de l'interface utilisateur de l'application, change. Si nous le souhaitons, nous pouvons désactiver la fonctionnalité en créant une requête comme suit: + +```js +const App = () => { + // ... + const result = useQuery({ + queryKey: ['notes'], + queryFn: getNotes, + refetchOnWindowFocus: false // highlight-line + }) + + // ... +} +``` + +Si vous ajoutez une instruction console.log dans le code, vous pouvez voir depuis la console du navigateur à quelle fréquence React Query provoque le re-rendu de l'application. La règle générale est que le re-rendu se produit au moins chaque fois qu'il est nécessaire, c'est-à-dire lorsque l'état de la requête change. Vous pouvez en lire plus à ce sujet par exemple [ici](https://tkdodo.eu/blog/react-query-render-optimizations). + +Le code de l'application est sur [GitHub](https://github.com/fullstack-hy2020/query-notes/tree/part6-3) dans la branche part6-3. + +React Query est une bibliothèque polyvalente qui, comme nous l'avons déjà vu, simplifie l'application. Est-ce que React Query rend les solutions de gestion d'état plus complexes comme Redux inutiles? Non. React Query peut remplacer partiellement l'état de l'application dans certains cas, mais comme le stipule la [documentation](https://tanstack.com/query/latest/docs/react/guides/does-this-replace-client-state) + +- React Query est une bibliothèque d'état serveur, responsable de la gestion des opérations asynchrones entre votre serveur et client +- Redux, etc. sont des bibliothèques d'état client qui peuvent être utilisées pour stocker des données asynchrones, bien que de manière inefficace par rapport à un outil comme React Query + +Ainsi, React Query est une bibliothèque qui maintient l'état serveur dans le frontend, c'est-à-dire qu'elle agit comme un cache pour ce qui est stocké sur le serveur. React Query simplifie le traitement des données sur le serveur, et peut dans certains cas éliminer le besoin de sauvegarder les données du serveur dans l'état du frontend. + +La plupart des applications React ont besoin non seulement d'un moyen de stocker temporairement les données servies, mais aussi d'une solution pour la gestion du reste de l'état du frontend (par exemple, l'état des formulaires ou des notifications). + +
    + +
    + +### Exercices 6.20.-6.22. + +Maintenant, créons une nouvelle version de l'application d'anecdotes qui utilise la bibliothèque React Query. Prenez [ce projet](https://github.com/fullstack-hy2020/query-anecdotes) comme point de départ. Le projet dispose d'un JSON Server déjà installé, dont le fonctionnement a été légèrement modifié (revoyez le fichier server.js pour plus de détails. Assurez-vous de vous connecter au bon _PORT_). Démarrez le serveur avec npm run server. + +#### Exercice 6.20 + +Implémentez la récupération des anecdotes depuis le serveur en utilisant React Query. + +L'application doit fonctionner de telle manière que si des problèmes de communication avec le serveur surviennent, seule une page d'erreur sera affichée: + +![navigateur indiquant que le service d'anecdotes n'est pas disponible en raison de problèmes sur le serveur sur localhost](../../images/6/65new.png) + +Vous pouvez trouver [ici](https://tanstack.com/query/latest/docs/react/guides/queries) des informations sur comment détecter les erreurs possibles. + +Vous pouvez simuler un problème avec le serveur, par exemple, en éteignant le JSON Server. Veuillez noter que dans une situation de problème, la requête est d'abord dans l'état isLoading pendant un moment, car si une demande échoue, React Query tente la demande plusieurs fois avant de déclarer que la demande n'a pas abouti. Vous pouvez éventuellement spécifier qu'aucune nouvelle tentative ne soit faite: + +```js +const result = useQuery( + { + queryKey: ['anecdotes'], + queryFn: getAnecdotes, + retry: false + } +) +``` + +ou que la requête soit réessayée, par exemple, une seule fois: + +```js +const result = useQuery( + { + queryKey: ['anecdotes'], + queryFn: getAnecdotes, + retry: 1 + } +) +``` + +#### Exercice 6.21 + +Implémentez l'ajout de nouvelles anecdotes au serveur en utilisant React Query. L'application devrait afficher par défaut une nouvelle anecdote. Notez que le contenu de l'anecdote doit être long d'au moins 5 caractères, sinon le serveur rejettera la requête POST. Vous n'avez pas à vous préoccuper de la gestion des erreurs pour le moment. + +#### Exercice 6.22 + +Implémentez le vote pour les anecdotes en utilisant à nouveau React Query. L'application devrait automatiquement afficher le nombre de votes augmenté pour l'anecdote votée. + +
    + +
    + +### useReducer + +Même si l'application utilise React Query, une sorte de solution est généralement nécessaire pour gérer le reste de l'état du frontend (par exemple, l'état des formulaires). Assez souvent, l'état créé avec useState est une solution suffisante. L'utilisation de Redux est bien sûr possible, mais il existe d'autres alternatives. + +Regardons une simple application de compteur. L'application affiche la valeur du compteur et propose trois boutons pour mettre à jour le statut du compteur: + +![navigateur montrant les boutons + - 0 et 7 au-dessus](../../images/6/63new.png) + +Nous allons maintenant implémenter la gestion de l'état du compteur en utilisant un mécanisme de gestion d'état similaire à Redux fourni par le hook [useReducer](https://react.dev/reference/react/useReducer) intégré à React. Le code ressemble à ce qui suit: + +```js +import { useReducer } from 'react' + +const counterReducer = (state, action) => { + switch (action.type) { + case "INC": + return state + 1 + case "DEC": + return state - 1 + case "ZERO": + return 0 + default: + return state + } +} + +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) + + return ( +
    +
    {counter}
    +
    + + + +
    +
    + ) +} + +export default App +``` + +Le hook [useReducer](https://react.dev/reference/react/useReducer) fournit un mécanisme pour créer un état pour une application. Le paramètre pour créer un état est la fonction reducer qui gère les changements d'état, et la valeur initiale de l'état: + +```js +const [counter, counterDispatch] = useReducer(counterReducer, 0) +``` + +La fonction reducer qui gère les changements d'état est similaire aux reducers de Redux, c'est-à-dire que la fonction reçoit comme paramètres l'état actuel et l'action qui change l'état. La fonction retourne le nouvel état mis à jour en fonction du type et du contenu possible de l'action: + +```js +const counterReducer = (state, action) => { + switch (action.type) { + case "INC": + return state + 1 + case "DEC": + return state - 1 + case "ZERO": + return 0 + default: + return state + } +} +``` + +Dans notre exemple, les actions n'ont qu'un type. Si le type de l'action est INC, il augmente la valeur du compteur de un, etc. Comme les reducers de Redux, les actions peuvent également contenir des données arbitraires, qui sont généralement placées dans le champ payload de l'action. + +La fonction useReducer retourne un tableau qui contient un élément pour accéder à la valeur actuelle de l'état (premier élément du tableau) et une fonction dispatch (deuxième élément du tableau) pour changer l'état: + +```js +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) // highlight-line + + return ( +
    +
    {counter}
    // highlight-line +
    + // highlight-line + + +
    +
    + ) +} +``` + +Comme on peut le voir, le changement d'état est effectué exactement comme dans Redux: la fonction de dispatch reçoit en paramètre l'action appropriée qui change l'état: + +```js +counterDispatch({ type: "INC" }) +``` + +Le code actuel de l'application se trouve dans le dépôt [https://github.com/fullstack-hy2020/hook-counter](https://github.com/fullstack-hy2020/hook-counter/tree/part6-1) dans la branche part6-1. + +Utiliser le contexte pour passer l'état aux composants +Si nous voulons diviser l'application en plusieurs composants, la valeur du compteur et la fonction de dispatch utilisée pour le gérer doivent également être passées aux autres composants. Une solution serait de les passer comme props de la manière habituelle: + +```js +const Display = ({ counter }) => { + return
    {counter}
    +} + +const Button = ({ dispatch, type, label }) => { + return ( + + ) +} + +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) + + return ( +
    + // highlight-line +
    + // highlight-start +
    +
    + ) +} +``` + +La solution fonctionne, mais n'est pas optimale. Si la structure des composants se complique, par exemple, si le dispatcheur doit être transmis à l'aide de props à travers de nombreux composants vers les composants qui en ont besoin, même si les composants intermédiaires dans l'arbre des composants n'ont pas besoin du dispatcheur. Ce phénomène est appelé prop drilling. + +[L'API Contexte](https://react.dev/learn/passing-data-deeply-with-context) intégrée à React fournit une solution pour nous. Le contexte de React est une sorte d'état global de l'application, auquel il est possible de donner un accès direct à n'importe quel composant de l'application. + +Créons maintenant un contexte dans l'application qui stocke la gestion de l'état du compteur. + +Le contexte est créé avec le hook [createContext](https://react.dev/reference/react/createContext) de React. Créons un contexte dans le fichier CounterContext.jsx: + +```js +import { createContext } from 'react' + +const CounterContext = createContext() + +export default CounterContext +``` + +Le composant App peut maintenant fournir un contexte à ses composants enfants comme suit: + +```js +import CounterContext from './CounterContext' // highlight-line + +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) + + return ( + // highlight-line + +
    +
    +
    // highlight-line + ) +} +``` + +Comme on peut le voir, fournir le contexte est réalisé en enveloppant les composants enfants à l'intérieur du composant CounterContext.Provider et en définissant une valeur appropriée pour le contexte. + +La valeur du contexte est maintenant définie comme étant un tableau contenant la valeur du compteur, et la fonction dispatch. + +Les autres composants accèdent maintenant au contexte en utilisant le hook [useContext](https://react.dev/reference/react/useContext): + +```js +import { useContext } from 'react' // highlight-line +import CounterContext from './CounterContext' + +const Display = () => { + const [counter] = useContext(CounterContext) // highlight-line + return
    + {counter} +
    +} + +const Button = ({ type, label }) => { + const [counter, dispatch] = useContext(CounterContext) // highlight-line + return ( + + ) +} +``` + +Le code actuel de l'application se trouve sur [GitHub](https://github.com/fullstack-hy2020/hook-counter/tree/part6-2) dans la branche part6-2. + +### Définir le contexte du compteur dans un fichier séparé + +Notre application a une caractéristique ennuyeuse, à savoir que la fonctionnalité de gestion de l'état du compteur est en partie définie dans le composant App. Déplaçons maintenant tout ce qui est lié au compteur vers CounterContext.jsx: + +```js +import { createContext, useReducer } from 'react' + +const counterReducer = (state, action) => { + switch (action.type) { + case "INC": + return state + 1 + case "DEC": + return state - 1 + case "ZERO": + return 0 + default: + return state + } +} + +const CounterContext = createContext() + +export const CounterContextProvider = (props) => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) + + return ( + + {props.children} + + ) +} + +export default CounterContext +``` + +Le fichier exporte désormais, en plus de l'objet CounterContext correspondant au contexte, le composant CounterContextProvider, qui est pratiquement un fournisseur de contexte dont la valeur est un compteur et un dispatcher utilisé pour sa gestion d'état. + +Activons le fournisseur de contexte en effectuant un changement dans main.jsx: + +```js +import ReactDOM from 'react-dom/client' +import App from './App' +import { CounterContextProvider } from './CounterContext' // highlight-line + +ReactDOM.createRoot(document.getElementById('root')).render( + // highlight-line + + // highlight-line +) +``` + +Maintenant, le contexte définissant la valeur et la fonctionnalité du compteur est disponible pour tous les composants de l'application. + +Le composant App est simplifié sous la forme suivante: + +```js +import Display from './components/Display' +import Button from './components/Button' + +const App = () => { + return ( +
    + +
    +
    +
    + ) +} + +export default App +``` + +Le contexte est toujours utilisé de la même manière, par exemple, le composant Button est défini comme suit: + +```js +import { useContext } from 'react' +import CounterContext from '../CounterContext' + +const Button = ({ type, label }) => { + const [counter, dispatch] = useContext(CounterContext) + return ( + + ) +} + +export default Button +``` + +Le composant Button a seulement besoin de la fonction dispatch du compteur, mais il obtient aussi la valeur du compteur à partir du contexte en utilisant la fonction useContext: + +```js + const [counter, dispatch] = useContext(CounterContext) +``` + +Ce n'est pas un gros problème, mais il est possible de rendre le code un peu plus agréable et expressif en définissant quelques fonctions d'aide dans le fichier CounterContext: + +```js +import { createContext, useReducer, useContext } from 'react' // highlight-line + +const CounterContext = createContext() + +// ... + +export const useCounterValue = () => { + const counterAndDispatch = useContext(CounterContext) + return counterAndDispatch[0] +} + +export const useCounterDispatch = () => { + const counterAndDispatch = useContext(CounterContext) + return counterAndDispatch[1] +} + +// ... +``` + +Avec l'aide de ces fonctions d'assistance, il est possible pour les composants qui utilisent le contexte de s'emparer de la partie du contexte dont ils ont besoin. Le composant Display change comme suit: + +```js +import { useCounterValue } from '../CounterContext' // highlight-line + +const Display = () => { + const counter = useCounterValue() // highlight-line + return
    + {counter} +
    +} + + +export default Display +``` + +Le composant Button devient: + +```js +import { useCounterDispatch } from '../CounterContext' // highlight-line + +const Button = ({ type, label }) => { + const dispatch = useCounterDispatch() // highlight-line + return ( + + ) +} + +export default Button +``` + +La solution est assez élégante. L'ensemble de l'état de l'application, c'est-à-dire la valeur du compteur et le code pour sa gestion, est maintenant isolé dans le fichier CounterContext, qui fournit aux composants des fonctions auxiliaires bien nommées et faciles à utiliser pour la gestion de l'état. + +Le code final de l'application se trouve sur [GitHub](https://github.com/fullstack-hy2020/hook-counter/tree/part6-3) dans la branche part6-3. + +Comme détail technique, il convient de noter que les fonctions auxiliaires useCounterValue et useCounterDispatch sont définies comme des [hooks personnalisés](https://react.dev/learn/reusing-logic-with-custom-hooks), car l'appel de la fonction hook useContext est [possible](https://legacy.reactjs.org/docs/hooks-rules.html) uniquement à partir de composants React ou de hooks personnalisés. Les hooks personnalisés sont des fonctions JavaScript dont le nom doit commencer par la chaîne use. Nous reviendrons sur les hooks personnalisés un peu plus en détail dans la [partie 7](/en/part7/custom_hooks) du cours. + +
    + +
    + +### Exercices 6.23-6.24 + +#### Exercice 6.23 + +L'application possède un composant Notification pour afficher des notifications à l'utilisateur. + +Implémentez la gestion de l'état des notifications de l'application en utilisant le hook useReducer et le contexte. La notification doit informer l'utilisateur lorsqu'une nouvelle anecdote est créée ou lorsqu'une anecdote est votée: + +![navigateur affichant la notification pour une anecdote ajoutée](../../images/6/66new.png) + +La notification est affichée pendant cinq secondes. + +#### Exercice 6.24 + +Comme indiqué dans l'exercice 6.21, le serveur exige que le contenu de l'anecdote à ajouter soit d'au moins 5 caractères. Implémentez maintenant la gestion des erreurs pour l'insertion. En pratique, il suffit d'afficher une notification à l'utilisateur en cas de requête POST échouée: + +![navigateur affichant une notification d'erreur pour avoir tenté d'ajouter une anecdote trop courte](../../images/6/67new.png) + +La condition d'erreur doit être gérée dans la fonction de rappel enregistrée à cet effet, voir [ici](https://tanstack.com/query/latest/docs/react/reference/useMutation) comment enregistrer une fonction. + +C'était le dernier exercice pour cette partie du cours et il est temps de pousser votre code sur GitHub et de marquer tous vos exercices complétés dans le [système de soumission des exercices](https://studies.cs.helsinki.fi/stats/courses/fullstackopen). + +
    + +
    + +### Quelle solution de gestion d'état choisir? + +Dans les chapitres 1 à 5, toute la gestion de l'état de l'application était réalisée à l'aide du hook useState de React. Les appels asynchrones au backend nécessitaient l'utilisation du hook useEffect dans certaines situations. En principe, rien d'autre n'est nécessaire. + +Un problème subtil avec une solution basée sur un état créé avec le hook useState est que si une partie de l'état de l'application est nécessaire à plusieurs composants de l'application, l'état et les fonctions pour le manipuler doivent être passés via les props à tous les composants qui gèrent cet état. Parfois, les props doivent être passés à travers plusieurs composants, et les composants intermédiaires peuvent même ne pas être intéressés par l'état de quelque manière que ce soit. Ce phénomène quelque peu désagréable est appelé prop drilling (forage de props). + +Au fil des ans, plusieurs solutions alternatives ont été développées pour la gestion de l'état des applications React, qui peuvent être utilisées pour faciliter les situations problématiques (par exemple, le prop drilling). Cependant, aucune solution n'a été "finale", toutes ont leurs propres avantages et inconvénients, et de nouvelles solutions sont développées tout le temps. + +La situation peut confondre un débutant et même un développeur web expérimenté. Quelle solution faut-il utiliser? + +Pour une application simple, useState est certainement un bon point de départ. Si l'application communique avec le serveur, la communication peut être gérée de la même manière que dans les chapitres 1 à 5, en utilisant l'état de l'application elle-même. Récemment, cependant, il est devenu plus courant de déplacer la communication et la gestion de l'état associée au moins partiellement sous le contrôle de React Query (ou d'une autre bibliothèque similaire). Si le useState et le prop drilling qu'il entraîne vous préoccupent, utiliser le contexte peut être une bonne option. Il existe également des situations où il peut être judicieux de gérer une partie de l'état avec useState et une partie avec des contextes. + +La solution de gestion d'état la plus complète et robuste est Redux, qui est une manière de mettre en oeuvre l'architecture dite [Flux](https://facebookarchive.github.io/flux/). Redux est un peu plus ancien que les solutions présentées dans cette section. La rigidité de Redux a été la motivation pour de nombreuses nouvelles solutions de gestion d'état, telles que le useReducer de React. Certaines des critiques sur la rigidité de Redux sont déjà devenues obsolètes grâce au [Redux Toolkit](https://redux-toolkit.js.org/). + +Au fil des ans, d'autres bibliothèques de gestion d'état similaires à Redux ont également été développées, telles que le nouvel entrant [Recoil](https://recoiljs.org/) et le légèrement plus ancien [MobX](https://mobx.js.org/). Cependant, selon [Npm trends](https://npmtrends.com/mobx-vs-recoil-vs-redux), Redux domine clairement toujours, et semble en fait augmenter son avance: + +![graphique montrant la croissance de la popularité de redux au cours des 5 dernières années](../../images/6/64new.png) + +De plus, Redux n'a pas à être utilisé dans son intégralité dans une application. Il peut être judicieux, par exemple, de gérer l'état du formulaire en dehors de Redux, surtout dans les situations où l'état d'un formulaire n'affecte pas le reste de l'application. Il est également parfaitement possible d'utiliser Redux et React Query ensemble dans la même application. + +La question de savoir quelle solution de gestion d'état devrait être utilisée n'est pas du tout simple. Il est impossible de donner une réponse correcte unique. Il est également probable que la solution de gestion d'état sélectionnée puisse s'avérer sous-optimale à mesure que l'application grandit à tel point que la solution doit être changée même si l'application a déjà été mise en usage de production. + +
    \ No newline at end of file diff --git a/src/content/6/zh/part6.md b/src/content/6/zh/part6.md index ca7bbfdbf45..ab547b341cd 100644 --- a/src/content/6/zh/part6.md +++ b/src/content/6/zh/part6.md @@ -6,10 +6,7 @@ lang: zh
    - - - -到目前为止,我们通过将应用的状态和状态改变逻辑直接写在了 React 组件中。当应用变得庞大时,状态管理应当与 React 组件进行解耦。在这一章节,我们会引入 Redux 库,也是目前 React 应用生态中最流行的状态管理解决方案。 + + 到目前为止,我们已经将应用的状态和状态逻辑直接放在React组件内。当应用规模扩大时,状态管理应该被移到React组件之外。在这一部分,我们将介绍Redux库,它是目前最流行的管理React应用状态的解决方案。
    - diff --git a/src/content/6/zh/part6a.md b/src/content/6/zh/part6a.md index 39186a2bc3d..17996e95ba5 100644 --- a/src/content/6/zh/part6a.md +++ b/src/content/6/zh/part6a.md @@ -7,60 +7,58 @@ lang: zh
    - - -到目前为止,我们已经遵循了 React 推荐的状态管理约定。 我们已经将状态和处理它的方法放置到应用程序的[根组件](https://reactjs.org/docs/lifting-state-up.html) 中。 然后,状态及其处理程序方法通过属性传递给其他组件。 这在一定程度上是可行的,但是当应用程序变得更大时,状态管理就变得极具挑战性。 + + 到目前为止,我们一直遵循React推荐的状态管理约定。我们把状态和处理状态的方法放到了应用的[根组件](https://reactjs.org/docs/lifting-state-up.html)。然后,状态和它的处理方法被用prop传递给其他组件。这在一定程度上是可行的,但当应用越来越大时,状态管理就变得很有挑战性。 ### Flux-architecture -【Flux-架构】 - - - -Facebook 开发了 [Flux](https://facebook.github.io/flux/docs/in-depth-overview/) 架构,使状态管理更加容易。 在 Flux 中,状态完全从 React-components 分离到自己的存储中。 存储中的状态不会直接更改,而是使用不同的 actions进行更改。 + + Facebook开发了[Flux](https://facebookarchive.github.io/flux/docs/in-depth-overview//)-架构,使状态管理更容易。在Flux中,状态被完全从React组件中分离出来,进入它自己的存储。 + + 存储器中的状态不是直接改变的,而是通过不同的动作改变的。 - -当一个操作改变了存储的状态时,视图会被重新渲染: + +当一个动作改变了商店的状态时,视图会被重新渲染。 -![](https://facebook.github.io/flux/img/overview/flux-simple-f8-diagram-explained-1300w.png) +![](https://facebookarchive.github.io/flux/img/overview/flux-simple-f8-diagram-1300w.png) - - -如果应用程序上的某个 Action(例如按下按钮)导致状态更改,则会通过一个 action 进行更改。 这将导致再次重新渲染视图: + + 如果应用上的某些动作,例如按下一个按钮,导致需要改变状态,则用一个动作进行改变。 + +这将导致再次重新渲染视图。 -![](https://facebook.github.io/flux/img/overview/flux-simple-f8-diagram-with-client-action-1300w.png) +![](https://facebookarchive.github.io/flux/img/overview/flux-simple-f8-diagram-with-client-action-1300w.png) - -Flux 提供了一种标准的方式来保存应用程序的状态以及如何修改它。 + + Flux为应用的状态如何保存、在哪里保存以及如何修改提供了一个标准的方法。 ### Redux - -Facebook 有一个 Flux 的实现,但是我们会使用 Redux 库。 它使用相同的原理,但是更简单一些。 Facebook 现在也使用 Redux 而不是原来的 Flux。 + + Facebook有一个Flux的实现,但我们将使用[Redux](https://redux.js.org) - 库。它的工作原理是一样的,但要简单一些。Facebook现在也使用Redux,而不是他们原来的Flux。 - -我们将通过再次实现一个计数器应用程序来了解 Redux: + + 我们将再次通过实现一个计数器应用来了解Redux。 ![](../../images/6/1.png) - -创建一个新的 create-react-app 应用 使用以下命令安装 redux + + 创建一个新的create-react-app-application并安装redux,命令如下 ```bash -npm install redux --save +npm install redux ``` - -正如在 Flux 中一样,在 Redux 中,状态也存储在[store](https://redux.js.org/basics/store)中。 - - + + 和Flux一样,在Redux中,状态也被存储在一个[存储](https://redux.js.org/basics/store)中。 -应用程序的整个状态存储在 store 中的一个 javascript 对象中。 因为我们的应用程序只需要计数器的值,所以我们将它直接保存到存储中。 如果状态更复杂,那么状态中的不同内容将被保存为对象的不同字段 + + 应用的整个状态被存储在商店的一个JavaScript-object中。因为我们的应用只需要计数器的值,所以我们将直接把它保存到存储区。如果状态更复杂,状态中的不同事物将被保存为对象的独立字段。 - - - -存储的状态通过 [actions](https://redux.js.org/basics/actions)改变。 Action 是对象,它至少有一个字段确定操作的类型。 例如,我们的应用程序需要以下操作: + + 存储器的状态是通过[动作](https://redux.js.org/basics/actions)改变的。行动是对象,它至少有一个字段决定行动的类型。 + + 例如,我们的应用需要以下动作。 ```js { @@ -68,15 +66,14 @@ npm install redux --save } ``` - - -如果操作涉及数据,则可以根据需要声明其他字段。 然而,我们的计数应用程序很简单,只需要类型字段就可以了。 + + 如果行动中涉及到数据,可以根据需要声明其他字段。 然而,我们的计数应用非常简单,动作只需要类型字段就可以了。 - -Action 对应用程序状态的影响是通过使用一个 [reducer](https://redux.js.org/basics/reducers) 来定义的。 实际上,reducer 是一个函数,它以当前状态和 action 为参数。 它返回一个新的状态。 + + 动作对应用状态的影响是用一个[reducer](https://redux.js.org/basics/reducers)来定义的。在实践中,还原器是一个函数,它被赋予当前状态和一个动作作为参数。它返回一个新的状态。 - -现在让我们为我们的应用程序定义一个 reducer: + + 现在让我们为我们的应用定义一个还原器。 ```js const counterReducer = (state, action) => { @@ -92,14 +89,14 @@ const counterReducer = (state, action) => { } ``` - -第一个参数是 store 中的 state。 Reducer 返回一个基于 action 类型的新状态。 + + 第一个参数是商店里的状态。还原器根据动作类型返回一个新状态。 - -让我们稍微修改一下代码。 在 reducer 中通常使用 [switch](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/switch)命令而不是 ifs。 + + 让我们改变一下代码。习惯上,在还原器中使用[switch](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/switch) -命令而不是ifs。 - -我们还可以为参数状态定义一个默认值[default value](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters) 0。 现在 reducer 可以工作了,即使store-state尚未被载入。 + + 我们也为参数state定义一个[默认值](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters)为0。现在,即使商店的状态还没有被引出,还原器也能工作。 ```js const counterReducer = (state = 0, action) => { @@ -111,13 +108,13 @@ const counterReducer = (state = 0, action) => { case 'ZERO': return 0 default: // if none of the above matches, code comes here - return state + return state } } ``` - -Reducer 不应该直接从应用程序中调用。 Reducer 只作为创建store,即 _createStore_ 的一个参数给出: + + Reducer不应该从应用代码中直接调用。还原器只是作为创建存储的_createStore_函数的一个参数。 ```js import { createStore } from 'redux' @@ -129,20 +126,19 @@ const counterReducer = (state = 0, action) => { const store = createStore(counterReducer) ``` - -store 现在使用 reducer 来处理actions,这些action通过 [dispatch](https://redux.js.org/api-reference/store#dispatch-action)-方法 被分派或“发送”到 store 中。 + + 存储器现在使用还原器来处理操作,这些操作被分派或"发送"到存储器的[dispatch](https://redux.js.org/api/store#dispatchaction)-方法。 ```js store.dispatch({type: 'INCREMENT'}) ``` - -您可以使用方法[getState](https://redux.js.org/api/store#getState)查找存储的状态。 - + + 你可以用[getState](https://redux.js.org/api/store#getstate)方法找出商店的状态。 - -例如下面的代码: + + 例如,下面的代码。 ```js const store = createStore(counterReducer) @@ -156,22 +152,23 @@ store.dispatch({type: 'DECREMENT'}) console.log(store.getState()) ``` - -会在控制台上打印以下内容 + +会在控制台中打印以下内容 -
    +```
     0
     3
     -1
    -
    - -因为一开始 store 的状态是 0。 在三个 INCREMENT-actions 之后,状态是 3。 最后,在 ZERO 和 DECREMENT 操作之后,状态是 -1。 +``` + + +因为一开始商店的状态是0,经过三个INCREMENT动作后,状态是3。 最后,经过ZERODECREMENT动作,状态是-1。 - -store拥有的第三个重要方法是[订阅](https://redux.js.org/api/store#subscribelistener) ,它用于在store状态改变时创建调用的回调函数。 + + 存储器的第三个重要方法是[subscribe](https://redux.js.org/api/store#subscribelistener),它被用来创建存储器在其状态改变时调用的回调函数。 - -例如,如果我们要添加以下函数来订阅,那么存储中的每次更改都将被打印到控制台。 + + 例如,如果我们将添加以下函数到subscribe,商店的每一个变化将被打印到控制台。 ```js store.subscribe(() => { @@ -180,8 +177,8 @@ store.subscribe(() => { }) ``` - -所以代码为 + +所以代码 ```js const store = createStore(counterReducer) @@ -198,22 +195,24 @@ store.dispatch({ type: 'ZERO' }) store.dispatch({ type: 'DECREMENT' }) ``` - -会导致以下内容被打印出来 + +将导致以下内容被打印出来 -
    +```
     1
     2
     3
     0
     -1
    -
    - -我们的计数器应用代码如下。 所有代码都是在同一个文件中编写的,因此 React-代码的 store 是直接可用的。 稍后我们将了解构造 redux 代码的更好方法。 +``` + + + 我们的计数器应用的代码如下。所有的代码都写在同一个文件中,所以store对React代码来说是直接可用的。我们以后会了解到更好的结构React/Redux代码的方法。 ```js import React from 'react' -import ReactDOM from 'react-dom' +import ReactDOM from 'react-dom/client' + import { createStore } from 'redux' const counterReducer = (state = 0, action) => { @@ -237,7 +236,7 @@ const App = () => {
    {store.getState()}
    - -
    ### Uncontrolled form -【非受控表单】 - -让我们添加新增 Note 和改变其重要性的功能: + + 让我们添加添加新笔记和改变其重要性的功能。 ```js const generateId = () => @@ -779,17 +781,16 @@ const App = () => { return (
    - +
      {store.getState().map(note =>
    • toggleImportance(note.id)} > - {note.content} - {note.important ? 'important' : ''} + {note.content} {note.important ? 'important' : ''}
    • )}
    @@ -798,17 +799,18 @@ const App = () => { } ``` - -这两个功能的实现都很简单。 值得注意的是,我们没有像前面那样将表单字段的状态绑定到 App 组件的状态。 React 称这种形式为不受控的[uncontrolled](https://reactjs.org/docs/uncontrolled-components.html)。 + + 这两个功能的实现都很简单。值得注意的是,我们没有像之前那样将表单字段的状态与App组件的状态绑定。React称这种表单为[uncontrolled](https://reactjs.org/docs/uncontrolled-components.html)。 + + + >不受控制的表单有一定的限制(例如,动态错误信息或根据输入禁用提交按钮是不可能的)。但是它们适合我们目前的需要。 -> Uncontrolled forms have certain limitations (for example, dynamic error messages or disabling the submit button based on input are not possible). However they are suitable for our current needs.
    -> 非受控的表单有某些限制(例如,不能发送动态错误消息或根据输入禁用提交按钮)。 然而,他们是适合我们目前需求的。 + + 你可以阅读更多关于不受控制的表单[这里](https://goshakkk.name/controlled-vs-uncontrolled-inputs-react/)。 - -你可以在[这里](https://goshakkk.name/controlled-vs-uncontrolled-inputs-react/)阅读更多关于非受控表单的内容。 - -添加新 Note 的方法很简单,它只是分派添加便笺的 action: + + 处理添加新笔记的方法很简单,它只是分派添加笔记的动作。 ```js addNote = (event) => { @@ -826,8 +828,8 @@ addNote = (event) => { } ``` - -我们可以直接从表单栏获取新 Note 的内容。 因为字段有name,我们可以通过事件对象event.target.note.value访问内容。 + + 我们可以直接从表单字段中获取新注释的内容。因为这个字段有一个名字,我们可以通过事件对象event.target.note.value来访问其内容。 ```js
    @@ -836,8 +838,9 @@ addNote = (event) => {
    ``` - -可以通过点击它的名字来改变 Note 的重要性。事件处理程序非常简单: + + + 一个笔记的重要性可以通过点击它的名字来改变。该事件处理程序非常简单。 ```js toggleImportance = (id) => { @@ -847,16 +850,15 @@ toggleImportance = (id) => { }) } ``` - ### Action creators -【Action 创造器】 - -我们开始注意到,即使在像我们这样简单的应用程序中,使用 Redux 也可以简化前端代码。 然而,我们可以做得更好。 + + 我们开始注意到,即使在像我们这样简单的应用中,使用Redux可以简化前端的代码。然而,我们可以做得更好。 - - -实际上,Redux 组件并不需要知道 Redux 操作的类型和形式。 让我们将创建行为分离到它们自己的功能中: + + 实际上,React组件没有必要知道Redux的动作类型和形式。 + + 让我们把创建动作分离到他们自己的函数中。 ```js const createNote = (content) => { @@ -878,11 +880,11 @@ const toggleImportanceOf = (id) => { } ``` - -创建action的函数称为action创建器[action creators](https://redux.js.org/advanced/async-actions#synchronous-action-creators)。 + + 创建动作的函数被称为[动作创建者](https://redux.js.org/advanced/async-actions#synchronous-action-creators)。 - -App 组件不再需要知道任何关于 action 的内部表示,它只需要调用 creator-函数就可以获得正确的操作: + + App组件不必再知道任何关于动作的内部表示,它只是通过调用创建者函数来获得正确的动作。 ```js const App = () => { @@ -891,9 +893,9 @@ const App = () => { const content = event.target.note.value event.target.note.value = '' store.dispatch(createNote(content)) // highlight-line - + } - + const toggleImportance = (id) => { store.dispatch(toggleImportanceOf(id))// highlight-line } @@ -902,44 +904,36 @@ const App = () => { } ``` - ### Forwarding Redux-Store to various components -【Redux-Store 到多种组件】 - - -除了reducer,我们的应用是在一个文件。 这当然是不明智的,我们应该将App 分离到它自己的模块中。 - - -现在的问题是,移动后App 如何访问store? 更广泛地说,当一个组件由许多较小的组件组成时,必须有一种方法让所有组件访问store。 + + 除了还原器之外,我们的应用是在一个文件中。这当然是不理智的,我们应该把App分成自己的模块。 + + 现在的问题是,移动之后,App如何访问存储空间?而且更广泛地说,当一个组件由许多小的组件组成时,必须有一种方法让所有的组件都能访问存储空间。 + +有多种方法可以与组件共享redux-store。首先我们将研究最新的,也可能是最简单的方法,使用[react-redux](https://react-redux.js.org/)库的[hooks](https://react-redux.js.org/api/hooks)-api。 - -有多种方法可以与组件共享 redux-store。 首先,我们将研究使用最新的,也是最简单的方法,即 [react-redux](https://react-redux.js.org/) 的[hooks](https://react-redux.js.org/api/hooks)-api 。 + + 首先我们安装 react-redux - - - - -首先我们安装 react-redux - -```js -npm install --save react-redux +```bash +npm install react-redux ``` + + 接下来我们把_App_组件移到它自己的文件_App.js_中。让我们看看这对其他的应用文件有什么影响。 + + _index.js_变成。 - -接下来,我们将 App 组件移动到它自己的文件 App.js 中。 让我们看看这将如何影响其余的应用文件。 - +```js +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' - -对 App 组件的更改很小。 这个 store 现在可以通过props.store 的属性进入: -```js -import React from 'react' -import ReactDOM from 'react-dom' import { createStore } from 'redux' import { Provider } from 'react-redux' // highlight-line import App from './App' @@ -947,7 +941,7 @@ import noteReducer from './reducers/noteReducer' const store = createStore(noteReducer) -ReactDOM.render( +ReactDOM.createRoot(document.getElementById('root')).render( // highlight-line , // highlight-line @@ -955,15 +949,15 @@ ReactDOM.render( ) ``` + + 注意,应用现在被定义为由react redux库提供的[Provider](https://react-redux.js.org/api/provider)-组件的一个子组件。 + + 应用的存储被赋予给Provider,作为其属性。 + + store。 - - -请注意,应用现在被定义为由 redux 库提供的[Provider](https://react-redux.js.org/api/provider)的子组件。 - -应用的存储作为store属性提供给Provider - - -action创建器的定义已经移到了 reducer 文件中 + + 定义动作创建者已被移到文件reducers/noteReducer.js,其中定义了还原器。文件如下所示: ```js const noteReducer = (state = [], action) => { @@ -994,52 +988,46 @@ export const toggleImportanceOf = (id) => { // highlight-line export default noteReducer ``` - -如果应用程序有许多需要存储的组件,那么App-组件必须将store作为所有这些组件的属性。 + + 如果应用有许多需要存储的组件,App-组件必须将store作为prop传递给所有这些组件。 - -该模块现在有多个 [导出](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export)命令。 + + 该模块现在有多个[export](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export)命令。 - -函数仍然使用 export default 命令返回,因此可以使用通常的方式导入 reducer: + + 减速器函数仍然用export default命令返回,所以减速器可以用通常的方式被导入。 ```js import noteReducer from './reducers/noteReducer' ``` - -一个模块只能有一个默认导出 one default export,但是有多个“正常”导出 + + 一个模块只能有一个默认导出,但有多个 "正常 "导出 ```js export const createNote = (content) => { // ... } -export const toggleImportanceOf = (id) => { +export const toggleImportanceOf = (id) => { // ... } ``` - -导出的函数通常可以使用大括号语法导入: + + 通常(不是作为默认)导出的函数可以用大括号语法导入。 ```js import { createNote } from './../reducers/noteReducer' ``` - - - - App 组件的代码 + + App组件的代码组件的代码 ```js -import React from 'react' -import { - createNote, toggleImportanceOf -} from './reducers/noteReducer' +import { createNote, toggleImportanceOf } from './reducers/noteReducer' // highlight-line import { useSelector, useDispatch } from 'react-redux' // highlight-line - const App = () => { const dispatch = useDispatch() // highlight-line const notes = useSelector(state => state) // highlight-line @@ -1058,13 +1046,13 @@ const App = () => { return (
    - +
      {notes.map(note => // highlight-line
    • toggleImportance(note.id)} > {note.content} {note.important ? 'important' : ''} @@ -1078,10 +1066,8 @@ const App = () => { export default App ``` - - - -在代码中有一些事情需要注意。 在此之前,代码通过调用 redux-store 的 dispatch 方法来分派操作: + + 在代码中,有几件事需要注意。以前,代码通过调用redux-store的dispatch方法来分配动作。 ```js store.dispatch({ @@ -1090,10 +1076,8 @@ store.dispatch({ }) ``` - - - -现在它使用[useDispatch](https://react-redux.js.org/api/hooks#useDispatch)-hook 中的dispatch-函数来完成。 + + 现在它通过[useDispatch](https://react-redux.js.org/api/hooks#usedispatch) -hook的dispatch-函数来做。 ```js import { useSelector, useDispatch } from 'react-redux' // highlight-line @@ -1110,16 +1094,13 @@ const App = () => { } ``` + + useDispatch-hook为任何React组件提供了对index.js中定义的redux-store的dispatch-function的访问。 + + 这允许所有组件对redux-store的状态进行更改。 - - useDispatch-hook 提供了所有 React 组件对dispatch-函数的访问,这个 redux-store 的 dispatch-函数是在index.js 中定义的 。 - -这就允许所有组件对 redux-store 的状态进行更改。 - - - - -该组件可以通过 react-redux 库的[useSelector](https://react-redux.js.org/api/hooks#useselector)-hook访问存储在store中的便笺。 + + 组件可以通过react-redux库的[useSelector](https://react-redux.js.org/api/hooks#useselector)-hook访问存储在商店中的笔记。 ```js @@ -1132,22 +1113,17 @@ const App = () => { } ``` - - - -useSelector 接收一个函数作为参数,该函数可以搜索或选择来自 redux-store 的数据。 - -这里我们需要所有的便笺,所以我们的 selector 函数返回整个状态: +useSelector receives a function as a parameter. The function either searches for or selects data from the redux-store. + + 这里我们需要所有的笔记,所以我们的选择器函数返回整个状态。 ```js state => state ``` - - - -也就是如下的简写 + +这是对以下内容的简写 ```js (state) => { @@ -1155,22 +1131,21 @@ state => state } ``` - - - -通常选择器函数比较有趣,只返回 redux-store 内容的选定部分。 - -例如,我们可以只返回标记为重要的便笺: + + 通常选择器函数会更有趣一些,它只返回redux-store内容中的选定部分。 + + 例如,我们可以只返回标记为重要的笔记。 ```js -const importantNotes = useSelector(state => state.filter(note => note.important)) +const importantNotes = useSelector(state => state.filter(note => note.important)) ``` - -让我们将新建 Note 分离到它自己的组件中。 +### More components + + + 让我们把创建一个新的笔记分离成自己的组件。 ```js -import React from 'react' import { useDispatch } from 'react-redux' // highlight-line import { createNote } from '../reducers/noteReducer' // highlight-line @@ -1195,22 +1170,21 @@ const NewNote = (props) => { export default NewNote ``` - -与我们在没有 Redux 的情况下所做的 React 代码不同,用于改变应用程序状态的事件处理程序(现在存在于 Redux 中)已经从 App移到了子组件。 在 Redux 中更改状态的逻辑仍然与应用程序的整个 React 部分完全分离。 + + 与我们不使用Redux的React代码不同,改变应用状态的事件处理程序(现在住在Redux中)已经从App移到了一个子组件。Redux中改变状态的逻辑仍然与整个应用的React部分整齐地分开。 - -我们还将分离便笺列表,并将一个便笺显示到它们自己的组件中(这两个组件都将放在Notes.js 文件中) : + + 我们还将把笔记列表和显示单个笔记分离成各自的组件(这两个组件都将被放在Notes.js文件中)。 ```js -import React from 'react' import { useDispatch, useSelector } from 'react-redux' // highlight-line import { toggleImportanceOf } from '../reducers/noteReducer' // highlight-line const Note = ({ note, handleClick }) => { return(
    • - {note.content} - {note.important ? 'important' : ''} + {note.content} + {note.important ? 'important' : ''}
    • ) } @@ -1225,7 +1199,7 @@ const Notes = () => { + handleClick={() => dispatch(toggleImportanceOf(note.id)) } /> @@ -1237,11 +1211,11 @@ const Notes = () => { export default Notes ``` - -改变 Note 重要性的逻辑现在在管理 Note 列表的组件中。 + + 改变一个笔记的重要性的逻辑现在在管理笔记列表的组件中。 - -App 中没有多少代码了: + +在App中已经没有多少代码了。 ```js const App = () => { @@ -1249,90 +1223,89 @@ const App = () => { return (
      - +
      ) } ``` - - -Note,负责渲染单个note非常简单,并且不知道它获得的事件处理作为属性分派到 action。 在 React 术语中,这种类型的组件被称为[展示层presentational](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) 。 +Note, responsible for rendering a single note, is very simple, and is not aware that the event handler it gets as props dispatches an action. These kind of components are called [presentational](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) in React terminology. - -Note,从另一方面来说, 是一个[容器container](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) 组件,因为它包含一些应用逻辑: 它定义 Note 组件的事件处理程序做什么,并协调表示 presentational组件的配置,即Notes。【TODO】 +Notes, on the other hand, is a [container](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) component, as it contains some application logic: it defines what the event handlers of the Note components do and coordinates the configuration of presentational components, that is, the Notes. - -我们将在本章节后面回顾表现层/容器部分。 + + 我们将在本章节的后面回到渲染/容器的划分。 - -Redux 应用的代码可以在[Github](https://Github.com/fullstack-hy2020/Redux-notes/tree/part6-1) ,branchpart6-1 上找到。 + + Redux应用的代码可以在[Github](https://github.com/fullstack-hy2020/redux-notes/tree/part6-1)找到,分支part6-1
    - ### Exercises 6.3.-6.8. - -让我们从第1章节创建一个新版本的八卦投票应用。 把这个项目从这个资源库中 https://github.com/fullstack-hy2020/redux-anecdotes 拉取,你的解决方案基于这个库。 - -如果您将该项目克隆到现有的 git-repository 中,记得删除应用的git 配置 + + 让我们为第一章节中的名言警句投票应用制作一个新版本。从这个仓库中获取项目 https://github.com/fullstack-hy2020/redux-anecdotes ,作为你的解决方案的基础。 + + + 如果你把项目克隆到一个现有的git-repository中,删除克隆的应用的git-configuration:。 ```bash cd redux-anecdotes // go to the cloned repository rm -rf .git ``` - -应用可以像平常一样启动,但是你必须先安装依赖项: + + 应用可以像往常一样启动,但你必须先安装依赖性。 ```bash npm install -npm start +npm run dev ``` - -完成这些练习后,您的应用应该是这样的: + + 完成这些练习后,你的应用应该是这样的。 ![](../../images/6/3.png) +#### 6.3: anecdotes, step1 -#### 6.3: anecdotes, 步骤1 - -实现投票八卦的功能。投票数量必须保存到 redux 存储中。 + + 实现投票名言警句的功能。投票的数量必须被保存到Redux-store中。 -#### 6.4: anecdotes, 步骤2 +#### 6.4: anecdotes, step2 - -实现添加八卦的功能。 + + 实现添加新名言警句的功能。 - -您可以保持表单不受控制,就像我们 [之前](/zh/part6/flux架构与_redux#uncontrolled-form)所做的。 + + 你可以保持表单不受控制,就像我们[之前]做的那样(/en/part6/flux_architecture_and_redux#uncontrolled-form)。 -#### 6.5*: anecdotes, 步骤3 - -确保这些八卦是按票数排序的。 +#### 6.5: anecdotes, step3 -#### 6.6: anecdotes, 步骤4 - -如果你还没有这样做,将action对象的创建分离到[action创建器](https://redux.js.org/basics/actions#action-creators)-函数中,并将它们放在 src/reducers/anecdoteReducer.js 文件,就像我们在[action创建器](https://redux.js.org/basics/actions#action-creators)中所做的那样。 + + 确保名言警句是按投票数排序的。 -#### 6.7: anecdotes, 步骤5 - -将新八卦的创建分离到它自己的名为 AnecdoteForm的组件中。 将创建一个新八卦的所有逻辑移动到这个新组件中。 +#### 6.6: anecdotes, step4 -#### 6.8: anecdotes, 步骤6 - -将这个八卦列表的渲染分离到它自己的AnecdoteList中。 将所有与投票选举八卦相关的逻辑移动到这个新组件中。 + + 如果你还没有这样做,把行动对象的创建分离到[行动创造者](https://read.reduxbook.com/markdown/part1/04-action-creators.html)函数中,并把它们放在src/reducers/anecdoteReducer.js文件中,这样做就像我们从[行动创造者](/en/part6/flux_architecture_and_redux#action-creators)这一章中一直做的。 +#### 6.7: anecdotes, step5 - -现在App 组件应该是这样的: + + 将创建新的名言警句分离到自己的组件中,称为AnecdoteForm。将所有创建新名言警句的逻辑移到这个新组件中。 + +#### 6.8: anecdotes, step6 + + + 将名言警句列表的渲染分离到自己的组件中,称为AnecdoteList。将所有与投票给名言警句有关的逻辑移到这个新组件中。 + + + 现在,App组件应该如下所示: ```js -import React from 'react' import AnecdoteForm from './components/AnecdoteForm' import AnecdoteList from './components/AnecdoteList' @@ -1341,7 +1314,7 @@ const App = () => {

    Anecdotes

    - +
    ) } @@ -1349,4 +1322,3 @@ const App = () => { export default App ```
    - diff --git a/src/content/6/zh/part6b.md b/src/content/6/zh/part6b.md index aa1e9abf4d6..3173cd48e6a 100644 --- a/src/content/6/zh/part6b.md +++ b/src/content/6/zh/part6b.md @@ -7,13 +7,11 @@ lang: zh
    + + 让我们用简化的[redux版本](/en/part6/flux_architecture_and_redux#redux-notes)来继续我们的笔记应用的工作。 - - -让我们继续使用简化[redux 版本](/zh/part6/flux架构与_redux#redux-notes)的notes应用进行工作。 - - -为了简化我们的开发,让我们改变我们的 reducer,这样store被初始化为一个包含两个便笺的状态: + + 为了简化我们的开发,让我们改变我们的还原器,使存储空间被初始化为一个包含几个笔记的状态。 ```js const initialState = [ @@ -38,20 +36,17 @@ export default noteReducer ``` -### Store with complex state -【复杂状态的储存】 - -让我们实现对显示给用户的便笺的过滤。 用户界面的过滤器将利用[单选按钮](https://developer.mozilla.org/en-us/docs/web/html/element/input/radio) 实现: - -![](../../images/6/01e.png) +### Store with complex state + + 让我们实现对显示给用户的笔记的过滤。过滤器的用户界面将用[单选按钮](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/radio)来实现。 +![](../../images/6/01e.png) - -让我们从一个非常简单直接的实现开始: + + 让我们从一个非常简单和直接的实现开始。 ```js -import React from 'react' import NewNote from './components/NewNote' import Notes from './components/Notes' @@ -81,14 +76,17 @@ const App = () => { } ``` - -由于所有单选按钮的name 属性都是相同的,所以它们形成了一个按钮组,其中只能选择一个选项。 - -这些按钮有一个更改处理程序,当前只将与单击按钮关联的字符串打印到控制台。 + + 由于所有的单选按钮的名称属性是相同的,它们形成一个按钮组,其中只有一个选项可以选择。 + + + + 这些按钮有一个变化处理程序,目前只将与被点击的按钮相关的字符串打印到控制台。 + - -我们决定通过将 filter 的值存储在 redux 存储中来实现这个过滤器功能。 store的状态在做了如下修改后应该是这样的: + +我们决定通过在redux存储中存储过滤器的值来实现过滤器的功能,除了笔记本身之外。在做了这些改变之后,商店的状态应该是这样的。 ```js { @@ -100,13 +98,15 @@ const App = () => { } ``` - -当前应用的实现,只有便笺数组存储在状态中。 在新实现中,state 对象有两个属性, notes 包含 notes 数组, filter 包含一个字符串,说明应该向用户显示哪些便笺。 -### Combined reducers -【复合reducer】 - -我们可以修改现有的reducer来适应新的状态。 不过,在这种情况下,一个更好的解决方案是为过滤器的状态定义一个新的单独的 reducer: + + 在我们应用的当前实现中,只有笔记数组被存储在状态中。在新的实现中,状态对象有两个属性,notes包含笔记数组,filter包含一个字符串,表示哪些笔记应该被显示给用户。 + +### Combined reducers + + + + 我们可以修改我们当前的还原器来处理状态的新形状。然而,在这种情况下,一个更好的解决方案是为过滤器的状态定义一个新的单独的还原器。 ```js const filterReducer = (state = 'ALL', action) => { @@ -119,8 +119,9 @@ const filterReducer = (state = 'ALL', action) => { } ``` - -改变过滤器状态的action如下: + + + 改变过滤器状态的动作如下所示: ```js { @@ -129,8 +130,9 @@ const filterReducer = (state = 'ALL', action) => { } ``` - -我们还要创建一个新的action创建函数。 我们将在一个新的src/reducers/filterReducer.js中为action创建器编写代码 模块: + + + 让我们也创建一个新的_action creator_函数。我们将在一个新的src/reducers/filterReducer.js模块中编写动作创建者的代码。 ```js const filterReducer = (state = 'ALL', action) => { @@ -147,17 +149,18 @@ export const filterChange = filter => { export default filterReducer ``` - -我们可以为我们的应用创建实际的reducer,通过结合现有的两个reducer和[combineReducers](https://redux.js.org/api/combineReducers)函数。 - -让我们在index.js 文件中定义组合的 reducer: + + 我们可以通过使用[combinedReducers](https://redux.js.org/api/combinereducers)函数结合两个现有的减速器,为我们的应用创建实际的减速器。 + + + 让我们在index.js文件中定义组合减速器。 ```js import React from 'react' -import ReactDOM from 'react-dom' +import ReactDOM from 'react-dom/client' import { createStore, combineReducers } from 'redux' // highlight-line -import { Provider } from 'react-redux' +import { Provider } from 'react-redux' import App from './App' import noteReducer from './reducers/noteReducer' @@ -174,33 +177,32 @@ const store = createStore(reducer) console.log(store.getState()) -ReactDOM.render( +ReactDOM.createRoot(document.getElementById('root')).render( /* , */ -
    , - document.getElementById('root') +
    ) ``` - -由于我们的应用在这一点上完全中断,因此我们渲染一个空的div 元素,而不是App 组件。 + + 由于我们的应用在此时完全中断,我们渲染一个空的div元素而不是App组件。 - -存储的状态被打印到控制台: + + 商店的状态被打印到控制台。 ![](../../images/6/4e.png) + + 从输出中我们可以看到,商店的形状正是我们想要的! - -正如我们可以看到的输出信息,store正是我们想要的! - -让我们仔细看看组合reducer是如何创建的: + + 让我们仔细看看组合减速器是如何创建的。 ```js const reducer = combineReducers({ @@ -209,11 +211,13 @@ const reducer = combineReducers({ }) ``` - -上面由 reducer 定义的存储状态是一个具有两个属性的对象:notesfilternotes 属性的值由noteReducer 定义,它不必处理状态的其他属性。 类似地,filter属性由filterReducer管理。 - -在对代码进行更多更改之前,让我们看看不同的action是如何更改由组合的 reducer 定义的存储状态的。 让我们在index.js 文件中添加如下内容: + + 上面的还原器所定义的商店的状态是一个有两个属性的对象。notesfilternotes属性的值由noteReducer定义,它不需要处理状态的其他属性。同样地,filter属性也由filterReducer管理。 + + + + 在我们对代码做更多修改之前,让我们看看不同的动作是如何改变由组合式还原器定义的存储的状态的。让我们在index.js文件中添加以下内容。 ```js import { createNote } from './reducers/noteReducer' @@ -224,15 +228,15 @@ store.dispatch(filterChange('IMPORTANT')) store.dispatch(createNote('combineReducers forms one reducer from many simple reducers')) ``` - -通过模拟创建一个便笺,并以这种方式更改过滤器的状态,在对存储进行每次更改后,存储的状态都会被记录到控制台: -![](../../images/6/5e.png) + + 通过模拟创建一个笔记,并以这种方式改变过滤器的状态,商店的状态会在每次对商店进行改变后被记录到控制台。 +![](../../images/6/5e.png) - -在这一点上,意识到一个微小但重要的细节是很好的。 如果我们在 reducers 的开头添加一个控制台 log 语句: + + 在这一点上,最好能意识到一个微小但重要的细节。如果我们在两个还原器的开头添加一个控制台日志语句,那么。 ```js const filterReducer = (state = 'ALL', action) => { @@ -241,24 +245,25 @@ const filterReducer = (state = 'ALL', action) => { } ``` - -基于控制台输出,你可能会得到这样的感觉: 每个action都被复制了: -![](../../images/6/6.png) + +根据控制台的输出,人们可能会得到这样的印象:每个动作都被重复了。 +![](../../images/6/6.png) - -我们的代码中有错误吗? 没有。 组合reducer的工作方式使得每个action 在组合reducer的每个 部分都得到处理。 通常只有一个reducer对任何给定的action感兴趣,但是在有些情况下,多个reducer根据相同的action改变它们各自的状态部分。 + +我们的代码中存在一个错误吗?不是的。组合式还原器的工作方式是每个动作都在组合式还原器的每个部分得到处理。通常情况下,只有一个还原器对任何给定的动作感兴趣,但也有这样的情况:多个还原器基于同一个动作改变各自的状态部分。 ### Finishing the filters -【完成过滤器】 - -让我们完成应用,使用组合reducer。 我们首先修改应用的渲染方式,并在index.js 文件中将存储区挂到应用: + + + + 让我们完成应用,使其使用组合式还原器。我们首先改变应用的渲染,并在index.js文件中把商店与应用挂起。 ```js -ReactDOM.render( +ReactDOM.createRoot(document.getElementById('root')).render( , @@ -266,14 +271,13 @@ ReactDOM.render( ) ``` - -接下来,让我们修复一个错误,这个错误是由代码期望应用存储为一个便笺数组而引起的: + + 接下来,让我们修复一个bug,这个bug是由代码期望应用商店是一个笔记数组所引起的。 ![](../../images/6/7ea.png) - - -解决起来很简单。 因为便笺在store的字段notes 中,所以我们只需要对选择器函数做一个小小的修改: + + 这是个简单的修正。因为笔记是在商店的字段notes中,我们只需对选择器函数做一点改变。 ```js const Notes = () => { @@ -286,7 +290,7 @@ const Notes = () => { + handleClick={() => dispatch(toggleImportanceOf(note.id)) } /> @@ -296,29 +300,25 @@ const Notes = () => { } ``` - - - -以前,selector 函数返回存储的整个状态: + + + 以前的选择器函数返回整个商店的状态。 ```js const notes = useSelector(state => state) ``` - - - -现在它只返回字段notes + + 而现在它只返回它的字段notes。 ```js const notes = useSelector(state => state.notes) ``` - -让我们将可见性过滤器提取到它自己的 src/components/VisibilityFilter.js 组件中: + + 让我们把可见性过滤器提取到自己的src/components/VisibilityFilter.js组件中。 ```js -import React from 'react' import { filterChange } from '../reducers/filterReducer' import { useDispatch } from 'react-redux' @@ -327,19 +327,19 @@ const VisibilityFilter = (props) => { return (
    - all - dispatch(filterChange('ALL'))} /> - important + important dispatch(filterChange('IMPORTANT'))} /> - nonimportant + nonimportant { export default VisibilityFilter ``` - -使用新的组件App 可以简化如下: + + 有了这个新的组件,App就可以简化成如下。 ```js -import React from 'react' import Notes from './components/Notes' import NewNote from './components/NewNote' import VisibilityFilter from './components/VisibilityFilter' @@ -374,11 +373,11 @@ const App = () => { export default App ``` - -实现相当简单。单击不同的单选按钮会改变存储区的filter 属性的状态。 + + 实现起来相当简单。点击不同的单选按钮可以改变商店的过滤器属性的状态。 - -让我们改变Notes 组件来合并过滤器: + + 让我们改变Notes组件以纳入过滤器。 ```js const Notes = () => { @@ -388,7 +387,7 @@ const Notes = () => { if ( state.filter === 'ALL' ) { return state.notes } - return state.filter === 'IMPORTANT' + return state.filter === 'IMPORTANT' ? state.notes.filter(note => note.important) : state.notes.filter(note => !note.important) }) @@ -400,7 +399,7 @@ const Notes = () => { + handleClick={() => dispatch(toggleImportanceOf(note.id)) } /> @@ -409,88 +408,267 @@ const Notes = () => { ) ``` - - - -我们只对选择器函数进行更改,这个函数过去是 + + + 我们只对选择器函数做了修改,它以前是 ```js useSelector(state => state.notes) ``` - - - -让我们通过将字段作为参数从它接收的状态中解构来简化选择器: + + + 让我们简化选择器,从它收到的状态中解构字段作为一个参数。 ```js const notes = useSelector(({ filter, notes }) => { if ( filter === 'ALL' ) { return notes } - return filter === 'IMPORTANT' + return filter === 'IMPORTANT' ? notes.filter(note => note.important) : notes.filter(note => !note.important) }) ``` - -我们的应用中有一个小小的表面瑕疵。 即使在默认情况下将筛选器设置为ALL,也不会选择相关的单选按钮。 当然,这个问题是可以修复的,但是由于这是一个令人不快但是最终无害的错误,我们将把修复留到以后。 + +在我们的应用中存在一个轻微的外观缺陷。即使过滤器被默认设置为ALL,相关的单选按钮也没有被选中。自然,这个问题可以被修复,但由于这是一个令人不快但最终无害的错误,我们将把修复工作留到以后。为了缓解这些常见的Redux相关问题 -### Redux DevTools - -有一个扩展[Redux DevTools](https://Chrome.google.com/webstore/detail/Redux-DevTools/lmhkpmbekcpmknklioeibfkpmmfibljd)可以安装在 Chrome 上,其中 Redux-store 的状态和改变它的action可以在浏览器的控制台上监视。 +### Redux Toolkit - -在调试时,除了浏览器扩展外,我们还有软件库[redux-devtools-extension](https://www.npmjs.com/package/redux-devtools-extension 扩展)。 让我们使用如下命令来安装它: + + 正如我们到目前为止所看到的,Redux's的配置和状态管理实现需要相当多的努力。例如,这体现在减速器和动作创建者的相关代码中,这些代码有一些重复的模板。[Redux Toolkit](https://redux-toolkit.js.org/)是一个解决这些常见的Redux相关问题的库。例如,该库大大简化了Redux商店的配置,并提供了大量的工具来简化状态管理。 + + + 让我们通过重构现有代码开始在我们的应用中使用Redux工具包。首先,我们需要安装该库。 -```js -npm install --save redux-devtools-extension +``` +npm install @reduxjs/toolkit ``` - -我们将不得不稍微改变store的定义,以使库开始运行: + + 接下来,打开目前创建Redux商店的index.js文件。取代Redux的createStore函数,让我们使用Redux工具包的[configureStore](https://redux-toolkit.js.org/api/configureStore)函数创建商店。 ```js -// ... -import { createStore, combineReducers } from 'redux' -import { composeWithDevTools } from 'redux-devtools-extension' // highlight-line +import React from 'react' +import ReactDOM from 'react-dom/client' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' // highlight-line +import App from './App' import noteReducer from './reducers/noteReducer' import filterReducer from './reducers/filterReducer' -const reducer = combineReducers({ - notes: noteReducer, - filter: filterReducer + // highlight-start +const store = configureStore({ + reducer: { + notes: noteReducer, + filter: filterReducer + } }) +// highlight-end -const store = createStore( - reducer, - // highlight-start - composeWithDevTools() - // highlight-end +console.log(store.getState()) + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , + document.getElementById('root') ) +``` + + + 我们已经摆脱了几行代码,现在我们不需要combineReducers函数来创建商店的还原器。我们很快就会看到,configureStore函数有许多额外的好处,比如毫不费力地集成开发工具和许多常用的库,而不需要额外的配置。 -export default store + + 让我们继续重构还原器,这才真正体现了Redux Toolkit的好处。通过Redux Toolkit,我们可以使用[createSlice](https://redux-toolkit.js.org/api/createSlice)函数轻松创建还原器和相关的动作创建器。我们可以使用createSlice函数来重构reducers/noteReducer.js文件中的reducer和action creators,方法如下。 + +```js +import { createSlice } from '@reduxjs/toolkit' // highlight-line + +const initialState = [ + { + content: 'reducer defines how redux store works', + important: true, + id: 1, + }, + { + content: 'state of store can contain any data', + important: false, + id: 2, + }, +] + +const generateId = () => + Number((Math.random() * 1000000).toFixed(0)) + +// highlight-start +const noteSlice = createSlice({ + name: 'notes', + initialState, + reducers: { + createNote(state, action) { + const content = action.payload + + state.push({ + content, + important: false, + id: generateId(), + }) + }, + toggleImportanceOf(state, action) { + const id = action.payload + + const noteToChange = state.find(n => n.id === id) + + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + + return state.map(note => + note.id !== id ? note : changedNote + ) + } + }, +}) +// highlight-end +``` + + + createSlice函数的name参数定义了动作类型值中使用的前缀。例如,稍后定义的createNote动作将有notes/createNote类型值。一个好的做法是给参数一个在还原器中唯一的值。这样就不会出现应用的动作类型值之间的意外冲突。initialState参数定义了还原器的初始状态。reducers参数把reducer本身作为一个对象,其中的函数处理由某些动作引起的状态变化。注意,函数中的action.payload包含调用动作创建者提供的参数。 + +```js +dispatch(createNote('Redux Toolkit is awesome!')) +``` + + + 这个调度调用响应了调度以下对象。 + +```js +dispatch({ type: 'notes/createNote', payload: 'Redux Toolkit is awesome!' }) +``` + + + 如果你密切关注,你可能已经注意到在createNote动作里面,似乎发生了一些违反前面提到的reducers'' immutability原则的事情。 + +```js +createNote(state, action) { + const content = action.payload + + state.push({ + content, + important: false, + id: generateId(), + }) +} +``` + + + 我们通过调用push方法来改变state参数的数组,而不是返回数组的一个新实例。这到底是怎么回事? + + + Redux Toolkit利用[Immer](https://immerjs.github.io/immer/)库与由createSlice函数创建的还原器,这使得在还原器内部改变state参数成为可能。Immer使用改变的状态来产生一个新的、不可变的状态,因此状态的改变仍然是不可变的。请注意,状态可以在不 "改变 "的情况下被改变,就像我们在toggleImportanceOf动作中做的那样。在这种情况下,函数会返回新的状态。然而,改变状态经常会派上用场,特别是当一个复杂的状态需要被更新时。 + + + createSlice函数返回一个对象,包含还原器以及由reducers参数定义的动作创建者。可以通过noteSlice.reducer属性访问还原器,而通过noteSlice.actions属性访问动作创建者。我们可以通过以下方式产生文件''的输出。 + +```js +const noteSlice = createSlice(/* ... */) + +// highlight-start +export const { createNote, toggleImportanceOf } = noteSlice.actions + +export default noteSlice.reducer +// highlight-end +``` + + + 其他文件中的导入将像以前一样工作。 + +```js +import noteReducer, { createNote, toggleImportanceOf } from './reducers/noteReducer' +``` + + + 由于ReduxToolkit的命名惯例,我们需要改变一下测试。 + +```js +import noteReducer from './noteReducer' +import deepFreeze from 'deep-freeze' + +describe('noteReducer', () => { + test('returns new state with action notes/createNote', () => { + const state = [] + const action = { + type: 'notes/createNote', // highlight-line + payload: 'the app state is in redux store', // highlight-line + } + + deepFreeze(state) + const newState = noteReducer(state, action) + + expect(newState).toHaveLength(1) + expect(newState.map(s => s.content)).toContainEqual(action.payload) // highlight-line + }) + + test('returns new state with action notes/toggleImportanceOf', () => { + const state = [ + { + content: 'the app state is in redux store', + important: true, + id: 1 + }, + { + content: 'state changes are made with actions', + important: false, + id: 2 + }] + + const action = { + type: 'notes/toggleImportanceOf', // highlight-line + payload: 2 // highlight-line + } + + deepFreeze(state) + const newState = noteReducer(state, action) + + expect(newState).toHaveLength(2) + + expect(newState).toContainEqual(state[0]) + + expect(newState).toContainEqual({ + content: 'state changes are made with actions', + important: true, + id: 2 + }) + }) +}) ``` - -现在当你打开控制台,redux 标签看起来像这样: +### Redux DevTools + + + [Redux DevTools](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd) 是一个Chrome插件,为Redux提供有用的开发工具。例如,它可以用来检查Redux商店的状态,并通过浏览器的控制台调度行动。当使用Redux Toolkit的configureStore函数创建存储时,Redux DevTools不需要额外的配置就可以工作。 + + + 一旦插件安装完毕,点击浏览器控制台中的Redux标签就可以打开开发工具。 ![](../../images/6/11ea.png) - -每一种对store的影响都可以很容易地观察到 + + 你可以通过点击某个动作来检查调度某个动作如何改变状态。 ![](../../images/6/12ea.png) - -还可以使用控制台将action分派到存储区 + + 也可以使用开发工具向商店调度动作。 ![](../../images/6/13ea.png) - -您可以在[this Github repository](https://github.com/fullstack-hy2020/redux-notes/tree/part6-2)的/ part6-2 分支中找到我们当前应用的全部代码, + + 你可以在[这个Github仓库](https://github.com/fullstack-hy2020/redux-notes/tree/part6-2)的part6-2分支中找到我们当前应用的全部代码。
    @@ -498,22 +676,20 @@ export default store ### Exercises 6.9.-6.12. + + 让我们继续研究我们在练习6.3中开始的使用Redux的名言警句应用。 - -让我们继续使用我们在6.3中使用redux创建的八卦应用。 +#### 6.9 Better anecdotes, step7 + + 为该项目安装Redux工具包。将Redux商店的创建移到自己的文件store.js中,并使用Redux Toolkit's configureStore来创建商店。同时,开始使用Redux DevTools来更容易地调试应用的状态。 -#### 6.9 Better anecdotes, 步骤7 - -开始使用 React dev 工具,将 Redux-store 定义到它自己的文件store.js 中。 +#### 6.10 Better anecdotes, step8 -#### 6.10 Better anecdotes, 步骤8 - -应用的Notification 组件有一个现成的body: + +该应用有一个现成的Notification组件主体。 ```js -import React from 'react' - const Notification = () => { const style = { border: 'solid', @@ -530,15 +706,14 @@ const Notification = () => { export default Notification ``` - -扩展组件,使其渲染 redux 存储中存储的消息,使组件采用如下形式: + + 扩展该组件,使其渲染存储在Redux商店中的消息,使该组件采取以下形式。 ```js -import React from 'react' import { useSelector } from 'react-redux' // highlight-line const Notification = () => { - const notification = useSelector(/*s omething here */) // highlight-line + const notification = useSelector(/* something here */) // highlight-line const style = { border: 'solid', padding: 10, @@ -552,45 +727,36 @@ const Notification = () => { } ``` - -您必须对应用现有的 reducer 进行更改。 为新的功能创建一个单独的reducer,并重构应用,以便它使用一个组合的reducer,如教材的这一章节所教的那样。 + + 你将不得不对应用现有的还原器进行修改。通过使用Redux工具包的createSlice函数,为新功能创建一个单独的reducer。同时,重构应用,使其使用一个组合的还原器,如教材中的这一部分所示。 - -在练习的这一点上,应用不必以任何智能方式使用Notification 组件。 应用只需在notificationReducer 中显示消息的初始值集即可。 + + 在练习的这一点上,应用不需要以智能方式使用Notification组件。应用只需显示在notificationReducer中为消息设置的初始值即可。 -#### 6.11 Better anecdotes, 步骤9 +#### 6.11 Better anecdotes, step9 - - -扩展应用,以便在用户投票支持一个八卦或创建一个新八卦时,使用Notification 组件显示一条消息,持续时间为5秒钟: + + 扩展应用,使其使用Notification组件,在用户为名言警句投票或创建新的名言警句时,显示一条消息五秒钟。 ![](../../images/6/8ea.png) + + 建议创建单独的[动作创建者](https://redux-toolkit.js.org/api/createSlice#reducers)来设置和删除通知。 +#### 6.12* Better anecdotes, step10 - -建议创建单独的[action creators](https://redux.js.org/basics/actions#action-creators) 来设置和删除通知 - - -#### 6.12* Better anecdotes, 步骤10 - - -对显示给用户的八卦进行筛选。 + + 对显示给用户的名言警句实施过滤。 ![](../../images/6/9ea.png) + + 在redux存储中存储过滤器的状态。建议为此目的创建一个新的还原器和动作创建器。使用Redux工具包的createSlice函数实现还原器和动作创建器。 - - -在 redux 存储中存储过滤器的状态。 建议为此创建一个新的 reducer 和 action creators。 - - - -创建一个新的Filter 组件来显示过滤器。 您可以使用如下代码作为组件的模板: + + 创建一个新的Filter组件来显示过滤器。你可以使用下面的代码作为该组件的模板。 ```js -import React from 'react' - const Filter = () => { const handleChange = (event) => { // input-field value is in variable event.target.value @@ -610,4 +776,3 @@ export default Filter ```
    - diff --git a/src/content/6/zh/part6c.md b/src/content/6/zh/part6c.md index 11327b6cf84..964676fa1e7 100644 --- a/src/content/6/zh/part6c.md +++ b/src/content/6/zh/part6c.md @@ -7,12 +7,11 @@ lang: zh
    + + 让我们扩展应用,使笔记被存储到后端。我们将使用[json-server](/en/part2/getting_data_from_server),这在第二章节中已经很熟悉。 - -让我们扩展应用,将便笺存储到后端,我们将使用 [json-server](/zh/part2/从服务器获取数据),我们在第二章已经很熟悉了。 - - -数据库的初始状态存储在文件db.json 中,该文件位于项目的根目录中: + + 数据库的初始状态被存储在文件db.json中,它被放置在项目的根部。 ```json { @@ -31,19 +30,15 @@ lang: zh } ``` - - - -我们将为这个项目安装 json-server... + + 我们将为项目安装json-server... ```js -npm install json-server --save +npm install json-server --save-dev ``` - - - -并将如下行添加到我 package.json 文件的  scripts 部分 + + 然后在文件package.jsonscripts部分添加以下一行 ```js "scripts": { @@ -52,11 +47,11 @@ npm install json-server --save } ``` - -现在,让我们使用命令 npm run server 启动 json-server。 + + 现在让我们用命令_npm run server_来启动json-server。 - -接下来,我们将在文件 services/notes.js 中创建一个方法,该方法使用axios 从后端获取数据 + + 接下来我们将在文件services/notes.js中创建一个方法,使用axios从后端获取数据。 ```js import axios from 'axios' @@ -71,40 +66,87 @@ const getAll = async () => { export default { getAll } ``` - -我们将在项目中添加 axios + + 我们将axios添加到项目中 -```js -npm install axios --save +```bash +npm install axios ``` - -我们将在noteReducer 中更改状态的初始化,这样默认情况下不存在便笺: + + 我们将改变noteReducer中状态的初始化,这样默认情况下就没有笔记了。 ```js -const noteReducer = (state = [], action) => { +const noteSlice = createSlice({ + name: 'notes', + initialState: [], // highlight-line // ... -} +}) ``` - -根据服务器上的数据初始化状态的一种便捷方法是从文件index.js 中获取便笺,并为每个便笺分派action NEW\_NOTE: + + 我们还要添加一个新的动作appendNote来添加一个笔记对象。 + +```js +const noteSlice = createSlice({ + name: 'notes', + initialState, + reducers: { + createNote(state, action) { + const content = action.payload + + state.push({ + content, + important: false, + id: generateId(), + }) + }, + toggleImportanceOf(state, action) { + const id = action.payload + + const noteToChange = state.find(n => n.id === id) + + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + + return state.map(note => + note.id !== id ? note : changedNote + ) + }, + // highlight-start + appendNote(state, action) { + state.push(action.payload) + } + // highlight-end + }, +}) + +export const { createNote, toggleImportanceOf, appendNote } = noteSlice.actions // highlight-line + +export default noteSlice.reducer +``` + + + 基于从服务器收到的数据初始化笔记状态的快速方法是在index.js文件中获取笔记,并使用appendNote动作创建器为每个单独的笔记对象分派一个动作。 ```js // ... import noteService from './services/notes' // highlight-line +import noteReducer, { appendNote } from './reducers/noteReducer' // highlight-line -const reducer = combineReducers({ - notes: noteReducer, - filter: filterReducer, +const store = configureStore({ + reducer: { + notes: noteReducer, + filter: filterReducer, + } }) -const store = createStore(reducer) - // highlight-start noteService.getAll().then(notes => notes.forEach(note => { - store.dispatch({ type: 'NEW_NOTE', data: note }) + store.dispatch(appendNote(note)) }) ) // highlight-end @@ -112,71 +154,100 @@ noteService.getAll().then(notes => // ... ``` - - - -让我们在 reducer 中为action INIT\_NOTES 添加支持,通过调度单个action可以使用它来完成初始化。 我们还要创建一个action创建器函数 _initializeNotes_。 + + 派遣多个动作似乎有点不切实际。让我们添加一个动作创建器setNotes,可以用来直接替换笔记数组。我们将通过实现setNotes动作,从createSlice函数中获得动作创建器。 ```js // ... -const noteReducer = (state = [], action) => { - console.log('ACTION:', action) - switch (action.type) { - case 'NEW_NOTE': - return [...state, action.data] - case 'INIT_NOTES': // highlight-line - return action.data // highlight-line - // ... - } -} -export const initializeNotes = (notes) => { - return { - type: 'INIT_NOTES', - data: notes, - } -} +const noteSlice = createSlice({ + name: 'notes', + initialState, + reducers: { + createNote(state, action) { + const content = action.payload + + state.push({ + content, + important: false, + id: generateId(), + }) + }, + toggleImportanceOf(state, action) { + const id = action.payload -// ... + const noteToChange = state.find(n => n.id === id) + + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + + return state.map(note => + note.id !== id ? note : changedNote + ) + }, + appendNote(state, action) { + state.push(action.payload) + }, + // highlight-start + setNotes(state, action) { + return action.payload + } + // highlight-end + }, +}) + +export const { createNote, toggleImportanceOf, appendNote, setNotes } = noteSlice.actions // highlight-line + +export default noteSlice.reducer ``` - -index.js 简化为: + + 现在,index.js文件中的代码看起来好多了。 ```js -import noteReducer, { initializeNotes } from './reducers/noteReducer' // ... +import noteService from './services/notes' +import noteReducer, { setNotes } from './reducers/noteReducer' // highlight-line + +const store = configureStore({ + reducer: { + notes: noteReducer, + filter: filterReducer, + } +}) noteService.getAll().then(notes => - store.dispatch(initializeNotes(notes)) + store.dispatch(setNotes(notes)) // highlight-line ) ``` + + > **NB:**我们为什么不用await来代替 promise 和事件处理程序(注册到_then_-methods)? + + > + + > 等待只在async函数中起作用,而index.js中的代码不在函数中,所以由于操作的简单性,我们这次就不使用async了。 - ->**注意** 为什么我们没有使用 await 来代替 promises 和事件处理程序(注册到 then-methods) ? -> - -> Await 只在async 函数中工作,而index.js 中的代码不在函数中,因此由于action的简单性质,这次我们不使用async。 - - -但是,我们确实决定将便笺的初始化移动到App 组件中,并且,像往常一样,在从服务器获取数据时,我们将使用effect hook。 + + 然而,我们决定将笔记的初始化移到App组件中,并且,像往常一样,当从服务器获取数据时,我们将使用effect hook。 ```js -import React, {useEffect} from 'react' // highlight-line -import NewNote from './components/NowNote' +import { useEffect } from 'react' // highlight-line +import NewNote from './components/NewNote' import Notes from './components/Notes' import VisibilityFilter from './components/VisibilityFilter' -import noteService from './services/notes' -import { initializeNotes } from './reducers/noteReducer' // highlight-line +import noteService from './services/notes' // highlight-line +import { setNotes } from './reducers/noteReducer' // highlight-line import { useDispatch } from 'react-redux' // highlight-line const App = () => { + // highlight-start const dispatch = useDispatch() - // highlight-start useEffect(() => { noteService - .getAll().then(notes => dispatch(initializeNotes(notes))) + .getAll().then(notes => dispatch(setNotes(notes))) }, []) // highlight-end @@ -192,68 +263,62 @@ const App = () => { export default App ``` - - - -使用 useEffect hook 会导致一个 eslint-warning: + + + 使用useEffect钩子会导致一个eslint警告。 ![](../../images/6/26ea.png) - - - -我们可以通过如下方法来摆脱它: + + + 我们可以通过下面的操作摆脱它。 ```js const App = () => { const dispatch = useDispatch() useEffect(() => { noteService - .getAll().then(notes => dispatch(initializeNotes(notes))) + .getAll().then(notes => dispatch(setNotes(notes))) }, [dispatch]) // highlight-line // ... } ``` - -现在,我们在 App 组件中定义的变量dispatch (实际上是 redux-store 的 dispatch 函数)已经被添加到作为参数接收的数组 useEffect 中。 - -如果 dispatch-变量的值在运行期间发生变化, - -该效果将再次执行。但是,这不能在我们的应用中发生,所以警告是不必要的。 - + + + 现在我们在_App_组件中定义的变量dispatch,实际上是redux-store的调度函数,已经被添加到useEffect的数组中作为参数接收。 + + **如果**调度变量的值在运行时发生变化。 + +该效果将被再次执行。然而这不会发生在我们的应用中,所以这个警告是不必要的。 - - -另一个消除警告的方法是禁用该行上的 eslint: + + + 摆脱警告的另一个方法是在这一行禁用eslint。 ```js const App = () => { const dispatch = useDispatch() useEffect(() => { noteService - .getAll().then(notes => dispatch(initializeNotes(notes))) + .getAll().then(notes => dispatch(setNotes(notes))) // highlight-start - },[]) // eslint-disable-line react-hooks/exhaustive-deps + },[]) // eslint-disable-line react-hooks/exhaustive-deps // highlight-end // ... } ``` + + 一般来说,当eslint抛出一个警告时禁用它并不是一个好主意。尽管有关的eslint规则引起了一些[争论](https://github.com/facebook/create-react-app/issues/6880),我们将使用第一个解决方案。 + + 更多关于需要定义钩子的依赖关系,请看 [React文档](https://reactjs.org/docs/hooks-faq.html#is-it-safe-to-omit-functions-from-the-list-of-dependencies)。 - -通常在 eslint 抛出警告时禁用它不是一个好主意。 尽管所讨论的 eslint 规则引起了一些[争论](https://github.com/facebook/create-react-app/issues/6880) ,我们将使用第一个解决方案。 - - - - -更多关于需要定义Hook依赖关系,可以参考[react documentation](https://reactjs.org/docs/hooks-faq.html#is-it-safe-to-omit-functions-from-the-list-of-dependencies)。 - - -当涉及到创建一个新的便笺,我们可以做同样的事情。 让我们将与服务器通信的代码展开如下: + +当涉及到创建一个新的笔记时,我们可以做同样的事情。让我们扩展一下与服务器通信的代码,如下。 ```js const baseUrl = 'http://localhost:3001/notes' @@ -277,19 +342,18 @@ export default { } ``` - -组件NoteForm 的方法 addNote 略有变化: + + 组件NewNote的_addNote_方法略有变化。 ```js -import React from 'react' import { useDispatch } from 'react-redux' import { createNote } from '../reducers/noteReducer' import noteService from '../services/notes' // highlight-line const NewNote = (props) => { const dispatch = useDispatch() - - const addNote = async (event) => { + + const addNote = async (event) => { // highlight-line event.preventDefault() const content = event.target.note.value event.target.note.value = '' @@ -308,76 +372,68 @@ const NewNote = (props) => { export default NewNote ``` - -因为后端为便笺生成 id,所以我们将更改action 创建器 _createNote_ + +因为后端为笔记生成了ID,我们将相应地改变动作创建者createNote。 ```js -export const createNote = (data) => { - return { - type: 'NEW_NOTE', - data, - } +createNote(state, action) { + state.push(action.payload) } ``` - -更改便笺的重要性可以使用相同的原则实现,这意味着对服务器进行异步方法调用,然后调度适当的action。 + + 改变笔记的重要性可以用同样的原则来实现,通过对服务器进行异步方法调用,然后分派一个适当的动作。 - -应用代码的当前状态可以在分支part6-3 中的 [github](https://github.com/fullstack-hy2020/redux-notes/tree/part6-3)上找到。 + + 该应用的代码的当前状态可以在[GitHub](https://github.com/fullstack-hy2020/redux-notes/tree/part6-3)的分支part6-3中找到。
    -
    - ### Exercises 6.13.-6.14. +#### 6.13 Anecdotes and the backend, step1 -#### 6.13 Anecdotes and the backend, 步骤1 + +当应用启动时,从使用json-server实现的后端获取名言警句。 + + 作为初始的后端数据,你可以使用,例如[this](https://github.com/fullstack-hy2020/misc/blob/master/anecdotes.json)。 - -当应用启动时,从使用 json-server 实现的后端获取八卦。 +#### 6.14 Anecdotes and the backend, step2 - -作为初始的后端数据,你可以使用,例如[this](https://github.com/fullstack-hy2020/misc/blob/master/anecdotes.json)。 - -#### 6.14 Anecdotes and the backend, 步骤2 - -修改新八卦的创建,以便将八卦存储在后端。 + + 修改创建新的名言警句,使名言警句存储在后端。
    -
    - ### Asynchronous actions and redux thunk -【异步action和 redux thunk】 - -我们的方法是可行的,但是与服务器的通信发生在组件的功能内部并不是很好。 如果能够将通信从组件中抽象出来就更好了,这样它们就不必做任何其他事情,只需调用适当的action creator。 例如,App 将应用的状态初始化如下: + + + 我们的方法很好,但与服务器的通信发生在组件的功能中,这不是很好。如果能将通信从组件中抽象出来就更好了,这样它们就不必做任何其他事情,只需调用相应的动作创建器。举个例子,App将初始化应用的状态,如下所示。 ```js const App = () => { const dispatch = useDispatch() useEffect(() => { - dispatch(initializeNotes())) - },[dispatch]) + dispatch(initializeNotes()) + },[dispatch]) // ... } ``` - -NoteForm 将创建一个新的便笺如下: + + 而NewNote将创建一个新的笔记,如下所示。 ```js const NewNote = () => { const dispatch = useDispatch() - + const addNote = async (event) => { event.preventDefault() const content = event.target.note.value @@ -389,85 +445,47 @@ const NewNote = () => { } ``` - -这两个组件将只使用提供给它们的功能作为一个props,而不考虑与服务器的后台通信。 + + 在这个实现中,两个组件都会派发一个动作,而不需要知道幕后发生的服务器之间的通信。这类async动作可以使用[Redux Thunk](https://github.com/reduxjs/redux-thunk)库来实现。当使用Redux工具包的configureStore函数创建Redux商店时,使用该库不需要任何额外配置。 - -现在让我们安装[redux-thunk](https://github.com/gaearon/redux-thunk)-库,它允许我们创建asynchronous actions: + + 现在让我们安装该库 -```js -npm install --save redux-thunk ``` - - -Redux-thunk-库 是所谓的redux-中间件,它必须在store的初始化过程中初始化。 在这里,让我们将store的定义提取到它自己的文件 src/store.js 中。: - -```js -import { createStore, combineReducers, applyMiddleware } from 'redux' -import thunk from 'redux-thunk' -import { composeWithDevTools } from 'redux-devtools-extension' - -import noteReducer from './reducers/noteReducer' -import filterReducer from './reducers/filterReducer' - -const reducer = combineReducers({ - notes: noteReducer, - filter: filterReducer, -}) - -const store = createStore( - reducer, - composeWithDevTools( - applyMiddleware(thunk) - ) -) - -export default store +npm install redux-thunk ``` - -更改之后,文件 src/index.js如下所示 + + 通过Redux Thunk可以实现action creators,它返回一个函数而不是一个对象。该函数接收Redux存储的dispatchgetState方法作为参数。这允许异步动作创建者的实现,它首先等待某个异步操作的完成,然后分派一些动作,改变商店的状态。 -```js -import React from 'react' -import ReactDOM from 'react-dom' -import { Provider } from 'react-redux' -import store from './store' // highlight-line -import App from './App' - -ReactDOM.render( - - - , - document.getElementById('root') -) -``` - - -感谢 redux-thunk,可以定义action creators,这样它们就可以返回一个函数,其参数是 redux-store 的dispatch-method。 因此,可以创建异步action创建器,它们首先等待某个action完成,然后分派真正的action。 + + 我们可以定义一个动作创建器initializeNotes,根据从服务器收到的数据初始化笔记。 +```js +// ... +import noteService from '../services/notes' // highlight-line +const noteSlice = createSlice(/* ... */) - -现在,我们可以定义action创建器initializeNotes,它初始化便笺的状态如下: +export const { createNote, toggleImportanceOf, setNotes, appendNote } = noteSlice.actions -```js +// highlight-start export const initializeNotes = () => { return async dispatch => { const notes = await noteService.getAll() - dispatch({ - type: 'INIT_NOTES', - data: notes, - }) + dispatch(setNotes(notes)) } } +// highlight-end + +export default noteSlice.reducer ``` - -在内部函数(即异步 action)中,操作首先从服务器获取所有便笺,然后 便笺分发到action中,从而将它们添加到store中。 + + 在内部函数中,指的是异步操作,该操作首先从服务器获取所有笔记,然后分派setNotes操作,将它们添加到存储中。 - -组件App 现在可以定义如下: + + 组件App现在可以被定义如下。 ```js const App = () => { @@ -475,8 +493,8 @@ const App = () => { // highlight-start useEffect(() => { - dispatch(initializeNotes()) - },[dispatch]) + dispatch(initializeNotes()) + },[dispatch]) // highlight-end return ( @@ -489,34 +507,76 @@ const App = () => { } ``` - -这个解决方案非常优雅。便笺的初始化逻辑已经完全分离到 React 组件之外。 + + 这个解决方案很优雅。笔记的初始化逻辑已经完全从React组件中分离出来。 - -action创作者 createNew 添加了一个新的便笺,看起来像这样 + + 接下来,让我们用一个异步的动作创建器来取代由createSlice函数创建的createNote动作创建器。 ```js +// ... +import noteService from '../services/notes' + +const noteSlice = createSlice({ + name: 'notes', + initialState, + reducers: { + // highlight-start + toggleImportanceOf(state, action) { + const id = action.payload + + const noteToChange = state.find(n => n.id === id) + + const changedNote = { + ...noteToChange, + important: !noteToChange.important + } + + return state.map(note => + note.id !== id ? note : changedNote + ) + }, + appendNote(state, action) { + state.push(action.payload) + }, + setNotes(state, action) { + return action.payload + } + // highlight-end + }, +}) + +export const { toggleImportanceOf, appendNote, setNotes } = noteSlice.actions // highlight-line + +export const initializeNotes = () => { + return async dispatch => { + const notes = await noteService.getAll() + dispatch(setNotes(notes)) + } +} + +// highlight-start export const createNote = content => { return async dispatch => { const newNote = await noteService.createNew(content) - dispatch({ - type: 'NEW_NOTE', - data: newNote, - }) + dispatch(appendNote(newNote)) } } +// highlight-end + +export default noteSlice.reducer ``` - -这里的原理是相同的: 首先执行一个异步操作,然后调度改变store态的action。 + + 这里的原理是一样的:首先,执行一个异步操作,之后,改变存储状态的动作被dispatched。Redux工具包提供了大量的工具来简化异步状态管理。适合这个用例的工具有:[createAsyncThunk](https://redux-toolkit.js.org/api/createAsyncThunk)函数和[RTK Query](https://redux-toolkit.js.org/rtk-query/overview) API。 - -NewNote组件更改如下: + + 组件NewNote的变化如下。 ```js const NewNote = () => { const dispatch = useDispatch() - + const addNote = async (event) => { event.preventDefault() const content = event.target.note.value @@ -533,37 +593,73 @@ const NewNote = () => { } ``` - -应用代码的当前状态可以在分支part6-4 中的[github](https://github.com/fullstack-hy2020/redux-notes/tree/part6-4)上找到。 + + 最后,让我们清理一下index.js文件,把与创建Redux商店有关的代码移到自己的store.js文件中。 -
    +```js +import { configureStore } from '@reduxjs/toolkit' +import noteReducer from './reducers/noteReducer' +import filterReducer from './reducers/filterReducer' -
    +const store = configureStore({ + reducer: { + notes: noteReducer, + filter: filterReducer + } +}) + +export default store +``` + + + 更改后,index.js的内容如下。 +```js +import React from 'react' +import ReactDOM from 'react-dom/client' +import { Provider } from 'react-redux' +import store from './store' // highlight-line +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , + document.getElementById('root') +) +``` + + + 应用的代码的当前状态可以在[GitHub](https://github.com/fullstack-hy2020/redux-notes/tree/part6-4)的分支part6-4中找到。 + +
    + +
    ### Exercises 6.15.-6.18. +#### 6.15 Anecdotes and the backend, step3 -#### 6.15 Anecdotes and the backend, 步骤3 + + 修改Redux存储的初始化,使其使用异步动作创建器来发生,这是由Redux Thunk库实现的。 +#### 6.16 Anecdotes and the backend, step4 - -使用异步action创建器修改 redux-store 的初始化, redux-thunk-库 使异步action创建器成为可能。 + + 还修改了创建一个新的名言警句,使其使用异步动作创建器发生,Redux Thunk库使之成为可能。 -#### 6.16 Anecdotes and the backend, 步骤4 - -还可以使用异步action创建器(由redux-thunk-library 提供)修改新八卦的创建。 +#### 6.17 Anecdotes and the backend, step5 -#### 6.17 Anecdotes and the backend, 步骤5 - -投票还不能保存对后端的更改。请在redux-thunk-library 的帮助下修复这种情况。 + + 投票还不能将变化保存到后端。在Redux Thunk库的帮助下修复了这种情况。 -#### 6.18 Anecdotes and the backend, 步骤6 - -创建通知仍然有点繁琐,因为必须执行两个action并使用 setTimeout 函数: +#### 6.18 Anecdotes and the backend, step6 + + + 创建通知仍然有点繁琐,因为必须做两个动作并使用_setTimeout_函数。 ```js dispatch(setNotification(`new anecdote '${content}'`)) @@ -572,18 +668,17 @@ setTimeout(() => { }, 5000) ``` - -创建一个异步action创建器,它可以提供如下通知: + + 做一个动作的创建者,这使得人们能够提供通知,如下。 ```js dispatch(setNotification(`you voted '${anecdote.content}'`, 10)) ``` - -第一个参数是要渲染的文本,第二个参数是以秒为单位显示通知的时间。 + + 第一个参数是要渲染的文本,第二个参数是显示通知的时间,单位是秒。 - -在您的应用中实现这个改进的通知的使用。 + +在你的应用中实现使用这个改进的通知。
    - diff --git a/src/content/6/zh/part6d.md b/src/content/6/zh/part6d.md index e0c2f4e5b3e..dac73ad74dd 100644 --- a/src/content/6/zh/part6d.md +++ b/src/content/6/zh/part6d.md @@ -1,743 +1,1023 @@ --- -mainImage: ../../../images/part-5.svg +mainImage: ../../../images/part-6.svg part: 6 letter: d lang: zh ---
    + -【TODO】 - -到目前为止,我们已经使用了 redux-store,借助于 redux 中的 [hook](https://react-redux.js.org/api/hooks)-api。 - -实际上,这意味着使用了[useSelector](https://react-redux.js.org/api/hooks#useSelector)和[useDispatch](https://react-redux.js.org/api/hooks#useDispatch)函数。 +在本章结束,我们将会了解几种管理应用状态的不同方式。 + +我们继续回到 note 应用。这次我们将关注与服务器的通信。我们从头开始打造应用,它的第一版如下: - -为了完成这一章节,我们将研究使用 redux 的另一种更古老、更复杂的方法,redux 提供的[connect](https://github.com/reactjs/react-redux/blob/master/docs/api.md#connectmapstatetoprops-mapdispatchtoprops-mergeprops-options)-函数。 +```js +const App = () => { + const addNote = async (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + console.log(content) + } + const toggleImportance = (note) => { + console.log('toggle importance of', note.id) + } + const notes = [] - -在新的应用中,您绝对应该使用 hook-api,但是在使用 redux 维护老项目时,了解如何使用 connect 非常有用。 + return( +
    +

    Notes app

    +
    + + +
    + {notes.map(note => +
  • toggleImportance(note)}> + {note.content} + {note.important ? 'important' : ''} +
  • + )} +
    + ) +} -### Using the connect-function to share the redux store to components -【使用 connect-function 将 redux 存储共享给组件】 +export default App +``` + - -让我们修改Notes 组件,以便使用 connect-function 而不是 hook-api (useDispatch 和 useSelector 函数)。 +初始代码可以在 GitHub 仓库 [https://github.com/fullstack-hy2020/query-notes](https://github.com/fullstack-hy2020/query-notes/tree/part6-0) 中 part6-0 的分支中找到. - -我们必须修改组件的如下部分: +### -````js -import React from 'react' -import { useDispatch, useSelector } from 'react-redux' // highlight-line -import { toggleImportanceOf } from '../reducers/noteReducer' +### 利用 React Query 管理服务器端数据 -const Notes = () => { - // highlight-start - const dispatch = useDispatch() - const notes = useSelector(({filter, notes}) => { - if ( filter === 'ALL' ) { - return notes - } - return filter === 'IMPORTANT' - ? notes.filter(note => note.important) - : notes.filter(note => !note.important) - }) - // highlight-end + - return( -
      - {notes.map(note => - - dispatch(toggleImportanceOf(note.id)) // highlight-line - } - /> - )} -
    - ) -} +我们现在将用 [React Query](https://tanstack.com/query/latest/docs/react/) 存储并管理从服务器检索的数据。 -export default Notes -```` + - -Connect 函数可用于转换“常规” React 组件,以便将 Redux 存储的状态“映射”到组件的props中。 +用以下命令安装 React Query 库: + +```bash +npm install react-query +``` + + + +在 index.js 中需要增加一些内容,以便将这个库中的函数传递给整个应用。 - -让我们首先使用 connect 函数将Notes 组件转换为连接组件: ```js import React from 'react' -import { connect } from 'react-redux' // highlight-line -import { toggleImportanceOf } from '../reducers/noteReducer' +import ReactDOM from 'react-dom/client' +import { QueryClient, QueryClientProvider } from 'react-query' // highlight-line -const Notes = () => { - // ... -} +import App from './App' -const ConnectedNotes = connect()(Notes) // highlight-line -export default ConnectedNotes // highlight-line +const queryClient = new QueryClient() // highlight-line + +ReactDOM.createRoot(document.getElementById('root')).render( + // highlight-line + + // highlight-line +) ``` - -该模块导出的连接组件 与之前的常规组件工作方式完全相同。 + - -组件需要 Redux 存储中的便笺列表和筛选器的值。 Connect 函数接受所谓的[mapStateToProps](https://github.com/reduxjs/react-redux/blob/master/docs/api/connect.md#mapStateToProps-state-ownprops--object)函数作为它的第一个参数。 这个函数可以用来定义基于 Redux 存储状态的连接组件 的props。 +我们现在就可以从 App 组件中获取笔记了。相关代码如下: - -如果我们定义: +```js +import { useQuery } from 'react-query' // highlight-line +import axios from 'axios' // highlight-line +const App = () => { + // ... -```js -const Notes = (props) => { // highlight-line - const dispatch = useDispatch() + // highlight-start + const result = useQuery( + 'notes', + () => axios.get('http://localhost:3001/notes').then(res => res.data) + ) -// highlight-start - const notesToShow = () => { - if ( props.filter === 'ALL ') { - return props.notes - } - - return props.filter === 'IMPORTANT' - ? props.notes.filter(note => note.important) - : props.notes.filter(note => !note.important) + console.log(result) + // highlight-end + + // highlight-start + if ( result.isLoading ) { + return
    loading data...
    } // highlight-end - return( -
      - {notesToShow().map(note => // highlight-line - - dispatch(toggleImportanceOf(note.id)) - } - /> - )} -
    + const notes = result.data // highlight-line + + return ( + // ... ) } +``` -const mapStateToProps = (state) => { - return { - notes: state.notes, - filter: state.filter, - } -} + -const ConnectedNotes = connect(mapStateToProps)(Notes) // highlight-line +从服务器中获取数据的方式和 Axios 的 *get* 方法类似。然而,Axios 的调用方法现在被包装在一个用 [useQuery](https://tanstack.com/query/latest/docs/react/reference/useQuery) 函数形成的 [query](https://tanstack.com/query/latest/docs/react/guides/queries) 查询中。在这个函数调用中,第一个参数(字符串 "notes" )是已定义查询的 [key](https://tanstack.com/query/latest/docs/react/guides/query-keys),即笔记列表。 -export default ConnectedNotes + + +*useQuery* 函数的返回值是一个包含查询状态的对象。控制台中的输出展现了这个情境: + +![browser devtools showing success status](../../images/6/60new.png) + + + +当组件第一次被渲染时,查询仍处于*加载*状态,即,相关的 HTTP 请求仍在等待中。在这个阶段,只有如下元素会被渲染: + +```html +
    loading data...
    ``` - -Notes组件可以直接访问存储的状态,例如通过包含便笺列表的 propss.Notes。 类似地,props.filter 引用了过滤器的值。 + + +然而, HTTP 请求在瞬息之内完成,甚至最敏锐的人也无法看到这个文本。当请求完成后,这个组件会被重新渲染。在第二次渲染中,查询的状态为*成功*,而且,查询对象的 *data* 字段中包含了请求返回的数据,即,屏幕上显示的笔记列表。 - -使用connect 和我们定义的mapStateToProps 函数的结果可以这样可视化: + -![](../../images/6/24c.png) +因此,这个应用可以从服务器中获取数据并将其渲染到屏幕上,而完全不使用我们在第 2 章至第 5 章谈及的 React 钩子—— *useState* 和 *useEffect*。服务器中的数据现在完全在 React Query 库的管理下,应用程序完全不需要用 React 的 useState 钩子定义状态! + +让我们将发出实际 HTTP 请求的函数,移动到单独的 requests.js 文件中。 - -Notes 组件通过 props.Notes 和props.filter 具有“直接访问”功能,用于检查 Redux 存储的状态。 +```js +import axios from 'axios' + +export const getNotes = () => + axios.get('http://localhost:3001/notes').then(res => res.data) +``` - -Notelist 组件实际上不需要关于选择哪个过滤器的信息,因此我们可以将过滤逻辑移到其他位置。 - -我们只需要在便笺props中给它正确过滤的便笺: + + +现在,*APP* 组件变得稍微简洁了。 ```js -const Notes = (props) => { // highlight-line - const dispatch = useDispatch() +import { useQuery } from 'react-query' +import { getNotes } from './requests' // highlight-line - return( -
      - {props.notes.map(note => - - dispatch(toggleImportanceOf(note.id)) - } - /> - )} -
    - ) +const App = () => { + // ... + + const result = useQuery('notes', getNotes) // highlight-line + + // ... } +``` -// highlight-start -const mapStateToProps = (state) => { - if ( state.filter === 'ALL' ) { - return { - notes: state.notes - } - } +The current code for the application is in [GitHub](https://github.com/fullstack-hy2020/query-notes/tree/part6-1) in the branch part6-1. + +当前应用的代码可以在 [GitHub](https://github.com/fullstack-hy2020/query-notes/tree/part6-1) 上 *part6-1* 的分支中找到。 + +### + +### 使用 React Query 将数据同步至服务器 - return { - notes: (state.filter === 'IMPORTANT' - ? state.notes.filter(note => note.important) - : state.notes.filter(note => !note.important) - ) + + +数据已经成功地从服务器中检索出来。接下来,我们将确保对数据的新增和修改也会存储到服务器中。让我们从新增笔记开始。 + + + +让我们在 *requests.js* 中构建一个 *createNote* 函数,用以存储新笔记: + +```js +import axios from 'axios' + +const baseUrl = 'http://localhost:3001/notes' + +export const getNotes = () => + axios.get(baseUrl).then(res => res.data) + +export const createNote = newNote => // highlight-line + axios.post(baseUrl, newNote).then(res => res.data) // highlight-line +``` + + + +*App* 组件相应做出如下更新: + +```js +import { useQuery, useMutation } from 'react-query' // highlight-line +import { getNotes, createNote } from './requests' // highlight-line + +const App = () => { + const newNoteMutation = useMutation(createNote) // highlight-line + + const addNote = async (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + newNoteMutation.mutate({ content, important: true }) // highlight-line } + + // + } -// highlight-end +``` -const ConnectedNotes = connect(mapStateToProps)(Notes) -export default ConnectedNotes + + +为了新增一条笔记,我们需要用 [useMutation](https://tanstack.com/query/latest/docs/react/reference/useMutation) 创建一个 [mutation(突变)](https://tanstack.com/query/latest/docs/react/guides/mutations)。 + +```js +const newNoteMutation = useMutation(createNote) ``` -### mapDispatchToProps + +*useMutation* 的参数即是我们在 *requests.js* 中添加的函数——它使用 Axios 向服务器发送一条新笔记。 + + + +事件处理器 *addNote* 在调用 mutation 对象中的 mutate 函数时,将新笔记作为参数传入,以执行 mutation : - -现在我们已经摆脱了 useSelector,但是Notes 仍然使用 useDispatch Hook和 dispatch 函数返回它: ```js -const Notes = (props) => { - const dispatch = useDispatch() // highlight-line +newNoteMutation.mutate({ content, important: true }) +``` - return( -
      - {props.notes.map(note => - - dispatch(toggleImportanceOf(note.id)) // highlight-line - } - /> - )} -
    - ) + + +我们的解决方案挺不错,但仍有改进空间。新的笔记虽然存储在了服务器上,但并没有在屏幕上更新。 + + + +为了能渲染出新的笔记,我们需要告诉 React Query,应该使 key 为 *notes* 的旧查询结果 [invalidated(无效)](https://tanstack.com/query/latest/docs/react/guides/invalidations-from-mutations)。 + + + +幸运的是,无效化很容易,它可以通过为 mutation 定义适当的 *onSuccess* 回调函数来完成: + +```js +import { useQuery, useMutation, useQueryClient } from 'react-query' // highlight-line +import { getNotes, createNote } from './requests' + +const App = () => { + const queryClient = useQueryClient() // highlight-line + + const newNoteMutation = useMutation(createNote, { + onSuccess: () => { // highlight-line + queryClient.invalidateQueries('notes') // highlight-line + }, + }) + + // ... } ``` - -Connect 函数的第二个参数可用于定义[mapDispatchToProps](https://github.com/reduxjs/react-redux/blob/master/docs/api/connect.md#mapDispatchToProps-object--dispatch-ownprops--object) ,它是一组作为props传递给连接组件的 action creator 函数。 让我们对现有的连接操作进行如下更改: + +在 mutation 已经成功执行后,一个函数被调用: ```js -const mapStateToProps = (state) => { - return { - notes: state.notes, - filter: state.filter, +queryClient.invalidateQueries('notes') +``` + + + +这让 React Query 通过从服务器上获取笔记。自动更新 key 为 *notes* 的查询。因此,应用渲染了服务器上最新的状态,包括刚刚新增的笔记。 + + + +让我们加入更改笔记重要性的功能。更新笔记的函数被加入到文件 *requests.js* 中: + +```js +export const updateNote = updatedNote => + axios.put(`${baseUrl}/${updatedNote.id}`, updatedNote).then(res => res.data) +``` + + + +更新笔记同样通过 mutation 来完成。*App* 组件扩展为如下: + +```js +import { useQuery, useMutation, useQueryClient } from 'react-query' +import { getNotes, createNote, updateNote } from './requests' // highlight-line + +const App = () => { + // ... + + const updateNoteMutation = useMutation(updateNote, { + onSuccess: () => { + queryClient.invalidateQueries('notes') + }, + }) + + const toggleImportance = (note) => { + updateNoteMutation.mutate({...note, important: !note.important }) } -} -// highlight-start -const mapDispatchToProps = { - toggleImportanceOf, + // ... } -// highlight-end +``` + + + +一个能够无效化查询的 mutation 被再次创建,更新后的笔记也可以正常渲染。使用 mutation 很轻松,*mutate* 方法接收一个笔记作为参数,这个笔记的重要性已变为旧值的反义。 -const ConnectedNotes = connect( - mapStateToProps, - mapDispatchToProps // highlight-line -)(Notes) + -export default ConnectedNotes +当前应用的代码可以在 [GitHub](https://github.com/fullstack-hy2020/query-notes/tree/part6-2) 上 *part6-2* 的分支中找到。 + +### + +### 优化性能 + + + +应用目前运转良好,代码也相对简单。对笔记列表的更改也异常轻松。例如,当我们改变了笔记的重要性,使 key 为 *notes* 的查询无效即可更新应用中的数据。 + +```js + const updateNoteMutation = useMutation(updateNote, { + onSuccess: () => { + queryClient.invalidateQueries('notes') // highlight-line + }, + }) ``` - -现在这个组件可以通过它的props调用函数直接调用_toggleImportanceOf_ action creator 定义的action: + + +但这样的话,应用会在一个导致笔记更新的 PUT 请求后,创建一个 GET 请求向服务器获取数据。 + +![devtools network tab with highlight over 3 and notes requests](../../images/6/61new.png) + + + +如果应用从服务器中获取的数据量不大,这样的更新流程无关紧要。毕竟,从浏览器功能的角度来看,额外的一个 HTTP 请求并不重要,但在某些情况下,这可能会给服务器带来压力。 + + +必要情况下,也可以通过 [手动更新](https://tanstack.com/query/latest/docs/react/guides/updates-from-mutation-responses) React Query 所维护的查询状态,以实现性能优化。 + + + +对新增笔记的 mutation,做出如下更改: ```js -const Notes = (props) => { - return( -
      - {props.notes.map(note => - props.toggleImportanceOf(note.id)} - /> - )} -
    - ) +const App = () => { + const queryClient = useQueryClient() + + const newNoteMutation = useMutation(createNote, { + onSuccess: (newNote) => { + const notes = queryClient.getQueryData('notes') // highlight-line + queryClient.setQueryData('notes', notes.concat(newNote)) // highlight-line + } + }) + // ... } ``` - -这意味着不要像这样分派action: + + +在 *onSuccess* 的回调函数中,*queryClient* 对象首先读取已经存在的笔记状态,并加入在回调函数参数中获取到的新增笔记以实现更新。回调函数参数的值,即为在 requests.js 中定义的 *createNote* 函数所返回的值: ```js -dispatch(toggleImportanceOf(note.id)) +export const createNote = newNote => + axios.post(baseUrl, newNote).then(res => res.data) ``` - -当使用 connect 时,我们可以简单地这样做: + + +用类似的方法去更新笔记的重要性也相对简单,但我们把这留作一个可选练习。 + + + +如果我们仔细观察浏览器的网络面板,我们会注意到:当我们将光标移动至输入框时,React Query 会立即去获取全部笔记。 + +![dev tools notes app with input text field highlighted and arrow on network over notes request as 200](../../images/6/62new.png) + + + +发生了什么?通过阅读 [文档](https://tanstack.com/query/latest/docs/react/reference/useQuery) ,我们注意到 React Query 查询的默认功能是:当窗口焦点,即应用中用户界面的活动元素,发生变化时,查询(其状态为 *stale*)会被更新。如果我们希望,我们可以按以下方式创建查询,以关闭这个功能: ```js -props.toggleImportanceOf(note.id) +const App = () => { + // ... + const result = useQuery('notes', getNotes, { + refetchOnWindowFocus: false // highlight-line + }) + + // ... +} ``` - -不需要单独调用 dispatch 函数,因为 connect 已经将 _toggleImportanceOf_ action creator 修改为包含 dispatch 的形式。 + + +如果你在代码中放入 console.log,就会在浏览器的控制台中发现: React Query 引发的应用重复渲染是多么频繁。经验法则是,应在有需要的时候(即在查询状态发生变化时),才进行重新渲染。你可以在 [这里](https://tkdodo.eu/blog/react-query-render-optimizations) 了解更多。 + + + +当前应用的代码可以在 [GitHub](https://github.com/fullstack-hy2020/query-notes/tree/part6-3) 上 *part6-3* 的分支中找到。 + + + +React Query 一个多功能的库,根据我们已看到的情况,它简化了应用。那么,React Query 是否让更复杂的状态管理解决方案,如 Redux,变得无足轻重了呢?并非如此,在某些情况下,React Query 可以部分替代应用程序的状态,但是正如 [文档](https://tanstack.com/query/latest/docs/react/guides/does-this-replace-client-state) 所说: - -了解 mapDispatchToProps 的工作原理可能需要一些时间,特别是当我们了解了[使用它的替代方法](/zh/part6/connect方法#alternative-way-of-using-map-dispatch-to-props)之后。 +- +- React Query 是 *服务器状态的库*,负责管理服务器和客户端之间的异步操作。 +- +- Redux 等则是*客户端状态的库*,可以用来存储异步数据,尽管效率不如 React Query 这样的工具。 - -使用连接产生的结果可以这样想象: + -![](../../images/6/25b.png) +因此,React Query 是一个在前端维护服务器状态的库,即作为服务器存储内容的缓存。React Query 简化了对服务器数据的处理,在某些情况下,可以消除将服务器数据存储在前端的需求。 - -除了通过props.notesprops.filter 访问存储的状态外,该组件还引用了一个函数,该函数可以通过其toggleimportof prop 用于分派TOGGLE IMPORTANCE-类型操作。 + + +大多数 React 应用不仅需要一种临时存储服务器数据的方法,还需要一些处理其他前端状态(例如表单和通知的状态)的解决方案。 + +
    + +
    + +### Exercises 6.20.-6.22. + + + +现在,让我们用 React Query 打造一个新版的箴言应用。用 [这个项目](https://github.com/fullstack-hy2020/query-anecdotes) 作为你的起点。初始项目已经安装了 JSON 服务器,其操作方式被稍加修改。使用 *npm run server* 启动应用。 + +#### Exercise 6.20 + + + +使用 React Query,实现从服务器上获取箴言。 + + + +当和服务器通信出现问题时,将只展示一个错误页面。 + +![browser saying anecdote service not available due to problems in server on localhost](../../images/6/65new.png) + + + +你可以在 [这里](https://tanstack.com/query/latest/docs/react/guides/queries) 找到如何检测可能错误的信息。 + + + +你可以在通过关闭 JSON 服务器来模拟服务器故障。请注意在某种故障情况下,查询会在 *isLoading* 状态中停留一会儿,这是因为在一次请求失败后,React Query 会在多尝试几次后,才反馈请求失败。你可以选择不进行这种额外尝试: - -新重构的Notes 组件的代码如下: ```js -import React from 'react' -import { connect } from 'react-redux' -import { toggleImportanceOf } from '../reducers/noteReducer' +const result = useQuery( + 'anecdotes', getAnecdotes, + { + retry: false + } +) +``` -const Notes = (props) => { - return( -
      - {props.notes.map(note => - props.toggleImportanceOf(note.id)} - /> - )} -
    - ) -} + -const mapStateToProps = (state) => { - if ( state.filter === 'ALL' ) { - return { - notes: state.notes - } +你也可以指定仅额外尝试一次: + +```js +const result = useQuery( + 'anecdotes', getAnecdotes, + { + retry: 1 } +) +``` + +#### Exercise 6.21 + + + +使用 React Query 向服务器添加新的箴言。这个应用默认应渲染出全部箴言。注意,箴言的内容应不少于 5 个字符,否则,服务器将拒绝 POST 请求。你目前还不用考虑异常处理。 + +#### Exercise 6.22 + + + +使用 React Query 再次实现以投票功能。应用应该可以自动渲染被投票箴言的最新票数。 + +
    + +
    - return { - notes: (state.filter === 'IMPORTANT' - ? state.notes.filter(note => note.important) - : state.notes.filter(note => !note.important) - ) + +### useReducer + + + +即使应用使用了 React query,通常还需要某种解决方案以管理前端的其他状态(例如,表单状态)。通常,利用 *useState* 创建的状态足以应对这种状况。使用 Redux 当然也没问题,但是我们还有其他选择。 + + + +让我们看一个简单的计数应用。这个应用显示计数器的值,并提供三个按钮以更新计数器的状态: + +![browser showing + - 0 buttons and 7 above](../../images/6/63new.png) + + + +现在,我们利用 React 内置的 [useReducer](https://beta.reactjs.org/reference/react/useReducer) 钩子来进行状态管理,useReducer 钩子具有类似 Redux 的状态管理机制。代码如下: + +```js +import { useReducer } from 'react' + +const counterReducer = (state, action) => { + switch (action.type) { + case "INC": + return state + 1 + case "DEC": + return state - 1 + case "ZERO": + return 0 + default: + return state } } -const mapDispatchToProps = { - toggleImportanceOf +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) + + return ( +
    +
    {counter}
    +
    + + + +
    +
    + ) } -// eksportoidaan suoraan connectin palauttama komponentti -export default connect( - mapStateToProps, - mapDispatchToProps -)(Notes) +export default App ``` - -我们也可以使用 connect 来创建新便笺: + + +[useReducer](https://beta.reactjs.org/reference/react/useReducer) 钩子提供了为应用创建状态的机制。创建一个状态所需的参数有:处理状态变化的 reducer 函数,以及状态的初始值: ```js -import React from 'react' -import { connect } from 'react-redux' -import { createNote } from '../reducers/noteReducer' +const [counter, counterDispatch] = useReducer(counterReducer, 0) +``` -const NewNote = (props) => { // highlight-line - - const addNote = async (event) => { - event.preventDefault() - const content = event.target.note.value - event.target.note.value = '' - props.createNote(content) // highlight-line + + +处理状态变化的 reducer 函数和 Redux 中的 reducers 类似,即,用该函数获得当前状态和改变此状态的 action 作为参数。该函数根据 action 的类型和其中的内容而返回更新后的状态。 + +```js +const counterReducer = (state, action) => { + switch (action.type) { + case "INC": + return state + 1 + case "DEC": + return state - 1 + case "ZERO": + return 0 + default: + return state } +} +``` + + + +在我们的例子中,action 只有类型这一个字段。如果动作的类型是 *INC*,它就会将计数器的值增加 1,其他也类似。正如 Redux 的 reducers,actions 也可以包含任意的数据,这些数据通常都被放在 *payload* 字段中。 + + + +useReducer 函数返回一个数组,该数组包含一个可以访问当前状态值的元素(数组的第一个元素),以及一个用于改变状态的 *dispatch* 函数(数组的第二个元素): + +```js +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) // highlight-line return ( -
    - - -
    +
    +
    {counter}
    // highlight-line +
    + // highlight-line + + +
    +
    ) } +``` + + -// highlight-start -export default connect( - null, - { createNote } -)(NewNote) -// highlight-end +我们对状态的更改顺利完成,正如利用 Redux 一样。恰当的状态改变类型被传入 dispatch 函数作为参数: + +```js +counterDispatch({ type: "INC" }) ``` - -由于组件不需要访问存储的状态,我们可以简单地将null 作为连接的第一个参数。 + - -您可以在 [this Github repository](https://github.com/fullstack-hy2020/redux-notes/tree/part6-5)的part6-5 分支中找到我们当前应用的全部代码 +当前应用的代码可以在 [GitHub](https://github.com/fullstack-hy2020/hook-counter/tree/part6-1) 上 *part6-1* 的分支中找到。 -### Referencing action creators passed as props - -让我们把注意力转移到 newnote 组件中一个有趣的细节上: +### -```js -import React from 'react' -import { connect } from 'react-redux' -import { createNote } from '../reducers/noteReducer' // highlight-line +### 使用 context 传递组件的状态 -const NewNote = (props) => { - - const addNote = async (event) => { - event.preventDefault() - const content = event.target.note.value - event.target.note.value = '' - props.createNote(content) // highlight-line - } + + +如果我们希望将应用拆分成多个组件,我们必须将计数器的值和用于管理它的 dispatch 函数也传递给其他组件。一个解决方案是将计数器的值和 dispatch 函数作为参数传递: + +```js +const Display = ({ counter }) => { + return
    {counter}
    +} +const Button = ({ dispatch, type, label }) => { return ( -
    - - -
    + ) } -export default connect( - null, - { createNote } // highlight-line -)(NewNote) +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) + + return ( +
    + // highlight-line +
    + // highlight-start +
    +
    + ) +} ``` - -刚开始connect的开发人员可能会感到困惑,因为组件中有两个版本的 createNoteaction创建器。 + + +这个解决方案是可行的,但并不是最优的。如果组件的结构变得更复杂,例如,需要经过多个组件才能将 dispatch 函数转发到真正需要它的组件,即使处于组件树中的其他组件都不需要它。这种现象被称为 *prop drilling*. + + + +React 内置的 [Context API](https://beta.reactjs.org/learn/passing-data-deeply-with-context) 为我们提供了一个解决方案。React 的 context 类似应用的全局状态,应用中的组件均可以直接访问它。 + + + +现在,让我们在应用中创建一个 context,用以存储计数器的状态。 - -必须通过组件的 props.createNote 引用该函数,因为这是 包含由 connect 添加的自动分派 的版本。 + - -根据导入action创建器的方式: +使用 React 的 [createContext](https://beta.reactjs.org/reference/react/createContext) 钩子创建 context。让我们在文件 *CounterContext.js* 中创建 context: ```js -import { createNote } from './../reducers/noteReducer' +import { createContext } from 'react' + +const CounterContext = createContext() + +export default CounterContext ``` - -还可以通过调用 createNote 直接引用action创建者。 您不应该这样做,因为这是action创建者的未修改版本,不包含添加的自动分派。 + - -如果我们从代码打印函数到控制台(我们还没有看到这个有用的调试技巧) : +*App* 组件现在可以通过如下的方式,向子组件提供 context: ```js -const NewNote = (props) => { - console.log(createNote) - console.log(props.createNote) +import CounterContext from './CounterContext' // highlight-line - const addNote = (event) => { - event.preventDefault() - const content = event.target.note.value - event.target.note.value = '' - props.createNote(content) - } +const App = () => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) - // ... + return ( + // highlight-line + +
    +
    +
    // highlight-line + ) } ``` - -我们可以看到这两个函数之间的区别: + -![](../../images/6/10.png) +可以看到,我们通过将子组件包裹在 *CounterContext.Provider* 组件中,并为 context 设置合适的值,以传递 context。 - -第一个函数是一个常规的action creator,而第二个函数包含对由 connect 添加的存储的附加分派。 + - -连接是一个非常有用的工具,尽管由于它的抽象级别,乍看起来可能很困难。 +context 的值现在被设置为一个包含了计数器的值和 *dispatch* 函数的数组。 -### Alternative way of using mapDispatchToProps -【使用 mapDispatchToProps 的另一种方式】 - -我们如下面的方式定义了从连接的NewNote 组件发送操作的函数: + + +其他的组件现在可以通过使用 [useContext](https://beta.reactjs.org/reference/react/useContext) 钩子来访问 context。 ```js -const NewNote = () => { - // ... +import { useContext } from 'react' // highlight-line +import CounterContext from './CounterContext' + +const Display = () => { + const [counter, dispatch] = useContext(CounterContext) // highlight-line + return
    + {counter} +
    } -export default connect( - null, - { createNote } -)(NewNote) +const Button = ({ type, label }) => { + const [counter, dispatch] = useContext(CounterContext) // highlight-line + return ( + + ) +} ``` - -上面的 connect 表达式允许组件使用 props.createNote('a new note') 命令分派用于创建新便笺的操作。 + + +当前应用的代码可以在 [GitHub](https://github.com/fullstack-hy2020/hook-counter/tree/part6-2) 上 *part6-2* 的分支中找到。 - -在mapDispatchToProps 中传递的函数必须是action creators,即返回 Redux 操作的函数。 +### - -值得注意的是, mapDispatchToProps 参数是一个 JavaScript object,作为定义: +### 在单独的文件中定义计数器的 context + + + +我们的应用有个令人讨厌的特点:计数器一部分状态管理的功能,是在 *APP* 组件中定义的。现在,让我们将和计数器有关的内容,都移动到 *CounterContext.js*。 ```js -{ - createNote +import { createContext, useReducer } from 'react' + +const counterReducer = (state, action) => { + switch (action.type) { + case "INC": + return state + 1 + case "DEC": + return state - 1 + case "ZERO": + return 0 + default: + return state + } } -``` - -只是定义 object 语义的简写形式: +const CounterContext = createContext() -```js -{ - createNote: createNote +export const CounterContextProvider = (props) => { + const [counter, counterDispatch] = useReducer(counterReducer, 0) + + return ( + + {props.children} + + ) } + +export default CounterContext ``` - -它是一个具有单个createNote 属性的对象,其值为createNote 函数。 + - -或者,我们可以将下面的function 定义作为连接的第二个参数: +这个文件除了导出和 context 对应的 *CounterContext* 对象外,还导出了CounterContextProvider 组件,这个组件实际上是一个 context 提供方,它的值包括一个计数器和一个用于其状态管理的调度器。 -```js -const NewNote = (props) => { - // ... -} + -// highlight-start -const mapDispatchToProps = dispatch => { - return { - createNote: value => { - dispatch(createNote(value)) - }, - } -} -// highlight-end +让我们更新 *index.js* ,以启用 context 提供方: -export default connect( - null, - mapDispatchToProps -)(NewNote) +```js +import ReactDOM from 'react-dom/client' +import App from './App' +import { CounterContextProvider } from './CounterContext' // highlight-line + +ReactDOM.createRoot(document.getElementById('root')).render( + // highlight-line + + // highlight-line +) ``` - -在这个替代定义中, mapDispatchToProps是一个函数,它通过将 dispatch-function 作为参数传递给它来调用它。 函数的返回值是一个对象,它定义了一组作为props传递给连接组件的函数。 我们的示例将传递的函数定义为 createNote prop: + + +现在,定义了计数器的值和功能的 context,可以被应用中的*所有*组件使用。 + + + +*App* 组件则被简化成如下的形式: ```js -value => { - dispatch(createNote(value)) +import Display from './components/Display' +import Button from './components/Button' + +const App = () => { + return ( +
    + +
    +
    +
    + ) } + +export default App ``` - -它只是分发使用createNote action创建器创建的action。 + - -然后,该组件通过其 props.createNote 引用该函数: +对 context 的使用仍然遵循先前的相同方法,例如, *Button* 组件可以通过如下的方式定义: ```js -const NewNote = (props) => { - const addNote = (event) => { - event.preventDefault() - const content = event.target.note.value - event.target.note.value = '' - props.createNote(content) - } +import { useContext } from 'react' +import CounterContext from '../CounterContext' +const Button = ({ type, label }) => { + const [counter, dispatch] = useContext(CounterContext) return ( -
    - - -
    + ) } + +export default Button ``` - -这个概念相当复杂,通过文本来描述它是具有挑战性的。 在大多数情况下,使用更简单的mapDispatchToProps 就足够了。 然而,在有些情况下,需要更复杂的定义,比如分派的操作 需要引用[组件的支持](https://github.com/gaearon/redux-devtools/issues/250#issuecomment-186429931)。 + - -Redux的创建者 Dan Abramov 创建了一个非常棒的教程,叫做 [Getting started with Redux](https://egghead.io/courses/getting-started-with-redux) ,你可以在 [Egghead.io](https://Egghead.io/courses/Getting-started-with-Redux)上找到这个 。 我向每个人强烈推荐这个教程。 最后四个视频讨论了连接方法,特别是使用它的更“复杂”的方式。 +*Button* 组件仅需要计数器的 *dispatch* 函数,但是它也可以通过 *useContext* 从 context 中获取计数器的值: -### Presentational/Container revisited -【复习表现层/容器】 +```js + const [counter, dispatch] = useContext(CounterContext) +``` - -重构的Notes 组件几乎完全集中在渲染便笺上,并且非常接近于所谓的[表示组件](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0组件)。 根据 Dan Abramov 提供的 [description](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0),演示组件: + - -关心事物的外观。 - -- 可能包含表示和容器组件,并且通常有一些 DOM 标签和它们自己的样式。 - -- 经常允许通过建筑物进行隔离。 - -- 不依赖于应用的其他部分,如 Redux 操作或store。 - -- 不要说明数据是如何加载或Mutation的。 - -- 只通过props接收数据和回调。 - -- 很少有自己的状态(当他们这样做时,是 UI 状态而不是数据)。 - -- 除非需要状态、生命周期Hook或性能优化,否则被编写为功能组件。 +这不是个大问题,但是我们可以通过在 *CounterContext* 文件中编写一些辅助函数,使我们的代码更加优雅和清晰: - -使用 connect 函数创建的连接组件 : +```js +import { createContext, useReducer, useContext } from 'react' // highlight-line + +const CounterContext = createContext() + +// ... + +export const useCounterValue = () => { + const counterAndDispatch = useContext(CounterContext) + return counterAndDispatch[0] +} + +export const useCounterDispatch = () => { + const counterAndDispatch = useContext(CounterContext) + return counterAndDispatch[1] +} + +// ... +``` + + + +有了辅助函数的帮助,组件使用 context 就可以只获取它们所需要的那部分。*Display* 组件的更新如下: ```js -const mapStateToProps = (state) => { - if ( state.filter === 'ALL' ) { - return { - notes: state.notes - } - } +import { useCounterValue } from '../CounterContext' // highlight-line - return { - notes: (state.filter === 'IMPORTANT' - ? state.notes.filter(note => note.important) - : state.notes.filter(note => !note.important) - ) - } +const Display = () => { + const counter = useCounterValue() // highlight-line + return
    + {counter} +
    } -const mapDispatchToProps = { - toggleImportanceOf, + +export default Display +``` + + + +Button 组件更新为: + +```js +import { useCounterDispatch } from '../CounterContext' // highlight-line + +const Button = ({ type, label }) => { + const dispatch = useCounterDispatch() // highlight-line + return ( + + ) } -export default connect( - mapStateToProps, - mapDispatchToProps -)(Notes) +export default Button ``` - -符合容器 组件的描述,根据 Dan Abramov 提供的[description](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0),容器组件: + + +这个解决方案非常优雅。整个应用的状态,即,计数器的值和管理值的代码,已经独立放置于 CounterContext 文件中,这个文件提供了命名良好和易于使用的辅助函数来管理状态。 + + - -- 关心事物的运作方式。 - -- 内部可能包含表示和容器组件,但通常没有它们自己的 DOM 标签,除了一些包装的 div,并且从来没有任何样式。 - -- 为表示或其他容器组件提供数据和行为。 - -- 调用 Redux 操作,并将其作为表示组件的回调提供。 - -通常是有状态的,因为它们倾向于作为数据源。 - -- 通常使用高阶组件(如 React Redux 中的 connect)生成,而不是手写。 +当前应用的代码可以在 [GitHub](https://github.com/fullstack-hy2020/hook-counter/tree/part6-3) 上 *part6-3* 的分支中找到。 - -将应用划分为表示和容器组件是构造 React 应用的一种方法,这种方法被认为是有益的。 划分可能是一个很好的设计选择,也可能不是,这取决于上下文。 + - -Abramov将如下[benefits](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0)归功于这个部分: +作为一个技术细节,应当注意到辅助函数——useCounterValueuseCounterDispatch,是 [自定义钩子(custom hooks)](https://reactjs.org/docs/hooks-custom.html),因为[只能](https://reactjs.org/docs/hooks -rules.html)通过 React 组件或自定义钩子调用钩子函数——*useContext*。此外,自定义钩子是必须以 *use* 作为名称开头的 JavaScript 函数。我们将在这门课程的 [part 7](/en/part7/custom_hooks) 更深入地探讨自定义钩子。 - -- 更好的关注点分离。通过这种方式编写组件,你可以更好地理解你的应用和你的用户界面。 - -- 更好的可重用性。 您可以使用具有完全不同状态源的相同表示组件,并将其转换为可以进一步重用的单独容器组件。 - -- 表现组件本质上是你的应用的“调色板”。 你可以把它们放在一个页面上,让设计师在不触及应用逻辑的情况下调整它们的所有变化。 您可以在该页面上运行屏幕截图回归测试。 +
    + +
    - -阿布拉莫夫提到了术语[高阶组件](https://reactjs.org/docs/higher-order-components.html)。Notes 组件是常规组件的一个例子,而 React-Redux 提供的connect 方法是高阶组件 的一个例子。 从本质上讲,高阶组件是接受“ regular”组件作为参数的函数,然后返回一个新的“ regular”组件作为其返回值。 +### Exercises 6.22.-6.23. - -高阶组件(High order components,简称 hoc)是定义可应用于组件的通用功能的一种方法。 这是一个来自函数式编程的概念,非常类似于面向对象编程中的继承。 +#### Exercise 6.22. - -Hoc 实际上是[高阶函数](https://en.wikipedia.org/wiki/higher-order_function)(HOF)概念的推广。 Hofs 是接受函数作为参数或返回函数的函数。 实际上我们在整个课程中一直在使用 HOFs,例如,所有用于处理数组如 map、 filter 和 find 的方法都是 HOFs。 + +应用有一个 Notification 的组件,用于向用户展示通知。 + - -React hook-api 发布之后,HOCs 变得越来越不受欢迎。 几乎所有过去基于 hoc 的库现在都被修改为使用Hook。 大多数基于Hook的 api 比基于 HOC 的 api 简单得多,redux 的情况也是如此。 +使用 useReducer 和 context 实现应用程序通知功能的状态管理。当新的箴言被创建或被投票时,应该向用户推送通知。 -### Redux and the component state -【Redux 和组件状态】 - -我们在这个过程中已经走了很长的路,最后,我们已经到了我们使用 React“ the right way”的地步,意思是 React 只关注于生成视图,应用状态完全独立于 Redux 组件,并传递到 Redux、 Redux 的action和 Redux 的还原器。 +![browser showing notification for added anecdote](../../images/6/66new.png) - -那么 useState-hook 呢? 它为组件提供它们自己的状态? 如果应用正在使用 Redux 或其他外部状态管理解决方案,它是否有任何作用? 如果应用具有更复杂的形式,那么使用 useState 函数提供的状态实现它们的本地状态可能有益。 当然,可以让 Redux 管理表单的状态,但是,如果表单的状态只在填写表单时有关(例如用于验证) ,那么将状态的管理留给负责表单的组件可能是明智的。 + +通知应显示 5 秒。 +#### Exercise 6.24. - -我们应该一直使用 redux 吗? 可能不是。 Redux 的开发者 Dan Abramov 在他的文章 [You Might Not Need Redux](https://medium.com/@dan_abramov/you-might-not-need-redux-be46360cf367)中讨论了这个 + +正如在练习 6.20 中说明的,被添加至服务器的箴言,长度不应少于 5 个字符。现在我们在新增操作中添加异常处理。在实践中,当 POST 请求失败时,向用户展示一条通知就足够了。 +![browser showing error notification for trying to add too short of an anecdoate](../../images/6/67new.png) + +触发的错误情境应在回调函数中处理——被注册的回调函数会专门处理该种错误情境,你可以在[这里](https://tanstack.com/query/latest/docs/react/reference/useMutation)了解如何注册一个函数。 - -现在,通过使用 React [context](https://reactjs.org/docs/context.html)-api 和[useReducer](https://reactjs.org/docs/hooks-reference.html#useReducer)-hook,不需要 redux 就可以实现类似 redux 的状态管理。 - -更多关于这个[这里](https://www.simplethread.com/cant-replace-redux-with-hooks/)和[这里](https://hswolff.com/blog/how-to-usecontext-with-usereducer/)的内容,我们也会在[第9章](/zh/part9)中提及 + + +这是该部分课程的最后一个练习,现在是时候将你的代码推送至 GitHub,并在[练习提交系统](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)中将所有你已完成的练习进行标注。
    +
    +### -
    +### 应该选择哪一个状态管理方案? -### Exercises 6.19.-6.21. -#### 6.19 anecdotes and connect, 步骤1 - -redux store 目前通过props传递给所有组件。 - -添加[react-redux](google https://github.com/reactjs/react-redux)包到您的应用中,并修改/ 八卦列表,以便它借助 connect 函数访问存储的状态。 + - -在这个练习之后,投票选举和创造新的八卦就不需要工作了。 +在 1 至 5 章中,应用的所有状态管理都通过 React 的钩子 —— *useState* 来处理. 偶尔,对后端的异步调用还需要用上 *useEffect*。通常情况下,我们就不再需要额外的东西了。 - -在这个练习中,您需要的mapStateToProps 函数大致如下: + -```js -const mapStateToProps = (state) => { - // sometimes it is useful to console log from mapStateToProps - console.log(state) - return { - anecdotes: state.anecdotes, - filter: state.filter - } -} -``` +在使用 *useState* 作为状态管理解决方案时,存在一个微妙的问题:如果应用某部分状态被多个组件需要,那么该状态和对应的操作状态的函数,必须通过 props 在所有处理状态的组件中层层传递。有时,props 需要在多个组件中传递,虽然这些过程中的组件并不需要该状态。这种有些令人不快的现象叫做 prop drilling 。 + + + +过去几年中,一些针对 Rect 应用状态管理的替代方案开始显露头角,它们可用于解决棘手的状况(例如:prop drilling)。然而,目前还不存在一个终极方案,当下所有的方案都有其自己的优势和劣势,而且新的解决方案还在层出不穷。 + + + +这种状况可能让初学者、甚至经验丰富的网页开发者感到无所适从——究竟应该使用哪一种方案? -#### 6.20 anecdotes and connect, 步骤2 + +对于简单的应用,*useState* 是个很好的起点。如果应用需要和服务器进行通信的话,这样的通信可以用与 1 - 5 章中相同的方式处理——即利用应用本身的状态。然而最近,利用 React Query (或类似的库)去处理全部,或至少一部分,通信和相关的状态管理,已变得越来越普遍。如果你对 *useState* 及相应的 prop drilling 抱有疑虑,context 可能会是一个好的选择。在一些情境下,利用 useState 管理部分状态,同时使用 context 管理其他部分的状态,也会是合理的。 - -对Filterembarriteform 组件执行同样的操作。 + -#### 6.21 anecdotes, the grand finale - -您(可能)在应用中有一个讨厌的 bug。 如果用户连续多次单击投票按钮,通知就会显示得非常有趣。 例如,如果一个用户在三秒内投票两次, - -最后一个通知只显示两秒钟(假设通知通常显示5秒钟)。 这是因为删除第一个通知时意外地删除了第二个通知。 +Redux 是其中最全面和强大的状态管理方案,它是实现所谓 [Flux](https://facebookarchive.github.io/flux/) 架构的一种方式。Redux 比本章介绍的方案更有历史。Redux 过去的僵化成为了当前很多新状态管理工具的开发动力,例如 React 的 *useReducer* 。但在有了 [Redux Toolkit](https://redux-toolkit.js.org/) 后,对 Redux 僵化的批评已经消散。 + +过去几年中,类似 Redux 的状态管理库层出不穷,比如新晋的 [Recoil](https://recoiljs.org/) 和略老一些的 [MobX](https://mobx.js.org/)。然而,根据 [Npm 趋势](https://npmtrends.com/mobx-vs-recoil-vs-redux),Redux 仍旧是主宰,而且甚至扩大了领先优势。 - -修正此 bug,以便在一行中进行多次投票后,最后一次投票的通知会显示5秒钟。 - -这可以通过在必要时显示新通知时取消删除以前的通知来实现。 - -Settimeout 函数的[documentation](https://developer.mozilla.org/en-us/docs/web/api/windoworworkerglobalscope/setTimeout 文档)对此也很有用。 - -这是本课程这一章节的最后一个练习,现在是时候把你的代码推送到 GitHub,并将所有完成的练习标记到[练习提交系统](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)。 +![graph showing redux growing in popularity over past 5 years](../../images/6/64new.png) + + + +此外,Redux 不需要应用于整个应用。例如,当一个表单状态完全不影响应用的其他状态时,不使用 Reudx 去管理表单状态也是合理的。另外,在一个应用中,同时使用 Redux 和 React Query 也是完全可以接受的。 + + + +该选择哪一个状态管理方案?这个问题并不容易回答,也无法给出一个单一的正确答案。当应用增长到一定程度,此前的状态管理方案也可能成为一个次优的选择,即使该应用已经投入了生产使用。
    + diff --git a/src/content/6/zh/part6e.md b/src/content/6/zh/part6e.md new file mode 100644 index 00000000000..79e2a6a119e --- /dev/null +++ b/src/content/6/zh/part6e.md @@ -0,0 +1,727 @@ +--- +mainImage: ../../../images/part-6.svg +part: 6 +letter: e +lang: zh +--- + + + +
    + +**注意**: 本章作为旧版第 6 部分的结束章节,已经在2023年1月30日被替换为“ [React Query, useReducer 和 context](/zh/part6/react_query_use_reducer_and_the_context)“。这一章将仅在此保留几周。 + + + +如果你已经用 Redux 的 connect 方法开始了 6.19 - 6.21 的练习,你可以继续将它完成。但如果你还没有开始,我建议你从更新后的新章节开始。 + +
    + +
    + +### + + + 到目前为止,我们在react-redux的[hook](https://react-redux.js.org/api/hooks)-api的帮助下使用我们的redux-store。 + + 实际上这意味着使用[useSelector](https://react-redux.js.org/api/hooks#useselector)和[useDispatch](https://react-redux.js.org/api/hooks#usedispatch)函数。 + + + 为了完成这一部分,我们将研究另一种更古老、更复杂的使用redux的方式,即 react-redux 提供的 [connect](https://github.com/reduxjs/react-redux/blob/master/docs/api/connect.md) 函数。 + +**In new applications you should absolutely use the hook-api**, but knowing how to use connect is useful when maintaining older projects using redux. + +### Using the connect-function to share the redux store to components + + + 让我们修改Notes组件,使其不再使用hook-api(*useDispatch* 和 *useSelector* 函数)而是使用 *connect* 函数。 + + 我们必须修改该组件的以下部分。 + +````js +import { useDispatch, useSelector } from 'react-redux' // highlight-line +import { toggleImportanceOf } from '../reducers/noteReducer' + +const Notes = () => { + // highlight-start + const dispatch = useDispatch() + const notes = useSelector(({filter, notes}) => { + if ( filter === 'ALL' ) { + return notes + } + return filter === 'IMPORTANT' + ? notes.filter(note => note.important) + : notes.filter(note => !note.important) + }) + // highlight-end + + return( +
      + {notes.map(note => + + dispatch(toggleImportanceOf(note.id)) // highlight-line + } + /> + )} +
    + ) +} + +export default Notes +```` + + + *connect* 函数可以用来转换 "常规 "的React组件,这样Redux商店的状态就可以 "映射 "到组件的props中。 + + + 让我们首先使用connect函数将我们的Notes组件转换成connected component。 + +```js +import { connect } from 'react-redux' // highlight-line +import { toggleImportanceOf } from '../reducers/noteReducer' + +const Notes = () => { + // ... +} + +const ConnectedNotes = connect()(Notes) // highlight-line +export default ConnectedNotes // highlight-line +``` + + + 该模块导出了连接组件,其工作原理与之前的普通组件完全相同。 + + + 该组件需要Redux商店中的笔记列表和过滤器的值。*connect*函数接受一个所谓的[mapStateToProps](https://github.com/reduxjs/react-redux/blob/master/docs/api/connect.md#mapstatetoprops-state-ownprops--object)函数作为其第一个参数。该函数可用于定义连接组件的prop,这些prop是基于Redux商店的状态。 + + + 如果我们定义。 + + +```js +const Notes = (props) => { // highlight-line + const dispatch = useDispatch() + +// highlight-start + const notesToShow = () => { + if ( props.filter === 'ALL' ) { + return props.notes + } + + return props.filter === 'IMPORTANT' + ? props.notes.filter(note => note.important) + : props.notes.filter(note => !note.important) + } + // highlight-end + + return( +
      + {notesToShow().map(note => // highlight-line + + dispatch(toggleImportanceOf(note.id)) + } + /> + )} +
    + ) +} + +const mapStateToProps = (state) => { + return { + notes: state.notes, + filter: state.filter, + } +} + +const ConnectedNotes = connect(mapStateToProps)(Notes) // highlight-line + +export default ConnectedNotes +``` + + + Notes组件可以直接访问商店的状态,例如,通过包含笔记列表的props.notes。 类似地,props.filter引用过滤器的值。 + + + 使用connect和我们定义的mapStateToProps函数所产生的情况可以这样可视化。 + +![](../../images/6/24c.png) + + + + Notes组件可以通过props.notesprops.filter"直接访问 "Redux商店的状态。 + + + *NoteList*组件实际上不需要关于哪个过滤器被选中的信息,所以我们可以将过滤逻辑移到其他地方。 + + 我们只需要在*notes*prop中给它正确的过滤的笔记。 + +```js +const Notes = (props) => { + const dispatch = useDispatch() + + return( +
      + {props.notes.map(note => + + dispatch(toggleImportanceOf(note.id)) + } + /> + )} +
    + ) +} + +// highlight-start +const mapStateToProps = (state) => { + if ( state.filter === 'ALL' ) { + return { + notes: state.notes + } + } + + return { + notes: (state.filter === 'IMPORTANT' + ? state.notes.filter(note => note.important) + : state.notes.filter(note => !note.important) + ) + } +} +// highlight-end + +const ConnectedNotes = connect(mapStateToProps)(Notes) +export default ConnectedNotes +``` + +### mapDispatchToProps + + + 现在我们已经摆脱了*useSelector*,但是Notes仍然使用*useDispatch*钩子和返回它的*dispatch*函数。 + +```js +const Notes = (props) => { + const dispatch = useDispatch() // highlight-line + + return( +
      + {props.notes.map(note => + + dispatch(toggleImportanceOf(note.id)) // highlight-line + } + /> + )} +
    + ) +} +``` + + + *connect*函数的第二个参数可用于定义[mapDispatchToProps](https://github.com/reduxjs/react-redux/blob/master/docs/api/connect.md#mapdispatchtoprops-object--dispatch-ownprops--object),这是一组action creator函数,作为props传递给连接的组件。让我们对我们现有的连接操作做如下修改。 + + +```js +const mapStateToProps = (state) => { + return { + notes: state.notes, + filter: state.filter, + } +} + +// highlight-start +const mapDispatchToProps = { + toggleImportanceOf, +} +// highlight-end + +const ConnectedNotes = connect( + mapStateToProps, + mapDispatchToProps // highlight-line +)(Notes) + +export default ConnectedNotes +``` + + + 现在,组件可以直接调度由*toggleImportanceOf*动作创建者定义的动作,通过其props调用该函数。 + +```js +const Notes = (props) => { + return( +
      + {props.notes.map(note => + props.toggleImportanceOf(note.id)} + /> + )} +
    + ) +} +``` + + + 这意味着,不再像这样调度动作了。 + +```js +dispatch(toggleImportanceOf(note.id)) +``` + + + 当使用*connect*时,我们可以简单地这样做。 + +```js +props.toggleImportanceOf(note.id) +``` + + + 没有必要单独调用*dispatch*函数,因为*connect*已经将*toggleImportanceOf*动作创建者修改为包含调度的形式。 + + + 你可能需要一些时间来理解_mapDispatchToProps_的工作原理,尤其是当我们看了一个[使用它的替代方法](/en/part6/connect_the_old_part#an-alternative-way-of-using-map-dispatch-to-props)。 + + + 使用*connect*所产生的情况可以被可视化为这样。 + +![](../../images/6/25b.png) + + + 除了通过props.notesprops.filter访问商店的状态外,该组件还引用了一个函数,该函数可用于通过其toggleImportanceOfprop调度notes/toggleImportanceOf型动作。 + + + 新重构的Notes组件的代码如下所示: + +```js +import { connect } from 'react-redux' +import { toggleImportanceOf } from '../reducers/noteReducer' + +const Notes = (props) => { + return( +
      + {props.notes.map(note => + props.toggleImportanceOf(note.id)} + /> + )} +
    + ) +} + +const mapStateToProps = (state) => { + if ( state.filter === 'ALL' ) { + return { + notes: state.notes + } + } + + return { + notes: (state.filter === 'IMPORTANT' + ? state.notes.filter(note => note.important) + : state.notes.filter(note => !note.important) + ) + } +} + +const mapDispatchToProps = { + toggleImportanceOf +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Notes) +``` + + + 让我们也使用*connect*来创建新的笔记。 + +```js +import { connect } from 'react-redux' +import { createNote } from '../reducers/noteReducer' + +const NewNote = (props) => { // highlight-line + + const addNote = (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + props.createNote(content) // highlight-line + } + + return ( +
    + + +
    + ) +} + +// highlight-start +export default connect( + null, + { createNote } +)(NewNote) +// highlight-end +``` + + + 由于该组件不需要访问商店的状态,我们可以简单地传递null作为*connect*的第一个参数。 + + + + 你可以在[这个Github仓库](https://github.com/fullstack-hy2020/redux-notes/tree/part6-5)的part6-5分支中找到我们当前应用的全部代码。 + +### Referencing action creators passed as props + + + 让我们注意一下NewNote组件中一个有趣的细节。 + +```js +import { connect } from 'react-redux' +import { createNote } from '../reducers/noteReducer' // highlight-line + +const NewNote = (props) => { + + const addNote = (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + props.createNote(content) // highlight-line + } + + return ( +
    + + +
    + ) +} + +export default connect( + null, + { createNote } // highlight-line +)(NewNote) +``` + + + 初次接触连接的开发者可能会发现,组件中的createNote动作创建者有两个版本,这令人费解。 + + + 该函数必须通过组件的props被引用为props.createNote,因为这是包含*connect*添加的自动调度的那个版本。 + + + 由于动作创建者的导入方式。 + +```js +import { createNote } from './../reducers/noteReducer' +``` + + 也可以通过调用*createNote*直接引用动作创建者。你不应该这样做,因为这是未经修改的动作创建者的版本,不包含添加的自动调度。 + + + 如果我们从代码中把这些函数打印到控制台(我们还没有看这个有用的调试技巧)。 + +```js +const NewNote = (props) => { + console.log(createNote) + console.log(props.createNote) + + const addNote = (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + props.createNote(content) + } + + // ... +} +``` + + + 我们可以看到这两个函数之间的区别。 + +![](../../images/6/10.png) + + + 第一个函数是一个普通的动作创建者,而第二个函数则包含了由connect添加的对存储的额外调度。 + + + Connect是一个非常有用的工具,尽管由于它的抽象程度,一开始可能看起来很困难。 + +### Alternative way of using mapDispatchToProps + + + 我们以如下方式定义了用于从连接的NewNote组件中调度动作的函数。 + +```js +const NewNote = () => { + // ... +} + +export default connect( + null, + { createNote } +)(NewNote) +``` + + + + 上面的连接表达式使该组件能够通过props.createNote("a new note")命令来调度创建新笔记的动作。 + + + + mapDispatchToProps中传递的函数必须是action creators,也就是返回Redux动作的函数。 + + + + 值得注意的是,mapDispatchToProps参数是一个JavaScript对象,正如其定义。 + +```js +{ + createNote +} +``` + + + 这只是定义对象字面的速记。 + +```js +{ + createNote: createNote +} +``` + + + 这是一个具有单一的createNote属性的对象,其值为createNote函数。 + + + 另外,我们可以把下面的函数定义作为第二个参数传递给 *connect*。 + +```js +const NewNote = (props) => { + // ... +} + +// highlight-start +const mapDispatchToProps = dispatch => { + return { + createNote: value => { + dispatch(createNote(value)) + }, + } +} +// highlight-end + +export default connect( + null, + mapDispatchToProps +)(NewNote) +``` + + + + 在这个替代定义中,mapDispatchToProps是一个函数,*connect*将把*dispatch*-函数作为其参数传递给它,从而调用该函数。该函数的返回值是一个定义了一组函数的对象,这些函数被作为prop传递给连接的组件。我们的例子定义了作为createNoteprop传递的函数。 + +```js +value => { + dispatch(createNote(value)) +} +``` + + + 它简单地分配了用createNote动作创建器创建的动作。 + + + 然后组件通过它的prop调用props.createNote来引用这个函数。 + +```js +const NewNote = (props) => { + const addNote = (event) => { + event.preventDefault() + const content = event.target.note.value + event.target.note.value = '' + props.createNote(content) + } + + return ( +
    + + +
    + ) +} +``` + + + 这个概念相当复杂,通过文字来描述它是有难度的。在大多数情况下,使用mapDispatchToProps的较简单形式就足够了。然而,在某些情况下,更复杂的定义是必要的,例如,如果dispatched actions需要引用[组件的prop](https://github.com/gaearon/redux-devtools/issues/250#issuecomment-186429931)。 + + + Redux的创造者Dan Abramov创造了一个精彩的教程,叫做[Redux入门](https://egghead.io/courses/getting-started-with-redux),你可以在Egghead.io上找到。我向大家强烈推荐这个教程。最后四个视频讨论了*connect*方法,特别是使用它的更 "复杂 "的方式。 + +### Presentational/Container revisited + + + 重构后的Notes组件几乎完全专注于渲染笔记,而且相当接近于所谓的[展示性组件](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0)。根据Dan Abramov提供的[描述](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0),演示组件。 + + + - 关注事物的外观。 + + - 里面可能同时包含展示组件和容器组件,并且通常有一些DOM标记和它们自己的样式。 + + - 通常允许通过props.children进行包含。 + + - 对应用的其他部分没有依赖性,例如Redux动作或商店。 + + - 不指定数据是如何被加载或改变的。 + + - 完全通过props接收数据和回调。 + + - 很少有自己的状态(如果有,也是UI状态而不是数据)。 + + - 除非它们需要状态、生命周期钩子或性能优化,否则就被写成功能组件。 + + + 用*connect*函数创建的_connected组件_。 + +```js +const mapStateToProps = (state) => { + if ( state.filter === 'ALL' ) { + return { + notes: state.notes + } + } + + return { + notes: (state.filter === 'IMPORTANT' + ? state.notes.filter(note => note.important) + : state.notes.filter(note => !note.important) + ) + } +} + +const mapDispatchToProps = { + toggleImportanceOf, +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Notes) +``` + + + 符合container组件的描述。根据Dan Abramov提供的[描述](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0),容器组件。 + + + - 关注事物的工作方式。 + + - 里面可能同时包含展示性组件和容器组件,但除了一些包裹性的div,通常没有任何自己的DOM标记,也没有任何样式。 + + - 为展示性或其他容器组件提供数据和行为。 + + - 调用Redux动作,并将其作为回调提供给渲染组件。 + + - 通常是有状态的,因为它们往往作为数据源。 + + - 通常使用高阶组件生成,如来自React Redux的connect,而不是手工编写。 + + + 将应用分为展示性组件和容器组件是一种被认为是有益的React应用结构的方式。这种划分可能是一个好的设计选择,也可能不是,这取决于环境。 + + + Abramov将以下[好处](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0)归于这种划分。 + + + - 更好地分离关注点。通过这样写组件,你可以更好地理解你的应用和你的用户界面。 + + - 更好的重用性。你可以在完全不同的状态源中使用相同的表现型组件,并将这些组件变成可以进一步重用的独立容器组件。 + + - 渲染式组件本质上是你的应用的 "调色板"。你可以把它们放在一个页面上,让设计者调整它们的所有变化,而不触及应用的逻辑。你可以在该页面上运行屏幕截图回归测试。 + + +阿布拉莫夫提到了术语[高阶组件](https://reactjs.org/docs/higher-order-components.html)。Notes组件是一个普通组件的例子,而React-Redux提供的connect方法是一个高阶组件的例子。从本质上讲,高阶组件是一个接受 "常规 "组件作为其参数的函数,然后返回一个新的 "常规 "组件作为其返回值。 + + +高阶组件,或称HOC,是一种定义可以应用于组件的通用功能的方式。这是一个来自函数式编程的概念,与面向对象编程中的继承非常相似。 + + + HOCs实际上是[高阶函数](https://en.wikipedia.org/wiki/Higher-order_function) (HOF)概念的概括。HOFs是接受函数作为参数或返回函数的函数。实际上,我们在整个课程中一直在使用HOF,例如,所有用于处理数组的方法,如*map、filter和find*都是HOF。 + + + + 在React hook-api发布后,HOCs变得越来越不流行了。几乎所有曾经基于HOCs的库现在都被修改为使用钩子。大多数时候,基于钩子的api比基于HOC的api要简单得多,redux也是如此。 + +### Redux and the component state + + + 在这个课程中,我们已经走了很长的路,最后,我们已经走到了 "正确的方式 "使用React的地步,这意味着React只专注于生成视图,应用状态完全与React组件分离,并传递给Redux、其动作和其还原器。 + + + 那么*useState*-hook呢,它为组件提供了它们自己的状态?如果一个应用使用Redux或其他外部状态管理解决方案,它是否有任何作用?如果应用有更复杂的表单,使用*useState*函数提供的状态来实现它们的本地状态可能是有益的。当然,人们可以让Redux管理表单的状态,然而,如果表单的状态只在填写表单时才相关(例如用于验证),那么将状态的管理留给负责表单的组件可能是明智的。 + + + 我们应该总是使用redux吗?也许不是。redux的开发者Dan Abramov在他的文章[You Might Not Need Redux](https://medium.com/@dan_abramov/you-might-not-need-redux-be46360cf367)中讨论了这个问题。 + + + 现在可以通过使用React [context](https://reactjs.org/docs/context.html)-api和[useReducer](https://reactjs.org/docs/hooks-reference.html#usereducer)-hook来实现类似redux的状态管理,而无需redux。 + + 关于这个的更多信息[这里](https://www.simplethread.com/cant-replace-redux-with-hooks/)和[这里](https://hswolff.com/blog/how-to-usecontext-with-usereducer/)。我们还将在以下方面进行实践 + + [第9章节](/en/part9)。 + +
    + +
    + +### Exercises 6.19.-6.21 + + +**注意**: 本章作为旧版第 6 部分的结束章节,已经在2023年1月30日被替换为“ [React Query, useReducer 和 context](/zh/part6/react_query_use_reducer_and_the_contex)“。这一章将仅在此保留几周。 + + + +如果你已经用 Redux 的 connect 方法开始了 6.19 - 6.21 的练习,你可以继续将它完成。但如果你还没有开始,我建议你从更新后的新章节开始。 + +#### 6.19 anecdotes and connect, step1 + + + redux存储目前被组件通过useSelectoruseDispatch挂钩访问。 + + + 修改Notification组件,使其使用*connect*函数而不是钩子。 + +#### 6.20 anecdotes and connect, step2 + + + 对FilterAnecdoteForm组件进行同样的修改。 +#### 6.21 anecdotes, the grand finale + + + 你的应用中(可能)有一个讨厌的错误。如果用户连续多次点击投票按钮,通知就会有趣地显示出来。例如,如果一个用户在三秒钟内投票两次。 + +最后一个通知只显示两秒(假设通知通常显示5秒)。出现这种情况是因为删除第一条通知时,意外地删除了第二条通知。 + + + 修复这个错误,使连续多次投票后,最后一次投票的通知显示5秒。 + + 这可以通过在必要时显示新的通知时取消对前一个通知的删除来实现。 + + setTimeout函数的[文档](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout)也可能对此有用。 + + + 这是这部分课程的最后一个练习,是时候把你的代码推送到GitHub,并把你所有完成的练习标记到[练习提交系统](https://studies.cs.helsinki.fi/stats/courses/fullstackopen)。 + +
    diff --git a/src/content/7/en/part7.md b/src/content/7/en/part7.md index a8c5c8e11a0..379f50665d3 100644 --- a/src/content/7/en/part7.md +++ b/src/content/7/en/part7.md @@ -6,6 +6,10 @@ lang: en
    -The seventh part of the course touches on several different themes. First, we'll get familiar with React router. React router helps us divide the application into different views that are shown based on the URL in the browser's address bar. After this, we'll look at a few more ways to add CSS-styles to React applications. During the entire course we've used create-react-app to generate the body of our applications. This time we'll take a look under the hood: we'll learn how Webpack works and how we can use it to configure the application ourselves. We shall also have a look on hook-functions and how to define a custom hook. +The seventh part of the course touches on several different themes. First, we'll get familiar with React Router. React Router helps us divide the application into different views that are shown based on the URL in the browser's address bar. After this, we'll look at a few more ways to add CSS styles to React applications. During the entire course, we've used Vite to build all of our applications. +It is also possible to configure the whole toolchain yourself, and in this part we will see how this can be done with a tool called Webpack. We shall also have a look at hook functions and how to define a custom hook. + +Part updated 26th August 2023 +- Create React App replaced with Vite
    diff --git a/src/content/7/en/part7a.md b/src/content/7/en/part7a.md index 29ae9052d24..97d7e70134d 100644 --- a/src/content/7/en/part7a.md +++ b/src/content/7/en/part7a.md @@ -7,35 +7,35 @@ lang: en
    -The exercises in this seventh part of the course differ a bit from the ones before. In this and the next chapter, as usual, there are [exercises related to the theory in the chapter](/en/part7/react_router#exercises-7-1-7-3). +The exercises in this seventh part of the course differ a bit from the ones before. In this and the next chapter, as usual, there are [exercises related to the theory of the chapter](/en/part7/react_router#exercises-7-1-7-3). -In addition to the exercises in this and the next chapter, there are a series of exercises which will be revising what we've learned during the whole course by expanding the Bloglist application, which we worked on during parts 4 and 5. +In addition to the exercises in this and the next chapter, there are a series of exercises in which we'll be revising what we've learned during the whole course, by expanding the BlogList application, which we worked on during parts 4 and 5. ### Application navigation structure Following part 6, we return to React without Redux. -It is very common for web-applications to have a navigation bar, which enables switching the view of the application. +It is very common for web applications to have a navigation bar, which enables switching the view of the application. Our app could have a main page -![](../../images/7/1ea.png) +![browser showing notes app with home nav link](../../images/7/1ea.png) and separate pages for showing information on notes and users: -![](../../images/7/2ea.png) +![browser showing notes app with notes nav link](../../images/7/2ea.png) In an [old school web app](/en/part0/fundamentals_of_web_apps#traditional-web-applications), changing the page shown by the application would be accomplished by the browser making an HTTP GET request to the server and rendering the HTML representing the view that was returned. -In single page apps, we are, in reality, always on the same page. The Javascript code run by the browser creates an illusion of different "pages". If HTTP requests are made when switching view, they are only for fetching JSON formatted data, which the new view might require for it to be shown. +In single-page apps, we are, in reality, always on the same page. The Javascript code run by the browser creates an illusion of different "pages". If HTTP requests are made when switching views, they are only for fetching JSON-formatted data, which the new view might require for it to be shown. -The navigation bar and an application containing multiple views is very easy to implement using React. +The navigation bar and an application containing multiple views are very easy to implement using React. Here is one way: ```js -import React, { useState } from 'react' -import ReactDOM from 'react-dom' +import { useState } from 'react' +import ReactDOM from 'react-dom/client' const Home = () => (

    TKTL notes app

    @@ -52,7 +52,7 @@ const Users = () => ( const App = () => { const [page, setPage] = useState('home') - const toPage = (page) => (event) => { + const toPage = (page) => (event) => { event.preventDefault() setPage(page) } @@ -90,20 +90,21 @@ const App = () => { ) } -ReactDOM.render(, document.getElementById('root')) +ReactDOM.createRoot(document.getElementById('root')).render() ``` Each view is implemented as its own component. We store the view component information in the application state called page. This information tells us which component, representing a view, should be shown below the menu bar. -However, the method is not very optimal. As we can see from the pictures, the address stays the same even though at times we are in different views. Each view should preferably have its own address, e.g. to make bookmarking possible. The back-button doesn't work as expected for our application either, meaning that back doesn't move you to the previously displayed view of the application, but somewhere completely different. If the application were to grow even bigger and we wanted to, for example, add separate views for each user and note, then this self made routing, which means the navigation management of the application, would get overly complicated. +However, the method is not very optimal. As we can see from the pictures, the address stays the same even though at times we are in different views. Each view should preferably have its own address, e.g. to make bookmarking possible. The back button doesn't work as expected for our application either, meaning that back doesn't move you to the previously displayed view of the application, but somewhere completely different. If the application were to grow even bigger and we wanted to, for example, add separate views for each user and note, then this self-made routing, which means the navigation management of the application, would get overly complicated. - -Luckily, React has the [React router](https://github.com/ReactTraining/react-router)-library, which provides an excellent solution for managing navigation in a React-application. +### React Router -Let's change the above application to use React router. First, we install React router with the command +Luckily, React has the [React Router](https://reactrouter.com/) library which provides an excellent solution for managing navigation in a React application. -```js -npm install --save react-router-dom +Let's change the above application to use React Router. First, we install React Router with the command: + +```bash +npm install react-router-dom ``` The routing provided by React Router is enabled by changing the application as follows: @@ -111,8 +112,8 @@ The routing provided by React Router is enabled by changing the application as f ```js import { BrowserRouter as Router, - Switch, Route, Link -} from "react-router-dom" + Routes, Route, Link +} from 'react-router-dom' const App = () => { @@ -128,44 +129,38 @@ const App = () => { users
    - - - - - - - - - - - + + } /> + } /> + } /> +
    - Note app, Department of Computer Science 2020 + Note app, Department of Computer Science 2024
    ) } ``` -Routing, or the conditional rendering of components based on the url in the browser, is used by placing components as children of the Router component, meaning inside Router-tags. +Routing, or the conditional rendering of components based on the URL in the browser, is used by placing components as children of the Router component, meaning inside Router tags. -Notice that, even though the component is referred to by the name Router, we are in fact talking about [BrowserRouter](https://reacttraining.com/react-router/web/api/BrowserRouter), because here the import happens by renaming the imported object: +Notice that, even though the component is referred to by the name Router, we are talking about [BrowserRouter](https://reactrouter.com/en/main/router-components/browser-router), because here the import happens by renaming the imported object: ```js import { BrowserRouter as Router, // highlight-line - Switch, Route, Link -} from "react-router-dom" + Routes, Route, Link +} from 'react-router-dom' ``` -According to the [manual](https://reacttraining.com/react-router/web/api/BrowserRouter): +According to the [v5 docs](https://v5.reactrouter.com/web/api/BrowserRouter): > BrowserRouter is a Router that uses the HTML5 history API (pushState, replaceState and the popState event) to keep your UI in sync with the URL. -Normally the browser loads a new page when the URL in the address bar changes. However, with the help of the [HTML5 history API](https://css-tricks.com/using-the-html5-history-api/) BrowserRouter enables us to use the URL in the address bar of the browser for internal "routing" in a React-application. So, even if the URL in the address bar changes, the content of the page is only manipulated using Javascript, and the browser will not load new content form the server. Using the back and forward actions, as well as making bookmarks, is still logical like on a traditional web page. +Normally the browser loads a new page when the URL in the address bar changes. However, with the help of the [HTML5 history API](https://css-tricks.com/using-the-html5-history-api/), BrowserRouter enables us to use the URL in the address bar of the browser for internal "routing" in a React application. So, even if the URL in the address bar changes, the content of the page is only manipulated using Javascript, and the browser will not load new content from the server. Using the back and forward actions, as well as making bookmarks, is still logical like on a traditional web page. -Inside the router we define links that modify the address bar with the help of the [Link](https://reacttraining.com/react-router/web/api/Link) component. For example, +Inside the router, we define links that modify the address bar with the help of the [Link](https://reactrouter.com/en/main/components/link) component. For example: ```js notes @@ -173,61 +168,35 @@ Inside the router we define links that modify the address bar with the he creates a link in the application with the text notes, which when clicked changes the URL in the address bar to /notes. -Components rendered based on the URL of the browser are defined with the help of the component [Route](https://reacttraining.com/react-router/web/api/Route). For example, +Components rendered based on the URL of the browser are defined with the help of the component [Route](https://reactrouter.com/en/main/route/route). For example, ```js - - - -``` - - -defines, that if the browser address is /notes, we render the Notes component. - - -We wrap the components to be rendered based on the url with a [Switch](https://reacttraining.com/react-router/web/api/Switch)-component - -```js - - - - - - - - - - - +} /> ``` - -The switch works by rendering the first component whose path matches the url in the browser's address bar. +defines that, if the browser address is /notes, we render the Notes component. -Note that the order of the components is important. If we would put the Home-component, whose path is path="/", first, nothing else would ever get rendered because the "nonexistent" path "/" is the start of every path: +We wrap the components to be rendered based on the URL with a [Routes](https://reactrouter.com/en/main/components/routes) component -```js - - // highlight-line - // highlight-line - // highlight-line - - - - - // ... - +```js + + } /> + } /> + } /> + ``` +The Routes works by rendering the first component whose path matches the URL in the browser's address bar. + ### Parameterized route -Let's examine the slightly modified version from the previous example. The complete code for the example can be found [here](https://github.com/fullstack-hy2020/misc/blob/master/router-app-v1.js). +Let's examine a slightly modified version from the previous example. The complete code for the updated example can be found [here](https://github.com/fullstack-hy2020/misc/blob/master/router-app-v1.js). The application now contains five different views whose display is controlled by the router. In addition to the components from the previous example (Home, Notes and Users), we have Login representing the login view and Note representing the view of a single note. Home and Users are unchanged from the previous exercise. Notes is a bit more complicated. It renders the list of notes passed to it as props in such a way that the name of each note is clickable. -![](../../images/7/3ea.png) +![notes app showing notes are clickable](../../images/7/3ea.png) The ability to click a name is implemented with the component Link, and clicking the name of a note whose id is 3 would trigger an event that changes the address of the browser into notes/3: @@ -238,7 +207,7 @@ const Notes = ({notes}) => (
      {notes.map(note =>
    • - {note.content} + {note.content} // highlight-line
    • )}
    @@ -246,49 +215,35 @@ const Notes = ({notes}) => ( ) ``` - -We define parametrized urls in the routing in App-component as follows: +We define parameterized URLs in the routing of the App component as follows: ```js -
    -
    - home - notes - users -
    - - - // highlight-start - - - - // highlight-end - - - - - - - + // ... + + } /> // highlight-line + } /> + : } /> + } /> + } /> + ``` -We define the route rendering a specific note "express style" by marking the parameter with a colon :id +We define the route rendering a specific note "express style" by marking the parameter with a colon - :id ```js - +} /> ``` - -When a browser navigates to the url for a specific note, for example /notes/3, we render the Note component: +When a browser navigates to the URL for a specific note, for example, /notes/3, we render the Note component: ```js import { // ... useParams // highlight-line -} from "react-router-dom" +} from 'react-router-dom' const Note = ({ notes }) => { const id = useParams().id // highlight-line @@ -303,14 +258,13 @@ const Note = ({ notes }) => { } ``` -The _Note_ component receives all of the notes as props notes, and it can access the url parameter (the id of the note to be displayed) with the [useParams](https://reacttraining.com/react-router/web/api/Hooks/useparams) function of the react-router. +The _Note_ component receives all of the notes as props notes, and it can access the URL parameter (the id of the note to be displayed) with the [useParams](https://reactrouter.com/en/main/hooks/use-params) function of the React Router. -### useHistory +### useNavigate - -We have also implemented a simple log in function in our application. If a user is logged in, information about a logged in user is saved to the user field of the state of the App component. +We have also implemented a simple login function in our application. If a user is logged in, information about a logged-in user is saved to the user field of the state of the App component. -The option to navigate to the Login-view is rendered conditionally in the menu. +The option to navigate to the Login view is rendered conditionally in the menu. ```js @@ -330,25 +284,25 @@ The option to navigate to the Login-view is rendered conditionally in the ``` -So if the user is already logged in, instead of displaying the link Login, we show the username of the user: +So if the user is already logged in, instead of displaying the link Login, we show its username: -![](../../images/7/4a.png) +![browser notes app showing username logged in](../../images/7/4a.png) The code of the component handling the login functionality is as follows: ```js import { // ... - useHistory // highlight-line + useNavigate // highlight-line } from 'react-router-dom' const Login = (props) => { - const history = useHistory() // highlight-line + const navigate = useNavigate() // highlight-line const onSubmit = (event) => { event.preventDefault() props.onLogin('mluukkai') - history.push('/') // highlight-line + navigate('/') // highlight-line } return ( @@ -368,30 +322,24 @@ const Login = (props) => { } ``` - -What is interesting about this component is the use of the [useHistory](https://reacttraining.com/react-router/web/api/Hooks/usehistory) function of the react-router. -With this function, the component can access a [history](https://reacttraining.com/react-router/web/api/history) object. The history object can be used to modify the browser's url programmatically. +What is interesting about this component is the use of the [useNavigate](https://reactrouter.com/en/main/hooks/use-navigate) function of the React Router. With this function, the browser's URL can be changed programmatically. - -With user log in, we call the push method of the history object. The _history.push('/')_ call causes the browser's url to change to _/_ and the application renders the corresponding component Home. +With user login, we call _navigate('/')_ which causes the browser's URL to change to _/_ and the application renders the corresponding component Home. +Both [useParams](https://reactrouter.com/en/main/hooks/use-params) and [useNavigate](https://reactrouter.com/en/main/hooks/use-navigate) are hook functions, just like useState and useEffect which we have used many times now. As you remember from part 1, there are some [rules](/en/part1/a_more_complex_state_debugging_react_apps/#rules-of-hooks) to using hook functions. -Both [useParams](https://reacttraining.com/react-router/web/api/Hooks/useparams) and [useHistory](https://reacttraining.com/react-router/web/api/Hooks/usehistory) are hook-functions, just like useState and useEffect which we have used many times now. As you remember from part 1, there are some [rules](/en/part1/a_more_complex_state_debugging_react_apps/#rules-of-hooks) to using hook-functions. Create-react-app has been configured to warn you if you break these rules, for example, by calling a hook-function from a conditional statement. +### Redirect -### redirect - -There is one more interesting detail about the Users route: +There is one more interesting detail about the Users route: ```js - - user ? : -} /> + : } /> ``` -If a user isn't logged in, the Users component is not rendered. Instead, the user is redirected using the Redirect-component to the login view +If a user isn't logged in, the Users component is not rendered. Instead, the user is redirected using the component [Navigate](https://reactrouter.com/en/main/components/navigate) to the login view: ```js - + ``` In reality, it would perhaps be better to not even show links in the navigation bar requiring login if the user is not logged into the application. @@ -410,7 +358,9 @@ const App = () => { setUser(user) } - const padding = { padding: 5 } + const padding = { + padding: 5 + } return (
    @@ -425,39 +375,28 @@ const App = () => { }
    - - - - - - - - - {user ? : } - - - - - - - - + + } /> + } /> + : } /> + } /> + } /> + -
    +

    - Note app, Department of Computer Science 2020 -
    + Note app, Department of Computer Science 2024 +
    ) } ``` -We define an element common for modern web apps called footer, which defines the part at the bottom of the screen, outside of the Router, so that it is shown regardless of the component shown in the routed part of the application. +We define an element common for modern web apps called footer, which defines the part at the bottom of the screen, outside of the Router, so that it is shown regardless of the component shown in the routed part of the application. ### Parameterized route revisited -Our application has a flaw. The _Note_ component receives all of the notes, even though it only displays the one whose id matches the url parameter: - +Our application has a flaw. The _Note_ component receives all of the notes, even though it only displays the one whose id matches the URL parameter: ```js const Note = ({ notes }) => { @@ -467,8 +406,7 @@ const Note = ({ notes }) => { } ``` - -Would it be possible to modify the application so that _Note_ receives only the component it should display? +Would it be possible to modify the application so that the _Note_ component receives only the note that it should display? ```js const Note = ({ note }) => { @@ -482,33 +420,32 @@ const Note = ({ note }) => { } ``` -One way to do this would be to use react-router's [useRouteMatch](https://reacttraining.com/react-router/web/api/Hooks/useroutematch) hook to figure out the id of the note to be displayed in the _App_ component. +One way to do this would be to use React Router's [useMatch](https://reactrouter.com/en/main/hooks/use-match) hook to figure out the id of the note to be displayed in the _App_ component. -It is not possible to use useRouteMatch-hook in the component which defines the routed part of the application. Let's move the use of the _Router_ components from _App_: +It is not possible to use the useMatch hook in the component which defines the routed part of the application. Let's move the use of the _Router_ components from _App_: ```js -ReactDOM.render( +ReactDOM.createRoot(document.getElementById('root')).render( // highlight-line - , // highlight-line - document.getElementById('root') + // highlight-line ) ``` - The _App_component becomes: ```js import { // ... - useRouteMatch // highlight-line -} from "react-router-dom" + useMatch // highlight-line +} from 'react-router-dom' const App = () => { // ... // highlight-start - const match = useRouteMatch('/notes/:id') + const match = useMatch('/notes/:id') + const note = match ? notes.find(note => note.id === Number(match.params.id)) : null @@ -521,32 +458,29 @@ const App = () => { // ... - - - // highlight-line - - - - - // ... - + + } /> // highlight-line + } /> + : } /> + } /> + } /> +
    - Note app, Department of Computer Science 2020 + Note app, Department of Computer Science 2024
    ) -} +} ``` - -Every time the component is rendered, so practically every time the browser's url changes, the following command is executed: +Every time the component is rendered, so practically every time the browser's URL changes, the following command is executed: ```js -const match = useRouteMatch('/notes/:id') +const match = useMatch('/notes/:id') ``` -If the url matches _/notes/:id_, the match variable will contain an object from which we can access the parametrized part of the path, the id of the note to be displayed, and we can then fetch the correct note to display. +If the URL matches _/notes/:id_, the match variable will contain an object from which we can access the parameterized part of the path, the id of the note to be displayed, and we can then fetch the correct note to display. ```js const note = match @@ -554,60 +488,60 @@ const note = match : null ``` - The completed code can be found [here](https://github.com/fullstack-hy2020/misc/blob/master/router-app-v2.js). +
    -### Exercises 7.1.7.3. +### Exercises 7.1.-7.3. Let's return to working with anecdotes. Use the redux-free anecdote app found in the repository as the starting point for the exercises. -If you clone the project into an existing git repository remember to delete the git configuration of the cloned application: +If you clone the project into an existing git repository, remember to delete the git configuration of the cloned application: ```bash cd routed-anecdotes // go first to directory of the cloned repository rm -rf .git ``` -The application starts the usual way, but first you need to install the dependencies of the application: +The application starts the usual way, but first, you need to install its dependencies: ```bash npm install -npm start +npm run dev ``` -#### 7.1: routed anecdotes, step1 +#### 7.1: Routed Anecdotes, step 1 -Add React Router to the application so that by clicking links in the Menu-component the view can be changed. +Add React Router to the application so that by clicking links in the Menu component the view can be changed. At the root of the application, meaning the path _/_, show the list of anecdotes: -![](../../assets/teht/40.png) +![browser at baseURL showing anecdotes and footer](../../assets/teht/40.png) -The Footer-component should always be visible at the bottom. +The Footer component should always be visible at the bottom. The creation of a new anecdote should happen e.g. in the path create: -![](../../assets/teht/41.png) +![browser anecdotes /create shows create form](../../assets/teht/41.png) -#### 7.2: routed anecdotes, step2 +#### 7.2: Routed Anecdotes, step 2 Implement a view for showing a single anecdote: -![](../../assets/teht/42.png) +![browser /anecdotes/number showing single anecdote](../../assets/teht/42.png) -Navigating to the page showing the single anecdote is done by clicking the name of that anecdote +Navigating to the page showing the single anecdote is done by clicking the name of that anecdote: -![](../../assets/teht/43.png) +![browser showing previous link that was clicked](../../assets/teht/43.png) -#### 7.3: routed anecdotes, step3 +#### 7.3: Routed Anecdotes, step3 -The default functionality of the creation form is quite confusing, because nothing seems to be happening after creating a new anecdote using the form. +The default functionality of the creation form is quite confusing because nothing seems to be happening after creating a new anecdote using the form. -Improve the functionality such that after creating a new anecdote the application transitions automatically to showing the view for all anecdotes and the user is shown a notification informing them of this successful creation for the next 10 seconds: +Improve the functionality such that after creating a new anecdote the application transitions automatically to showing the view for all anecdotes and the user is shown a notification informing them of this successful creation for the next five seconds: -![](../../assets/teht/44.png) +![browser anecdotes showing success message for adding anecdote](../../assets/teht/44.png) - +
    diff --git a/src/content/7/en/part7b.md b/src/content/7/en/part7b.md index 5b865123117..f1271d97875 100644 --- a/src/content/7/en/part7b.md +++ b/src/content/7/en/part7b.md @@ -7,53 +7,42 @@ lang: en
    - -The exercises in this part are a bit different than the exercises in the previous parts. The exercises in the previous part and the exercises in this part [are about the theory presented in this part](/en/part7/custom_hooks#exercises-7-4-7-8). - - -This part also contains a [series of exercises](/en/part7/exercises_extending_the_bloglist) in which we modify the Bloglist application from parts 4 and 5 to rehearse and apply the skills we have learned. - ### Hooks -React offers 10 different [built-in hooks](https://reactjs.org/docs/hooks-reference.html), of which the most popular ones are the [useState](https://reactjs.org/docs/hooks-reference.html#usestate) and [useEffect](https://reactjs.org/docs/hooks-reference.html#useeffect) hooks, that we have already been using extensively. - -In [part 5](/en/part5/props_children_and_proptypes#references-to-components-with-ref) we used the [useImperativeHandle](https://reactjs.org/docs/hooks-reference.html#useimperativehandle)-hook which allows for components to provide their functions to other components. +React offers 15 different [built-in hooks](https://react.dev/reference/react/hooks), of which the most popular ones are the [useState](https://react.dev/reference/react/useState) and [useEffect](https://react.dev/reference/react/useEffect) hooks that we have already been using extensively. -Within the last year many React libraries have begun to offer hook based apis. [In part 6](/en/part6/flux_architecture_and_redux) -we used the [useSelector](https://react-redux.js.org/api/hooks#useselector) and [useDispatch](https://react-redux.js.org/api/hooks#usedispatch) hooks from the react-redux library to share our redux-store and dispatch function to our components. Redux's hook based api is a lot easier to use than its older, still available, [connect](/en/part6/connect)-api. +In [part 5](/en/part5/props_children_and_proptypes#references-to-components-with-ref) we used the [useImperativeHandle](https://react.dev/reference/react/useImperativeHandle) hook which allows components to provide their functions to other components. In [part 6](/en/part6/react_query_use_reducer_and_the_context) we used [useReducer](https://react.dev/reference/react/useReducer) and [useContext](https://react.dev/reference/react/useContext) to implement a Redux like state management. -[React-router's](https://reacttraining.com/react-router/web/guides) api we introduced in the [previous part](/en/part7/react_router) is also partially [hook](https://reacttraining.com/react-router/web/api/Hooks) based. Its hooks can be used to access url parameters and the history object, which allows for manipulating the browser url programmatically. +Within the last couple of years, many React libraries have begun to offer hook-based APIs. [In part 6](/en/part6/flux_architecture_and_redux) we used the [useSelector](https://react-redux.js.org/api/hooks#useselector) and [useDispatch](https://react-redux.js.org/api/hooks#usedispatch) hooks from the react-redux library to share our redux-store and dispatch function to our components. -As mentioned in [part 1](/en/part1/a_more_complex_state_debugging_react_apps#rules-of-hooks), hooks are not normal functions, and when using those we have to adhere to certain [rules or limitations](https://reactjs.org/docs/hooks-rules.html). Let's recap the rules of using hooks, copied verbatim from the official React documentation: +The [React Router's](https://reactrouter.com/en/main/start/tutorial) API we introduced in the [previous part](/en/part7/react_router) is also partially hook-based. Its hooks can be used to access URL parameters and the navigation object, which allows for manipulating the browser URL programmatically. -**Don’t call Hooks inside loops, conditions, or nested functions.** Instead, always use Hooks at the top level of your React function. +As mentioned in [part 1](/en/part1/a_more_complex_state_debugging_react_apps#rules-of-hooks), hooks are not normal functions, and when using these we have to adhere to certain [rules or limitations](https://react.dev/warnings/invalid-hook-call-warning#breaking-rules-of-hooks). Let's recap the rules of using hooks, copied verbatim from the official React documentation: -**Don’t call Hooks from regular JavaScript functions.** Instead, you can: +**Don’t call Hooks inside loops, conditions, or nested functions.** Instead, always use Hooks at the top level of your React function. -- Call Hooks from React function components. -- Call Hooks from custom Hooks +**You can only call Hooks while React is rendering a function component:** -There's an existing [ESlint](https://www.npmjs.com/package/eslint-plugin-react-hooks) rule that can be used to verify that the application uses hooks correctly. +- Call them at the top level in the body of a function component. +- Call them at the top level in the body of a custom Hook. -Create-react-app has readily configured rule [eslint-plugin-react-hooks](https://www.npmjs.com/package/eslint-plugin-react-hooks) that complains if hooks are used in an illegal manner: +There's an existing [ESlint plugin](https://www.npmjs.com/package/eslint-plugin-react-hooks) that can be used to verify that the application uses hooks correctly: -![](../../images/7/60ea.png) +![vscode error useState being called conditionally](../../images/7/60ea.png) ### Custom hooks -React offers the option to create our own [custom](https://reactjs.org/docs/hooks-custom.html) hooks. According to React, the primary purpose of custom hooks is to facilitate the reuse of the logic used in components. +React offers the option to create [custom](https://react.dev/learn/reusing-logic-with-custom-hooks) hooks. According to React, the primary purpose of custom hooks is to facilitate the reuse of the logic used in components. > Building your own Hooks lets you extract component logic into reusable functions. - Custom hooks are regular JavaScript functions that can use any other hooks, as long as they adhere to the [rules of hooks](/en/part1/a_more_complex_state_debugging_react_apps#rules-of-hooks). Additionally, the name of custom hooks must start with the word _use_. - -We implemented a counter application in [part 1](/en/part1/component_state_event_handlers#event-handling), that can have its value incremented, decremented, or reset. The code of the application is as follows: +We implemented a counter application in [part 1](/en/part1/component_state_event_handlers#event-handling) that can have its value incremented, decremented, or reset. The code of the application is as follows: ```js -import React, { useState } from 'react' -const App = (props) => { +import { useState } from 'react' +const App = () => { const [counter, setCounter] = useState(0) return ( @@ -73,7 +62,7 @@ const App = (props) => { } ``` -Let's extract the counter logic into its own custom hook. The code for the hook is as follows: +Let's extract the counter logic into a custom hook. The code for the hook is as follows: ```js const useCounter = () => { @@ -100,13 +89,12 @@ const useCounter = () => { } ``` -Our custom hook uses the _useState_ hook internally to create its own state. The hook returns an object, the properties of which include the value of the counter as well as functions for manipulating the value. - +Our custom hook uses the _useState_ hook internally to create its state. The hook returns an object, the properties of which include the value of the counter as well as functions for manipulating the value. React components can use the hook as shown below: ```js -const App = (props) => { +const App = () => { const counter = useCounter() return ( @@ -126,11 +114,9 @@ const App = (props) => { } ``` - By doing this we can extract the state of the _App_ component and its manipulation entirely into the _useCounter_ hook. Managing the counter state and logic is now the responsibility of the custom hook. - -The same hook could be reused in the application that was keeping track of the amount of clicks made to the left and right buttons: +The same hook could be reused in the application that was keeping track of the number of clicks made to the left and right buttons: ```js @@ -153,11 +139,9 @@ const App = () => { } ``` - The application creates two completely separate counters. The first one is assigned to the variable _left_ and the other to the variable _right_. - -Dealing with forms in React is somewhat tricky. The following application presents the user with a form that requests the user to input their name, birthday, and height: +Dealing with forms in React is somewhat tricky. The following application presents the user with a form that requires him to input their name, birthday, and height: ```js const App = () => { @@ -197,11 +181,9 @@ const App = () => { } ``` +Every field of the form has its own state. To keep the state of the form synchronized with the data provided by the user, we have to register an appropriate onChange handler for each of the input elements. -Every field of the form has its own state. In order to keep the state of the form synchronized with the data provided by the user, we have to register an appropriate onChange handler for each of the input elements. - - -Let's define our own custom _useField_ hook, that simplifies the state management of the form: +Let's define our own custom _useField_ hook that simplifies the state management of the form: ```js const useField = (type) => { @@ -219,9 +201,7 @@ const useField = (type) => { } ``` - -The hook function receives the type of the input field as a parameter. The function returns all of the attributes required by the input: its type, value and the onChange handler. - +The hook function receives the type of the input field as a parameter. It returns all of the attributes required by the input: its type, value and the onChange handler. The hook can be used in the following way: @@ -245,18 +225,15 @@ const App = () => { } ``` - ### Spread attributes - -We could simplify things a bit further. Since the _name_ object has exactly all of the attributes that the input element expects to receive as props, we can pass the props to the element using the [spread syntax](https://reactjs.org/docs/jsx-in-depth.html#spread-attributes) in the following way: +We could simplify things a bit further. Since the _name_ object has exactly all of the attributes that the input element expects to receive as props, we can pass the props to the element using the [spread syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) in the following way: ```js ``` - -As the [example](https://reactjs.org/docs/jsx-in-depth.html#spread-attributes) in the React documentation states, the following two ways of passing props to a component achieve the exact same result: +As the [example](https://react.dev/learn/updating-objects-in-state#copying-objects-with-the-spread-syntax) in the React documentation states, the following two ways of passing props to a component achieve the exact same result: ```js @@ -269,7 +246,6 @@ const person = { ``` - The application gets simplified into the following format: ```js @@ -298,36 +274,32 @@ const App = () => { } ``` +Dealing with forms is greatly simplified when the unpleasant nitty-gritty details related to synchronizing the state of the form are encapsulated inside our custom hook. -Dealing with forms is greatly simplified when the unpleasant nitty-gritty details related to synchronizing the state of the form is encapsulated inside of our custom hook. - - -Custom hooks are clearly not only a tool for reuse, they also provide a better way for dividing our code into smaller modular parts. +Custom hooks are not only a tool for reusing code; they also provide a better way for dividing it into smaller modular parts. ### More about hooks The internet is starting to fill up with more and more helpful material related to hooks. The following sources are worth checking out: -* [Awesome React Hooks Resouces](https://github.com/rehooks/awesome-react-hooks) -* [Easy to understand React Hook recipes by Gabe Ragland](https://usehooks.com/) -* [Why Do React Hooks Rely on Call Order?](https://overreacted.io/why-do-hooks-rely-on-call-order/) +- [Awesome React Hooks Resources](https://github.com/rehooks/awesome-react-hooks) +- [Easy to understand React Hook recipes by Gabe Ragland](https://usehooks.com/) +- [Why Do React Hooks Rely on Call Order?](https://overreacted.io/why-do-hooks-rely-on-call-order/)
    - ### Exercises 7.4.-7.8. -We'll continue with the app from [exercises](/en/part7/custom_hooks#exercises-7-4-7-8) of the chapter [react router](/en/part7/react_router). +We'll continue with the app from the [exercises](/en/part7/react_router#exercises-7-1-7-3) of the [react router](/en/part7/react_router) chapter. -#### 7.4: anecdotes and hooks step1 +#### 7.4: Anecdotes and Hooks step 1 Simplify the anecdote creation form of your application with the _useField_ custom hook we defined earlier. One natural place to save the custom hooks of your application is in the /src/hooks/index.js file. - If you use the [named export](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export#Description) instead of the default export: ```js @@ -353,7 +325,6 @@ export const useAnotherHook = () => { // highlight-line } ``` - Then [importing](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) happens in the following way: ```js @@ -366,26 +337,25 @@ const App = () => { } ``` - -#### 7.5: anecdotes and hooks step2 +#### 7.5: Anecdotes and Hooks step 2 Add a button to the form that you can use to clear all the input fields: -![](../../images/7/61ea.png) +![browser anecdotes with reset button](../../images/7/61ea.png) -Expand the functionality of the useField hook so that it offers a new reset operation for clearing the field. +Expand the functionality of the useField hook so that it offers a new reset operation for clearing the field. -Depending on your solution you may see the following warning in your console: +Depending on your solution, you may see the following warning in your console: -![](../../images/7/62ea.png) +![devtools console warning invalid value for reset prop](../../images/7/62ea.png) We will return to this warning in the next exercise. -#### 7.6: anecdotes and hooks step3 +#### 7.6: Anecdotes and Hooks step 3 -If your solution did not cause a warning to appear in the console you have already finished this exercise. +If your solution did not cause a warning to appear in the console, you have already finished this exercise. -If you see the warning in the console, make the necessary changes to get rid of the `Invalid value for prop reset' on tag` console warning. +If you see the _Invalid value for prop \`reset\` on \ tag_ warning in the console, make the necessary changes to get rid of it. The reason for this warning is that after making the changes to your application, the following expression: @@ -393,7 +363,6 @@ The reason for this warning is that after making the changes to your application ``` - Essentially, is the same as this: ```js @@ -405,10 +374,8 @@ Essentially, is the same as this: /> ``` - The input element should not be given a reset attribute. - One simple fix would be to not use the spread syntax and write all of the forms like this: ```js @@ -419,36 +386,29 @@ One simple fix would be to not use the spread syntax and write all of the forms /> ``` +If we were to do this, we would lose much of the benefit provided by the useField hook. Instead, come up with a solution that fixes the issue, but is still easy to use with the spread syntax. -If we were to do this we would lose much of the benefit provided by the useField hook. Instead, come up with a solution that fixes the issue, but is still easy to use with spread syntax. - -#### 7.7: country hook +#### 7.7: Country hook -Let's return to the exercises [2.12-14](/en/part2/getting_data_from_server#exercises-2-11-2-14). +Let's return to exercises [2.18-2.20](/en/part2/adding_styles_to_react_app#exercises-2-18-2-20). - -Use the code from https://github.com/fullstack-hy2020/country-hook as your starting point. +Use the code from as your starting point. - -The application can be used to search for country details from the https://restcountries.eu/ interface. If country is found, the details of the country are displayed +The application can be used to search for a country's details from the service in . If a country is found, its details are displayed: -![](../../images/7/69ea.png) +![browser displaying country details](../../images/7/69ea.png) - -If country is not found, message is displayed to the user +If no country is found, a message is displayed to the user: -![](../../images/7/70ea.png) +![browser showing country not found](../../images/7/70ea.png) - -The application is otherwise complete, but in this exercise you have to implement a custom hook _useCountry_, which can be used to search for the details of the country given to the hook as a parameter. +The application is otherwise complete, but in this exercise, you have to implement a custom hook _useCountry_, which can be used to search for the details of the country given to the hook as a parameter. - -Use the api endpoint [full name](https://restcountries.eu/#api-endpoints-full-name) to fetch country details in a _useEffect_-hook within your custom hook. +Use the API endpoint [name](https://studies.cs.helsinki.fi/restcountries/) to fetch a country's details in a _useEffect_ hook within your custom hook. - -Note, that in this exercise it is essential to use useEffect's [second parameter](https://reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect) array to control when the effect function is executed. +Note that in this exercise it is essential to use useEffect's [second parameter](https://react.dev/reference/react/useEffect#parameters) array to control when the effect function is executed. See the course [part 2](/en/part2/adding_styles_to_react_app#couple-of-important-remarks) for more info how the second parameter could be used. -#### 7.8: ultimate hooks +#### 7.8: Ultimate Hooks The code of the application responsible for communicating with the backend of the note application of the previous parts looks like this: @@ -462,9 +422,9 @@ const setToken = newToken => { token = `bearer ${newToken}` } -const getAll = () => { - const request = axios.get(baseUrl) - return request.then(response => response.data) +const getAll = async () => { + const response = await axios.get(baseUrl) + return response.data } const create = async newObject => { @@ -476,9 +436,9 @@ const create = async newObject => { return response.data } -const update = (id, newObject) => { - const request = axios.put(`${ baseUrl } /${id}`, newObject) - return request.then(response => response.data) +const update = async (id, newObject) => { + const response = await axios.put(`${ baseUrl }/${id}`, newObject) + return response.data } export default { getAll, create, update, setToken } @@ -488,7 +448,7 @@ We notice that the code is in no way specific to the fact that our application d Extract the code for communicating with the backend into its own _useResource_ hook. It is sufficient to implement fetching all resources and creating a new resource. -You can do the exercise for the project found in the https://github.com/fullstack-hy2020/ultimate-hooks repository. The App component for the project is the following: +You can do the exercise in the project found in the repository. The App component for the project is the following: ```js const App = () => { @@ -532,8 +492,8 @@ const App = () => { The _useResource_ custom hook returns an array of two items just like the state hooks. The first item of the array contains all of the individual resources and the second item of the array is an object that can be used for manipulating the resource collection, like creating new ones. -If you implement the hook correctly, it can be used for both notes and phone numbers (start the server with the _npm run server_ command at the port 3005). +If you implement the hook correctly, it can be used for both notes and persons (start the server with the _npm run server_ command at port 3005). -![](../../images/5/21e.png) +![browser showing notes and persons](../../images/5/21e.png)
    diff --git a/src/content/7/en/part7c.md b/src/content/7/en/part7c.md index 60cb79a9474..2ab34863be4 100644 --- a/src/content/7/en/part7c.md +++ b/src/content/7/en/part7c.md @@ -7,19 +7,19 @@ lang: en
    -In part 2 we examined two different ways of adding styles to our application: the old-school [single CSS](/en/part2/adding_styles_to_react_app) file and [inline-styles](/en/part2/adding_styles_to_react_app#inline-styles). In this part we will take a look at a few other ways. +In part 2, we examined two different ways of adding styles to our application: the old-school [single CSS](/en/part2/adding_styles_to_react_app) file and [inline styles](/en/part2/adding_styles_to_react_app#inline-styles). In this part, we will take a look at a few other ways. ### Ready-made UI libraries One approach to defining styles for an application is to use a ready-made "UI framework". -One of the first widely popular UI frameworks was the [Bootstrap](https://getbootstrap.com/) toolkit created by Twitter, that may still be the most popular framework. Recently there has been an explosion in the number of new UI frameworks that have entered the arena. In fact, the selection is so vast that there is little hope of creating an exhaustive list of options. +One of the first widely popular UI frameworks was the [Bootstrap](https://getbootstrap.com/) toolkit created by Twitter which may still be the most popular. Recently, there has been an explosion in the number of new UI frameworks that have entered the arena. The selection is so vast that there is little hope of creating an exhaustive list of options. -Many UI frameworks provide developers of web applications with ready-made themes and "components" like buttons, menus, and tables. We write components in quotes, because in this context we are not talking about React components. Usually UI frameworks are used by including the CSS stylesheets and JavaScript code of the framework in the application. +Many UI frameworks provide developers of web applications with ready-made themes and "components" like buttons, menus, and tables. We write components in quotes because, in this context, we are not talking about React components. Usually, UI frameworks are used by including the CSS stylesheets and JavaScript code of the framework in the application. -There are many UI frameworks that have React-friendly versions, where the framework's "components" have been transformed into React components. There are a few different React versions of Bootstrap like [reactstrap](http://reactstrap.github.io/) and [react-bootstrap](https://react-bootstrap.github.io/). +Many UI frameworks have React-friendly versions where the framework's "components" have been transformed into React components. There are a few different React versions of Bootstrap like [reactstrap](http://reactstrap.github.io/) and [react-bootstrap](https://react-bootstrap.github.io/). -Next we will take a closer look at two UI frameworks, Bootstrap and [MaterialUI](https://material-ui.com/). We will use both frameworks to add similar styles to the application we made in the [React-router](/en/part7/react_router) section of the course material. +Next, we will take a closer look at two UI frameworks, Bootstrap and [MaterialUI](https://mui.com/). We will use both frameworks to add similar styles to the application we made in the [React Router](/en/part7/react_router) section of the course material. ### React Bootstrap @@ -27,28 +27,29 @@ Let's start by taking a look at Bootstrap with the help of the [react-bootstrap] Let's install the package with the command: -```js -npm install --save react-bootstrap +```bash +npm install react-bootstrap ``` -Then let's add a link for loading the CSS stylesheet for Bootstrap inside of the head tag in the public/index.html file of the application: +Then let's add a [link for loading the CSS stylesheet](https://react-bootstrap.github.io/docs/getting-started/introduction#stylesheets) for Bootstrap inside of the head tag in the public/index.html file of the application: ```js // ... ``` + When we reload the application, we notice that it already looks a bit more stylish: -![](../../images/7/5ea.png) +![browser notes app with bootstrap](../../images/7/5ea.png) -In Bootstrap, all of the contents of the application are typically rendered inside of a [container](https://getbootstrap.com/docs/4.1/layout/overview/#containers). In practice this is accomplished by giving the root _div_ element of the application the _container_ class attribute: +In Bootstrap, all of the contents of the application are typically rendered inside a [container](https://getbootstrap.com/docs/4.1/layout/overview/#containers). In practice this is accomplished by giving the root _div_ element of the application the _container_ class attribute: ```js const App = () => { @@ -62,21 +63,21 @@ const App = () => { } ``` +We notice that this already affected the appearance of the application. The content is no longer as close to the edges of the browser as it was earlier: -We notice that this already has an effect on the appearance of the application. The content is no longer as close to the edges of the browser as it was earlier: - -![](../../images/7/6ea.png) +![browser notes app with margin spacing](../../images/7/6ea.png) +#### Tables -Next, let's make some changes to the Notes component, so that it renders the list of notes as a [table](https://getbootstrap.com/docs/4.1/content/tables/). React Bootstrap provides a built-in [Table](https://react-bootstrap.github.io/components/table/) component for this purpose, so there is no need to define CSS classes separately. +Next, let's make some changes to the Notes component so that it renders the list of notes as a [table](https://getbootstrap.com/docs/4.1/content/tables/). React Bootstrap provides a built-in [Table](https://react-bootstrap.github.io/docs/components/table/) component for this purpose, so there is no need to define CSS classes separately. ```js -const Notes = (props) => ( +const Notes = ({ notes }) => (

    Notes

    // highlight-line - {props.notes.map(note => + {notes.map(note =>
    @@ -96,7 +97,7 @@ const Notes = (props) => ( The appearance of the application is quite stylish: -![](../../images/7/7e.png) +![browser notes tab with built-in table](../../images/7/7e.png) Notice that the React Bootstrap components have to be imported separately from the library as shown below: @@ -108,7 +109,7 @@ import { Table } from 'react-bootstrap' Let's improve the form in the Login view with the help of Bootstrap [forms](https://getbootstrap.com/docs/4.1/components/forms/). -React Bootstrap provides built-in [components](https://react-bootstrap.github.io/components/forms/) for creating forms (although the documentation for them is slightly lacking): +React Bootstrap provides built-in [components](https://react-bootstrap.github.io/docs/forms/overview/) for creating forms (although the documentation for them is slightly lacking): ```js let Login = (props) => { @@ -123,17 +124,20 @@ let Login = (props) => { type="text" name="username" /> + + password: - + -)} + ) +} ``` The number of components we need to import increases: @@ -144,15 +148,15 @@ import { Table, Form, Button } from 'react-bootstrap' After switching over to the Bootstrap form, our improved application looks like this: -![](../../images/7/8ea.png) +![browser notes app with bootstrap login](../../images/7/8ea.png) #### Notification Now that the login form is in better shape, let's take a look at improving our application's notifications: -![](../../images/7/9ea.png) +![browser notes app with bootstrap notification](../../images/7/9ea.png) -Let's add a message for the notification when a user logs in to the application. We will store it in the _message_ variable in the App component's state: +Let's add a message for the notification when a user logs into the application. We will store it in the _message_ variable in the App component's state: ```js const App = () => { @@ -176,8 +180,7 @@ const App = () => { } ``` - -We will render the message as a Bootstrap [Alert](https://getbootstrap.com/docs/4.1/components/alerts/) component. Once again, the React Bootstrap library provides us with a matching [React component](https://react-bootstrap.github.io/components/alerts/): +We will render the message as a Bootstrap [Alert](https://getbootstrap.com/docs/4.1/components/alerts/) component. Once again, the React Bootstrap library provides us with a matching [React component](https://react-bootstrap.github.io/docs/components/alerts/): ```js
    @@ -194,13 +197,13 @@ We will render the message as a Bootstrap [Alert](https://getbootstrap.com/docs/ #### Navigation structure -Lastly, let's alter the application's navigation menu to use Bootstrap's [Navbar](https://getbootstrap.com/docs/4.1/components/navbar/) component. The React Bootstrap library provides us with [matching built-in components](https://react-bootstrap.github.io/components/navbar/#navbars-mobile-friendly). Through trial and error, we end up with a working solution in spite of the cryptic documentation: +Lastly, let's alter the application's navigation menu to use Bootstrap's [Navbar](https://getbootstrap.com/docs/4.1/components/navbar/) component. The React Bootstrap library provides us with [matching built-in components](https://react-bootstrap.github.io/docs/components/navbar/#responsive-behaviors). Through trial and error, we end up with a working solution despite the cryptic documentation: ```js -