### **PR Type** Enhancement ___ ### **Description** - Add cross-framework Todos CRUD examples - Introduce file upload/download tutorials - Provide unified AuthProvider context implementations - Include email templates and backend actions ___ <details> <summary><h3> File Walkthrough</h3></summary> <table><thead><tr><th></th><th align="left">Relevant files</th></tr></thead><tbody><tr><td><strong>Enhancement</strong></td><td><details><summary>19 files</summary><table> <tr> <td><strong>Todos.tsx</strong><dd><code>Add web demo Todos CRUD page</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-53f3b3d582fef21d5ec90cb590b73afcf09407071dba60883ed1ed7360955fc5">+648/-0</a> </td> </tr> <tr> <td><strong>todos.tsx</strong><dd><code>Add React Native tutorial Todos</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3f6be5ef4f443091687addd404fe71f219498b9db7dea992d18d78b4f1b6ffa3">+561/-0</a> </td> </tr> <tr> <td><strong>commonStyles.ts</strong><dd><code>Add common React Native tutorial styles</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-bf0de16179ecc80a8e88e223c890dc2c73c30b4a9b7cadd62e910ca015ce342b">+667/-0</a> </td> </tr> <tr> <td><strong>Todos.tsx</strong><dd><code>Add React tutorial Todos page</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-be700e4847b0a745821f156c381e583097f2083123065a45a20611c2ba1876a7">+504/-0</a> </td> </tr> <tr> <td><strong>files.tsx</strong><dd><code>Add React Native tutorial file upload</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-d116805053a943f271a3297d06d14dba39c0f5775080e67e1e9e2778c176e9da">+454/-0</a> </td> </tr> <tr> <td><strong>Files.tsx</strong><dd><code>Add React tutorial Files page</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-cdbca4ed68690df84463df7765dff52c85a60502f175c19519c8b42474e9282c">+404/-0</a> </td> </tr> <tr> <td><strong>signup.tsx</strong><dd><code>Enhance signup flow with email verification</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-262b09b9dd7234cad96fe092d7131a23451c9e50b98c126c9e36599b3a127ac6">+185/-141</a></td> </tr> <tr> <td><strong>FilesClient.tsx</strong><dd><code>Add Next.js Files client component</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ea74386f232b9ae7e7957ab4eb1f0d1d6076b338173e8b1e917369fb7f1b39bb">+359/-0</a> </td> </tr> <tr> <td><strong>TodosClient.tsx</strong><dd><code>Add Next.js Todos client component</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-96efb4db6bd61a7faee3c383e77b092eccee1a1876770c36691e7356268cfad4">+368/-0</a> </td> </tr> <tr> <td><strong>AuthProvider.tsx</strong><dd><code>Implement React AuthProvider context</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-4cc7e41420d71d448eeec4f77043e0c5bff2c606986439454dade5ffcd433e33">+174/-0</a> </td> </tr> <tr> <td><strong>AuthProvider.tsx</strong><dd><code>Implement RN AuthProvider context</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-93f9f27a35d0039a64cd6889a296d04d37542aa5a777925e61e8e60ee5a6d744">+148/-0</a> </td> </tr> <tr> <td><strong>actions.ts</strong><dd><code>Add Next.js server actions for Todos</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-76d57097940d3043c8e0ab29761767861b78fe86ab8a90a2d8209f1818131d31">+223/-0</a> </td> </tr> <tr> <td><strong>signup.tsx</strong><dd><code>Add RN signup tutorial screen</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-153c2f0bf6b3744bb84b95e356dd78c8771206a1b22218bc4c6f90641e4143ad">+183/-0</a> </td> </tr> <tr> <td><strong>explore.tsx</strong><dd><code>Add RN tutorial Explore screen</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-980313b6c7f75f2ecd45fc476895bea122364d000f5988e21564bf5db73d7f57">+125/-0</a> </td> </tr> <tr> <td><strong>route.ts</strong><dd><code>Add Next.js file download route</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-5c39cd02d478ad625a7cdb6df3f7b6d20a76f40488636fdd87282c66174b2bd8">+57/-0</a> </td> </tr> <tr> <td><strong>SignUp.tsx</strong><dd><code>Enhance demo signup with verification state</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-75657efa0e1c29f59692ced3cd90e9c734836977900dc64015dd5d217bb263da">+38/-3</a> </td> </tr> <tr> <td><strong>SignUpForm.tsx</strong><dd><code>Add Next.js signup client form</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-9034cd412c47033a01e604a8250984aa1d1ecefc9884b79ebd7f7f3af17e3167">+89/-0</a> </td> </tr> <tr> <td><strong>SignInForm.tsx</strong><dd><code>Add Next.js signin client form</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-c276aadee4507d515832909164d447c3c1a4870277d0adbd1f7e836f7c66259e">+75/-0</a> </td> </tr> <tr> <td><strong>server.tsx</strong><dd><code>Add server Nhost client for Next.js</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-9d650defc223584e4aa06ddbf1d9a97c47a5a7ec4c9589a72ac7ea5369853400">+89/-0</a> </td> </tr> </table></details></td></tr><tr><td><strong>Configuration changes</strong></td><td><details><summary>1 files</summary><table> <tr> <td><strong>_layout.tsx</strong><dd><code>Configure RN tutorial tab navigation</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-c3b0bac088aef9f1a2d5cd2f4b51dd19fb301034668a062860a1e6a3512c15c9">+39/-0</a> </td> </tr> </table></details></td></tr><tr><td><strong>Additional files</strong></td><td><details><summary>101 files</summary><table> <tr> <td><strong>examples_tutorials_checks.yaml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-745712dc409289d30fe4caa74de567df9cc9fac97750ae5566f93a51e48bf539">+94/-0</a> </td> </tr> <tr> <td><strong>docs.json</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-873ce17c654718debe2fe308a2f2279bde8663686423c51f97fab2dd0722b8d9">+52/-7</a> </td> </tr> <tr> <td><strong>overview.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-8c9b35da559a5de5fe14ee078573e8d487453e26ed760c03ffd7f0ad476ca24d">+5/-5</a> </td> </tr> <tr> <td><strong>nextjs.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a5210d45e7d33a57d43078dbe2a2ccbf0667b157291fd92c3986092d7d33ab9c">+0/-508</a> </td> </tr> <tr> <td><strong>1-introduction.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-35413705a524a3eb2c7d096daef02d660b479fbc288674aa260e3f159988652b">+116/-0</a> </td> </tr> <tr> <td><strong>2-protected-routes.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-d901b3a6d8a96e3070f27afb934c34365aa79aeee1505238de2cb77a9ffd8546">+1364/-0</a></td> </tr> <tr> <td><strong>3-user-authentication.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-7b96576ad1b1b6b8e50dd3889e24df391662df1b7b51a111f0239f655135939f">+804/-0</a> </td> </tr> <tr> <td><strong>4-graphql-operations.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-16906637a292d22c018167d45f2da67a17fe73155ca161de91c3b329fee9d399">+958/-0</a> </td> </tr> <tr> <td><strong>5-file-uploads.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-d6153781dbf251499f2ffeeb707102349776d788859c2da0f63c8b0a8e35c821">+822/-0</a> </td> </tr> <tr> <td><strong>react.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-6f5adda9f7b29d98c68cab6ec754c0bac501666a49dd635ee830789e2c812b68">+0/-497</a> </td> </tr> <tr> <td><strong>1-introduction.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-4dac916b2b27af668674965d9680f4ba8d2a417f7b155d4b052b1043fc71beb6">+116/-0</a> </td> </tr> <tr> <td><strong>2-protected-routes.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-8ab0206ae4a4c73e540cf549fdbdd9de4ed7c8d59cc88ea26a4a7ddc0a9a4460">+1435/-0</a></td> </tr> <tr> <td><strong>3-user-authentication.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-e02466ba1abd5d457724c7508da083a94b16d700cab36384567592c7d772cbf6">+647/-0</a> </td> </tr> <tr> <td><strong>4-graphql-operations.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ae759670f7bf513c66bc08952556514e7193f23eb7cc536aebf38ad79e12c02f">+856/-0</a> </td> </tr> <tr> <td><strong>5-file-uploads.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-02dc5fa1da67b0478bb797ed99c89685be3ec4509023441fb29b730ba74fff4e">+715/-0</a> </td> </tr> <tr> <td><strong>reactnative.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a85b3aa1caaeaa0a2cb6219ca531f89aaf2b23b41cd424c189b3cb948af1fd57">+0/-443</a> </td> </tr> <tr> <td><strong>1-introduction.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-573f696fb008f0e047e97209a1595b1f5c69dd5a1fe4907b65d6883273c53096">+121/-0</a> </td> </tr> <tr> <td><strong>2-protected-routes.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-b4ab0ba037b62e9eb5ee10cf5f56fd1c148f1e053f95291e42113d9f924ae711">+1499/-0</a></td> </tr> <tr> <td><strong>3-user-authentication.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-dda89bc27be58e7fa58aef9aeb937dce455f69d9566e734a10ef4e0df993ab42">+805/-0</a> </td> </tr> <tr> <td><strong>4-graphql-operations.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-59a46fa74310dc797cdfd6e1a34d09fd8002e9accdade9d865b898419addc2bc">+881/-0</a> </td> </tr> <tr> <td><strong>5-file-uploads.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-cb48bca7fdd422250b2482167b9a136d76ab60d2d5efccb94fb1a022b814d449">+776/-0</a> </td> </tr> <tr> <td><strong>6-sign-in-with-apple.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a4da2a574e88122505df17fcf1f9705841a8240b33f60b6fcde86e068b8b6cfe">+684/-0</a> </td> </tr> <tr> <td><strong>1-introduction.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-1eb8289a9ffc0484d2f8f21ede882c9e892d169f36b69f2ed9af940e2d1b8faa">+116/-0</a> </td> </tr> <tr> <td><strong>2-protected-routes.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-bff6efc64eeda0dae0d2a133ab6c9be9dfe725ee24b364b89060ec93344ad436">+1308/-0</a></td> </tr> <tr> <td><strong>3-user-authentication.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-f6bac1bb9476f921272f61911c703529663fb6ff37217c38d1702a1c8b70171a">+578/-0</a> </td> </tr> <tr> <td><strong>4-graphql-operations.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3a43d1ebc0b4fd7f74699c4f33ea110bba5ffa2695e6228dccafbfa38798c633">+763/-0</a> </td> </tr> <tr> <td><strong>5-file-uploads.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-2244e838acdbe909772f82f66cc63800aa9dbf5fae292a40d2d4f663d6618dd4">+642/-0</a> </td> </tr> <tr> <td><strong>sveltekit.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-9ed4ad735d1142a65a2da2dbbd8d46c508b7aef3d032cdb102d0f329602c4ce1">+0/-497</a> </td> </tr> <tr> <td><strong>vue.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-f6c4215fa6909fd3accebe0691a7364d17befb8ef90da5a4aeaee83d598c0540">+0/-504</a> </td> </tr> <tr> <td><strong>1-introduction.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-9485f8a6ad6398b19aa7a3739af3eef5158a7d285dc5a55e8bc6c1d58970fb84">+116/-0</a> </td> </tr> <tr> <td><strong>2-protected-routes.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-5ba36ef4388ad18b7efc6d405e2b66a69b063d483d4521d6acb5b5da65aee97a">+1368/-0</a></td> </tr> <tr> <td><strong>3-user-authentication.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-63f56a07c48f78462b47f74420e58cb8411d46aef880cfdf24b11ddea4bb382e">+637/-0</a> </td> </tr> <tr> <td><strong>4-graphql-operations.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ad7a84d0c924b946d992f5277cde596fb9d42eff021b888b5a8046822afabca8">+828/-0</a> </td> </tr> <tr> <td><strong>5-file-uploads.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-00c26651dea4650fd77222a24b31387569067783a87d9c6715935e2bf133428e">+720/-0</a> </td> </tr> <tr> <td><strong>auth_user_security_keys.yaml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-385036f3c18b6efd97138842e95f992a0569f612f627e71091c10952d8d31609">+2/-11</a> </td> </tr> <tr> <td><strong>auth_users.yaml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-aba47a0c058f0a0a8122dee4d7b4394c4a8997bac9d4ccea04c41f0d00819050">+0/-24</a> </td> </tr> <tr> <td><strong>storage_files.yaml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ea0619f75a1f7dd13ca80d81cc2e28529594a73fff0356dc0b1d49ee0d1cc9ad">+17/-36</a> </td> </tr> <tr> <td><strong>tables.yaml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a57c35142b6c4029fad7d5c8407305a61d18078cd0e264d41286625cf5cc4d30">+1/-5</a> </td> </tr> <tr> <td><strong>down.sql</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-d7be59389936d96f1c3b10f2147b177d488906d2155139536fae635201859c3e">+1/-0</a> </td> </tr> <tr> <td><strong>up.sql</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-69c6c175ba9819f7b41d6a27e78fd0390e222665c9cc95fbb7fde702f14a1f4c">+26/-0</a> </td> </tr> <tr> <td><strong>nhost.toml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-45587ca86b6441ceacd1318e72a60ee0c30c70c00edb0a06cf061e404998f3bc">+4/-4</a> </td> </tr> <tr> <td><strong>actions.ts</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a0279ec5849eb0c93ec7c2f444fbecd2b681f47d954a84da5221f72d9805c2ca">+12/-0</a> </td> </tr> <tr> <td><strong>page.tsx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-8fffbc65277c93c49eea289ae1174cdba0632e5a295b74668437e9b1b0669097">+61/-30</a> </td> </tr> <tr> <td><strong>middleware.ts</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-817666e0f29fa78d2d7f77ed93cff4efbc8d85a996ab58d0dd13ad61e47c9125">+5/-3</a> </td> </tr> <tr> <td><strong>App.tsx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-849f3aa52970f348de49a27094aac4e4b8cb8cf29580cada70d37f1a04249725">+2/-0</a> </td> </tr> <tr> <td><strong>Navigation.tsx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-9845c755953914963f404a14104d93cd326f78e48614ece8c4d9df3ab1600ffd">+3/-0</a> </td> </tr> <tr> <td><strong>index.css</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-8e415aa656412ff29b45f56035e84691f3abcc924bfb5b4e3cd0c9ba5237543b">+200/-0</a> </td> </tr> <tr> <td><strong>+page.svelte</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-00da2a0f738ffa5c66a07437c7e85cacb7780551bad7e81899d905f065bf0892">+29/-8</a> </td> </tr> <tr> <td><strong>main.css</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-1d2ba653024753e96f2db949f818dccba45498456146171c4a2f883b58177d2f">+373/-0</a> </td> </tr> <tr> <td><strong>Navigation.vue</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-27e2f662a54c47023a1342005bcf92602a846ad7a59e9d7d528aa8fbf40a0250">+7/-0</a> </td> </tr> <tr> <td><strong>index.ts</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3bf427bc13a501168d321cf4bd428d09d8c3a042ef3be955c7a2e99bbf5f39dd">+7/-0</a> </td> </tr> <tr> <td><strong>SignUp.vue</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-1838012876d16508d53d0345615b91c7c6fbccca3c9333d2311ac5fcd7b4c210">+28/-3</a> </td> </tr> <tr> <td><strong>Todos.vue</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3334950776983589e7061d69be930b71663329a6ba56726bcbd3f1c2352dab47">+466/-0</a> </td> </tr> <tr> <td><strong>Makefile</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-85a3083c78e211e9eb36d741342bcbc85a1a0c375060f45c5426b560196de27f">+2/-2</a> </td> </tr> <tr> <td><strong>.secrets.example</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-be7a988f18be877344f8584befc094ec2e66fe7784a2007300053707ad8ec7f1">+16/-0</a> </td> </tr> <tr> <td><strong>Makefile</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-36623bab6fe16382fd3e61b06b9586f2b14bea7c1b492e50db14ea98935016a4">+7/-0</a> </td> </tr> <tr> <td><strong>README.md</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ed02e93d01774c3796335c83badc90cbc8a2035040965031946b69c8d91db3dd">+29/-0</a> </td> </tr> <tr> <td><strong>env-up.sh</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-b6846a8b7ff5eea35ba5ff2aac8773f526c118ef59712a750f69578f25afda54">+8/-0</a> </td> </tr> <tr> <td><strong>package.json</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-f0d63d57cf77fdb7f2526d10c2d32d51ed2b6d43e3495d3f8879499e0c04f04b">+13/-0</a> </td> </tr> <tr> <td><strong>tsconfig.json</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-cad0e83f030efa9fcad494c7797838c5099909f2c2ae0de0210812b966ffa6db">+11/-0</a> </td> </tr> <tr> <td><strong>config.yaml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-997042f08b088725275a28a7e2d275b86e2e74f3c972800e8116785d5d0fea59">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3c921023f82fc74f5b657cb92bc8906ea19d7eb8f746713e4fae2a5a894a8a59">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-5f67fb54ebc23bfccd1e23fbb7dc955bad8ad9011fae19369c87bf3f4d3e58fa">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3c21997bc2db49d5714afc007f214316d17eb69d8ea70dd1cfe558bdb47df4bf">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-e3ab8737d4a9ea68a115f28ce578b7502107f68212d92f035bb86178ccdfb6fe">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ed34cbd1c3e612ac3124ccce382b84ea7cda7eb22051b07102b68f5c28c58e97">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-b8cead9967aaddc7c52be376fcd54b27c6ec223445e0063729c7255ab6c4f5fc">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-295cddc1774338f5b6ba396ef5ca9a0832b9b2346703ec22abf3b3874f903cc2">+43/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-de3e6c264684c0dddb2f6e70dcb236fe8cb1a27789c01a11b2a707c110ce7fc2">+1/-0</a> </td> </tr> <tr> <td><strong>body.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ae93f5f97d52d1d42be91b50e2a44522aef884065559f993f781f1ebeaa89b66">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-13511e1d49a2796e2ee0bb2082f02ed59c49bef7f7533735e60ac62ad16cc2c6">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-293b460447946e2de5e4ba74cf64895a4f6fff7d879bb2e555c46ad7edd857a8">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-843300abe10319ac2b6ccffdd8daf3445b8eaa839486acb95566d398cad4e053">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-8ff22c43814b945c0a22ea9089755a144e54039bdd12a2f06a128b4b155b3e38">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-f9e07ca4b5b9ae54e1668c3dfb8ac32612df366eb7d44819163f17c792507295">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-4f829f73c5bce9bcbb67ed1357a111cdf9974c36d2db22493067d201b35f4cdf">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-64f6273ffcec137bbad9a104cd46e6a8776915a804bb3f89c24701071cbb2c50">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-e19bb81615465996e46da50eacac7fddf71dd8bb18200c410b1837575bd76f95">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a202edc97b202ec41b83decb7b358a6beb89f3edef46194b576ecaada51f073d">+43/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-c2338d16f093d53b8a86b9a859c7bef0d9ef2c83a762d46cd49ab9e2f1f2a773">+1/-0</a> </td> </tr> <tr> <td><strong>body.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-7fff5a89330dd91d3c9781e37d8f1e1f48bfc58c686b463b7fb1f43a2d01813d">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-c9031a1f7ac5ad3d0c3c94cff2f563560c1e3c05d1acc00353e603f3dcac439e">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-847d33f3faf4a7f928445eb389ba8aa24901b9025d4bae0c38becd5e9365a881">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3f5c827ff0c5a3270dc4c7737b0afd09327800104cb379ed2a556029eb58aef3">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-e499fcf53b46507a64be23ae309eba07e2c3ee289be4de316bd8ee83c16ea42e">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-5a6337adab234155f13e6b7d63eac5c654d5c75f4ea207c9db16b67685ce3e43">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-b8f9c01ea13429ca9990a95e81ac6bedb273ec41198f34903584e9a68c22c6ef">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-5bfe4c29aee11546871f9127b5c5a954f313c64d2344cd4e6cd402fe22cf0ded">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-262bf98a3f4801aff364e19a4c00bf37197764454b274faf1dbd947506b843a7">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-8a4ad52e5c3cd679ede51f7d411ca5aaf89f2f519f49c540e09408bf13823323">+43/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-46f8a60e052ff7b9b3d8583f83f36b826aea9c7a24af9e9998cc0b699900b3b1">+1/-0</a> </td> </tr> <tr> <td><strong>body.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-03da104febe035cf096fccbce0dbbf32197d289de814810792a319154241200c">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-d7fff652e5e21d234b1ab733de23d179d5901ff82165ffbd9495b3a1ab1c7611">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a84317c8ffe3b56140c923228666555d0e9e93c34eb5a8376b08ab97e6e4ae96">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-5025ef2a43d19c4f668177eea4e8002664a22522e11fef04aabbcdb2fbff3178">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-e941c399a4d0b15de5bf2b20e722c2e6ffc990ecdef15efc2367ae302a52ff63">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-126da45f26e2d9256a28fadd38f363277926de563e6b303027cbea24779c729c">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-4686c2e7f183e071c8ccd456189d9bd056ead4874078dba16b1a2b1e53c7e9e4">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-331385785301bc1e55cfb6a3c707e3704b32d82a38f7ccd9aa4aa834a8569df4">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-57d1b1020e0d2e327acca4154b0e4776f3a7a5db1d6c99edae3ee45561780c6a">+1/-0</a> </td> </tr> <tr> <td><strong>Additional files not shown</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-2f328e4cd8dbe3ad193e49d92bcf045f47a6b72b1e9487d366f6b8288589b4ca"></a></td> </tr> </table></details></td></tr></tr></tbody></table> </details> ___
829 lines
23 KiB
Plaintext
829 lines
23 KiB
Plaintext
---
|
||
title: GraphQL Operations in Vue
|
||
description: Learn how to perform GraphQL operations and manage database permissions while building a complete todos application with Nhost and Vue
|
||
sidebarTitle: "GraphQL Operations"
|
||
icon: code
|
||
---
|
||
|
||
This part builds upon the previous parts by demonstrating how to perform GraphQL operations with proper database permissions. You'll learn how to design database tables, configure user permissions, and implement complete CRUD operations through GraphQL queries and mutations in a real todos application.
|
||
|
||
<Info>
|
||
This is **Part 4** in the Full-Stack Vue Development with Nhost series. This part focuses on GraphQL operations, database management, and permission-based data access control in a production application.
|
||
</Info>
|
||
|
||
## Full-Stack Vue Development with Nhost
|
||
|
||
<CardGroup cols={3}>
|
||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/vue/1-introduction">
|
||
Set up your Nhost project
|
||
</Card>
|
||
|
||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/vue/2-protected-routes">
|
||
Route protection basics
|
||
</Card>
|
||
|
||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/vue/3-user-authentication">
|
||
Complete auth flow
|
||
</Card>
|
||
|
||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/vue/4-graphql-operations">
|
||
**Current** - CRUD operations with GraphQL
|
||
</Card>
|
||
|
||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/vue/5-file-uploads">
|
||
File upload and management
|
||
</Card>
|
||
</CardGroup>
|
||
|
||
## Prerequisites
|
||
|
||
- Complete the [User Authentication part](/getting-started/tutorials/vue/3-user-authentication) first
|
||
- The project from the previous part set up and running
|
||
|
||
## What You'll Build
|
||
|
||
By the end of this part, you'll have:
|
||
- **GraphQL queries and mutations** for complete CRUD operations
|
||
- **Database schema** with proper relationships and constraints
|
||
- **User permissions** for secure data access control
|
||
- **Vue components** that interact with GraphQL endpoint
|
||
|
||
## Step-by-Step Guide
|
||
|
||
<Steps>
|
||
<Step>
|
||
|
||
### Create the To-Dos Table
|
||
|
||
First, we'll perform the database changes to set up the todos table with proper schema and relationships to users.
|
||
|
||
In your Nhost project dashboard:
|
||
1. Navigate to **Database**
|
||
2. Click on the SQL Editor
|
||
|
||
Enter the following SQL:
|
||
|
||
<Tabs>
|
||
|
||
<Tab title="SQL">
|
||
|
||
```sql
|
||
CREATE TABLE public.todos (
|
||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||
created_at timestamptz DEFAULT now() NOT NULL,
|
||
updated_at timestamptz DEFAULT now() NOT NULL,
|
||
title text NOT NULL,
|
||
details text,
|
||
completed bool DEFAULT false NOT NULL,
|
||
user_id uuid NOT NULL,
|
||
PRIMARY KEY (id),
|
||
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE CASCADE ON DELETE CASCADE
|
||
);
|
||
|
||
|
||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||
RETURNS TRIGGER AS $$
|
||
BEGIN
|
||
NEW.updated_at = now();
|
||
RETURN NEW;
|
||
END;
|
||
$$ language 'plpgsql';
|
||
|
||
|
||
CREATE TRIGGER update_todos_updated_at
|
||
BEFORE UPDATE ON public.todos
|
||
FOR EACH ROW
|
||
EXECUTE FUNCTION update_updated_at_column();
|
||
|
||
```
|
||
|
||
</Tab>
|
||
|
||
<Tab title="UI">
|
||

|
||
</Tab>
|
||
|
||
|
||
</Tabs>
|
||
|
||
<Warning>
|
||
Please make sure to enable **Track this** so that the new table todos is available through the auto-generated APIs
|
||
</Warning>
|
||
|
||
</Step>
|
||
|
||
<Step>
|
||
|
||
### Set Up Permissions
|
||
|
||
It’s now time to set permission rules for the table you just created. With the table `todos` selected, click on **…**, followed by **Edit Permissions**.
|
||
You will set permissions for the **user** role and actions **insert**, **select**, **update**, and **delete**.
|
||
|
||
<Tabs>
|
||
|
||
<Tab title="Insert">
|
||
|
||
When inserting permissions we are only allowing users to set the `title`, `details`, and `completed` columns as the rest of the columns are set automatically by the backend. The `user_id` column is configured as a preset to the currently authenticated user's ID using the `X-Hasura-User-Id` session variable. This ensures that each todo is associated with the user who created it.
|
||
|
||
|
||

|
||
</Tab>
|
||
|
||
<Tab title="Select">
|
||
|
||
For selecting (reading) todos, we are allowing to read all columns but only for rows where the `user_id` matches the authenticated user's ID. This ensures that users can only see their own todos.
|
||
|
||

|
||
</Tab>
|
||
|
||
<Tab title="Update">
|
||
|
||
When updating todos, we are allowing users to modify the `title`, `details`, and `completed` columns but only for rows where the `user_id` matches their own ID. This prevents users from modifying todos that do not belong to them.
|
||
|
||

|
||
</Tab>
|
||
|
||
<Tab title="Delete">
|
||
|
||
For deleting todos, we are allowing users to delete rows only where the `user_id` matches their own ID. This ensures that users cannot delete todos that belong to other users.
|
||
|
||

|
||
</Tab>
|
||
|
||
</Tabs>
|
||
|
||
</Step>
|
||
|
||
|
||
<Step>
|
||
|
||
### Create the Todos Page Component
|
||
|
||
Now let's implement the Vue component that uses the database we just configured.
|
||
|
||
```vue src/views/Todos.vue lines
|
||
<template>
|
||
<div v-if="!session" class="auth-message">
|
||
<p>Please sign in to view your todos.</p>
|
||
</div>
|
||
|
||
<div v-else class="container">
|
||
<header class="page-header">
|
||
<h1 class="page-title">
|
||
My Todos
|
||
<button
|
||
v-if="!showAddForm"
|
||
type="button"
|
||
@click="showAddForm = true"
|
||
class="add-todo-btn"
|
||
title="Add a new todo"
|
||
>
|
||
+
|
||
</button>
|
||
</h1>
|
||
</header>
|
||
|
||
<div v-if="error" class="error-message">
|
||
<strong>Error:</strong> {{ error }}
|
||
</div>
|
||
|
||
<div v-if="showAddForm" class="todo-form-card">
|
||
<form @submit.prevent="addTodo" class="todo-form">
|
||
<h2 class="form-title">Add New Todo</h2>
|
||
<div class="form-fields">
|
||
<div class="field-group">
|
||
<label :for="titleId">Title *</label>
|
||
<input
|
||
:id="titleId"
|
||
type="text"
|
||
v-model="newTodoTitle"
|
||
placeholder="What needs to be done?"
|
||
required
|
||
/>
|
||
</div>
|
||
<div class="field-group">
|
||
<label :for="detailsId">Details</label>
|
||
<textarea
|
||
:id="detailsId"
|
||
v-model="newTodoDetails"
|
||
placeholder="Add some details (optional)..."
|
||
:rows="3"
|
||
/>
|
||
</div>
|
||
<div class="form-actions">
|
||
<button type="submit" class="btn btn-primary">
|
||
Add Todo
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="cancelAddForm"
|
||
class="btn btn-secondary"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<div v-if="!showAddForm">
|
||
<div v-if="loading" class="loading-container">
|
||
<div class="loading-content">
|
||
<div class="spinner"></div>
|
||
<span class="loading-text">Loading todos...</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="todos-list">
|
||
<div v-if="todos.length === 0" class="empty-state">
|
||
<svg
|
||
class="empty-icon"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
:stroke-width="1.5"
|
||
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||
/>
|
||
</svg>
|
||
<h3 class="empty-title">No todos yet</h3>
|
||
<p class="empty-description">
|
||
Create your first todo to get started!
|
||
</p>
|
||
</div>
|
||
|
||
<div
|
||
v-else
|
||
v-for="todo in todos"
|
||
:key="todo.id"
|
||
:class="['todo-card', { completed: todo.completed }]"
|
||
>
|
||
<div v-if="editingTodo?.id === todo.id" class="todo-edit">
|
||
<div class="edit-fields">
|
||
<div class="field-group">
|
||
<label :for="`${titleId}-edit`">Title</label>
|
||
<input
|
||
:id="`${titleId}-edit`"
|
||
type="text"
|
||
v-model="editingTodo.title"
|
||
/>
|
||
</div>
|
||
<div class="field-group">
|
||
<label :for="`${detailsId}-edit`">Details</label>
|
||
<textarea
|
||
:id="`${detailsId}-edit`"
|
||
v-model="editingTodo.details"
|
||
:rows="3"
|
||
/>
|
||
</div>
|
||
<div class="edit-actions">
|
||
<button
|
||
type="button"
|
||
@click="saveEdit"
|
||
class="btn btn-primary"
|
||
>
|
||
✓ Save Changes
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="editingTodo = null"
|
||
class="btn btn-cancel"
|
||
>
|
||
✕ Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="todo-content">
|
||
<div class="todo-header">
|
||
<button
|
||
type="button"
|
||
:class="['todo-title-btn', { completed: todo.completed }]"
|
||
@click="toggleTodoExpansion(todo.id)"
|
||
>
|
||
{{ todo.title }}
|
||
</button>
|
||
<div class="todo-actions">
|
||
<button
|
||
type="button"
|
||
@click="toggleComplete(todo)"
|
||
class="action-btn action-btn-complete"
|
||
:title="todo.completed ? 'Mark as incomplete' : 'Mark as complete'"
|
||
>
|
||
{{ todo.completed ? "↶" : "✓" }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="editingTodo = todo"
|
||
class="action-btn action-btn-edit"
|
||
title="Edit todo"
|
||
>
|
||
✏️
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="deleteTodo(todo.id)"
|
||
class="action-btn action-btn-delete"
|
||
title="Delete todo"
|
||
>
|
||
🗑️
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="expandedTodos.has(todo.id)" class="todo-details">
|
||
<div
|
||
v-if="todo.details"
|
||
:class="['todo-description', { completed: todo.completed }]"
|
||
>
|
||
<p>{{ todo.details }}</p>
|
||
</div>
|
||
|
||
<div class="todo-meta">
|
||
<div class="meta-dates">
|
||
<span class="meta-item">
|
||
Created: {{ new Date(todo.created_at).toLocaleString() }}
|
||
</span>
|
||
<span class="meta-item">
|
||
Updated: {{ new Date(todo.updated_at).toLocaleString() }}
|
||
</span>
|
||
</div>
|
||
<div v-if="todo.completed" class="completion-badge">
|
||
<svg
|
||
class="completion-icon"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
:stroke-width="2"
|
||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||
/>
|
||
</svg>
|
||
<span>Completed</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref, useId } from "vue";
|
||
import { useAuth } from "../lib/nhost/auth";
|
||
|
||
// The interfaces below define the structure of our data
|
||
// They are not strictly necessary but help with type safety
|
||
|
||
// Represents a single todo item
|
||
interface Todo {
|
||
id: string;
|
||
title: string;
|
||
details: string | null;
|
||
completed: boolean;
|
||
created_at: string;
|
||
updated_at: string;
|
||
user_id: string;
|
||
}
|
||
|
||
// This matches the GraphQL response structure for fetching todos
|
||
// Can be used as a generic type on the request method
|
||
interface GetTodos {
|
||
todos: Todo[];
|
||
}
|
||
|
||
// This matches the GraphQL response structure for inserting a todo
|
||
// Can be used as a generic type on the request method
|
||
interface InsertTodo {
|
||
insert_todos_one: Todo | null;
|
||
}
|
||
|
||
// This matches the GraphQL response structure for updating a todo
|
||
// Can be used as a generic type on the request method
|
||
interface UpdateTodo {
|
||
update_todos_by_pk: Todo | null;
|
||
}
|
||
|
||
const { nhost, session } = useAuth();
|
||
|
||
const todos = ref<Todo[]>([]);
|
||
const loading = ref(true);
|
||
const error = ref<string | null>(null);
|
||
const newTodoTitle = ref("");
|
||
const newTodoDetails = ref("");
|
||
const editingTodo = ref<Todo | null>(null);
|
||
const showAddForm = ref(false);
|
||
const expandedTodos = ref<Set<string>>(new Set());
|
||
|
||
const titleId = useId();
|
||
const detailsId = useId();
|
||
|
||
const fetchTodos = async () => {
|
||
try {
|
||
loading.value = true;
|
||
// Make GraphQL request to fetch todos using Nhost client
|
||
// The query automatically filters by user_id due to Hasura permissions
|
||
const response = await nhost.graphql.request<GetTodos>({
|
||
query: `
|
||
query GetTodos {
|
||
todos(order_by: { created_at: desc }) {
|
||
id
|
||
title
|
||
details
|
||
completed
|
||
created_at
|
||
updated_at
|
||
user_id
|
||
}
|
||
}
|
||
`,
|
||
});
|
||
|
||
// Check for GraphQL errors in the response body
|
||
if (response.body.errors) {
|
||
throw new Error(
|
||
response.body.errors[0]?.message || "Failed to fetch todos",
|
||
);
|
||
}
|
||
|
||
// Extract todos from the GraphQL response data
|
||
todos.value = response.body?.data?.todos || [];
|
||
error.value = null;
|
||
} catch (err) {
|
||
error.value = err instanceof Error ? err.message : "Failed to fetch todos";
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const addTodo = async () => {
|
||
if (!newTodoTitle.value.trim()) return;
|
||
|
||
try {
|
||
// Execute GraphQL mutation to insert a new todo
|
||
// user_id is automatically set by Hasura based on JWT token
|
||
const response = await nhost.graphql.request<InsertTodo>({
|
||
query: `
|
||
mutation InsertTodo($title: String!, $details: String) {
|
||
insert_todos_one(object: { title: $title, details: $details }) {
|
||
id
|
||
title
|
||
details
|
||
completed
|
||
created_at
|
||
updated_at
|
||
user_id
|
||
}
|
||
}
|
||
`,
|
||
variables: {
|
||
title: newTodoTitle.value.trim(),
|
||
details: newTodoDetails.value.trim() || null,
|
||
},
|
||
});
|
||
|
||
if (response.body.errors) {
|
||
throw new Error(response.body.errors[0]?.message || "Failed to add todo");
|
||
}
|
||
|
||
if (!response.body?.data?.insert_todos_one) {
|
||
throw new Error("Failed to add todo");
|
||
}
|
||
todos.value = [response.body?.data?.insert_todos_one, ...todos.value];
|
||
newTodoTitle.value = "";
|
||
newTodoDetails.value = "";
|
||
showAddForm.value = false;
|
||
error.value = null;
|
||
} catch (err) {
|
||
error.value = err instanceof Error ? err.message : "Failed to add todo";
|
||
}
|
||
};
|
||
|
||
const updateTodo = async (
|
||
id: string,
|
||
updates: Partial<Pick<Todo, "title" | "details" | "completed">>,
|
||
) => {
|
||
try {
|
||
// Execute GraphQL mutation to update an existing todo by primary key
|
||
// Hasura permissions ensure users can only update their own todos
|
||
const response = await nhost.graphql.request<UpdateTodo>({
|
||
query: `
|
||
mutation UpdateTodo($id: uuid!, $updates: todos_set_input!) {
|
||
update_todos_by_pk(pk_columns: { id: $id }, _set: $updates) {
|
||
id
|
||
title
|
||
details
|
||
completed
|
||
created_at
|
||
updated_at
|
||
user_id
|
||
}
|
||
}
|
||
`,
|
||
variables: {
|
||
id,
|
||
updates,
|
||
},
|
||
});
|
||
|
||
if (response.body.errors) {
|
||
throw new Error(
|
||
response.body.errors[0]?.message || "Failed to update todo",
|
||
);
|
||
}
|
||
|
||
if (!response.body?.data?.update_todos_by_pk) {
|
||
throw new Error("Failed to update todo");
|
||
}
|
||
|
||
const updatedTodo = response.body?.data?.update_todos_by_pk;
|
||
if (updatedTodo) {
|
||
todos.value = todos.value.map((todo) =>
|
||
todo.id === id ? updatedTodo : todo,
|
||
);
|
||
}
|
||
editingTodo.value = null;
|
||
error.value = null;
|
||
} catch (err) {
|
||
error.value = err instanceof Error ? err.message : "Failed to update todo";
|
||
}
|
||
};
|
||
|
||
const deleteTodo = async (id: string) => {
|
||
if (!confirm("Are you sure you want to delete this todo?")) return;
|
||
|
||
try {
|
||
// Execute GraphQL mutation to delete a todo by primary key
|
||
// Hasura permissions ensure users can only delete their own todos
|
||
const response = await nhost.graphql.request({
|
||
query: `
|
||
mutation DeleteTodo($id: uuid!) {
|
||
delete_todos_by_pk(id: $id) {
|
||
id
|
||
}
|
||
}
|
||
`,
|
||
variables: {
|
||
id,
|
||
},
|
||
});
|
||
|
||
if (response.body.errors) {
|
||
throw new Error(
|
||
response.body.errors[0]?.message || "Failed to delete todo",
|
||
);
|
||
}
|
||
|
||
todos.value = todos.value.filter((todo) => todo.id !== id);
|
||
error.value = null;
|
||
} catch (err) {
|
||
error.value = err instanceof Error ? err.message : "Failed to delete todo";
|
||
}
|
||
};
|
||
|
||
const toggleComplete = async (todo: Todo) => {
|
||
await updateTodo(todo.id, { completed: !todo.completed });
|
||
};
|
||
|
||
const saveEdit = async () => {
|
||
if (!editingTodo.value) return;
|
||
await updateTodo(editingTodo.value.id, {
|
||
title: editingTodo.value.title,
|
||
details: editingTodo.value.details,
|
||
});
|
||
};
|
||
|
||
const toggleTodoExpansion = (todoId: string) => {
|
||
const newExpanded = new Set(expandedTodos.value);
|
||
if (newExpanded.has(todoId)) {
|
||
newExpanded.delete(todoId);
|
||
} else {
|
||
newExpanded.add(todoId);
|
||
}
|
||
expandedTodos.value = newExpanded;
|
||
};
|
||
|
||
const cancelAddForm = () => {
|
||
showAddForm.value = false;
|
||
newTodoTitle.value = "";
|
||
newTodoDetails.value = "";
|
||
};
|
||
|
||
// Fetch todos when user session is available
|
||
// The session contains the JWT token needed for GraphQL authentication
|
||
onMounted(() => {
|
||
if (session.value) {
|
||
fetchTodos();
|
||
}
|
||
});
|
||
</script>
|
||
```
|
||
|
||
</Step>
|
||
|
||
<Step>
|
||
|
||
### Update Router Configuration
|
||
|
||
Add the todos page to your application routing.
|
||
|
||
```ts src/router/index.ts lines highlight={7,39-44}
|
||
import { createRouter, createWebHistory } from "vue-router";
|
||
import { useAuth } from "../lib/nhost/auth";
|
||
import HomeView from "../views/HomeView.vue";
|
||
import ProfileView from "../views/ProfileView.vue";
|
||
import SignIn from "../views/SignIn.vue";
|
||
import SignUp from "../views/SignUp.vue";
|
||
import Todos from "../views/Todos.vue";
|
||
import Verify from "../views/Verify.vue";
|
||
|
||
const router = createRouter({
|
||
history: createWebHistory(import.meta.env.BASE_URL),
|
||
routes: [
|
||
{
|
||
path: "/",
|
||
name: "home",
|
||
component: HomeView,
|
||
},
|
||
{
|
||
path: "/signin",
|
||
name: "SignIn",
|
||
component: SignIn,
|
||
},
|
||
{
|
||
path: "/signup",
|
||
name: "SignUp",
|
||
component: SignUp,
|
||
},
|
||
{
|
||
path: "/verify",
|
||
name: "Verify",
|
||
component: Verify,
|
||
},
|
||
{
|
||
path: "/profile",
|
||
name: "profile",
|
||
component: ProfileView,
|
||
meta: { requiresAuth: true },
|
||
},
|
||
{
|
||
path: "/todos",
|
||
name: "Todos",
|
||
component: Todos,
|
||
meta: { requiresAuth: true },
|
||
},
|
||
{
|
||
path: "/:pathMatch(.*)*",
|
||
redirect: "/",
|
||
},
|
||
],
|
||
});
|
||
|
||
// Navigation guard for protected routes
|
||
router.beforeEach((to) => {
|
||
if (to.meta["requiresAuth"]) {
|
||
const { isAuthenticated, isLoading } = useAuth();
|
||
|
||
// Show loading state while authentication is being checked
|
||
if (isLoading.value) {
|
||
// You can return a loading component path or handle loading in the component
|
||
return true; // Allow navigation, handle loading in component
|
||
}
|
||
|
||
if (!isAuthenticated.value) {
|
||
return "/"; // Redirect to home page
|
||
}
|
||
}
|
||
return true;
|
||
});
|
||
|
||
export default router;
|
||
```
|
||
|
||
</Step>
|
||
|
||
<Step>
|
||
|
||
### Update Navigation Links
|
||
|
||
Add a link to the todos page in the navigation bar.
|
||
|
||
```vue src/components/Navigation.vue lines highlight={14-16}
|
||
<template>
|
||
<nav class="navigation">
|
||
<div class="nav-container">
|
||
<RouterLink to="/" class="nav-logo">
|
||
Nhost Vue Demo
|
||
</RouterLink>
|
||
|
||
<div class="nav-links">
|
||
<RouterLink to="/" class="nav-link">
|
||
Home
|
||
</RouterLink>
|
||
|
||
<template v-if="isAuthenticated">
|
||
<RouterLink to="/todos" class="nav-link">
|
||
Todos
|
||
</RouterLink>
|
||
<RouterLink to="/profile" class="nav-link">
|
||
Profile
|
||
</RouterLink>
|
||
<button
|
||
@click="handleSignOut"
|
||
class="nav-link nav-button"
|
||
>
|
||
Sign Out
|
||
</button>
|
||
</template>
|
||
<template v-else>
|
||
<RouterLink to="/signin" class="nav-link">
|
||
Sign In
|
||
</RouterLink>
|
||
<RouterLink to="/signup" class="nav-link">
|
||
Sign Up
|
||
</RouterLink>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { RouterLink, useRouter } from "vue-router";
|
||
import { useAuth } from "../lib/nhost/auth";
|
||
|
||
const { isAuthenticated, session, nhost } = useAuth();
|
||
const router = useRouter();
|
||
|
||
const handleSignOut = async () => {
|
||
try {
|
||
if (session.value) {
|
||
await nhost.auth.signOut({
|
||
refreshToken: session.value.refreshToken,
|
||
});
|
||
}
|
||
router.push("/");
|
||
} catch (err: unknown) {
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
console.error("Error signing out:", message);
|
||
}
|
||
};
|
||
</script>
|
||
```
|
||
|
||
</Step>
|
||
|
||
<Step>
|
||
|
||
### Test Your Complete Application
|
||
|
||
Run your application and test all the functionality:
|
||
|
||
```bash
|
||
npm run dev
|
||
```
|
||
|
||
Things to try out:
|
||
|
||
1. Try signing in and out and see how the Todos page is only available when authenticated
|
||
2. Create, view, edit, complete, and delete todos. See how the UI updates accordingly
|
||
3. Open the application in another browser or incognito window, sign in with a different account and verify that you cannot see or modify todos from the first account
|
||
|
||
</Step>
|
||
</Steps>
|
||
|
||
## Key Features Implemented
|
||
|
||
<AccordionGroup>
|
||
<Accordion title="Database Schema" icon="database">
|
||
Properly designed todos table with constraints, indexes, and automatic timestamp updates for optimal performance.
|
||
</Accordion>
|
||
|
||
<Accordion title="GraphQL API" icon="webhook">
|
||
Auto-generated GraphQL API with queries and mutations for full CRUD operations on todos.
|
||
</Accordion>
|
||
|
||
<Accordion title="Row-Level Security" icon="shield-check">
|
||
Comprehensive permissions ensuring users can only access their own todos through all GraphQL operations.
|
||
</Accordion>
|
||
|
||
<Accordion title="CRUD Operations" icon="arrows-rotate">
|
||
Complete Create, Read, Update, Delete functionality with proper error handling and user feedback.
|
||
</Accordion>
|
||
|
||
<Accordion title="Rich Interface" icon="sparkles">
|
||
Expandable todo items, inline editing, completion status, and detailed timestamps.
|
||
</Accordion>
|
||
</AccordionGroup>
|