From 57c07b1415ed6b4463ea3bf220a1814b555f5d3a Mon Sep 17 00:00:00 2001 From: Kevin Lewis Date: Wed, 18 Dec 2024 16:29:15 +0100 Subject: [PATCH] Add tutorials --- .../check-permissions-in-a-custom-endpoint.md | 418 ++++++ ...reate-collection-items-in-custom-panels.md | 613 +++++++++ .../create-comments-in-custom-operations.md | 376 ++++++ ...ew-customers-in-stripe-in-a-custom-hook.md | 197 +++ ...l-api-data-from-vonage-in-custom-panels.md | 784 ++++++++++++ ...mat-dates-in-a-custom-display-extension.md | 256 ++++ ...-navigation-in-multipage-custom-modules.md | 992 +++++++++++++++ ...rate-algolia-indexing-with-custom-hooks.md | 93 ++ ...lasticsearch-indexing-with-custom-hooks.md | 98 ++ ...-meilisearch-indexing-with-custom-hooks.md | 100 ++ ...error-track-with-sentry-in-custom-hooks.md | 242 ++++ ...rnal-api-in-a-custom-endpoint-extension.md | 242 ++++ .../read-collection-data-in-custom-layouts.md | 317 +++++ ...ssages-with-twilio-in-custom-operations.md | 283 +++++ ...s-messages-with-twilio-in-custom-panels.md | 1132 +++++++++++++++++ ...nal-items-in-a-custom-display-extension.md | 482 +++++++ ...stand-available-slots-in-custom-modules.md | 344 +++++ ...ynamic-values-in-custom-email-templates.md | 210 +++ .../use-npm-packages-in-custom-operations.md | 175 +++ ...ne-numbers-with-twilio-in-a-custom-hook.md | 176 +++ ...e-blocks-with-many-to-any-relationships.md | 376 ++++++ ...ta-from-directus-in-android-with-kotlin.md | 796 ++++++++++++ ...h-data-from-directus-in-i-os-with-swift.md | 252 ++++ .../fetch-data-from-directus-with-angular.md | 457 +++++++ .../fetch-data-from-directus-with-astro.md | 311 +++++ .../fetch-data-from-directus-with-django.md | 332 +++++ ...etch-data-from-directus-with-eleventy-3.md | 259 ++++ .../fetch-data-from-directus-with-flask.md | 359 ++++++ .../fetch-data-from-directus-with-flutter.md | 506 ++++++++ .../fetch-data-from-directus-with-laravel.md | 383 ++++++ .../fetch-data-from-directus-with-nextjs.md | 337 +++++ .../fetch-data-from-directus-with-nuxt.md | 284 +++++ ...tch-data-from-directus-with-solid-start.md | 298 +++++ ...tch-data-from-directus-with-spring-boot.md | 246 ++++ ...etch-data-from-directus-with-svelte-kit.md | 328 +++++ .../implement-directus-auth-with-ios.md | 935 ++++++++++++++ .../implement-directus-auth-with-next-js.md | 857 +++++++++++++ ...implement-directus-auth-with-svelte-kit.md | 457 +++++++ ...al-content-with-directus-and-svelte-kit.md | 340 +++++ .../set-up-live-preview-with-next-js.md | 201 +++ .../set-up-live-preview-with-nuxt.md | 158 +++ .../migrate-from-notion-to-directus.md | 175 +++ .../migrate-from-nuxt-content-to-directus.md | 221 ++++ .../migrate-from-word-press-to-directus.md | 289 +++++ ...hanges-between-environments-in-directus.md | 37 + ...-with-java-script-and-directus-realtime.md | 389 ++++++ ...r-chat-with-react-and-directus-realtime.md | 422 ++++++ ...-chat-with-vue-js-and-directus-realtime.md | 402 ++++++ ...ook-chrome-extension-with-directus-auth.md | 475 +++++++ ...al-widget-with-svelte-kit-and-directus-.md | 556 ++++++++ ...ild-a-user-feedback-widget-with-vue-js-.md | 522 ++++++++ ...aming-app-with-svelte-kit-and-directus-.md | 360 ++++++ ...th-next-js-stripe-and-directus-automate.md | 1054 +++++++++++++++ ...th-next-js-stripe-and-directus-automate.md | 868 +++++++++++++ ...-passive-collaborative-event-booth-demo.md | 202 +++ ...-week-registration-and-referral-system-.md | 303 +++++ ...nture-an-ai-powered-game-with-directus-.md | 134 ++ ...ernal-weather-api-data-in-custom-panels.md | 181 +++ ...e-directus-with-esp-32-hardware-sensors.md | 280 ++++ ...health-tracker-with-owlet-and-ops-genie.md | 412 ++++++ ...igure-okta-as-a-single-sign-on-provider.md | 141 ++ .../deploy-directus-to-an-ubuntu-server.md | 327 +++++ .../deploy-directus-to-aws-ec-2.md | 185 +++ .../deploy-directus-to-azure-web-apps.md | 121 ++ .../deploy-directus-to-digital-ocean.md | 152 +++ ...eploy-directus-to-google-cloud-platform.md | 182 +++ .../self-hosting/understanding-kubernetes.md | 365 ++++++ .../advanced-types-with-the-directus-sdk.md | 289 +++++ ...ring-pipeline-for-flows-and-extensions-.md | 328 +++++ ...tion-and-infinite-scrolling-in-next-js-.md | 451 +++++++ .../importing-files-in-directus-automate.md | 36 + ...live-preview-with-google-docs-previews-.md | 22 + ...arch-engine-optimization-best-practices.md | 518 ++++++++ ...roval-workflows-with-custom-permissions.md | 118 ++ ...iew-and-content-versioning-with-next-js.md | 146 +++ ...te-github-issues-with-directus-automate.md | 73 ++ ...mbers-with-vonage-and-directus-automate.md | 131 ++ ...ata-with-clearbit-and-directus-automate.md | 74 ++ ...mages-with-dall-e-and-directus-automate.md | 74 ++ ...posts-with-gpt-4-and-directus-automate-.md | 68 + ...ts-with-deepgram-and-directus-automate-.md | 85 ++ ...ngual-content-with-directus-and-crowdin.md | 78 ++ ...e-future-content-with-directus-automate.md | 237 ++++ ...ges-with-clarifai-and-directus-automate.md | 88 ++ ...lify-site-builds-with-directus-automate.md | 129 ++ ...rcel-site-builds-with-directus-automate.md | 129 ++ server/utils/remote-content.ts | 18 +- 87 files changed, 27840 insertions(+), 9 deletions(-) create mode 100644 content/tutorials/extensions/check-permissions-in-a-custom-endpoint.md create mode 100644 content/tutorials/extensions/create-collection-items-in-custom-panels.md create mode 100644 content/tutorials/extensions/create-comments-in-custom-operations.md create mode 100644 content/tutorials/extensions/create-new-customers-in-stripe-in-a-custom-hook.md create mode 100644 content/tutorials/extensions/display-external-api-data-from-vonage-in-custom-panels.md create mode 100644 content/tutorials/extensions/format-dates-in-a-custom-display-extension.md create mode 100644 content/tutorials/extensions/implement-navigation-in-multipage-custom-modules.md create mode 100644 content/tutorials/extensions/integrate-algolia-indexing-with-custom-hooks.md create mode 100644 content/tutorials/extensions/integrate-elasticsearch-indexing-with-custom-hooks.md create mode 100644 content/tutorials/extensions/integrate-meilisearch-indexing-with-custom-hooks.md create mode 100644 content/tutorials/extensions/monitor-and-error-track-with-sentry-in-custom-hooks.md create mode 100644 content/tutorials/extensions/proxy-an-external-api-in-a-custom-endpoint-extension.md create mode 100644 content/tutorials/extensions/read-collection-data-in-custom-layouts.md create mode 100644 content/tutorials/extensions/send-sms-messages-with-twilio-in-custom-operations.md create mode 100644 content/tutorials/extensions/send-sms-messages-with-twilio-in-custom-panels.md create mode 100644 content/tutorials/extensions/summarize-relational-items-in-a-custom-display-extension.md create mode 100644 content/tutorials/extensions/understand-available-slots-in-custom-modules.md create mode 100644 content/tutorials/extensions/use-dynamic-values-in-custom-email-templates.md create mode 100644 content/tutorials/extensions/use-npm-packages-in-custom-operations.md create mode 100644 content/tutorials/extensions/validate-phone-numbers-with-twilio-in-a-custom-hook.md create mode 100644 content/tutorials/getting-started/create-reusable-blocks-with-many-to-any-relationships.md create mode 100644 content/tutorials/getting-started/fetch-data-from-directus-in-android-with-kotlin.md create mode 100644 content/tutorials/getting-started/fetch-data-from-directus-in-i-os-with-swift.md create mode 100644 content/tutorials/getting-started/fetch-data-from-directus-with-angular.md create mode 100644 content/tutorials/getting-started/fetch-data-from-directus-with-astro.md create mode 100644 content/tutorials/getting-started/fetch-data-from-directus-with-django.md create mode 100644 content/tutorials/getting-started/fetch-data-from-directus-with-eleventy-3.md create mode 100644 content/tutorials/getting-started/fetch-data-from-directus-with-flask.md create mode 100644 content/tutorials/getting-started/fetch-data-from-directus-with-flutter.md create mode 100644 content/tutorials/getting-started/fetch-data-from-directus-with-laravel.md create mode 100644 content/tutorials/getting-started/fetch-data-from-directus-with-nextjs.md create mode 100644 content/tutorials/getting-started/fetch-data-from-directus-with-nuxt.md create mode 100644 content/tutorials/getting-started/fetch-data-from-directus-with-solid-start.md create mode 100644 content/tutorials/getting-started/fetch-data-from-directus-with-spring-boot.md create mode 100644 content/tutorials/getting-started/fetch-data-from-directus-with-svelte-kit.md create mode 100644 content/tutorials/getting-started/implement-directus-auth-with-ios.md create mode 100644 content/tutorials/getting-started/implement-directus-auth-with-next-js.md create mode 100644 content/tutorials/getting-started/implement-directus-auth-with-svelte-kit.md create mode 100644 content/tutorials/getting-started/implement-multilingual-content-with-directus-and-svelte-kit.md create mode 100644 content/tutorials/getting-started/set-up-live-preview-with-next-js.md create mode 100644 content/tutorials/getting-started/set-up-live-preview-with-nuxt.md create mode 100644 content/tutorials/migration/migrate-from-notion-to-directus.md create mode 100644 content/tutorials/migration/migrate-from-nuxt-content-to-directus.md create mode 100644 content/tutorials/migration/migrate-from-word-press-to-directus.md create mode 100644 content/tutorials/migration/promoting-changes-between-environments-in-directus.md create mode 100644 content/tutorials/projects/build-a-multi-user-chat-with-java-script-and-directus-realtime.md create mode 100644 content/tutorials/projects/build-a-multi-user-chat-with-react-and-directus-realtime.md create mode 100644 content/tutorials/projects/build-a-multi-user-chat-with-vue-js-and-directus-realtime.md create mode 100644 content/tutorials/projects/build-a-notebook-chrome-extension-with-directus-auth.md create mode 100644 content/tutorials/projects/build-a-testimonial-widget-with-svelte-kit-and-directus-.md create mode 100644 content/tutorials/projects/build-a-user-feedback-widget-with-vue-js-.md create mode 100644 content/tutorials/projects/build-a-video-streaming-app-with-svelte-kit-and-directus-.md create mode 100644 content/tutorials/projects/build-an-ecommerce-platform-with-next-js-stripe-and-directus-automate.md create mode 100644 content/tutorials/projects/build-an-hotel-booking-platform-with-next-js-stripe-and-directus-automate.md create mode 100644 content/tutorials/projects/build-directus-garden-a-passive-collaborative-event-booth-demo.md create mode 100644 content/tutorials/projects/build-the-leap-week-registration-and-referral-system-.md create mode 100644 content/tutorials/projects/building-ai-venture-an-ai-powered-game-with-directus-.md create mode 100644 content/tutorials/projects/display-external-weather-api-data-in-custom-panels.md create mode 100644 content/tutorials/projects/integrate-directus-with-esp-32-hardware-sensors.md create mode 100644 content/tutorials/projects/use-directus-as-a-baby-health-tracker-with-owlet-and-ops-genie.md create mode 100644 content/tutorials/self-hosting/configure-okta-as-a-single-sign-on-provider.md create mode 100644 content/tutorials/self-hosting/deploy-directus-to-an-ubuntu-server.md create mode 100644 content/tutorials/self-hosting/deploy-directus-to-aws-ec-2.md create mode 100644 content/tutorials/self-hosting/deploy-directus-to-azure-web-apps.md create mode 100644 content/tutorials/self-hosting/deploy-directus-to-digital-ocean.md create mode 100644 content/tutorials/self-hosting/deploy-directus-to-google-cloud-platform.md create mode 100644 content/tutorials/self-hosting/understanding-kubernetes.md create mode 100644 content/tutorials/tips-and-tricks/advanced-types-with-the-directus-sdk.md create mode 100644 content/tutorials/tips-and-tricks/build-a-monitoring-pipeline-for-flows-and-extensions-.md create mode 100644 content/tutorials/tips-and-tricks/implement-pagination-and-infinite-scrolling-in-next-js-.md create mode 100644 content/tutorials/tips-and-tricks/importing-files-in-directus-automate.md create mode 100644 content/tutorials/tips-and-tricks/preview-files-in-live-preview-with-google-docs-previews-.md create mode 100644 content/tutorials/tips-and-tricks/search-engine-optimization-best-practices.md create mode 100644 content/tutorials/workflows/build-content-approval-workflows-with-custom-permissions.md create mode 100644 content/tutorials/workflows/combine-live-preview-and-content-versioning-with-next-js.md create mode 100644 content/tutorials/workflows/create-github-issues-with-directus-automate.md create mode 100644 content/tutorials/workflows/detect-high-risk-phone-numbers-with-vonage-and-directus-automate.md create mode 100644 content/tutorials/workflows/enrich-user-data-with-clearbit-and-directus-automate.md create mode 100644 content/tutorials/workflows/generate-images-with-dall-e-and-directus-automate.md create mode 100644 content/tutorials/workflows/generate-social-posts-with-gpt-4-and-directus-automate-.md create mode 100644 content/tutorials/workflows/generate-transcripts-with-deepgram-and-directus-automate-.md create mode 100644 content/tutorials/workflows/integrating-multilingual-content-with-directus-and-crowdin.md create mode 100644 content/tutorials/workflows/schedule-future-content-with-directus-automate.md create mode 100644 content/tutorials/workflows/tag-images-with-clarifai-and-directus-automate.md create mode 100644 content/tutorials/workflows/trigger-netlify-site-builds-with-directus-automate.md create mode 100644 content/tutorials/workflows/trigger-vercel-site-builds-with-directus-automate.md diff --git a/content/tutorials/extensions/check-permissions-in-a-custom-endpoint.md b/content/tutorials/extensions/check-permissions-in-a-custom-endpoint.md new file mode 100644 index 00000000..254cf653 --- /dev/null +++ b/content/tutorials/extensions/check-permissions-in-a-custom-endpoint.md @@ -0,0 +1,418 @@ +--- +id: a2177488-2e50-4206-97a2-a36f3a506541 +slug: check-permissions-in-a-custom-endpoint +title: Check Permissions in a Custom Endpoint +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- +Endpoints are used in the API to perform certain functions. In this guide, you will use internal Directus permissions +when creating a custom endpoint. + +As an example, this guide will proxy the Stripe API, but the same approach can be used for any API. + +## Install Dependencies + +Open a console to your preferred working directory and initialize a new extension, which will create the boilerplate +code for your operation. + +```shell +npx create-directus-extension@latest +``` + +A list of options will appear (choose endpoint), and type a name for your extension (for example, +`directus-endpoint-stripe`). For this guide, select JavaScript. + +Now the boilerplate has been created, install the `stripe` package, and then open the directory in your code editor. + +```shell +cd directus-endpoint-stripe +npm install stripe +``` + +You will also need a Stripe account and API token, and a collection in your Directus project with restricted permissions +and a role which has read and create permissions. + +## Build the Endpoint + +In the `src` directory open `index.js`. By default, the endpoint root will be the name of the extensions folder which +would be `/directus-endpoint-stripe/`. To change this, replace the code with the following: + +```js +import Stripe from 'stripe'; + +export default { + id: 'stripe', + handler: (router) => { + // Router config goes here + }, +}; +``` + +The `id` becomes the root and must be a unique identifier between all other endpoints. + +The Stripe library requires your account's secret key and is best placed in the environment file. To access these +variables, add the `env` context to the handler like so: + +```js +handler: (router, { env }) => { +``` + +Being sensitive information, it’s best practice to control who can access your Stripe account especially if you have +public enrollment in your Directus project. + + +Request your permissions from your project's API using `fetch`: + +```js +router.get('/payments', async (req, res) => { + try { + const response = await fetch("http://directus.example.com/permissions/me", { + headers: { + 'Authorization': `Bearer ${req.token}`, + 'Content-Type': 'application/json' + } + }); + + const permissions = await response.json(); + } + catch(e) { + res.sendStatus(401); + } +}); +``` + +Now you can check the user’s permission level at the collection level. In most cases this can be used in a simple if +statement. + +Bring these together with the Stripe `paymentIntents` function and you can return a list of payments. For those without +permission, respond with the 401 (unauthorized) code. + +```js +router.get('/payments', async (req, res) => { + try { + const response = await fetch("http://directus.example.com/permissions/me", { + headers: { + 'Authorization': `Bearer ${req.token}`, + 'Content-Type': 'application/json' + } + }); + const permissions = await response.json(); + + let output = []; // [!code ++] + + if (permissions.data[env.STRIPE_CUSTOMERS_COLLECTION]?.read?.access === "full")) { // [!code ++] + stripe.paymentIntents // [!code ++] + .list({ limit: 100 }) // [!code ++] + .autoPagingEach((payments) => { // [!code ++] + output.push(payments); // [!code ++] + }) // [!code ++] + .then(() => { // [!code ++] + res.json(output); // [!code ++] + }); // [!code ++] + } else { // [!code ++] + res.sendStatus(401); // [!code ++] + } // [!code ++] + } + catch(e) { + res.sendStatus(401); + } +}); +``` + +Note the use of Stripe’s `autoPagingEach` to help with pagination. This returns each payment individually despite +fetching 100 at a time. Use the `output` variable to save each result and then return the variable to as the endpoint +response. + +You can use this pattern for any endpoint offered by the Stripe Node.js library. To get a list of customers: + +```js{8} +router.get('/customers', async (req, res) => { + try { + const response = await fetch("http://directus.example.com/permissions/me", { + headers: { + 'Authorization': `Bearer ${req.token}`, + 'Content-Type': 'application/json' + } + }); + const permissions = await response.json(); + + let output = []; + if (permissions.data[env.STRIPE_CUSTOMERS_COLLECTION]?.read?.access === "full")) { + stripe.customers.list({limit: 100}).autoPagingEach((customer) => { + output.push(customer); + }).then(() => { + res.json(output); + }); + } else { + res.sendStatus(401); + } + } + catch(e) { + res.sendStatus(401); + } +}); +``` + +To fetch payments for a single customer, use a parameter in the endpoint. The structure is very similar except for the +parameter in the path (`/:customer_id`) and the additional parameter in the Stripe query: + +```js{1,9} +router.get('/payments/:customer_id', async (req, res) => { + try { + const response = await fetch("http://directus.example.com/permissions/me", { + headers: { + 'Authorization': `Bearer ${req.token}`, + 'Content-Type': 'application/json' + } + }); + const permissions = await response.json(); + + let output = []; + if (permissions.data[env.STRIPE_CUSTOMERS_COLLECTION]?.read?.access === "full")) { + stripe.paymentIntents.list({ + customer: req.params.customer_id, + limit: 100 + }).autoPagingEach(function(payments) { + output.push(payments); + }).then(() => { + res.json(output); + }); + } else { + res.sendStatus(401); + } + } + catch(e) { + res.sendStatus(401); + } +}); +``` + +To create a customer, information to be sent to this endpoint then passed onto Stripe. When dealing with inputs, it’s +important to validate the structure to ensure the required information is sent to Stripe. Create a POST route and use +the permission service to check for 'create' permissions: + +```js +router.post('/customers', async (req, res) => { + try { + const response = await fetch("http://directus.example.com/permissions/me", { + headers: { + 'Authorization': `Bearer ${req.token}`, + 'Content-Type': 'application/json' + } + }); + const permissions = await response.json(); + + if (permissions.data[env.STRIPE_CUSTOMERS_COLLECTION]?.read?.access === "full")) { + if (req.body.email) { + const customer = { + email: req.body.email, + }; + + if (req.body.name) { + customer.name = req.body.name; + } + + stripe.customers.create(customer).then((response) => { + res.json(response); + }); + } else { + res.sendStatus(400); // Bad Request + } + } else { + res.sendStatus(401); + } + } + catch(e) { + res.sendStatus(401); + } +}); +``` + +The response will be a customer object in Stripe which can be used to write the customer ID back to the collection. + +This is now complete and ready for testing. Build the endpoint with the latest changes. + +``` +npm run build +``` + +## Add Endpoint to Directus + +When Directus starts, it will look in the `extensions` directory for any subdirectory starting with +`directus-extension-`, and attempt to load them. + +To install an extension, copy the entire directory with all source code, the `package.json` file, and the `dist` +directory into the Directus `extensions` directory. Make sure the directory with your extension has a name that starts +with `directus-extension`. In this case, you may choose to use `directus-extension-endpoint-stripe`. + +For the permissions to work, add the collection from Directus where the permissions are assigned with the variable +`STRIPE_CUSTOMERS_COLLECTION` - ensure the `.env` file has `STRIPE_LIVE_SECRET_KEY` and `STRIPE_CUSTOMERS_COLLECTION` +variables. + +Restart Directus to load the extension. + +::callout{type="info" title="Required files"} + +Only the `package.json` and `dist` directory are required inside of your extension directory. However, adding the source +code has no negative effect. + +:: + +## Use the Endpoint + +Using an application such as Postman, create a new request. The URL will be: `https://example.directus.app/stripe/` (be +sure that you change the URL for your project's URL) + +- To view all payments: https://example.directus.app/stripe/payments +- To view all payments for a customer: https://example.directus.app/stripe/payments/CUS_XXX +- To create a customer: POST to https://example.directus.app/stripe/customer with the following payload: + +```json +{ + "email": "your-email@example.com", + "name": "Joe Bloggs" +} +``` + +## Summary + +With this endpoint, you can now query payments and create customers through the Stripe API within Directus using the +built-in credentials of the current user. Now that you know how to create your own routes for an endpoint and protect +them with the Permissions Service, you can discover more endpoints in Stripe and add them to your own. + +## Complete Code + +`index.js` + +```js +import Stripe from 'stripe'; + +export default { + id: 'stripe', + handler: (router, { env, services }) => { + const secretKey = env.STRIPE_LIVE_SECRET_KEY; + const stripe = new Stripe(secretKey); + + router.get('/payments', async (req, res) => { + try { + const response = await fetch("http://directus.example.com/permissions/me", { + headers: { + 'Authorization': `Bearer ${req.token}`, + 'Content-Type': 'application/json' + } + }); + const permissions = await response.json(); + + let output = []; // [!code ++] + + if (permissions.data[env.STRIPE_CUSTOMERS_COLLECTION]?.read?.access === "full")) { + stripe.paymentIntents + .list({ limit: 100 }) + .autoPagingEach((payments) => { + output.push(payments); + }) + .then(() => { + res.json(output); + }); + } else { + res.sendStatus(401); + } + } + catch(e) { + res.sendStatus(401); + } + }); + + router.get('/payments/:customer_id', async (req, res) => { + try { + const response = await fetch("http://directus.example.com/permissions/me", { + headers: { + 'Authorization': `Bearer ${req.token}`, + 'Content-Type': 'application/json' + } + }); + const permissions = await response.json(); + + let output = []; + if (permissions.data[env.STRIPE_CUSTOMERS_COLLECTION]?.read?.access === "full")) { + stripe.paymentIntents.list({ + customer: req.params.customer_id, + limit: 100 + }).autoPagingEach(function(payments) { + output.push(payments); + }).then(() => { + res.json(output); + }); + } else { + res.sendStatus(401); + } + } + catch(e) { + res.sendStatus(401); + } + }); + + router.get('/customers', async (req, res) => { + try { + const response = await fetch("http://directus.example.com/permissions/me", { + headers: { + 'Authorization': `Bearer ${req.token}`, + 'Content-Type': 'application/json' + } + }); + const permissions = await response.json(); + + let output = []; + if (permissions.data[env.STRIPE_CUSTOMERS_COLLECTION]?.read?.access === "full")) { + stripe.customers.list({limit: 100}).autoPagingEach((customer) => { + output.push(customer); + }).then(() => { + res.json(output); + }); + } else { + res.sendStatus(401); + } + } + catch(e) { + res.sendStatus(401); + } + }); + + router.post('/customers', async (req, res) => { + try { + const response = await fetch("http://directus.example.com/permissions/me", { + headers: { + 'Authorization': `Bearer ${req.token}`, + 'Content-Type': 'application/json' + } + }); + const permissions = await response.json(); + + if (permissions.data[env.STRIPE_CUSTOMERS_COLLECTION]?.read?.access === "full")) { + if (req.body.email) { + const customer = { + email: req.body.email, + }; + + if (req.body.name) { + customer.name = req.body.name; + } + + stripe.customers.create(customer).then((response) => { + res.json(response); + }); + } else { + res.sendStatus(400); // Bad Request + } + } else { + res.sendStatus(401); + } + } + catch(e) { + res.sendStatus(401); + } + }); + }, +}; +``` diff --git a/content/tutorials/extensions/create-collection-items-in-custom-panels.md b/content/tutorials/extensions/create-collection-items-in-custom-panels.md new file mode 100644 index 00000000..1203fee3 --- /dev/null +++ b/content/tutorials/extensions/create-collection-items-in-custom-panels.md @@ -0,0 +1,613 @@ +--- +id: b8fa9a76-063b-499f-80e7-737f08f94684 +slug: create-collection-items-in-custom-panels +title: Create Collection Items in Custom Panels +authors: [] +--- +Panels are used in dashboards as part of the Insights module. As well as read-only data panels, they can be interactive +with form inputs. In this guide, you will create a panel that automatically generates a form based on a collection's +fields, and allows item creation from an Insights dashboard. + +![A panel shows a form called Add Customer. It has a name, surname, and phone number text input.](https://product-team.directus.app/assets/2d35e9e1-5f77-4d2f-9df4-fe7cf181fe67.webp) + +## Install Dependencies + +Open a console to your preferred working directory and initialize a new extension, which will create the boilerplate +code for your operation. + +```shell +npx create-directus-extension@latest +``` + +A list of options will appear (choose panel), and type a name for your extension (for example, +`directus-panel-internal-form`). For this guide, select JavaScript. + +Now the boilerplate has been created, open the directory in your code editor. + +## Specify Configuration + +Panels have two parts - the `index.js` configuration file, and the `panel.vue` view. The first part is defining what +information you need to render the panel in the configuration. + +Open `index.js` and change the `id`, `name`, `icon`, and `description`. + +```js +id: 'panel-internal-form', +name: 'Internal Form', +icon: 'view_day', +description: 'Output a form to insert data into a collection.', +``` + +Make sure the `id` is unique between all extensions including ones created by 3rd parties - a good practice is to +include a professional prefix. You can choose an icon from the library [here](https://fonts.google.com/icons). + +The Panel will need some configuration options so the user can choose the collection and fields from that collection to +include on the panel. + +Replace the existing text field with the following fields inside the `options` array: + +```js +{ + field: 'collection', + type: 'string', + name: '$t:collection', + meta: { + interface: 'system-collection', + options: { + includeSystem: true, + includeSingleton: false, + }, + width: 'half', + }, +}, +{ + field: 'fields', + type: 'string', + name: 'Included Fields', + meta: { + interface: 'system-field', + options: { + collectionField: 'collection', + multiple: true, + }, + width: 'half', + }, +}, +{ + field: 'responseFormat', + name: 'Response', + type: 'string', + meta: { + interface: 'system-display-template', + options: { + collectionField: 'collection', + placeholder: '{{ field }}', + }, + width: 'full', + }, +}, +``` + +After the `options` section, there is the ability to limit the width and height of the panel. Since this panel will hold +a lot of data, set these to `24` for the width and `18` for the height: + +```js +minWidth: 24, +minHeight: 18, +skipUndefinedKeys: ['responseFormat'], +``` + +The output of these options will look like this: + +Form with collections and fields selection. + +## Prepare the View + +Open the `panel.vue` file and you will see the starter template and script. Skip to the script section and import the +following packages: + +```js +import { useApi, useCollection, useStores } from '@directus/extensions-sdk'; +import { ref, watch } from 'vue'; +``` + +In the `props`, `showHeader` is one of the built-in properties which you can use to alter your panel if a header is +showing. Remove the text property and add the collection and fields properties as well as the width and height which is +useful for styling: + +```js +props: { + showHeader: { + type: Boolean, + default: false, + }, + collection: { + type: String, + default: '', + }, + fields: { + type: Array, + default: [], + }, + responseFormat: { + type: String, + default: '', + }, + width: String, + height: String, +}, +``` + +After the `props`, create a `setup(props)` section and create the variables needed: + +```js +setup(props) { + const { useFieldsStore, usePermissionsStore } = useStores(); + const fieldsStore = useFieldsStore(); + const permissionsStore = usePermissionsStore(); + const hasPermission = permissionsStore.hasPermission(props.collection, 'create'); + const api = useApi(); + const { primaryKeyField } = useCollection(props.collection); + const formData = ref({}); + const fieldData = ref([]); + + const formResponse = ref({}); + const formError = ref({}); + const responseDialog = ref(false); +} +``` + +The `FieldsStore` fetches all of the collection’s fields, the `PermissionsStore` checks the current user’s access to the +collection, and the `Collection` store for fetching information about the selected collection and the API for performing +the final POST request. + +You will also need to capture a response to present to the user. The `responseFormat` contains a string where the user +can create their own response with data from the API. A `v-dialog` can show an important message to the user. This +requires a boolean value (here `responseDialog`) to control the visibility of the dialog box. + +Create a `getFields` function to fetch the detailed information for each selected field then call the function +afterwards so it populates the variable when the panel loads: + +```js +function getFields() { + fieldData.value = []; + + props.fields.forEach((field) => { + fieldData.value.push(fieldsStore.getField(props.collection, field)); + }); +} + +getFields(); +``` + +If the fields, collection, or response format is changed, the `getFields` function will need to be called again. Use the +following code: + +```js +watch([() => props.collection, () => props.fields, () => props.responseFormat], getFields); +``` + +Create a `submitForm` function. This will send the contents of `formData` to the selected collection and capture the +response, resetting the form once successful. If an error occurs, the response is captured in the `formError` variable: + +```js +function submitForm() { + api + .post(`/items/${props.collection}`, formData.value) + .then((response) => { + formResponse.value = response.data.data; + responseDialog.value = true; + formData.value = {}; + }) + .catch((error) => { + formError.value = error; + responseDialog.value = true; + }); +} +``` + +To show the response, the `responseDialog` variable is changed to `true`. + +In the successful response, it will be useful to have a link to the new record. Create the following function to build +the URL for the newly created item: + +```js +function getLinkForItem(item) { + if (item === undefined) return; + const primaryKey = item[primaryKeyField.value.field]; + return `/content/${props.collection}/${encodeURIComponent(primaryKey)}`; +} +``` + +At the end of the script, return the required constants and functions for use in the Vue template: + +```js +return { + hasPermission, + primaryKeyField, + formData, + fieldData, + submitForm, + formResponse, + formError, + responseDialog, + getLinkForItem, +}; +``` + +## Build the View + +Back to the template section, remove all the content between the template tags, then add the following code to handle +the permissions: + +```vue + +``` + +To help with small and large panel layouts, add the class `small` when the width is less than `30`, otherwise add the +class `large`. This allows you to use CSS to style the form. + +Add the following inside the panel - it will send the `fields` inside `fieldData` to the form component. This will +render the form and capture the outputs into the `formData` model. Below that add a button to submit the data to the +API. + +```vue + +Save +Save +``` + +::callout{type="info" title="Secondary Button"} + +The secondary button shows an inactive button when the form is not ready to submit. Use this conditional for any further +validation. + +:: + +Under the submit button, create the `v-dialog` component. This uses the `responseDialog` variable for visibility. Inside +the dialog, create some notices for various situations such as Success (primary key field exists), Error (`formError` +has value) and Empty: + +```vue + + + Saved + An Error Occurred + No Response +
+ + + + +
+
+ {{ formError }} +
+ Done +
+
+``` + +Use a `blockquote` to output a response using the `responseFormat` value and the `render-template` component. When you +supply the `collection`, `template`, and `formResponse` to this component, it will replace all placeholder variables. + +If the form response is empty, output `formError` which contains the details of the error. + +Use a button at the bottom to dismiss the dialog box. The click function needs to change the `responseDialog` to false. + +Lastly, replace the CSS at the bottom with this: + +```vue + +``` + +When it's all put together, the panel looks like this: + +![A panel shows a form called Add Customer. It has a name, surname, and phone number text input.](https://product-team.directus.app/assets/2d35e9e1-5f77-4d2f-9df4-fe7cf181fe67.webp) + +And the response looks like this: + +![A popup reads 'Saved - User Added!' with a link to the user and a but purple Done button.](https://product-team.directus.app/assets/70eecfe0-060e-4996-96f4-89e56c56afa3.webp) + +Both files are now complete. Build the panel with the latest changes. + +```shell +npm run build +``` + +## Add Panel to Directus + +When Directus starts, it will look in the `extensions` directory for any subdirectory starting with +`directus-extension-`, and attempt to load them. + +To install an extension, copy the entire directory with all source code, the `package.json` file, and the `dist` +directory into the Directus `extensions` directory. Make sure the directory with your extension has a name that starts +with `directus-extension`. In this case, you may choose to use `directus-extension-panel-internal-form`. + +Restart Directus to load the extension. + +::callout{type="info" title="Required files"} + +Only the `package.json` and `dist` directory are required inside of your extension directory. However, adding the source +code has no negative effect. + +:: + +## Use the Panel + +From an Insights dashboard, choose **Internal Form** from the list. + +Fill in the configuration fields as needed: + +- Choose a collection. +- Select all the fields to include from that collection. +- Create a custom response message. + +Form showing a collection is selected, 3 items are included, and a response is formed as a string with two dynamic variables. + +## Summary + +With this panel, you can create forms to create items in your collections. You have worked with the `FieldsStore` and +`PermissionsStore`, and can further expand on this example for other changes to your database. + +## Complete Code + +`index.js` + +```js +import PanelComponent from './panel.vue'; + +export default { + id: 'panel-internal-form', + name: 'Internal Form', + icon: 'view_day', + description: 'Output a form to insert data into a collection.', + component: PanelComponent, + options: [ + { + field: 'collection', + type: 'string', + name: '$t:collection', + meta: { + interface: 'system-collection', + options: { + includeSystem: true, + includeSingleton: false, + }, + width: 'half', + }, + }, + { + field: 'fields', + type: 'string', + name: 'Included Fields', + meta: { + interface: 'system-field', + options: { + collectionField: 'collection', + multiple: true, + }, + width: 'half', + }, + }, + { + field: 'responseFormat', + name: 'Response', + type: 'string', + meta: { + interface: 'system-display-template', + options: { + collectionField: 'collection', + placeholder: '{{ field }}', + }, + width: 'full', + }, + }, + ], + minWidth: 12, + minHeight: 8, + skipUndefinedKeys: ['responseFormat'], +}; +``` + +`panel.vue` + +```vue + + + + + +``` diff --git a/content/tutorials/extensions/create-comments-in-custom-operations.md b/content/tutorials/extensions/create-comments-in-custom-operations.md new file mode 100644 index 00000000..383850bf --- /dev/null +++ b/content/tutorials/extensions/create-comments-in-custom-operations.md @@ -0,0 +1,376 @@ +--- +id: b538fa8c-2e0c-4aaa-93f1-f06106c5efaa +slug: create-comments-in-custom-operations +title: Create Comments in Custom Operations +authors: [] +--- +Operations allow you to trigger your own code in a Flow. This guide will show you how to add comments to a record using +built-in services and make it available as a configurable Flow operation. + +![An Add Comment operation in a Flow](https://product-team.directus.app/assets/cb93f5b5-f020-4662-9386-9c6c7730b238.webp) + +## Install Dependencies + +Open a console to your preferred working directory and initialize a new extension, which will create the boilerplate +code for your operation. + +```shell +npx create-directus-extension@latest +``` + +A list of options will appear (choose operation), and type a name for your extension (for example, +`directus-operation-add-comment`). For this guide, select JavaScript. + +Now the boilerplate has been created, open the directory in your code editor. + +## Build the Operation UI + +Operations have 2 parts - the `api.js` file that performs logic, and the `app.js` file that describes the front-end UI +for the operation. + +Open `app.js` and change the `id`, `name`, `icon`, and `description`. + +```js +id: 'operation-add-comment', +name: 'Add Comment', +icon: 'chat', +description: 'Add a comment to a record', +``` + +Make sure the `id` is unique between all extensions including ones created by 3rd parties - a good practice is to +include a professional prefix. You can choose an icon from the library [here](https://fonts.google.com/icons). + +With the information above, the operation will appear in the list like this: + +Add comment - add a comment to a record. A chat icon is displayed in the box. + +`options` are the fields presented in the frontend when adding this operation to the Flow. To add a comment, you will +need to know the collection, item id and the comment itself. Replace the placeholder options with the following: + +```js +options: [ + { + field: 'collection', + name: '$t:collection', + type: 'string', + meta: { + width: 'half', + interface: 'system-collection', + }, + }, + { + field: 'comment_key', + name: 'ID', + type: 'string', + meta: { + width: 'half', + interface: 'tags', + options: { + iconRight: 'vpn_key', + }, + }, + }, + { + field: 'comment', + name: 'Comment', + type: 'text', + meta: { + width: 'full', + interface: 'input-multiline', + }, + }, +], +``` + +- `collection` - the interface system-collection which renders a searchable dropdown for all collections. This field + will also accept a manually entered string or a variable from the Flow. +- `comment_key` - field where the ID of the item is entered. This will accept a string, guid, int or a variable from the + Flow. This must be an existing record in Directus. +- `comment` - a simple input-multiline field (textarea) to match how comments are made. Variables from the Flow can be + mixed with standard text. + +Add comment operation is selected. There are three fields - collection, IDs, and comment. Collection is a dropdown and IDs is a key. + +The `overview` section defines what is visible inside the operation’s card on the Flow canvas. An overview object +contains 2 parameters, `label` and `text`. The label can be any string and does not need to match the field name. The +text parameter can be a variable or just another string. + +It will be useful to see the collection and the comment on the card. To do this you must include the fields value from +the options (`collection` and `comment`) as properties. Replace the placeholder objects with the following: + +```js +overview: ({ collection, comment }) => [ + { + label: '$t:collection', + text: collection, + }, + { + label: 'Comment', + text: comment, + }, +], +``` + +- `collection` will use the system’s label for a collection and the text will be the chosen collection from the user. +- `comment` uses a string for the label, and the text will be the contents of the comment textarea field from the user. + +The flow overview card shows a collection and comment value. + +## Build the API Function + +Open the `api.js` file and update the `id` to match the one used in the `app.js` file. + +The `handler` needs to include the values from the options and some key services to create a comment. Replace the +handler definition with the following: + +```js +handler: async ({ collection, comment_key, comment }, { services, database, accountability, getSchema }) => { +``` + +Notice the fields are added in the first object, then the services in the second. + +Comments are stored inside the `directus_activity` table so the `ActivityService` is required to perform the action. +Inside the handler, set the following constants: + +```js +const { ActivityService } = services; +const schema = await getSchema({ database }); + +const activityService = new ActivityService({ + schema: schema, + accountability: accountability, + knex: database, +}); +``` + +The id field called `comment_key` needs to be able to accept both a single ID or multiple IDs. Add the following to +convert a single ID to an array then add them to the `keys` constant. Note the use of JSON.parse to allow JSON entry +into the field. + +```js +if (!Array.isArray(comment_key) && comment_key.includes('[') === false) { + comment_key = [comment_key]; +} + +const keys = Array.isArray(comment_key) ? comment_key : JSON.parse(Array.isArray(comment_key)); +``` + +The final part of the script is to loop through all the keys and write the comment to them. Also it will be useful to +write the outcome back to the Flow’s logs. This is done by return as response to the function. + +Create some disposable variables results and activity, then write the response from `activtyService.createOne` to the +activity variable and append it to the results array. + +```js +let results = []; +let activity = null; + +for await (const key of keys) { + try { + activity = await activityService.createOne({ + action: 'comment', + comment: comment, + user: accountability?.user ?? null, + collection: collection, + ip: accountability?.ip ?? null, + user_agent: accountability?.userAgent ?? null, + origin: accountability?.origin ?? null, + item: key, + }); + results.push(activity); + } catch (error) { + return error; + } +}; +return results; +``` + +The function `activtyService.createOne` requires the above parameters. For the comment to work, the `action` must be +`'comment'`. Then update the comment, collection and item parameters with the fields from `app.js`. The exception being +item which uses the key from the loop. + +`user`, `ip`, `user_agent`, and `origin` all come from the `accountability` service. This means the comment will be left +by the user that triggers the workflow. At the end, the results array is returned and will be visible in the Flow logs. + +Here is an example of how the comment will appear on a record: + +Inside of the sidebar, the comment pane shows one new comment from Tim Butterfield saying 'reminder sent' + +Build the operation with the latest changes. + +``` +npm run build +``` + +## Add Operation to Directus + +When Directus starts, it will look in the `extensions` directory for any subdirectory starting with +`directus-extension-`, and attempt to load them. + +To install an extension, copy the entire directory with all source code, the `package.json` file, and the `dist` +directory into the Directus `extensions` directory. Make sure the directory with your extension has a name that starts +with `directus-extension`. In this case, you may choose to use `directus-extension-operation-add-comment`. + +Restart Directus to load the extension. + +::callout{type="info" title="Required files"} + +Only the `package.json` and `dist` directory are required inside of your extension directory. However, adding the source +code has no negative effect. + +:: + +## Use the Operation + +In the Directus Data Studio, open the Flows section in Settings. Create a new flow with an event trigger such as +`item.update`. Select the collection(s) to use. + +Add a new step (operation) in the flow by clicking the tick/plus on the card. This can be at any point in the workflow. +From the list of options, choose **Add Comment**. + +Select Add Comment. Inside of Collection, type {{$trigger.payload.collection}}. Inside of IDs, type {{$trigger.payload.keys}}. For the comment, type any that you would like to add as a comment. + +Save the operation, save the Flow, and then trigger it by updating a record from the chosen collections. Open the +collection records again to see the new comment. To see the response from the API function, open the flow and check out +the logs in the right side toolbar. + +## Summary + +With this operation, a comment will be created with the pre-configured settings on all submitted keys and respond with +an array of activity IDs for the comments. This operation requires setting up fields on the front-end and using Directus +services on the API side to complete the transaction. Now that you know how to interact with these services, you can +investigate other ways to extend your operations. + +## Complete Code + +`app.js` + +```js +export default { + id: 'your-extension-id', + name: 'Add Comment', + icon: 'chat', + description: 'Add a comment to a record', + overview: ({ collection, comment }) => [ + { + label: '$t:collection', + text: collection, + }, + { + label: 'Comment', + text: comment, + }, + ], + options: [ + { + field: 'collection', + name: '$t:collection', + type: 'string', + meta: { + width: 'half', + interface: 'system-collection', + }, + }, + { + field: 'permissions', + name: '$t:permissions', + type: 'string', + schema: { + default_value: '$trigger', + }, + meta: { + width: 'half', + interface: 'select-dropdown', + options: { + choices: [ + { + text: 'From Trigger', + value: '$trigger', + }, + { + text: 'Public Role', + value: '$public', + }, + { + text: 'Full Access', + value: '$full', + }, + ], + allowOther: true, + }, + }, + }, + { + field: 'comment_key', + name: 'ID', + type: 'string', + meta: { + width: 'half', + interface: 'tags', + options: { + iconRight: 'vpn_key', + }, + }, + }, + { + field: 'comment', + name: 'Comment', + type: 'text', + meta: { + width: 'full', + interface: 'input-multiline', + }, + }, + ], +}; +``` + +`api.js` + +```js +export default { + id: 'your-extension-id', + handler: async ({ collection, comment_key, comment }, { services, database, accountability, getSchema }) => { + const { ActivityService } = services; + const schema = await getSchema({ database }); + + const activityService = new ActivityService({ + schema: schema, + accountability: accountability, + knex: database, + }); + + if (!Array.isArray(comment_key) && comment_key.includes('[') === false) { + comment_key = [comment_key]; + } + + const keys = Array.isArray(comment_key) ? comment_key : JSON.parse(Array.isArray(comment_key)); + + console.log(`Converted ${keys}`); + + let results = []; + let activity = null; + + for await (const key of keys) { + try { + activity = await activityService.createOne({ + action: 'comment', + comment: comment, + user: accountability?.user ?? null, + collection: collection, + ip: accountability?.ip ?? null, + user_agent: accountability?.userAgent ?? null, + origin: accountability?.origin ?? null, + item: key, + }); + + results.push(activity); + } catch (error) { + return error; + } + } + + return results; + }, +}; +``` diff --git a/content/tutorials/extensions/create-new-customers-in-stripe-in-a-custom-hook.md b/content/tutorials/extensions/create-new-customers-in-stripe-in-a-custom-hook.md new file mode 100644 index 00000000..734c7172 --- /dev/null +++ b/content/tutorials/extensions/create-new-customers-in-stripe-in-a-custom-hook.md @@ -0,0 +1,197 @@ +--- +id: ba5c9666-330f-4349-9f1e-da113fb84d27 +slug: create-new-customers-in-stripe-in-a-custom-hook +title: Create New Customers in Stripe in a Custom Hook +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- +Hooks allow you to trigger your own code under certain conditions. This tutorial will show you how to create a Stripe +account when an item is created in Directus and write the customer ID back to the record. + +## Install Dependencies + +Open a console to your preferred working directory and initialize a new extension, which will create the boilerplate +code for your display. + +```shell +npx create-directus-extension@latest +``` + +A list of options will appear (choose hook), and type a name for your extension (for example, +`directus-hook-create-stripe-customer`). For this guide, select JavaScript. + +Now the boilerplate has been created, install the stripe package, and then open the directory in your code editor. + +``` +cd directus-endpoint-stripe +npm install stripe +``` + +## Build the Hook + +Create a collection called Customers with a field called `stripe_id` and the following required fields: `first_name`, +`last_name` and `email_address` (unique). This hook will be used to create a new customer in Stripe whenever a new +customer is created in Directus. + +Open the `index.js` file inside the src directory. Delete all the existing code and start with the import of the +`stripe` package: + +```js +import Stripe from 'stripe'; +``` + +Create an initial export. This hook will need to intercept the save function with `action` and include `env` for the +environment variables and `services` to write back to the record: + +```js +export default ({ action }, { env, services }) => {}; +``` + +Inside the function, define the internal `ItemsService` Directus API function from the `services` scope. Also include +the `MailService` to send yourself an email if the Stripe API fails. + +```js +export default ({ action }, { env, services }) => { + const { MailService, ItemsService } = services; // [!code ++] +}; +``` + +Next, capture the `items.create` stream using `action` and pull out the `key`, `collection`, and `payload`: + +```js +action('items.create', async ({ key, collection, payload }, { schema }) => {}); +``` + +When using filters and actions, it’s important to remember this will capture **all** events so you should set some +restrictions. Inside the action, exclude anything that’s not in the customers collection. + +```js +action('items.create', async ({ key, collection, payload }, { schema }) => { + if (collection !== 'customers') return; // [!code ++] +}); +``` + +Instantiate Stripe with the secret token: + +```js +const stripe = new Stripe(env.STRIPE_TOKEN); +``` + +`env` looks inside the Directus environment variables for `STRIPE_TOKEN`. In order to start using this hook, this +variable must be added to your `.env` file. This can be found in the developers area on your Stripe dashboard. + +Create a new customer with the customer's name and email as the input values. + +```js +stripe.customers + .create({ + name: `${payload.first_name} ${payload.last_name}`, + email: payload.email_address, + }) + .then((customer) => {}) + .catch((error) => {}); +``` + +If successful, update the record with the new customer id from stripe. The API call returns the customer object into the +`customer` variable, be sure to look up what other data is included in this response. + +Use the `ItemsService` to update the customer record. Initialize the service and perform the API query: + +```js +stripe.customers + .create({}) + .then((customer) => { + const customers = new ItemsService(collection, { schema }); // [!code ++] + customers.updateByQuery({ filter: { id: key } }, { stripe_id: customer.id }, { emitEvents: false }); // [!code ++] + }) + .catch((error) => {}); +``` + +By setting `emitEvents` to `false`, the `items.update` event will not trigger, which prevents flows or hooks from +running as a result of this item update. + +Add an exception if the Stripe API fails. + +```js +stripe.customers + .create({}) + .then((customer) => {}) + .catch((error) => { + const mailService = new MailService({ schema }); + mailService.send({ // [!code ++] + to: 'sharedmailbox@directus.io', // [!code ++] + from: 'noreply@directus.io', // [!code ++] + subject: `An error has occurred with Stripe API`, // [!code ++] + text: `The following error occurred for ${payload.first_name} ${payload.last_name} when attempting to create an account in Stripe.\r\n\r\n${error}\r\n\r\nPlease investigate.\r\n\r\nID: ${key}\r\nEmail: ${payload.email_address}`, // [!code ++] + }); // [!code ++] + }); +``` + +Build the hook with the latest changes. + +``` +npm run build +``` + +## Add Hook to Directus + +When Directus starts, it will look in the `extensions` directory for any subdirectory starting with +`directus-extension-`, and attempt to load them. + +To install an extension, copy the entire directory with all source code, the `package.json` file, and the `dist` +directory into the Directus `extensions` directory. Make sure the directory with your extension has a name that starts +with `directus-extension`. In this case, you may choose to use `directus-extension-hook-create-stripe-customer`. + +Ensure the `.env` file has `STRIPE_TOKEN` variable. + +Restart Directus to load the extension. + +::callout{type="info" title="Required files"} + +Only the `package.json` and `dist` directory are required inside of your extension directory. However, adding the source +code has no negative effect. + +:: + +## Summary + +With Stripe now integrated in this hook, whenever a new customer is created, this hook will create a customer in Stripe +and write back the customer ID to Directus. Now that you know how to interact with the Stripe API, you can investigate +other endpoints that Stripe has to offer. + +## Complete Code + +`index.js` + +```js +import Stripe from 'stripe'; + +export default ({ action }, { env, services }) => { + const { MailService, ItemsService } = services; + + action('items.create', async ({ key, collection, payload }, { schema }) => { + if (collection !== 'customers') return; + const stripe = new Stripe(env.STRIPE_TOKEN); + + stripe.customers + .create({ + name: `${payload.first_name} ${payload.last_name}`, + email: payload.email_address, + }) + .then((customer) => { + const customers = new ItemsService(collection, { schema }); + customers.updateByQuery({ filter: { id: key } }, { stripe_id: customer.id }, { emitEvents: false }); + }) + .catch((error) => { + const mailService = new MailService({ schema }); + mailService.send({ + to: 'sharedmailbox@directus.io', + from: 'noreply@directus.io', + subject: `An error has occurred with Stripe API`, + text: `The following error occurred for ${payload.first_name} ${payload.last_name} when attempting to create an account in Stripe.\r\n\r\n${error}\r\n\r\nPlease investigate.\r\n\r\nID: ${key}\r\nEmail: ${payload.email_address}`, + }); + }); + }); +}; +``` diff --git a/content/tutorials/extensions/display-external-api-data-from-vonage-in-custom-panels.md b/content/tutorials/extensions/display-external-api-data-from-vonage-in-custom-panels.md new file mode 100644 index 00000000..9722b051 --- /dev/null +++ b/content/tutorials/extensions/display-external-api-data-from-vonage-in-custom-panels.md @@ -0,0 +1,784 @@ +--- +id: cb0c443f-e261-4d33-a420-aef1a97f9b06 +slug: display-external-api-data-from-vonage-in-custom-panels +title: Display External API Data From Vonage In Custom Panels +authors: [] +--- +Panels are used in dashboards as part of the Insights module, and typically allow users to better-understand data held +in their Directus collections. In this guide, you will instead fetch data from an external API and display it in a table +as part of a panel. + +Table with header Messages shows several items with status, sent delative date, a recipient ID, and a provider + +Panels can only talk to internal Directus services, and can't reliably make external web requests because browser +security protections prevent these cross-origin requests from being made. To create a panel that can interact with +external APIs, this guide will create a bundle of an endpoint (that can make external requests) and a panel (that uses +the endpoint). + +## Before You Start + +You will need a Directus project - check out our [quickstart guide](/getting-started/create-a-project) +if you don't already have one. You will also need a +[Vonage Developer API account](https://developer.vonage.com/sign-up), taking note of your API Key and Secret. + +## Create Bundle + +Open a console to your preferred working directory and initialize a new extension, which will create the boilerplate +code for your operation. + +```shell +npx create-directus-extension@latest +``` + +A list of options will appear (choose bundle), and type a name for your extension (for example, +`directus-extension-bundle-vonage-activity`). + +Now the boilerplate bundle has been created, navigate to the directory with +`cd directus-extension-bundle-vonage-activity` and open the directory in your code editor. + +## Add an Endpoint to the Bundle + +In your terminal, run `npm run add` to create a new extension in this bundle. A list of options will appear (choose +endpoint), and type a name for your extension (for example, `directus-endpoint-vonage`). For this guide, select +JavaScript. + +This will add an entry to the `directus:extension` metadata in your `package.json` file. + +## Build the Endpoint + +As there is a more detailed guide on +[building an authenticated custom endpoint to proxy external APIs](/guides/extensions/endpoints-api-proxy-twilio), this +guide will be more brief in this section. + +Open the `src/directus-endpoint-vonage/index.js` file and replace it with the following: + +```js +import { createError } from '@directus/errors'; + +const ForbiddenError = createError('VONAGE_FORBIDDEN', 'You need to be authenticated to access this endpoint'); + +export default { + id: 'vonage', + handler: (router, { env }) => { + const { VONAGE_API_KEY, VONAGE_API_SECRET } = env; + const baseURL = 'https://api.nexmo.com'; + const token = Buffer.from(`${VONAGE_API_KEY}:${VONAGE_API_SECRET}`).toString('base64'); + const headers = { Authorization: `Basic ${token}` }; + + router.get('/records', async (req, res) => { + if (req.accountability == null) throw new ForbiddenError(); + + try { + const url = baseURL + `/v2/reports/records?account_id=${VONAGE_API_KEY}&${req._parsedUrl.query}`; + const response = await fetch(url, { headers }); + + if (response.ok) { + res.json(await response.json()); + } else { + res.status(response.status).send(response.statusText); + } + } catch (error) { + res.status(500).send(response.statusText); + } + }); + }, +}; +``` + +This extension introduces the `/vonage/records` endpoint to your application. Make sure to add the `VONAGE_API_KEY` and +`VONAGE_API_SECRET` to your environment variables. + +## Add a View to the Bundle + +In your terminal, run `npm run add` to create a new extension in this bundle. A list of options will appear (choose +panel), and type a name for your extension (for example, `directus-panel-vonage-activity`). For this guide, select +JavaScript. + +This will add an entry to the `directus:extension` metadata in your `package.json` file. + +## Configure the View + +Panels have two parts - the `index.js` configuration file, and the `panel.vue` view. The first part is defining what +information you need to render the panel in the configuration. + +Open `index.js` and change the `id`, `name`, `icon`, and `description`. + +```js +id: 'panel-vonage-activity', +name: 'Vonage Reports', +icon: 'list_alt', +description: 'View recent Vonage SMS activity.', +``` + +Make sure the `id` is unique between all extensions including ones created by 3rd parties - a good practice is to +include a professional prefix. You can choose an icon from the library [here](https://fonts.google.com/icons). + +The Panel will accept configuration options. The Vonage API supports `date_start`, `date_end`, `status`, `direction` +(incoming/outgoing), and `product` type (SMS/Messages). + +For the product type, add a selection field with the options `SMS` and `MESSAGES`: + +```js +{ + field: 'type', + name: 'Product Type', + type: 'string', + meta: { + width: 'half', + interface: 'select-dropdown', + options: { + choices: [ + { text: 'SMS', value: 'SMS' }, + { text: 'Messages', value: 'MESSAGES' } + ], + }, + }, +}, +``` + +Add another selection field for the ‘direction’ of the messages, inbound and outbound. + +```js +{ + field: 'direction', + name: 'Direction', + type: 'string', + meta: { + width: 'half', + interface: 'select-dropdown', + options: { + choices: [ + { text: 'Outbound', value: 'outbound' }, + { text: 'Inbound', value: 'inbound' } + ], + } + } +}, +``` + +It would be useful to control the scope of data for those who transact larger amounts of messages. Add the following +option for the user to select a range: + +```js +{ + field: 'range', + type: 'dropdown', + name: '$t:date_range', + schema: { default_value: '1 day' }, + meta: { + interface: 'select-dropdown', + width: 'half', + options: { + choices: [ + { text: 'Past 5 Minutes', value: '5 minutes' }, + { text: 'Past 15 Minutes', value: '15 minutes' }, + { text: 'Past 30 Minutes', value: '30 minutes' }, + { text: 'Past 1 Hour', value: '1 hour' }, + { text: 'Past 4 Hours', value: '4 hours' }, + { text: 'Past 1 Day', value: '1 day' }, + { text: 'Past 2 Days', value: '2 days' } + ] + } + } +}, +``` + +Vonage has the ability to include the message in the response. This will be useful to provide as a preview upon click +but for larger datasets may impact the performance of the API. Create an option to toggle this on/off: + +```js +{ + field: 'includeMessage', + name: 'Include Message', + type: 'boolean', + meta: { + interface: 'boolean', + width: 'half', + }, + schema: { + default_value: false, + } +}, +``` + +Lastly, add the option to limit the messages to a specific state such as delivered or failed. The default option is +`Any`: + +```js +{ + field: 'status', + name: 'Status', + type: 'string', + schema: { + default_value: 'any', + }, + meta: { + width: 'half', + interface: 'select-dropdown', + options: { + choices: [ + { text: 'Any', value: 'any' }, + { text: 'Delivered', value: 'delivered' }, + { text: 'Expired', value: 'expired' }, + { text: 'Failed', value: 'failed' }, + { text: 'Rejected', value: 'rejected' }, + { text: 'Accepted', value: 'accepted' }, + { text: 'buffered', value: 'buffered' }, + { text: 'Unknown', value: 'unknown' }, + { text: 'Deleted', value: 'deleted' } + ] + } + } +}, +``` + +After the `options` section, there is the ability to limit the width and height of the panel. Since this panel will hold +a lot of data, set these to `24` for the width and `18` for the height: + +```js +minWidth: 24, +minHeight: 18, +``` + +The output of these options will look like this: + +Form shows product type dropdown, direction dropdown, date range dropdown, included message checkbox, and status dropdown. + +## Prepare the View + +Open the `panel.vue` file and you will see the starter template and script. Skip to the script section and import the +following packages: + +```js +import { useApi } from '@directus/extensions-sdk'; +import { adjustDate } from '@directus/shared/utils'; +import { formatISO, formatDistanceToNow, parseISO } from 'date-fns'; +import { ref, watch } from 'vue'; +``` + +In the `props`, `showHeader` is one of the built-in properties which you can use to alter your panel if a header is +showing. Remove the text property and add all the options that were created in the previous file: + +```js +props: { + showHeader: { + type: Boolean, + default: false, + }, + type: { + type: String, + default: '', + }, + direction: { + type: String, + default: '', + }, + range: { + type: String, + default: '', + }, + includeMessage: { + type: Boolean, + default: false, + }, + status: { + type: String, + default: '', + }, +}, +``` + +After the `props`, create a `setup(props)` section and create the variables needed: + +```js + +setup(props) { + const api = useApi(); + const activityData = ref([]); + const now = ref(new Date()); + const isLoading = ref(true); + const errorMessage = ref(); +}, + +``` + +Create a `fetchData` function that will use the information provided to construct the query parameters and perform the +API query. The response is written to the `activityData` variable. + +Use the `isLoading` variable to hide or show the progress spinner to indicate that the query is running: + +```js +async function fetchData() { + isLoading.value = true; + activityData.value = []; + + const dateStart = adjustDate(now.value, props.range ? `-${props.range}` : '-1 day'); + + const params = { + product: props.type || 'SMS', + direction: props.direction || 'outbound', + include_message: props.includeMessage.toString(), + date_start: dateStart ? formatISO(dateStart) : '', + status: props.status || 'any', + }; + + if (props.status) params.status = props.status; + + const url_params = new URLSearchParams(params); + + try { + const response = await api.get(`/vonage/records?${url_params.toString()}`); + activityData.value = response.data.records; + } catch { + errorMessage.value = 'Internal Server Error'; + } finally { + isLoading.value = false; + } +} + +fetchData(); +``` + +The endpoint `/vonage/records` comes from the custom extension created in an earlier step. When `fetchData()` is called, +the `activityData` variable is updated with the result. + +If any of the properties are changed, the function will need to update the activity data again. Use the following code: + +```js +watch( + [() => props.type, () => props.direction, () => props.range, () => props.includeMessage, () => props.status], + fetchData +); +``` + +At the end of the script, return the required variables and functions for use in the Vue template: + +```js +return { activityData, isLoading, errorMessage, formatDistanceToNow, parseISO }; +``` + +## Build the View + +Back to the template section, remove all the content between the template tags, then add a fallback notice if some +essential information is missing. Start with this: + +```vue + +``` + +The `v-progress-circular` is a loading spinner that is active while the `isLoading` variable is true. After that, there +is a `danger` notice if `errorMessage` contains a value, then an `info` notice if there aren't any messages in the data. + +Next, build a table to present the data: + +```vue + + + + + + + + + + + + + + + + + + + + + + +
StatusSentReceivedMessageRecipientFromProvider
{{ message.status }} + {{ formatDistanceToNow(parseISO(message.date_finalized ? message.date_finalized : message.date_received)) }} ago + {{ message.message_body }}{{ message.to }}{{ message.from }}{{ type == 'MESSAGES' ? message.provider : message.network_name }}
+``` + +The `inbound` and `outbound` structure is a little different and needs different headings. Use `v-if` with the +`direction` property to change the headers as needed. + +Using `date-fns`, the date can be formatted into a user-friendly way. For an activity stream, showing the distance from +now is more helpful. + +Lastly, replace the CSS at the bottom with this: + +```vue + +``` + +Both extensions are now complete. Build the extensions with the latest changes from the root of the bundle: + +``` +npm run build +``` + +## Add Extensions to Directus + +When Directus starts, it will look in the `extensions` directory for any subdirectory starting with +`directus-extension-`, and attempt to load them. + +To install an extension, copy the entire directory with all source code, the `package.json` file, and the `dist` +directory into the Directus extensions `directory`. Make sure the directory with your bundle has a name that starts with +`directus-extension`. In this case, you may choose to use `directus-extension-bundle-vonage-activity`. + +Restart Directus to load the extensions. + +::callout{type="info" title="Required files"} + +Only the `package.json` and `dist` directory are required inside of your extension directory. However, adding the source +code has no negative effect. + +:: + +## Use the Panel + +From an Insights dashboard, choose **Vonage Reports** from the list. + +Fill in the configuration fields as needed: + +1. Choose the Product Type (Messages or SMS) +2. Choose the Direction (inbound or outbound messages) +3. Choose a time frame to fetch the data +4. Include or Exclude the message itself +5. (SMS only) Only show messages with a status. + +Save the panel and dashboard. It will look something like this: + +Table with header Messages shows several items with status, sent delative date, a recipient ID, and a provider + +## Summary + +With this panel, Messages and SMS recently sent through Vonage are listed on your dashboards. You can alter your custom +endpoint extension to create more panels for other Vonage APIs. + +## Complete Code + +### Endpoint + +`index.js` + +```js +import { createError } from '@directus/errors'; + +const ForbiddenError = createError('VONAGE_FORBIDDEN', 'You need to be authenticated to access this endpoint'); + +export default { + id: 'vonage', + handler: (router, { env }) => { + const { VONAGE_API_KEY, VONAGE_API_SECRET } = env; + const baseURL = 'https://api.nexmo.com'; + const token = Buffer.from(`${VONAGE_API_KEY}:${VONAGE_API_SECRET}`).toString('base64'); + const headers = { Authorization: `Basic ${token}` }; + + router.get('/records', async (req, res) => { + if (req.accountability == null) throw new ForbiddenError(); + + try { + const url = baseURL + `/v2/reports/records?account_id=${VONAGE_API_KEY}&${req._parsedUrl.query}`; + const response = await fetch(url, { headers }); + + if (response.ok) { + res.json(await response.json()); + } else { + res.status(response.status).send(response.statusText); + } + } catch (error) { + res.status(500).send(response.statusText); + } + }); + }, +}; +``` + +### Panel + +`index.js` + +```js +import PanelComponent from './panel.vue'; + +export default { + id: 'panel-vonage-sms-activity', + name: 'Vonage Reports', + icon: 'list_alt', + description: 'View recent SMS activity.', + component: PanelComponent, + options: [ + { + field: 'type', + name: 'Product Type', + type: 'string', + meta: { + width: 'half', + interface: 'select-dropdown', + options: { + choices: [ + { text: 'SMS', value: 'SMS' }, + { text: 'Messages', value: 'MESSAGES' }, + ], + }, + }, + }, + { + field: 'direction', + name: 'Direction', + type: 'string', + meta: { + width: 'half', + interface: 'select-dropdown', + options: { + choices: [ + { text: 'Outbound', value: 'outbound' }, + { text: 'Inbound', value: 'inbound' }, + ], + }, + }, + }, + { + field: 'range', + type: 'dropdown', + name: '$t:date_range', + schema: { + default_value: '1 day', + }, + meta: { + interface: 'select-dropdown', + width: 'half', + options: { + choices: [ + { text: 'Past 5 Minutes', value: '5 minutes' }, + { text: 'Past 15 Minutes', value: '15 minutes' }, + { text: 'Past 30 Minutes', value: '30 minutes' }, + { text: 'Past 1 Hour', value: '1 hour' }, + { text: 'Past 4 Hours', value: '4 hours' }, + { text: 'Past 1 Day', value: '1 day' }, + { text: 'Past 2 Days', value: '2 days' }, + ], + }, + }, + }, + { + field: 'includeMessage', + name: 'Include Message', + type: 'boolean', + meta: { + interface: 'boolean', + width: 'half', + }, + schema: { + default_value: false, + }, + }, + { + field: 'status', + name: 'Status', + type: 'string', + schema: { + default_value: 'any', + }, + meta: { + width: 'half', + interface: 'select-dropdown', + options: { + choices: [ + { text: 'Any', value: 'any' }, + { text: 'Delivered', value: 'delivered' }, + { text: 'Expired', value: 'expired' }, + { text: 'Failed', value: 'failed' }, + { text: 'Rejected', value: 'rejected' }, + { text: 'Accepted', value: 'accepted' }, + { text: 'buffered', value: 'buffered' }, + { text: 'Unknown', value: 'unknown' }, + { text: 'Deleted', value: 'deleted' }, + ], + }, + }, + }, + ], + minWidth: 24, + minHeight: 18, +}; +``` + +`panel.vue` + +```vue + + + + + +``` diff --git a/content/tutorials/extensions/format-dates-in-a-custom-display-extension.md b/content/tutorials/extensions/format-dates-in-a-custom-display-extension.md new file mode 100644 index 00000000..8181add2 --- /dev/null +++ b/content/tutorials/extensions/format-dates-in-a-custom-display-extension.md @@ -0,0 +1,256 @@ +--- +id: 8c3f5a07-162f-4902-8044-dd5fa8b823c2 +slug: format-dates-in-a-custom-display-extension +title: Format Dates in a Custom Display Extension +authors: [] +--- +Displays provide a meaningful way for users to consume data. This guide will show you how to create a display to change +a date of birth to the current age in years and months. + +![A table of data is shown with a value reading '22 years 10 months'](https://product-team.directus.app/assets/1f418678-6467-419c-a08b-baa87125663a.webp) + +## Install Dependencies + +Open a console to your preferred working directory and initialize a new extension, which will create the boilerplate +code for your display. + +```shell +npx create-directus-extension@latest +``` + +A list of options will appear (choose display), and type a name for your extension (for example, +`directus-display-age`). For this guide, select JavaScript. + +Now the boilerplate has been created, open the directory in your code editor. + +## Specify Configuration + +Displays have 2 parts, the `index.js` configuration file, and the `display.vue` view. The first part allows you to +configure options and the appearance when selecting the display for a field. + +Open the `index.js` file and update the existing information relevant to this display. Since you are working with dates +and not datetime or strings, you need to change types to `date`. This will ensure this display will only be available if +the field is a date. + +```js +import DisplayComponent from './display.vue'; + +export default { + id: 'directus-display-age', + name: 'Display Age', + icon: 'calendar_month', + description: 'Display the current age from the date of birth', + component: DisplayComponent, + options: null, + types: ['date'], +}; +``` + +Make sure the `id` is unique between all extensions including ones created by 3rd parties - a good practice is to +include a professional prefix. You can choose an icon from the library [here](https://fonts.google.com/icons). + +With the information above, the display will appear in the list like this: + +![A new display option is shown - Datetime.](https://product-team.directus.app/assets/4bf75eb5-a39f-4493-9ca5-ace7c0f1b225.webp) + +Currently the options object is `null`. To provide the option to include months, replace the `options` value with the +following object: + +```js +options: [ + { + field: 'show_months', + type: 'boolean', + name: 'Show months as well', + meta: { + interface: 'boolean', + options: { + label: 'Yes', + }, + width: 'half', + }, + }, +], +``` + +## Build the View + +The `display.vue` file contains the barebones code required for a display to work. The value is imported in the `props` +section, then output in the template: + +```vue + + + +``` + +Import the `boolean` needed to toggle the months value in the `props` object: + +```js +props: { + value: { + type: String, + default: null, + }, + show_months: { // [!code ++] + type: Boolean, // [!code ++] + default: false, // [!code ++] + }, // [!code ++] +}, +``` + +Import `date-fns` to manipulate dates in JavaScript. Add the following line above the export: + +```js +import { differenceInYears, intervalToDuration, parseISO } from 'date-fns'; +``` + +Create a function to change the date of birth into the person's age and make it available to the template. Create a +`setup` section after the `props` and include the following code: + +```js +setup(props) { + function calculateAge() { + if (props.show_months) { + const { years, months } = intervalToDuration({ start: parseISO(props.value), end: new Date() }); + return `${years} years ${months} months`; + } else { + const age = differenceInYears(new Date(), parseISO(props.value)); + return `${age} years`; + } + } + + return calculateAge; +}, +``` + +This will parse the date into the required format, then check the distance between that date and now. The result is +formatted into a string with the suffix years (and months if enabled). + +Update the template to use the `calculateAge` function instead of the direct value: + +```vue + +``` + +Build the display with the latest changes. + +``` +npm run build +``` + +## Add Display to Directus + +When Directus starts, it will look in the `extensions` directory for any subdirectory starting with +`directus-extension-`, and attempt to load them. + +To install an extension, copy the entire directory with all source code, the `package.json` file, and the `dist` +directory into the Directus `extensions` directory. Make sure the directory with your extension has a name that starts +with `directus-extension`. In this case, you may choose to use `directus-extension-display-age`. + +Restart Directus to load the extension. + +::callout{type="info" title="Required files"} + +Only the `package.json` and `dist` directory are required inside of your extension directory. However, adding the source +code has no negative effect. + +:: + +## Use the Display + +Now the display will appear in the list of available displays for a date field. To test, create a new date field and +select this display from the list and make sure to add some data. The results will appear in the layout if you have that +column showing. + +![Display age settings showing a checkbox to display months](https://product-team.directus.app/assets/f6721667-7957-4b8e-a897-3d3c18dc4e81.webp) + +![A table of data is shown with a value reading '22 years 10 months'](https://product-team.directus.app/assets/1f418678-6467-419c-a08b-baa87125663a.webp) + +## Summary + +With this display, you have learned how to use a boolean field to configure a display, then create a function to +transform the value using an imported package. Be mindful how much processing is happening inside a display because it +will run for every single row in the table. + +## Complete Code + +`index.js` + +```js +import DisplayComponent from './display.vue'; + +export default { + id: 'directus-display-age', + name: 'Display Age', + icon: 'calendar_month', + description: 'Display the current age from the date of birth', + component: DisplayComponent, + options: [ + { + field: 'show_months', + type: 'boolean', + name: 'Show Months as well', + meta: { + interface: 'boolean', + options: { + label: 'Yes', + }, + width: 'half', + }, + }, + ], + types: ['date'], +}; +``` + +`display.vue` + +```vue + + + +``` diff --git a/content/tutorials/extensions/implement-navigation-in-multipage-custom-modules.md b/content/tutorials/extensions/implement-navigation-in-multipage-custom-modules.md new file mode 100644 index 00000000..4f797b3b --- /dev/null +++ b/content/tutorials/extensions/implement-navigation-in-multipage-custom-modules.md @@ -0,0 +1,992 @@ +--- +id: 02b0514b-0ee9-426e-89f9-14dc73bfd62d +slug: implement-navigation-in-multipage-custom-modules +title: Implement Navigation in Multipage Custom Modules +authors: [] +--- +Modules are an empty canvas in Directus with an empty navigation panel on the left, page header at the top and the +sidebar on the right. This guide will help you set up a multi-page module with navigation in the navigation bar and link +breadcrumbs. + +![A custom module has three items in the navigation - Home, Hello World, and Contact Us. The homepage displays an image, three navigation tiles, and some copy.](https://product-team.directus.app/assets/db55ac3f-016e-4531-8282-a9445482c02e.webp) + +## Install Dependencies + +Open a console to your preferred working directory and initialize a new extension, which will create the boilerplate +code for your module. + +```shell +npx create-directus-extension@latest +``` + +A list of options will appear (choose module), and type a name for your extension (for example, +`directus-extension-module-landing-page`). For this guide, select JavaScript. + +Now that the boilerplate has been created, open the directory in your code editor. + +## Define the Config + +Open the extension directory that was created in the previous steps then open the directory called `src`. This is where +the source code is located - `index.js` and `module.vue`. Any new files that are required must go in this directory. + +As it stands, this module will load an empty page wrapped by the Directus UI: + +![An empty module.](https://product-team.directus.app/assets/02345e5c-1742-4382-92cc-78c8651bfd1d.webp) + +Open `index.js` and make the following changes: + +- Update the `id` to the root URI of this landing page. Make sure the `id` is unique between all extensions including + ones created by 3rd parties. +- Update the `name` to the name of your module. This appears in the page settings where you can enable/disable modules. +- Update the `icon`. You can choose an icon from the library [here](https://fonts.google.com/icons). + +```js +import ModuleComponent from './module.vue'; + +export default { + id: 'landing-page', // root URI + name: 'Landing Page', + icon: 'rocket_launch', + routes: [ + { + path: '', + props: true, + component: ModuleComponent, + }, + { + name: 'page', + path: ':page', + props: true, + component: ModuleComponent, + }, + ], +}; +``` + +The `routes` give you the ability to use different Vue components to render the page and receive props from the URI +path. The path will match anything after `/admin/landing-page/*`. For this reason, the default route will be our home +page. + +Create a second route with the path as `:page` to catch anything like `/admin/landing-page/some-page` and use the same +component. The value `some-page` will be available in `props.page` in this example. + +### Build the Page + +Open the `module.vue` file and the template will look like this: + +```vue + + + +``` + +Now you need to build your page inside the `private-view`. Import `ref` and `watch` from `vue` and `useApi` from the +`extensions-sdk` above the export: + +```js +import { ref, watch } from 'vue'; +import { useApi } from '@directus/extensions-sdk'; +``` + +Inside `export default` add the page property to receive the URI value. + +```js +props: { + page: { + type: String, + default: null, + }, +}, +``` + +Create a `setup()` section with props and call a function called `render_page` that will be created shortly. Add the +`watch` function to monitor the page property for changes and call the `render_page` function again when a change is +detected. At the bottom, include the return to utilize later. + +```js +setup(props) { + render_page(props.page); + + watch( + () => props.page, + () => { + render_page(props.page); + } + ); + + return { }; +}, +``` + +Directus has a header element at the top of the module that uses the title attribute of the private view as the page +title. This will need to be converted to a variable so it changes when the page changes. It also has a breadcrumb which +will help with page navigation. Create a variable inside the setup called `page_title` and breadcrumb using `ref`. + +```js +setup(props) { + const api = useApi(); + const page_title = ref(''); + const breadcrumb = ref([ + { + name: 'Home', + to: `/landing-page`, + }, + ]); + + // Existing code here +}, +``` + +Add `page_title` and `breadcrumb` to the returned objects and create the `render_page` function to update the +`page_title` and `breadcrumb`: + +```js +return { page_title, breadcrumb, }; + +function render_page(page){ + if(page === null){ + page_title.value = '500: Internal Server Error'; + breadcrumb.value[1] = {}; + } else { + switch(page) { + case 'home': + page_title.value = 'Home'; + break; + case 'hello-world': + page_title.value = 'Hello World'; + break; + case 'contact': + page_title.value = 'Contact Us'; + break; + default: + page_title.value = '404: Not Found'; + } + + + if(page === 'home'){ + breadcrumb.value[1] = {}; + } else { + breadcrumb.value[1] = { + name: page_title.value, + to: `/landing-page/${page}`, + }; + } + } + + + console.log(`Title: ${page_title.value}`); +}; +``` + +Ideally this would be an API query instead of the switch case. The `page` variable contains the current URI, use this to +fetch the page details through the API and return the page title. If no result is found in the API, respond with a 404 +page. Here is an example: + +```js +api.get(`/items/pages?fields=title&filter[uri][_eq]=${page}`).then((rsp) => { + if(rsp.data.data){ + rsp.data.data.forEach(item => { + page_title.value = item.title; + }); + } else { + page_title.value = "404: Not Found"; + } +}).catch((error) => { + console.log(error); +}); +``` + +To tie all this together, update the `private-view` `title` attribute to the `page_title` variable, include the +`breadcrumb` using the `#headline` template slot and add the `router-view` element at the bottom. Note that the router +view is linked to the `page` property from the URI. + +```html + + + + +``` + +Looking at this now, the page title will be Home for the root page and the breadcrumbs are above the title: + +![Breadcrumb showing only Home](https://product-team.directus.app/assets/8ecff87c-69f1-4524-87f4-7a997ffa889f.webp) + +When the page changes to `/admin/landing-page/hello-world`, the page title changes and the breadcrumbs are updated: + +![Breadcrumb showing both Home and Hello World as a second level item](https://product-team.directus.app/assets/ed29fd15-abf7-40e7-bb74-22ed5365ac3e.webp) + +## Implement Page Navigation + +On the left side is an empty navigation panel where you can add content through template slots. + +Create an `all_pages` variable after the breadcrumbs to use for the navigation object: + +```js +const page_title = ref(''); +const breadcrumb = ref([ + { + name: 'Home', + to: `/landing-page`, + }, +]); +const all_pages = ref([]); // [!code ++] +``` + +Return the object with the others: + +```js +return { page_title, breadcrumb }; // [!code --] +return { page_title, breadcrumb, all_pages }; // [!code ++] +``` + +Create a function called `fetch_all_pages` underneath the `render_pages` function that will output the required object +for a built-in Directus component called `v-list`. Ideally this function will use an API to fetch this information: + +```js +function fetch_all_pages(){ + all_pages.value = [ + { + label: 'Home', + uri: 'landing-page', + to: '/landing-page', + icon: 'home', + color: '', + }, + { + label: 'Hello World', + uri: 'hello-world', + to: '/landing-page/hello-world', + icon: 'public', + color: '', + }, + { + label: 'Contact Us', + uri: 'contact', + to: '/landing-page/contact', + icon: 'phone', + color: '', + }, + ]; +}; +``` + +Here is an example of the above code as an API using a collection in Directus called `pages`: + +```js +function fetch_all_pages(){ + api.get('/items/pages?fields=title,uri,icon,color').then((rsp) => { + all_pages.value = []; + rsp.data.data.forEach(item => { + all_pages.value.push({ + label: item.title, + uri: item.uri, + to: `/landing-page/${item.uri}`, + icon: item.icon, + color: item.color, + }); + }); + }).catch((error) => { + console.log(error); + }); +}; +``` + +Run this function after the `render_page` function: + +```js +render_page(props.page); +fetch_all_pages(); +``` + +If you need to update the navigation whenever the page changes, you can include this function in the watch callback, +however this can impact performance. + +Create a new folder called `components` and create a new vue file called `navigation.vue`. Copy and paste the following +code inside this file: + +```vue + + + + +``` + +This uses the built-in `v-list` and `v-list-item` to render the navigation from the pages property. The current property +is used to set the `v-list-item` to active when the current page matches the navigation item. + +::callout{type="info" title="Export Names"} + +The export names the component `PageNavigation`. This must match the component import in the module.vue. + +:: + +To start using the new component in `module.vue`, add it to the `export default` section before the `props`: + +```js +export default { + components: { // [!code ++] + PageNavigation, // [!code ++] + }, // [!code ++] + props: { + } +} +``` + +Now this can be used in the template. After the `breadcrumbs`, add the following code: + +```vue + +``` + +::callout{type="info" title="Linting"} + +`PageNavigation` must be a `page-navigation` when used in the template to meet lint syntax standards. + +:: + +The navigation panel now shows the available pages and will change the page when clicked. + +![A module is empty but shows the navigation with three items.](https://product-team.directus.app/assets/23e71113-b765-4ded-8bb6-54e7026ee8c6.webp) + +## Add Content and Styling + +Now that the framework is in place, you can start creating your own template and populate with content. This could be +static content placed within the code or dynamic code from an API. Here is an example to help you get started that will +create a page banner, clickable cards and some paragraphs. + +In the template, create the HTML structure after the navigation and some new variables that will contain the content. + +```html +
+
+ +
+
+
+ + {{ card.label }} +
+
+
+
+``` + +The three new variables need to be declared: + +```js +setup(props) { + const api = useApi(); + const page_title = ref(''); + const page_banner = ref(''); // [!code ++] + const page_cards = ref([]); // [!code ++] + const page_body = ref(''); // [!code ++] + + // Existing code +} +``` + +Add a new function to change the page called `change_page`. Import the `vue-router` package under the existing vue +import: + +```js +import { ref, watch } from 'vue'; +import { useApi } from '@directus/extensions-sdk'; +import { useRouter } from 'vue-router'; // [!code ++] +import PageNavigation from './components/navigation.vue'; +``` + +Assign the router to a variable: + +``` +setup(props) { + const router = useRouter(); // [!code ++] + const api = useApi(); + const page_title = ref(''); + + // Existing code +} +``` + +Create the function before the return and add the three new variables and the new function to the list of returned +items. This will allow them to be used in the template. + +```js +function change_page(to){ + const next = router.resolve(`${to}`); + router.push(next); +} + +return { page_title, page_banner, page_cards, page_body, breadcrumb, all_pages, change_page }; +``` + +Inside the render_page function, start adding content to these new variables. Here is an example using static content. + +```js +switch(page) { + case 'home': + page_title.value = 'Home'; + page_banner.value = '/assets/83BD365C-C3CE-4015-B2AD-63EDA9E52A69?width=2000&height=563&fit=cover'; + page_cards.value = all_pages.value; + page_body.value = '

Lorem ipsum dolor sit amet

'; + break; + case 'hello-world': + page_title.value = 'Hello World'; + page_banner.value = '/assets/853B243D-A1BF-6051-B1BF-23EDA8E32A09?width=2000&height=563&fit=cover'; + page_cards.value = all_pages.value; + page_body.value = '

Lorem ipsum dolor sit amet

'; + break; + case 'contact': + page_title.value = 'Contact Us'; + page_banner.value = '/assets/91CE173D-A1AD-4104-A1EC-74FCB8F41B58?width=2000&height=563&fit=cover'; + page_cards.value = []; + page_body.value = '

Lorem ipsum dolor sit amet

'; + break; + default: + page_title.value = '404: Not Found'; +} +``` + +Or from the internal API providing you have a table with the fields `title`, `banner` (image field) and `content` +(WYSIWYG field): + +```js +api.get(`/items/pages?fields=title,banner,content&filter[uri][_eq]=${page}`).then((rsp) => { + if(rsp.data.data){ + rsp.data.data.forEach(item => { + page_title.value = item.title; + page_banner.value = `/assets/${item.banner}?width=2000&height=563&fit=cover`; + page_body.value = item.content; + }); + } else { + page_title.value = "404: Not Found"; + } +}).catch((error) => { + console.log(error); +}); +``` + +### Work With Images + +::callout{type="warning" title="DEPRECATED"} + +Since [Directus version 10.10.0](/releases/breaking-changes.html#version-10-10-0) the query parameter authentication is +no longer required and considered deprecated, you can rely on +[session cookies](/reference/authentication.html#access-tokens) instead. + +:: + +To use internal images, an access token needs to be included in the request. Create a new file called +`use-directus-token.js` and copy the following code: + +```js +export default function useDirectusToken(directusApi) { + return { + addQueryToPath, + getToken, + addTokenToURL, + }; + + function addQueryToPath(path, query) { + const queryParams = []; + + for (const [key, value] of Object.entries(query)) { + queryParams.push(`${key}=${value}`); + } + + return path.includes('?') ? `${path}&${queryParams.join('&')}` : `${path}?${queryParams.join('&')}`; + } + + function getToken() { + return ( + directusApi.defaults?.headers?.['Authorization']?.split(' ')[1] || + directusApi.defaults?.headers?.common?.['Authorization']?.split(' ')[1] || + null + ); + } + + function addTokenToURL(url) { + const accessToken = getToken(); + if (!accessToken) return url; + return addQueryToPath(url, { + access_token: accessToken, + }); + } +}; +``` + +This will use the access token of the current user to render the images. Alternatively, you can enable Read permissions +on the Public role for the image ID or images with a specific folder ID to remove the need for an access token. + +Import the function into the `module.vue` file to make it available in your script: + +```js +import useDirectusToken from './use-directus-token'; +``` + +Include the function `AddTokenToURL` as a variable from the new script. + +```js +setup(props) { + const router = useRouter(); + const api = useApi(); + const { addTokenToURL } = useDirectusToken(api); + + // Existing code +} +``` + +Then wrap any internal images with this function: + +```js +page_banner.value = addTokenToURL(`/assets/${item.banner}?width=2000&height=563&fit=cover`); +``` + +::callout{type="info" title="External Images"} + +If you are using images from external sources, the host must be added to the Content Security Policy (CSP) inside the +environment or config file. + +:: + +## Style the Module + +Add some SCSS at the bottom of the `module.vue` file. When dealing with multiple vue files, don’t scope the SCSS, +instead prefix each class with a unique reference to prevent changing other components in Directus. In this example, use +the following SCSS: + +```vue + +``` + +This will format the banner, cards and the container. It’s a good idea to make use of the native CSS of Directus as much +as possible so your module appears part of Directus. + +Now the page will look like this: + +![A custom module has three items in the navigation - Home, Hello World, and Contact Us. The homepage displays an image, three navigation tiles, and some copy.](https://product-team.directus.app/assets/db55ac3f-016e-4531-8282-a9445482c02e.webp) + +Our files are now complete. Build the module with the latest changes: + +```shell +npm run build +``` + +## Add Module to Directus + +When Directus starts, it will look in the `extensions` directory for any subdirectory starting with +`directus-extension-`, and attempt to load them. + +To install an extension, copy the entire directory with all source code, the `package.json` file, and the `dist` +directory into the Directus `extensions` directory. Make sure the directory with your extension has a name that starts +with `directus-extension`. In this case, you may choose to use `directus-extension-module-landing-page`. + +Restart Directus to load the extension. + +::callout{type="info" title="Required files"} + +Only the `package.json` and `dist` directory are required inside of your extension directory. However, adding the source +code has no negative effect. + +:: + +## Use the Module + +To use your new module in Directus, you need to enable it in the +[Project Settings](/configuration/general). + +## Summary + +You have created a new module from the extension SDK boilerplate template and extended it to multiple pages that make +use of the `vue-router` and utilize the left navigation panel. You can also use the internal API to fetch content and +images from within Directus to surface on the page. From here you can create content rich modules driven by the features +of the Directus platform. + +```js [index.js] +import ModuleComponent from './module.vue'; + +export default { + id: 'landing-page', + name: 'Landing Page', + icon: 'rocket_launch', + routes: [ + { + name: 'home', + path: '', + props: true, + component: ModuleComponent, + }, + { + name: 'page', + path: ':page', + props: true, + component: ModuleComponent, + }, + ], +}; +``` + +```vue [module.vue] + + + + + +``` + +```vue [components/navigation.vue] + + + +``` + +```js [use-directus-token.js] +export default function useDirectusToken(directusApi) { + return { + addQueryToPath, + getToken, + addTokenToURL, + }; + + function addQueryToPath(path, query) { + const queryParams = []; + + for (const [key, value] of Object.entries(query)) { + queryParams.push(`${key}=${value}`); + } + + return path.includes('?') ? `${path}&${queryParams.join('&')}` : `${path}?${queryParams.join('&')}`; + } + + function getToken() { + return ( + directusApi.defaults?.headers?.['Authorization']?.split(' ')[1] || + directusApi.defaults?.headers?.common?.['Authorization']?.split(' ')[1] || + null + ); + } + + function addTokenToURL(url) { + const accessToken = getToken(); + if (!accessToken) return url; + return addQueryToPath(url, { + access_token: accessToken, + }); + } +} +``` + diff --git a/content/tutorials/extensions/integrate-algolia-indexing-with-custom-hooks.md b/content/tutorials/extensions/integrate-algolia-indexing-with-custom-hooks.md new file mode 100644 index 00000000..6b80ca8d --- /dev/null +++ b/content/tutorials/extensions/integrate-algolia-indexing-with-custom-hooks.md @@ -0,0 +1,93 @@ +--- +id: bb540f00-8933-46f1-a2ef-e353e2df160d +slug: integrate-algolia-indexing-with-custom-hooks +title: Integrate Algolia Indexing with Custom Hooks +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- + +In this article, we will explore how to index data from Directus in Algolia, enabling you to track created, updated, and deleted data to maintain an up-to-date index which you can then use in your external applications. Given that Algolia only support their official JavaScript client and not the REST API directly, we will build a hook extension which utilizes the client. + +## Setting Up Directus + +You will need to have a [local Directus project running](/getting-started/create-a-project) to develop extensions. + +In your new project, create a collection called `posts` with a `title`, `content`, and `author` field. + +## Initializing Your Extension + +In your `docker-compose.yml` file, set an `EXTENSIONS_AUTO_RELOAD` environment variable to `true` so that Directus will automatically watch and reload extensions as you save your code. Restart your project once your new environment variable is added. + +In your terminal, navigate to your `extensions` directory and run `npx create-directus-extension@latest`. Name your extension `algolia-indexing` and choose a `hook` type and create the extension with `JavaScript`. Allow Directus to automatically install dependencies and wait for them to install. + +## Setting Up Algolia + +To integrate Directus and Algolia we will need our Algolia application ID and write API key. If you don't have an account already, [create one](https://www.algolia.com/users/sign_up), and you will see the credentials in your dashboard. + +![An image of Algolia Dashboard](https://product-team.directus.app/assets/97c2157a-9b88-4d31-8b16-ac4e47c3ffac.webp) + +In your `docker-compose.yml` file, create an `ALGOLIA_APP_ID` and `ALGOLIA_ADMIN_KEY` environment variable and set them to the value from your Algolia dashboard. Restart your project as you have changed your environment variables. + +Navigate into your new extension directory, run `npm install algoliasearch`, and then `npm run dev` to start the automatic extension building. + +At the top of your extension's `src/index.js` file, initialize the Algolia client: + +```js +import algoliasearch from 'algoliasearch'; +const client = algoliasearch(process.env.ALGOLIA_APP_ID, process.env.ALGOLIA_ADMIN_KEY); +const index = client.initIndex('directus_index'); +``` + +## Saving New Objects to Index + +Update your exported function to run the Algolia `saveObjects()` method whenever a new item in the `posts` collection is created: + +```js +export default ({ action }) => { + action('posts.items.create', async (meta) => { + await index.saveObjects([{ objectID: `${meta.key}`, ...meta.payload }]); + }); +}; +``` + +An `action` hook runs after an item has been created. Data passed in the `meta` property includes the new `key` (ID) of the item, and all the value of all fields created in the `payload` property. + +For item creation (posts.items.create), the code registers a hook that triggers when a new item is added to the posts collection. The item is saved with an `objectID` set to the Directus item `id`, ensuring it can be accurately referenced and managed in Algolia. + +## Updating Objects in Index + +When one or more items are updated, the `.items.update` action receives an array of `keys` along with just the values in each item that have changed. Below the existing action, add another: + +```js +action('posts.items.update', async (meta) => { + await Promise.all( + meta.keys.map(async (key) => await index.partialUpdateObjects([{ objectID: `${key}`, ...meta.payload }])), + ); +}); +``` + +## Deleting Objects in Index + +When one or more items are deleted, the `.items.delete` action receives an array of `keys`. Add a new action: + +```js +action('posts.items.delete', async (meta) => { + await index.deleteObjects(meta.keys); +}); +``` + +## Testing Extension + +To test if the extension works, create a new post in Directus. + +To verify that the indexing process is functioning as expected, navigate to the Algolia Dashboard. Click on "Search" in the navigation menu on the left side of your screen, then select the index. You should see that Algolia has recognized the new data: + +![An image of the created blog post](https://product-team.directus.app/assets/3d583367-334f-48dc-bb55-c65c6b4d849b.webp) + +Also try updating and deleting posts and check if the index reflects the change. + + +## Summary + +By following this guide, you have learned how to set up extensions in Directus. You also saw how to test the extension by creating, updating, and deleting data in Directus, with changes being reflected in your Algolia index. This setup ensures that our data remains synchronized across both platforms. diff --git a/content/tutorials/extensions/integrate-elasticsearch-indexing-with-custom-hooks.md b/content/tutorials/extensions/integrate-elasticsearch-indexing-with-custom-hooks.md new file mode 100644 index 00000000..86511441 --- /dev/null +++ b/content/tutorials/extensions/integrate-elasticsearch-indexing-with-custom-hooks.md @@ -0,0 +1,98 @@ +--- +id: 11878553-5f03-4992-b321-5a983826c983 +slug: integrate-elasticsearch-indexing-with-custom-hooks +title: Integrate Elasticsearch Indexing with Custom Hooks +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- +In this article, we will explore how to index data from Directus in Elasticsearch through a custom hook extension, enabling you to track created, updated, and deleted data to maintain an up-to-date index which you can then use in your external applications. + +## Setting Up Directus + +You will need to have a [local Directus project running](/getting-started/create-a-project) to develop extensions. + +In your new project, create a collection called `books` with a `title` and a `description` field. + +## Initializing Your Extension + +In your `docker-compose.yml` file, set an `EXTENSIONS_AUTO_RELOAD` environment variable to `true` so that Directus will automatically watch and reload extensions as you save your code. Restart your project once your new environment variable is added. + +In your terminal, navigate to your `extensions` directory and run `npx create-directus-extension@latest`. Name your extension `elasticsearch-indexing` and choose a `hook` type and create the extension with `JavaScript`. Allow Directus to automatically install dependencies and wait for them to install. + +## Seting Up Elasticsearch + +To integrate Directus and Elasticsearch, you will need a running instance of both. For this tutorial, [Elastic Cloud](https://www.elastic.co/cloud/elasticsearch-service/signup) will be used. You will need both the Cloud ID and an API Key, which you can generate from your deployment dashboard. + +In your `docker-compose.yml` file, create an `ELASTIC_API_KEY` and `ELASTIC_CLOUD_ID` environment variable and set them to the value from your Elasticsearch dashboard. Restart your project as you have changed your environment variables. + +Navigate into your new extension directory, run `npm install @elastic/elasticsearch`, and then `npm run dev` to start the automatic extension building. + +At the top of your extension's `src/index.js` file, initialize the Elasticsearch client: + +```javascript +import { createRequire } from "module"; +const require = createRequire(import.meta.url); +const { Client } = require("@elastic/elasticsearch"); + +export default ({ action }, { env }) => { + const client = new Client({ + cloud: { id: env.ELASTIC_CLOUD_ID }, + auth: { apiKey: env.ELASTIC_API_KEY }, + }); +}; +``` +Because Elasticsearch is a CommonJS package, the `require()` function is constructed using the `createRequire()` Node utility method and used to import it to avoid errors. +## Saving Items to Index +Add the following lines of code after the `client` variable: +```javascript +action("books.items.create", async (meta) => { + await client.index({ + index: "books", + id: meta.key, + document: meta.payload, + }); +}); +``` +This `action` hook will be triggered when an item is created in `books` collection. This is achieved by specifying `books.items.create` as the event name. +When executed a document will be created in an Elasticsearch `books` index containing the newly created item fields which was accessed from the `meta` object. The `meta` object includes the ID of the newly created item in the `key` property and the item fields in the `payload` property. +Although the `books` index was not explicitly created, that will be done automatically if doesn’t exist and a new document is been created which is the default behavior. +## Updating Items in Index +Add the following lines of code below the existing action: +```javascript +action("books.items.update", async (meta) => { + await Promise.all( + meta.keys.map( + async (key) => + await client.update({ + index: "books", + id: key, + doc: meta.payload, + }) + ) + ); +}); +``` +For an update event, the `meta` object will includes an array of `keys` along with the updated fields even when only a single item is updated. So to modify the corresponding document or documents in `books` index, the array of keys is iterated over to send multiple update requests. + +## Deleting Items in Index +For a delete event, the `meta` object includes an array of keys of the of the deleted items. Fields are not included. Add the following lines of code after the `books.items.update` action: +```javascript +action("books.items.delete", async (meta) => { + await Promise.all( + meta.keys.map( + async (key) => + await client.delete({ + index: "books", + id: key, + }) + ) + ); +}); +``` + +## Testing Extension +When you create, update, or delete items in the `books` collection, the changes should reflect in your Elasticsearch `books` index. + +## Summary +By following this guide, you have learned how to set up extensions in Directus. You also saw how to test the extension by creating, updating, and deleting data in Directus, with changes being reflected in your Elasticsearch index. This setup ensures that our data remains synchronized across both platforms. diff --git a/content/tutorials/extensions/integrate-meilisearch-indexing-with-custom-hooks.md b/content/tutorials/extensions/integrate-meilisearch-indexing-with-custom-hooks.md new file mode 100644 index 00000000..7b44c8d0 --- /dev/null +++ b/content/tutorials/extensions/integrate-meilisearch-indexing-with-custom-hooks.md @@ -0,0 +1,100 @@ +--- +id: 27fd058d-83da-4ea5-b7cc-458c4c696079 +slug: integrate-meilisearch-indexing-with-custom-hooks +title: Integrate Meilisearch Indexing with Custom Hooks +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- +In this article, we will explore how to index data from Directus in Meilisearch by building a custom hook extension, enabling you to track created, updated, and deleted data to maintain an up-to-date index which you can then use in your external applications. + + +## Setting Up Directus + +You will need to have a [local Directus project running](/getting-started/create-a-project) to develop extensions. + +In your new project, create a collection called `articles` with a `title`, `content`, and `author` field. + +## Initializing Your Extension + +In your `docker-compose.yml` file, set an `EXTENSIONS_AUTO_RELOAD` environment variable to `true` so that Directus will automatically watch and reload extensions as you save your code. Restart your project once your new environment variable is added. + +In your terminal, navigate to your `extensions` directory and run `npx create-directus-extension@latest`. Name your extension `melisearch-indexing` and choose a `hook` type and create the extension with `JavaScript`. Allow Directus to automatically install dependencies and wait for them to install. + +## Setting Up Meilisearch + +Sign up for a Meilisearch account if you haven't already. Once you have your Meilisearch instance details, you will be able to copy your credentials in your dashboard. + +![Melisearch dashboard](https://product-team.directus.app/assets/d1aab892-21de-402a-84c5-024c0c0f2f88.webp) + +Add the following environment variables to your project: + +```dockerfile +MEILISEARCH_HOST=your_meilisearch_host +MEILISEARCH_API_KEY=your_meilisearch_api_key +``` + +Navigate into your new extension directory, run `npm install meilisearch`, and then `npm run dev` to start the automatic extension building. + +At the top of your extension's `src/index.js` file, initialize the Meilisearch client: + +```javascript +import { MeiliSearch } from 'meilisearch' + +const client = new MeiliSearch({ + host: process.env.MEILISEARCH_HOST, + apiKey: process.env.MEILISEARCH_API_KEY +}) +const index = client.index('directus_index') +``` + +## Saving New Items to Index + +Update your extension's exported function to process create events when a new `article` is added to the collection: + +```javascript +export default ({ action }) => { + action('articles.items.create', async (meta) => { + await index.addDocuments([{ id: meta.key, ...meta.payload }]) + }) +} +``` + +The `articles.items.create` action hook triggers after item creation. The `meta` object contains the new item's `key` (ID) and other fields in its `payload` property. By setting the `objectID` to the Directus item `id`, we ensure accurate referencing and management in Meilisearch. + +### Updating Items in Index + +Add another action hook to process updates when one or more articles are modified: + +```javascript +action('articles.items.update', async (meta) => { + await Promise.all( + meta.keys.map(async (key) => + await index.updateDocuments([{ id: key, ...meta.payload }]) + ) + ) +}) +``` + +The `articles.items.update` action hook triggers when articles are updated. It receives `meta.keys` (an array of updated item IDs) and `meta.payload` (changed values). The hook updates each document in Meilisearch. + +### Deleting Items in Index + +Add an action hook to remove items from Meilisearch when they're deleted in Directus: + +```javascript +action('articles.items.delete', async (meta) => { + await index.deleteDocuments(meta.keys) +}) +``` + +The `articles.items.delete` action hook triggers when articles are deleted. It receives `meta.keys`, an array of deleted item IDs. The hook uses these keys to remove the corresponding documents from the Meilisearch index. + +Now add 3 items to your articles collection and you should see them in your Meilisearch index. + +![Melisearch with data from Directus](https://product-team.directus.app/assets/90307d1c-889f-4067-a031-57b621898eaf.webp) + + +## Summary + +In this tutorial, you've learned how to integrate Meilisearch with Directus. You've learned how to setup the Directus hooks that automatically indexes data created, updated, or deleted from a Directus project in Meilisearch. diff --git a/content/tutorials/extensions/monitor-and-error-track-with-sentry-in-custom-hooks.md b/content/tutorials/extensions/monitor-and-error-track-with-sentry-in-custom-hooks.md new file mode 100644 index 00000000..cd38d0a9 --- /dev/null +++ b/content/tutorials/extensions/monitor-and-error-track-with-sentry-in-custom-hooks.md @@ -0,0 +1,242 @@ +--- +id: 05df72da-c73d-4f68-a651-a95343227e85 +slug: monitor-and-error-track-with-sentry-in-custom-hooks +title: Monitor and Error Track with Sentry in Custom Hooks +authors: [] +--- +If you self-host Directus, it becomes your responsibility to ensure your project is running smoothly. Part of this is knowing when things are going wrong so you can triage issues, fix errors, and get on with your day. + +This is where [Sentry](https://sentry.io/welcome/) comes in. Sentry is an error tracking and performance monitoring platform built for developers. With Sentry you can track and triage issues, warnings and crashes, and see issues replayed as they happened. Additionally, you can use Sentry to quickly identify [performance issues](https://docs.sentry.io/product/issues/issue-details/performance-issues/), and dive deep into the stack trace and breadcrumb trails that led to an error. Sentry is also Open Source, and supports a broad spectrum of [programming languages and platforms via official SDKs](https://docs.sentry.io/platforms/). + +In this post, we’ll create a [hook extension](/extensions/api-extensions/hooks) to set up Sentry error tracking on both the APIs that Directus generates, and the Data Studio applications. + +## Set up a New Directus Project for Extensions Development + +If you’re not already signed up to Sentry, [create a free account](https://sentry.io/signup/). Before we can get to the fun part, we’ll need to create a Directus project for extensions development. To do that: + +1. Install Docker +2. Create a new directory, for example `directus-self-hosted` +3. At the root of the new directory, create the following `docker-compose.yml` file, replacing the `KEY` and `SECRET` with random values. + +Head on over to Sentry and set up two new projects — one for your back end project (Node.js), and one for the front end Directus Data Studio (Browser JavaScript). + +``` +version: '3' +services: + directus: + image: directus/directus:latest + ports: + - 8055:8055 + volumes: + - ./database:/directus/database + - ./uploads:/directus/uploads + - ./extensions:/directus/extensions + environment: + KEY: 'replace-with-random-value' + SECRET: 'replace-with-random-value' + ADMIN_EMAIL: 'test@example.com' + ADMIN_PASSWORD: 'hunter2' + DB_CLIENT: 'sqlite3' + DB_FILENAME: '/directus/database/data.db' + WEBSOCKETS_ENABLED: true + EXTENSIONS_AUTO_RELOAD: true + CONTENT_SECURITY_POLICY_DIRECTIVES__SCRIPT_SRC: "'self' 'unsafe-eval' https://js.sentry-cdn.com https://browser.sentry-cdn.com" + SENTRY_DSN: 'replace-with-back end-project-dsn' +``` + +Head on over to Sentry and set up two new projects — one for your back end project (Node.js), and one for the front end Directus Data Studio (Browser JavaScript). + +![Sentry project listing showing two projects - a Node project for the backend and a browser JavaScript project for the frontend.](https://product-team.directus.app/assets/dd1f905c-74a3-4c93-a5e1-75d81e279d23.webp) + +In Sentry, select your back end project, navigate to project settings, click on Client Keys (DSN), and copy the DSN (Data Source Name) value. Replace the `SENTRY_DSN` value in the `docker-compose.yml` file with the value from your Sentry project. + +Next, make sure Docker is running on your machine, and run `docker compose up` at the root of your project directory. You’ll see that the following directories have been created for you: + +``` +directus-self-hosted +├ database +├ extensions +└ uploads +``` + +We’re going to create a Directus hook to be able to use Sentry in the back end application. In your terminal, navigate to the `extensions` directory, and run the following command with the following options to create the boilerplate code for your hook: + +``` +npx create-directus-extension@latest +├ extension type: hook +├ name: directus-extension-hook-sentry +└ language: javascript +``` + +Now the boilerplate has been created, navigate to the new hook directory, run the following command to install the Sentry Node.js SDK, and then open the directory in your code editor: + +``` +cd directus-extension-hook-sentry +npm install @sentry/node @sentry/profiling-node +``` + +Open `index.js` inside the `src` directory and delete the boilerplate. We’re ready to build the hook extension. + +## Understanding Hooks in Directus + +Custom API Hooks allow you to inject logic when specific events occur within your Directus project. These events include creating, updating, and deleting items in a collection, on a schedule, and at several points during Directus' startup process. + +For this extension project, we'll use the `init` hooks to monitor the API by registering Sentry's `requestHandler`. For error tracking in the front end Data Studio application, we’ll use the `embed` method to inject custom JavaScript needed to track front end events in Sentry. + +## Monitor the Directus API Using the Sentry Node SDK + +Copy and paste the following code to the `index.js` file in your new hook directory. This imports the Sentry SDK, creates the initial export, and initializes the SDK. Due to how the Sentry SDK is built and the fact that Directus extensions are exclusively [ES Modules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules), we need to use `createRequire` from the `node:module` API: + +```js +import { createRequire } from "module"; +const require = createRequire(import.meta.url); +const Sentry = require('@sentry/node'); +const { nodeProfilingIntegration } = require("@sentry/profiling-node"); + +export default ({ init }, { env }) => { + Sentry.init({ + dsn: env.SENTRY_DSN, + integrations: [ + nodeProfilingIntegration(), + ], + tracesSampleRate: 1.0, + profilesSampleRate: 1.0, + }); +}; +``` + +The first parameter of the default export makes the Directus `init` method available — this is used to define new `init` event types. In the Sentry initialization method, we’re passing in the DSN we defined in the `docker-compose.yml` file and the `tracesSampleRate`. The `tracesSampleRate` controls how many transactions arrive at Sentry and takes a value from 0.0 to 1.0 (from 0% to 100%). Whilst it may be useful to use a `tracesSampleRate` of 1.0 during testing, it is generally recommended to reduce this number in production. Finally we set the `profilesSampleRate`, which is relative to `tracesSampleRate`. + +To start monitoring your back end application with Sentry, add an `init` hook below the Sentry initialization. Under the hood, Directus uses Express for API routing. On `routes.custom.after`, we’re adding the Sentry `setupExpressErrorHandler`, which must be registered before any other error middleware, and after all controllers. + +If you’d like more context about this implementation, you can read more about the [Sentry Express SDK](https://docs.sentry.io/platforms/node/guides/express/) in the Sentry documentation. + +```js +import { createRequire } from "module"; +const require = createRequire(import.meta.url); +const Sentry = require('@sentry/node'); + +export default ({ init }, { env }) => { + Sentry.init({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0 + }); + + init('routes.custom.after', ({ app }) => { + Sentry.setupExpressErrorHandler(app); + console.log('-- Sentry Error Handler Added --'); + }); +}; +``` + +Next, let’s build the hook. In the `directus-extension-hook-sentry` directory, run `npm run build`. Restart the Directus Docker container, and you’ll see the two logs in your terminal. + +![A terminal showing the command docker compose up. Several info logs are shown, and two logs read 'sentry request handler added' and 'sentry error handler added'](https://product-team.directus.app/assets/97a17e04-8bae-4fbd-9812-d69fa65333b8.webp) + +## Monitor the Directus Data Studio Using the Sentry Loader Script + +Next, we’re going to add Sentry monitoring to your front end application (Directus Data Studio). To do this, we’ll need to inject some custom JavaScript to the page, and we can do this using embed hook events. Embed hook events allow custom JavaScript and CSS to be added to the `` and `` within the Directus Data Studio. + +Head over to Sentry, and navigate to the front end project you created earlier. Go to project settings, click on Loader Script, and copy the provided script tag code. + +
+ +Back in the `index.js` file of your extension, make the `embed` method available in the exported function of the file: + +```js +export default ({ init }, { env }) => { // [!code --] +export default ({ init, embed }, { env }) => { // [!code ++] +``` + +Below the two `init` hooks you created to monitor the back end application, add a new `embed` hook. The first parameter `head` instructs the extension to embed something into the `` of your Directus Data Studio Application, and the second parameter is the front end Loader Script you copied from Sentry just now: + +```js +embed( + `head`, + `` +); +``` + +Next, rebuild the extension with `npm run build`, restart Directus again, and you have successfully implemented full stack Sentry error tracking and monitoring to your Directus project. + +## Test Your Full Stack Setup + +Let’s send some test errors to Sentry to make sure everything is hooked up. + +## Test Back End Error Tracking + +We’re going to create a test endpoint to trigger an error event in Sentry by creating a new Directus extension. Navigate to the `extensions` directory, and run the following command with the following options to generate some boilerplate code for the test endpoint: + +``` +npx create-directus-extension@latest +├ type: endpoint +├ name: directus-extension-endpoint-fail +└ language: javascript +``` + +You’ll now see a new directory, `directus-extension-endpoint-fail` in your extensions directory. Open the `index.js` file in the newly created directory and replace it with the following code, which will throw a new error intentionally. + +```js +export default { + id: 'fail', + handler: (router) => { + router.get('/', (req, res) => { + throw new Error('Intentional back end error for Sentry test'); + }); + } +}; +``` + +In the root of the new extension directory, run `npm run build`, restart the Directus Docker container again, and navigate to `http://localhost:8055/fail` in your browser. You will see an error message on the browser page, in the terminal, and in your back end project's Sentry issues list. Boom! + +![An error is shown in the Sentry issues dashboard](https://product-team.directus.app/assets/e6c7e914-6c31-4315-81d9-3362cd30ef81.webp) + +## Test Front End Error Tracking + +Next, let’s confirm the front end Loader Script is tracking issues. Let’s create another extension to test an error in a front end template. Back in your Directus `extensions` directory, run the following command” + +``` +npx create-directus-extension@latest +├ type: module +├ name: directus-extension-module-fail +└ language: javascript +``` + +Open the newly created extension's `module.vue` file and replace it with the following code: + +```vue + + + +``` + +From the extension directory, run `npm run build`, restart the Directus Docker container, and navigate to `http://localhost:8055/admin/settings/project` in your browser. Sign in to Directus using the credentials in your `docker-compose.yml` file. Scroll down to Modules, and check the checkbox to enable the new custom module. For reference, the name of the module is defined in the `index.js` file of the module extension. + +![The Directus Project Setting showing the new custom module checkbox is enabled](https://product-team.directus.app/assets/5877631a-f9f7-4722-b262-b21e14d42050.webp) + +Navigate to the new custom module using the icon on the left menu bar, and click the **Trigger Error** button. + +![A custom module page with just one button reading 'trigger error'](https://product-team.directus.app/assets/5203541f-0848-4785-92a7-045a90b1d97d.webp) + +You’ll now see the error message in your front end project's Sentry issue list. We’re done! + +![An error shown in the sentry dashboard](https://product-team.directus.app/assets/fee718f4-ea8d-4801-9bc7-5d785a1379a9.webp) + +## Summary + +If you’re self-hosting Directus, you need a reliable way to monitor, triage and be alerted to issues in your back end and front end applications. Sentry makes this possible and ensures you spend less time searching for clues, and more time fixing what’s broken. Additionally, you can configure [Distributed Tracing](https://docs.sentry.io/product/sentry-basics/tracing/) with Sentry to provide a connected view of related errors and transactions by capturing interactions among your entire suite of Directus extensions and software applications. + +Head over to the [Sentry docs to learn about the wide range of language and platform support](https://docs.sentry.io/platforms/), and if you’re still not convinced, try out the [Sentry Sandbox](https://sandbox.sentry.io) to explore the platform with a bucket load of pre-populated real-world data. diff --git a/content/tutorials/extensions/proxy-an-external-api-in-a-custom-endpoint-extension.md b/content/tutorials/extensions/proxy-an-external-api-in-a-custom-endpoint-extension.md new file mode 100644 index 00000000..8e313dd5 --- /dev/null +++ b/content/tutorials/extensions/proxy-an-external-api-in-a-custom-endpoint-extension.md @@ -0,0 +1,242 @@ +--- +id: 5e47f786-eddb-4570-bda9-60d6427282af +slug: proxy-an-external-api-in-a-custom-endpoint-extension +title: Proxy an External API in a Custom Endpoint Extension +authors: [] +--- +Endpoints are used in the API to perform certain functions. + +Accessing a 3rd party API via a proxy in Directus has many advantages such as allowing multiple Directus users to access +a service via a single 3rd party auth token, simplifying front-end extensions by accessing 3rd party APIs using the +local API endpoint and credentials, and eliminating Cross-Origin issues. + +As an example, this guide will proxy the PokéAPI, but the same approach can be used for any API. + +::callout{type="warning" title="Consider Authenticating Proxies"} + +This guide will show you how to proxy an API that does not require authentication. In production, you should consider +requiring authentication in your proxy endpoints to avoid abuse, especially if the target API performs write operations +or costs money to use. + +:: + +## Install Dependencies + +Open a console to your preferred working directory and initialize a new extension, which will create the boilerplate +code for your operation. + +```shell +npx create-directus-extension@latest +``` + +A list of options will appear (choose endpoint), and type a name for your extension (for example, +`directus-endpoint-pokeapi`). For this guide, select JavaScript. + +Now the boilerplate has been created, open the directory in your code editor. + +## Build the Endpoint + +In the `src` directory open `index.js`. By default, the endpoint root will be the name of the extensions folder which +would be `/directus-endpoint-pokeapi/`. To change this, replace the code with the following: + +```js +export default { + id: 'pokeapi', + handler: (router, {services}) => { + // Router config goes here + }, +}; +``` + +The `id` becomes the root and must be a unique identifier between all other endpoints. + +The standard way to create an API route is to specify the method and the path. Rather than recreate every possible +endpoint that the PokéAPI has, use a wildcard (\*) to run this function for every route for each supported method. + +```js +router.get('/*', async (req, res) => { + try { + const response = await fetch(`https://pokeapi.co/api/v2/${req.url}`); + + if (response.ok) { + res.json(await response.json()); + } else { + res.status(response.status); + res.send(response.statusText); + } + } catch (error) { + res.status(500); + res.send(error.message); + } +}); +``` + +The route includes the request (`req`) and response (`res`). The request has useful information that was provided by the +user or application such as the URL, method, authentication and other HTTP headers. In this case, the URL needs to be +combined with the base URL to perform an API query. + +## Adding Authentication + +You should also require authentication for your endpoint. Without this, any person on the internet could use it. + +Use the `ItemsService` to query the `directus_users` system collection to see if the `req.accountability?.user` UUID is a valid one. + +Define a schema for querying at the top of the file: + +```js + +const schema = { + collections: { + directus_users: { + collection: 'directus_users', + primary: 'id', + singleton: false, + accountability: 'all', + fields: { + id: { + field: 'id', + defaultValue: null, + nullable: false, + generated: false, + type: 'uuid', + dbType: 'uuid', + special: [], + alias: false + } + } + } + } +}; +``` + +Then, change the API route to perform this validation: + +```js +router.get('/*', async (req, res) => { + try { + const user = req.accountability?.user; + const { ItemsService } = services; + const users = new ItemsService("directus_users", {schema}); + const authenticatedUser = await users.readOne(user); + if ( authenticatedUser == null) { + res.status(403); + return res.send(`You don't have permission to access this.`); + } + + const response = await fetch(`https://pokeapi.co/api/v2/${req.url}`); + + if (response.ok) { + res.json(await response.json()); + } else { + res.status(response.status); + res.send(response.statusText); + } + } catch (error) { + res.status(500); + res.send(error.message); + } +}); +``` + + +This is now complete and ready for testing. Build the endpoint with the latest changes. + +``` +npm run build +``` + +## Add Endpoint to Directus + +When Directus starts, it will look in the `extensions` directory for any subdirectory starting with +`directus-extension-`, and attempt to load them. + +To install an extension, copy the entire directory with all source code, the `package.json` file, and the `dist` +directory into the Directus `extensions` directory. Make sure the directory with your extension has a name that starts +with `directus-extension`. In this case, you may choose to use `directus-extension-endpoint-pokeapi`. + +Restart Directus to load the extension. + +::callout{type="info" title="Required files"} + +Only the `package.json` and `dist` directory are required inside of your extension directory. However, adding the source +code has no negative effect. + +:: + +## Use the Endpoint + +Using an application such as Postman, create a new request. The URL will be: `https://example.directus.app/pokeapi/` (be +sure that you change the URL for your project's URL) + +Visit the PokéAPI docs and find an endpoint - for example [Request a Pokémon](https://pokeapi.co/docs/v2#pokemon). + +Make sure to select CURL as the coding language and this will output the URL to use. Copy the URL without the host and +paste it to the end of your Directus endpoint. It will look something like: + +`https://example.directus.app/pokeapi/pokemon/25` + +You should receive the direct response from PokéAPI. + +## Summary + +With this endpoint, you now have access to the PokéAPI within Directus. Now that you know how to create a proxy to an +API, you can create proxies for other 3rd party services and simplify your other extensions. + +## Complete Code + +`index.js` + +```js + +const schema = { + collections: { + directus_users: { + collection: 'directus_users', + primary: 'id', + singleton: false, + accountability: 'all', + fields: { + id: { + field: 'id', + defaultValue: null, + nullable: false, + generated: false, + type: 'uuid', + dbType: 'uuid', + special: [], + alias: false + } + } + } + } +}; +export default { + id: 'pokeapi', + handler: (router, {services}) => { + router.get('/*', async (req, res) => { + try { + const user = req.accountability?.user; + const { ItemsService } = services; + const users = new ItemsService("directus_users", {schema}); + const authenticatedUser = await users.readOne(user); + if ( authenticatedUser == null) { + res.status(403); + return res.send(`You don't have permission to access this.`); + } + + const response = await fetch(`https://pokeapi.co/api/v2/${req.url}`); + + if (response.ok) { + res.json(await response.json()); + } else { + res.status(response.status); + res.send(response.statusText); + } + } catch (error) { + res.status(500); + res.send(error.message); + } + }); + }, +}; +``` diff --git a/content/tutorials/extensions/read-collection-data-in-custom-layouts.md b/content/tutorials/extensions/read-collection-data-in-custom-layouts.md new file mode 100644 index 00000000..720d4f55 --- /dev/null +++ b/content/tutorials/extensions/read-collection-data-in-custom-layouts.md @@ -0,0 +1,317 @@ +--- +id: a5598c32-0b99-4c24-9e3c-2e9b73b600e8 +slug: read-collection-data-in-custom-layouts +title: Read Collection Data in Custom Layouts +authors: [] +--- +Use the `CollectionsService`, `FieldsService` and `RelationsService` to configure and modify the data model of a +collection. + +## CollectionsService + +The `CollectionsService` is used for manipulating data and performing CRUD operations on a collection. + +```js +export default defineEndpoint((router, context) => { + const { services, getSchema } = context; + const { CollectionsService } = services; + + router.get('/', async (req, res) => { + const collectionsService = new CollectionsService({ + schema: await getSchema(), + accountability: req.accountability + }); + + // Your route handler logic + }); +}); +``` + +### Create a Collection + +```js +router.post('/', async (req, res) => { + const collectionsService = new CollectionsService({ + schema: await getSchema(), + accountability: req.accountability + }); + + const collectionKey = await collectionsService.createOne({ + collection:'articles', + meta: { + note: 'Blog posts', + }, + }); + + const data = await collectionsService.readOne(collectionKey); + + res.json(record); +}); +``` + +### Read a Collection + +```js +router.get('/', async (req, res) => { + const collectionsService = new CollectionsService({ + schema: await getSchema(), + accountability: req.accountability + }); + + const data = await collectionsService.readOne('collection_name'); + + res.json(data); +}); +``` + +### Update a Collection + +```js +router.patch('/', async (req, res) => { + const collectionsService = new CollectionsService({ + schema: await getSchema(), + accountability: req.accountability + }); + + const data = await collectionsService.updateOne('collection_name', { + meta: { + note: 'Updated blog posts', + }, + }); + + res.json(data); +}); +``` + +### Delete a Collection + +```js +router.delete('/', async (req, res) => { + const collectionsService = new CollectionsService({ + schema: await getSchema(), + accountability: req.accountability + }); + + const data = await collectionsService.deleteOne('collection_name'); + + res.json(data); +}); +``` + +## FieldsService + +The `FieldsService` provides access to perform CRUD operations on fields used in collections. + +```js +export default defineEndpoint((router, context) => { + const { services, getSchema } = context; + const { FieldsService } = services; + + router.get('/', async (req, res) => { + const fieldsService = new FieldsService({ + schema: await getSchema(), + accountability: req.accountability + }); + + // Your route handler logic + }); +}); +``` + +### Create a Field + +```js +router.post('/', async (req, res) => { + const field = { + field: 'title', + type: 'string', + meta: { + icon: 'title', + }, + schema: { + default_value: 'Hello World', + }, + }; + + const fieldsService = new FieldsService({ + schema: await getSchema(), + accountability: req.accountability + }); + + await fieldsService.createField('collection_name', field); + + const data = await fieldsService.readOne( + 'collection_name', + field.field, + ); + + res.json(createdField); +}); +``` + +### Read a Field + +```js +router.get('/', async (req, res) => { + const fieldsService = new FieldsService({ + schema: await getSchema(), + accountability: req.accountability + }); + + const data = await fieldsService.readAll('collection_name'); + + res.json(data); +}); +``` + +### Update a Field + +```js +router.patch('/', async (req, res) => { + const fieldsService = new FieldsService({ + schema: await getSchema(), + accountability: req.accountability + }); + + await fieldsService.updateField('collection_name', { + meta: { + note: 'Put the title here', + }, + schema: { + default_value: 'Hello World!', + }, + field: 'field_name', + }); + + const data = await fieldsService.readOne( + 'collection_name', + 'field_name', + ); + + res.json(updatedField); +}); +``` + +::callout{type="warning" title="Field Name"} + +Updating the field name is not supported at this time. + +:: + +### Delete a Field + +```js +router.delete('/', async (req, res) => { + const fieldsService = new FieldsService({ + schema: await getSchema(), + accountability: req.accountability + }); + + const data = await fieldsService.deleteField('collection_name', 'field_name'); + + res.json(data); +}); +``` + +## RelationsService + +The `RelationsService` allows you to perform CRUD operations on relations between items. + +```js +export default defineEndpoint((router, context) => { + const { services, getSchema } = context; + const { RelationsService } = services; + + router.get('/', async (req, res) => { + const relationsService = new RelationsService({ + schema: await getSchema(), + accountability: req.accountability + }); + + // Your route handler logic + }); +}); +``` + +### Create a Relation + +```js +router.post('/', async (req, res) => { + const relationsService = new RelationsService({ + schema: await getSchema(), + accountability: req.accountability + }); + + const data = await relationsService.createOne({ + collection: 'articles', + field: 'featured_image', + related_collection: 'directus_files', + }); + + const data = await relationsService.readOne(data); + + res.json(record); +}); +``` + +### Get a Relation + +```js +router.get('/', async (req, res) => { + const relationsService = new RelationsService({ + schema: await getSchema(), + accountability: req.accountability + }); + + const data = await relationsService.readOne('collection_name', 'field_name'); + + res.json(data); +}); +``` + +### Update a Relation + +```js +router.patch('/', async (req, res) => { + const relationsService = new RelationsService({ + schema: await getSchema(), + accountability: req.accountability + }); + + const data = await relationsService.updateOne( + 'collection_name', + 'field_name', + { + meta: { + one_field: 'articles', + }, + }, + ); + + res.json(data); +}); +``` + +### Delete a Relation + +```js +router.delete('/', async (req, res) => { + const relationsService = new RelationsService({ + schema: await getSchema(), + accountability: req.accountability + }); + + const data = await relationsService.deleteOne( + 'collection_name', + 'field_name', + ); + + res.json(data); +}); +``` + +::callout{type="info" title="Explore Services In-Depth"} + +Refer to the full list of methods [in our codebase](https://github.com/directus/directus/blob/main/api/src/services). + +:: diff --git a/content/tutorials/extensions/send-sms-messages-with-twilio-in-custom-operations.md b/content/tutorials/extensions/send-sms-messages-with-twilio-in-custom-operations.md new file mode 100644 index 00000000..6169e617 --- /dev/null +++ b/content/tutorials/extensions/send-sms-messages-with-twilio-in-custom-operations.md @@ -0,0 +1,283 @@ +--- +id: 79c82730-7768-4722-8538-7f3fecac3d43 +slug: send-sms-messages-with-twilio-in-custom-operations +title: Send SMS Messages with Twilio in Custom Operations +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- +Operations allow you to trigger your own code in a Flow. This guide will show you how to use the Twilio Node.js helper +library to send SMS messages in Flows. + +![A Twilio SMS operation in a Flow](https://product-team.directus.app/assets/63e8cd6f-d2d4-49a9-ab2f-0bb9d0da4446.webp) + +## Install Dependencies + +To follow this guide, you will need a Twilio API Key. + +Open a console to your preferred working directory and initialize a new extension, which will create the boilerplate +code for your operation. + +```shell +npx create-directus-extension@latest +``` + +A list of options will appear (choose operation), and type a name for your extension (for example, +`directus-operation-twilio-sms`). For this guide, select JavaScript. + +Now the boilerplate has been created, install the Twilio library, and then open the directory in your code editor. + +```shell +cd directus-operation-twilio-sms +npm install twilio +``` + +## Build the Operation UI + +Operations have 2 parts - the `api.js` file that performs logic, and the `app.js` file that describes the front-end UI +for the operation. + +Open `app.js` and change the `id`, `name`, `icon`, and `description`. + +```js +id: 'operation-twilio-sms', +name: 'Twilio SMS', +icon: 'forum', +description: 'Send SMS using the Twilio API.', +``` + +Make sure the `id` is unique between all extensions including ones created by 3rd parties - a good practice is to +include a professional prefix. You can choose an icon from the library [here](https://fonts.google.com/icons). + +With the information above, the operation will appear in the list like this: + +Twilio SMS - Send SMS using the Twilio API. A chat icon is displayed in the box. + +`options` are the fields presented in the frontend when adding this operation to the Flow. To send an SMS, you will need +the phone number and a message. Replace the placeholder options with the following: + +```js +options: [ + { + field: 'phone_number', + name: 'Phone Number', + type: 'string', + meta: { + width: 'half', + interface: 'input', + }, + }, + { + field: 'message', + name: 'Message', + type: 'text', + meta: { + width: 'full', + interface: 'input-multiline', + }, + }, +], +``` + +- `phone_number` is a standard string input to allow for international numbers that begin with a plus (+). +- `message` uses an input-multiline field (textarea) to allow for a long message to be sent. + +A form shows all of the defined fields above + +The `overview` section defines what is visible inside the operation’s card on the Flow canvas. An overview object +contains 2 parameters, `label` and `text`. The label can be any string and does not need to match the field name. The +text parameter can be a variable or just another string. + +It will be useful to see both fields on the card. Replace the placeholder objects with the following: + +```js +overview: ({ phone_number, message }) => [ + { + label: 'Phone Number', + text: phone_number, + }, + { + label: 'Message', + text: message, + }, +], +``` + +Now, the overview of the operation looks like this: + +The flow overview card shows a phone number and message. + +## Build the API Function + +Open the `api.js` file, import the Twilio library and update the `id` to match the one used in the `app.js` file: + +```js +import twilio from 'twilio'; + +export default { + id: 'operation-twilio-sms', + handler: () => { + // ... + }, +}; +``` + +The handler needs to include the fields from the `app.js` options and the environment variables from Directus. Replace +the handler definition with the following: + +```js +handler: ({ phone_number: toNumber, message }, { env }) => { +``` + +Set up the Twilio API and environment variables with the following code. These environment variables will need to be +added to the project when installing this extension. + +```js +const accountSid = env.TWILIO_ACCOUNT_SID; +const authToken = env.TWILIO_AUTH_TOKEN; +const fromNumber = env.TWILIO_PHONE_NUMBER; +const client = new twilio(accountSid, authToken); +``` + +Use the Twilio `messages` endpoint and create a new message, setting the `body`, `to`, and `from` parameters. `body` +will use the message variable from our handler, `to` will use the `phone_number` variable from our handler, aliased as +`toNumber` for clarity, and `from` will use the `fromNumber` constant from the environment variable +`TWILIO_PHONE_NUMBER`. + +```js +client.messages + .create({ + body: message, + to: toNumber, + from: fromNumber, + }) + .then((response) => { + return response; + }) + .catch((error) => { + return error; + }); +``` + +Make sure the return the `response` and `error` so they can be included in the Flow’s log. + +Both files are now complete. Build the operation with the latest changes. + +``` +npm run build +``` + +## Add Operation to Directus + +When Directus starts, it will look in the `extensions` directory for any subdirectory starting with +`directus-extension-`, and attempt to load them. + +To install an extension, copy the entire directory with all source code, the `package.json` file, and the `dist` +directory into the Directus `extensions` directory. Make sure the directory with your extension has a name that starts +with `directus-extension`. In this case, you may choose to use `directus-extension-operation-twilio-sms`. + +Ensure the `.env` file has `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, and `TWILIO_PHONE_NUMBER` variables. + +Restart Directus to load the extension. + +::callout{type="info" title="Required files"} + +Only the `package.json` and `dist` directory are required inside of your extension directory. However, adding the source +code has no negative effect. + +:: + +## Use the Operation + +In the Directus Data Studio, open the Flows section in Settings. Create a new flow with an event trigger. Select the +collection(s) to include. + +If the payload does not contain the phone number, use the **Read Data** operation to fetch the phone number from the +relevant collection. Add a new operation by clicking the tick/plus on the card, then choose **Twilio SMS** from the +list. + +The full form is filled with values. + +- For the **Phone Number**, you can use a dynamic value from a payload such as + `{{$trigger.payload.phone_number}}` or type a static number in the field. +- For the **Message**, type anything that you would like to send and remember to shorten your links. + +Save the operation, save the Flow, and then trigger the flow by creating a record in the chosen collection. + +## Summary + +This operation will create a Twilio API request to send an SMS using the supplied number and message and the response is +captured in the logs for reference. Now that you know how to interact with a third party API, you can investigate other +services that can be used in your workflows. + +## Complete Code + +`app.js` + +```js +export default { + id: 'operation-twilio-sms', + name: 'Twilio SMS', + icon: 'forum', + description: 'Send SMS using the Twilio API.', + overview: ({ phone_number, message }) => [ + { + label: 'Phone Number', + text: phone_number, + }, + { + label: 'Message', + text: message, + }, + ], + options: [ + { + field: 'phone_number', + name: 'Phone Number', + type: 'string', + meta: { + width: 'full', + interface: 'input', + }, + }, + { + field: 'message', + name: 'Message', + type: 'text', + meta: { + width: 'full', + interface: 'input-multiline', + }, + }, + ], +}; +``` + +`api.js` + +```js +import twilio from 'twilio'; + +export default { + id: 'operation-twilio-sms', + handler: ({ phone_number: toNumber, message }, { env }) => { + const accountSid = env.TWILIO_ACCOUNT_SID; + const authToken = env.TWILIO_AUTH_TOKEN; + const fromNumber = env.TWILIO_PHONE_NUMBER; + const client = new twilio(accountSid, authToken); + + client.messages + .create({ + body: message, + to: toNumber, + from: fromNumber, + }) + .then((response) => { + return response; + }) + .catch((error) => { + return error; + }); + }, +}; +``` diff --git a/content/tutorials/extensions/send-sms-messages-with-twilio-in-custom-panels.md b/content/tutorials/extensions/send-sms-messages-with-twilio-in-custom-panels.md new file mode 100644 index 00000000..be238a5c --- /dev/null +++ b/content/tutorials/extensions/send-sms-messages-with-twilio-in-custom-panels.md @@ -0,0 +1,1132 @@ +--- +id: 9dbc2c00-796b-48ff-8ed6-f01ddf72d7ef +slug: send-sms-messages-with-twilio-in-custom-panels +title: Send SMS Messages with Twilio in Custom Panels +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- +Panels are used in dashboards as part of the Insights module. As well as read-only data panels, they can be interactive +with form inputs. + +![An insights panel showing a form called message customer. The form has a dropdown with 4 items selected, and a text box for a message. The button reads send message.](https://product-team.directus.app/assets/f379b16d-b170-4355-a94d-8d23b32ef777.webp) + +## Install Dependencies + +Panels can only talk to internal Directus services, and can't reliably make external web requests because browser +security protections prevent these cross-origin requests from being made. To create a panel that can interact with +external APIs, you must interact with the API using an endpoint. This particular panel extension builds off of the +[Twilio Custom Endpoint Extension guide](/tutorials/extensions/proxy-an-external-api-in-a-custom-endpoint-extension). Make sure you have access to +these custom endpoints before starting this guide. + +Open a console to your preferred working directory and initialize a new extension, which will create the boilerplate +code for your operation. + +```shell +npx create-directus-extension@latest +``` + +A list of options will appear (choose panel), and type a name for your extension (for example, +`directus-panel-twilio-sms`). For this guide, select JavaScript. + +Now the boilerplate has been created, open the directory in your code editor. + +## Specify Configuration + +Panels have two parts - the `index.js` configuration file, and the `panel.vue` view. The first part is defining what +information you need to render the panel in the configuration. + +Open `index.js` and change the `id`, `name`, `icon`, and `description`. + +```js +id: 'panel-twilio-sms', +name: 'Twilio SMS', +icon: 'forum', +description: 'Send a SMS from a panel.', +``` + +Make sure the `id` is unique between all extensions including ones created by 3rd parties - a good practice is to +include a professional prefix. You can choose an icon from the library [here](https://fonts.google.com/icons). + +With the information above, the panel will appear in the list like this: + +Twilio SMS - Send a SMS from a panel. A chat icon is shown in the box. + +The Panel will need some configuration to be able to send messages such as the Twilio account, the sending number, where +to find the contacts and some visual customization. In the `options` section, add two fields to collect the Twilio Phone +Number and Account SID: + +```js +{ + field: 'twilioPhoneNumber', + name: 'Twilio Phone Number', + type: 'string', + meta: { + interface: 'input', + width: 'half', + }, +}, +{ + field: 'twilioSid', + name: 'Twilio Account SID', + type: 'string', + meta: { + interface: 'input', + width: 'half', + }, +}, +``` + +To fetch the contacts, add a field for selecting a collection using the system-collection interface and a field for +selecting the phone number field using the system-field interface. These will automatically populate the dropdown with +the values from Directus and form the basis for an API call. + +For occasions where the user might want to limit the scope of contacts, add a filter field using the system-filter +interface. + +```js +{ + field: 'collection', + type: 'string', + name: '$t:collection', + meta: { + interface: 'system-collection', + options: { + includeSystem: true, + includeSingleton: false, + }, + width: 'half', + }, +}, +{ + field: 'phoneNumberField', + type: 'string', + name: 'Phone Number', + meta: { + interface: 'system-field', + options: { + collectionField: 'collection', + typeAllowList: ['string','integer'], + }, + width: 'half', + }, +}, +{ + field: 'filter', + type: 'json', + name: '$t:filter', + meta: { + interface: 'system-filter', + options: { + collectionField: 'collection', + relationalFieldSelectable: false, + }, + }, +}, +``` + +There are many ways to implement this panel so customization is key. Add the following options to allow a fixed 'static' +message, a custom button label, batch recipient list and a custom display template for contacts: + +```js +{ + field: 'message', + type: 'text', + name: 'Message', + meta: { + interface: 'input-multiline', + width: 'full', + }, +}, +{ + field: 'buttonLabel', + name: 'Button Label', + type: 'string', + meta: { + interface: 'input', + width: 'half', + }, +}, +{ + field: 'batchSend', + name: 'Send to All', + type: 'boolean', + meta: { + interface: 'boolean', + width: 'half', + }, + schema: { + default_value: false, + }, +}, +{ + field: 'displayTemplate', + name: 'Name in list', + type: 'string', + meta: { + interface: 'system-display-template', + options: { + collectionField: 'collection', + placeholder: '{{ field }}', + }, + width: 'full', + }, +}, +``` + +After the options section, there is the ability to limit the width and height of the panel. Set these to 12 for the +width and 5 for the height. + +It is important to include `skipUndefinedKeys` which is a list of system-display-template fields. + +This completes the `index.js` file. The output of the options will look like this: + +![A long form showing Twilio credential fields, collection and field selection, a filter, message, button information, an optional Send to All checkbox, and a display template.](https://product-team.directus.app/assets/e8aa63f8-769d-41e3-912d-60a150223aea.webp) + +## Prepare the View + +Open the `panel.vue` file and import the following functions at the top of the ` + + +``` diff --git a/content/tutorials/extensions/summarize-relational-items-in-a-custom-display-extension.md b/content/tutorials/extensions/summarize-relational-items-in-a-custom-display-extension.md new file mode 100644 index 00000000..abc1b261 --- /dev/null +++ b/content/tutorials/extensions/summarize-relational-items-in-a-custom-display-extension.md @@ -0,0 +1,482 @@ +--- +id: dfddca20-1cc7-4d66-bce0-0e88b36daa18 +slug: summarize-relational-items-in-a-custom-display-extension +title: Summarize Relational Items in a Custom Display Extension +authors: [] +--- +Displays provide a meaningful way for users to consume data. This guide will show you how to create a display that +queries another table and returns the `SUM` or `COUNT` of a column. + +![In a table, a new field called 'Test Junction' is shown. The values are '5 items' and '2 items'.](https://product-team.directus.app/assets/ea21a1b0-0eeb-4f3d-a89c-6ee3b469f8fe.webp) + +## Install Dependencies + +Open a console to your preferred working directory and initialize a new extension, which will create the boilerplate +code for your display. + +```shell +npx create-directus-extension@latest +``` + +A list of options will appear (choose display), and type a name for your extension (for example, +`directus-display-sum-count`). For this guide, select JavaScript. + +Now the boilerplate has been created, open the directory in your code editor. + +## Specify Configuration + +Displays have 2 parts, the `index.js` configuration file, and the `display.vue` view. The first part allows you to +configure options and the appearance when selecting the display for a field. + +Open the `index.js` file and update the existing information relevant to this display. Since you are working with +relational fields, you need to change `types` value and add `localTypes` as well. This will ensure this display will +only be available for relational fields. + +```js +import DisplayComponent from './display.vue'; +import { useStores } from '@directus/extensions-sdk'; + +export default { + id: 'directus-display-count-sum', + name: 'Count or Sum a Column', + icon: '123', + description: 'Count the related records or display the sum of the select column', + component: DisplayComponent, + options: null, + types: ['alias', 'string', 'uuid', 'integer', 'bigInteger', 'json'], + localTypes: ['m2m', 'm2o', 'o2m', 'translations', 'm2a', 'file', 'files'], + fields: (options) => { + return []; + }, +}; +``` + +Make sure the `id` is unique between all extensions including ones created by 3rd parties - a good practice is to +include a professional prefix. You can choose an icon from the library [here](https://fonts.google.com/icons). + +With the information above, the display will appear in the list like this: + +![A new display option is shown - Related Values.](https://product-team.directus.app/assets/c7978989-5045-4443-bf49-695bf77e9fff.webp) + +Currently the options object is `null`. To provide the option to include months, update the `options` object with the +following code: + +```js +options: null, // [!code --] +options: ({ editing, relations }) => { // [!code ++] + return []; // [!code ++] +}, // [!code ++] +``` + +Before the `options` `return` value, add the following constants to retrieve the related collection and the field store +and determine if the related collection uses a junction table: + +```js +const relatedCollection = + relations.o2m?.meta.junction_field != null ? relations.m2o?.related_collection : relations.o2m?.collection; + +const junction_table = relations.o2m?.meta.junction_field != null ? relations.o2m?.collection : null; +const { useFieldsStore } = useStores(); +const fieldsStore = useFieldsStore(); +``` + +After the constants, add an `if` statement to disable the field selection dropdown while the relational field is still +being created. The variable called `editing` was included in the function which will equal `+` during this state. Use +the `presentation-notice` interface to display a message while this display is unavailable. + +```js +if (editing === '+') { + const fieldSelection = { + interface: 'presentation-notice', + options: { + text: 'Please complete the field before attempting to configure the display.', + }, + width: 'full', + }; +} else { +} +``` + +In the `else` block, use the `fieldStore` to fetch all the fields from the related collection into the `field_choices` +array, then create a selection dropdown interface with the choices set to `field_choices`: + +```js +if (editing === '+') { +} else { + const fields = fieldsStore.getFieldsForCollection(relatedCollection); // [!code ++] + const field_choices = []; // [!code ++] +// [!code ++] + fields.forEach((field) => { // [!code ++] + field_choices.push({ // [!code ++] + text: field.meta.field, // [!code ++] + value: junction_table ? `${relations.o2m.meta.junction_field}.${field.meta.field}` : field.meta.field, // [!code ++], + }); // [!code ++] + }); // [!code ++] +// [!code ++] + const fieldSelection = { // [!code ++] + interface: 'select-dropdown', // [!code ++] + options: { // [!code ++] + choices: field_choices, // [!code ++] + }, // [!code ++] + width: 'full', // [!code ++] + }; // [!code ++] +} +``` + +Inside the returned array, output all of the options to use with this display. For the field called `column`, set meta +to `fieldSelection`. The rest can be added as normal. + +```js +return [ + { + field: 'column', + name: 'Choose a column', + meta: fieldSelection, + }, + { + field: 'sum', + type: 'boolean', + name: 'Calculate Sum', + meta: { + interface: 'boolean', + options: { + label: 'Yes', + }, + width: 'half', + }, + }, + { + field: 'prefix', + type: 'string', + name: 'Prefix', + meta: { + interface: 'input', + options: { + font: 'monospace', + }, + width: 'half', + }, + }, + { + field: 'suffix', + type: 'string', + name: 'Suffix', + meta: { + interface: 'input', + options: { + font: 'monospace', + }, + width: 'half', + }, + }, +]; +``` + +Now that options are set up, use the `options.column` to set the scope for the fields at the very bottom of this script. +This section determines what fields are included in the `props.value`. For example, if you set this to `['*']`, all the +fields for the related collection will be included. For best performance, set this to the field chosen in the options. + +```js +fields: (options) => { + return []; // [!code --] + return [options.column] // [!code ++] +}, +``` + +Note, displays will fetch related collection values for each row on the page. Fetching more that you need will impact +the performance of Directus. + +Here is a preview of how this appears in Directus: + +![New display options showing a select field for column, a checkbox for calculate sum, and text fields for prefix and suffix.](https://product-team.directus.app/assets/bdeff396-adf1-4e4a-a996-f20b51846baa.webp) + +## Build the View + +The `display.vue` file contains the barebones code required for a display to work. The value is imported in the `props` +section, then output in the template: + +```vue + + + +``` + +Before the export, import the vue `ref` object: + +```js +import { ref } from 'vue'; +``` + +Import the new display options in the `props` object: + +```js +props: { + value: { + type: String, + default: null, + }, + column: { // [!code ++] + type: String, // [!code ++] + default: null, // [!code ++] + }, // [!code ++] + sum: { // [!code ++] + type: Boolean, // [!code ++] + default: false, // [!code ++] + }, // [!code ++] + prefix: { // [!code ++] + type: String, // [!code ++] + default: null, // [!code ++] + }, // [!code ++] + suffix: { // [!code ++] + type: String, // [!code ++] + default: null, // [!code ++] + }, // [!code ++] +}, +``` + +Create a `setup` section after the `props` and include the following code: + +```js +setup(props) { + const calculatedValue = ref(0); + + if(props.sum){ + props.value.forEach(item => { + const columns = props.column.split('.'); + + columns.forEach(col => { + item = item[col]; + }); + + calculatedValue.value = calculatedValue.value + parseFloat(item); + }); + } else { + calculatedValue.value = props.value.length; + } + + return { calculatedValue }; +}, +``` + +This code calculates the sum or count of the chosen column. The `props.value` will contain an array of objects with the +fields defined in the scope. Make sure to return the constant at the bottom. + +Update the template to use the `calculateValue` constant, `prefix` and `suffix` instead of the direct value. + +```vue + +``` + +Build the display with the latest changes. + +``` +npm run build +``` + +## Add Display to Directus + +When Directus starts, it will look in the `extensions` directory for any subdirectory starting with +`directus-extension-`, and attempt to load them. + +To install an extension, copy the entire directory with all source code, the `package.json` file, and the `dist` +directory into the Directus `extensions` directory. Make sure the directory with your extension has a name that starts +with `directus-extension`. In this case, you may choose to use `directus-extension-display-sum-count`. + +Restart Directus to load the extension. + +:::info Required files + +Only the `package.json` and `dist` directory are required inside of your extension directory. However, adding the source +code has no negative effect. + +::: + +## Use the Display + +Now the display will appear in the list of available displays for relational fields. Follow these steps to use the new +display: + +1. Create a new relational field and select your new display from the list. +2. After saving the new field, edit the field to configure the display and populate the fields as needed. +3. Save changes and add some data to the table. You will see the relational fields at work in the layout. + +![In a table, a new field called 'Test Junction' is shown. The values are '5 items' and '2 items'.](https://product-team.directus.app/assets/ea21a1b0-0eeb-4f3d-a89c-6ee3b469f8fe.webp) + +## Summary + +With this display, you have learned how to interact with relational fields and values for a display and use options to +customize the output. Be mindful of how much processing is happening inside a display because it will run for every +single row in the table and will impact the performance of Directus. + +## Complete Code + +`index.js` + +```js +import DisplayComponent from './display.vue'; +import { useStores } from '@directus/extensions-sdk'; + +export default { + id: 'directus-display-count-sum', + name: 'Count or Sum Column', + icon: '123', + description: 'Count the related records or display the sum of the select column', + component: DisplayComponent, + options: ({ editing, relations }) => { + const relatedCollection = + relations.o2m?.meta.junction_field != null ? relations.m2o?.related_collection : relations.o2m?.collection; + + const junction_table = relations.o2m?.meta.junction_field != null ? relations.o2m?.collection : null; + const { useFieldsStore } = useStores(); + const fieldsStore = useFieldsStore(); + + if (editing === '+') { + const displayTemplateMeta = { + interface: 'presentation-notice', + options: { + text: 'Please complete the field before attempting to configure the display.', + }, + width: 'full', + }; + } else { + const fields = fieldsStore.getFieldsForCollection(relatedCollection); + const field_choices = []; + + fields.forEach((field) => { + field_choices.push({ + text: field.meta.field, + value: junction_table ? `${relations.o2m.meta.junction_field}.${field.meta.field}` : field.meta.field, + }); + }); + + const displayTemplateMeta = { + interface: 'select-dropdown', + options: { + choices: field_choices, + }, + width: 'full', + }; + } + + return [ + { + field: 'column', + name: 'Choose a column', + meta: displayTemplateMeta, + }, + { + field: 'sum', + type: 'boolean', + name: 'Calulate Sum', + meta: { + interface: 'boolean', + options: { + label: 'Yes', + }, + width: 'half', + }, + }, + { + field: 'prefix', + type: 'string', + name: 'Prefix', + meta: { + interface: 'input', + options: { + font: 'monospace', + }, + width: 'half', + }, + }, + { + field: 'suffix', + type: 'string', + name: 'Suffix', + meta: { + interface: 'input', + options: { + font: 'monospace', + }, + width: 'half', + }, + }, + ]; + }, + types: ['alias', 'string', 'uuid', 'integer', 'bigInteger', 'json'], + localTypes: ['m2m', 'm2o', 'o2m', 'translations', 'm2a', 'file', 'files'], + fields: (options) => { + return [options.column]; + }, +}; +``` + +`display.vue` + +```vue + + + +``` diff --git a/content/tutorials/extensions/understand-available-slots-in-custom-modules.md b/content/tutorials/extensions/understand-available-slots-in-custom-modules.md new file mode 100644 index 00000000..2c9a3c02 --- /dev/null +++ b/content/tutorials/extensions/understand-available-slots-in-custom-modules.md @@ -0,0 +1,344 @@ +--- +id: 46046425-151c-4bb3-bd47-6274b5e24ecb +slug: understand-available-slots-in-custom-modules +title: Understand Available Slots in Custom Modules +authors: [] +--- +This guide follows on [Create a Custom Portal Module](/tutorials/extensions/implement-navigation-in-multipage-custom-modules), where you created +a landing page module. You will learn how to add native sidebar dropdown element, action buttons and search, a split +view window, and layout options using the built-in functions of Directus. These help provide a more coherent experience +from other Directus modules and collections. + +![A module showing title icon and append, action prepend, search box, and several UI buttons in the header](https://product-team.directus.app/assets/1c75b5c3-226d-4e1f-bac4-4568e59e2684.webp) + +## Available Slots + +The private view in Directus has a number of slots available which are empty by default but you can add content using a +template tag. For example: + +```vue + +``` + +The slots available to you in this view are: + +- `headline` +- `title-outer:prepend` +- `title-outer:append` +- `actions` +- `actions:prepend` +- `sidebar` +- `splitView` + +## `headline` + +This is the area above the page title utilized for the breadcrumbs. Use the following code to include a breadcrumb. + +```vue + +``` + +`v-breadcumb` accepts a list of objects which will output the pages in order of the list: + +```js +[ + { + name: 'Home', + to: '/landing-page', + }, +] +``` + +![Two examples of breadcrumbs. One showing just Home, and one adding a second-level page called Hello World](https://product-team.directus.app/assets/8284cf66-1d53-4113-b2f7-532d8d8498c3.webp) + +## `title-outer:prepend` + +You can add content to the left of the title using the **Title Outer Prepend** slot, which Directus uses this slot for +an icon inside a circle. The icon relates to the current page such as the collection icon or the cog icon for settings. + +```vue + +``` + +![An icon is shown to the left of the title and breadcrumbs](https://product-team.directus.app/assets/51558a50-8edf-490b-9c54-5c39a4d3b14d.webp) + +::callout{type="info" title="Styling Icon"} + +The icon is `rounded`, `disabled` and `secondary`. This will keep the same look as the rest of Directus but you can +remove these to customize the look and feel. + +[Learn more about the usage of Directus UI Components ->](/extensions/app-extensions/ui-library) + +:: + +## `title-outer:append` + +You can add content or clickable buttons to the right of the title which is normally used for version control and +bookmarks. In this example, the slot is used for a clickable icon button. + +```vue + +``` + +![An icon is shown to the right of the title and breadcrumbs](https://product-team.directus.app/assets/31933ee3-786f-4615-aad7-277fc9d23b89.webp) + +::callout{type="info" title="Adding Logic"} + +Any functions for button click actions will need to be included in the `setup` and returned to the template. + +:: + +## `actions` + +This slot is located in the header on the right-hand side. You can add content to this area such as clickable buttons +and a search bar. + +```vue + +``` + +You will also need to style the search. I suggest matching the existing one in Directus using this CSS: + +```scss +.v-input.full-width.module-search { + display: flex; + width: 300px; + height: 44px; + + .input { + width: auto; + padding: 0 10px; + overflow: hidden; + color: var(--theme--foreground); + text-overflow: ellipsis; + background-color: var(--theme--background); + border-radius: 22px; + } +} +``` + +![A search box and button with an icon](https://product-team.directus.app/assets/b84c2f45-0db0-4d48-a60d-d8edeb0eef1a.webp) + +::callout{type="info" title="Adding Logic"} + +Include functions for the search and any action buttons in your setup and return them to the template. + +:: + +## `actions:prepend` + +You can add content before the actions slot like page information and selection details but this requires your own CSS +to ensure it outputs on a single line. + +```vue + +``` + +![To the left of the search box is the text 'ACTION PREPEND' broken over two lines and unstyled.](https://product-team.directus.app/assets/42c337a1-61a8-4d75-88fb-85b1e1c9dac6.webp) + +::callout{type="info" title="Limits"} + +This space is quite limited due to the length of the page title and the amount of actions. + +:: + +## `sidebar` + +By default, your sidebar is empty but still present. It’s worth making use of this real estate with various tasks or +information that users have grown to expect in the right side menu. + +In the example below are two dropdown sections using the `sidebar-detail` component. The first section is the +Information section that is used throughout Directus. You can create a `page_description` variable to output information +related to the current page, then update this content within your `setup` whenever a new page is selected. The second +section outputs some custom text. + +```vue + +``` + +![Sidebar shows a title called Information with a close button, and a collapsible section called Sidebar Item with text inside of it.](https://product-team.directus.app/assets/7c814289-e3ed-4c29-a29e-eca48ffb432a.webp) + +::callout{type="info" title="Close Attribute"} + +The close attribute on the first sidebar-detail component changes the chevron icon to a close button and when clicked, +the sidebar collapses. This is highly recommended for usability. + +:: + +## `splitView` + +You can add your own content to the split view slot which normally handles the Live Preview feature of Directus. It +relies on the value "split" to be dynamically added to the `private-view`'s `v-model`. This is normally controlled by an +action button as shown below but can be triggered any way you choose. To use this feature, you need to add some +attributes to the parent `private-view` and create a toggle button. The actions slot is a convenient place: + +```html + + + + + + +``` + +In this example, `livePreviewMode` is toggled between `true` and `false` which can be achieved using the following code +inside your `setup`: + +```js +const livePreviewMode = ref(false); + +function toggleSplitView() { + livePreviewMode.value = !livePreviewMode.value; +} + +return { ..., toggleSplitView, livePreviewMode }; +``` + +Add the following CSS to your style for the default styling of the `SplitView` container. + +```scss +.live-preview { + width: 100%; + height: 100%; + .container { + width: 100%; + height: calc(100% - 44px); + overflow: auto; + } + .iframe-view { + width: 100%; + height: 100%; + overflow: auto; + display: grid; + padding: 48px; + #frame { + width: 100%; + height: 100%; + border: 0; + } + .resize-handle { + overflow: hidden; + box-shadow: 0px 4px 12px -4px rgba(0, 0, 0, 0.2); + } + } +} +``` + +This outputs an eye button in the `actions` slot. When clicked, the `SplitView` container slides in from the right. +Clicking the button again slides the container out to the right. + +![The screen is split into two panes. The left contains the existing module. The right contains a minimally-styled and quite empty split view.](https://product-team.directus.app/assets/9ec199ec-8a5b-4de7-8653-5200df6def9d.webp) + +## Permissions + +Modules don’t have access control like collections do but you can use the permissions of a collection or the admin +rights to limit access to content or the entire module. + +To start using permissions, make sure to import `useStores` from the extensions SDK: + +```js +import { useApi, useStores } from '@directus/extensions-sdk'; + +// At the top of setup(): +const { usePermissionsStore } = useStores(); +const { hasPermission } = usePermissionsStore(); +const permission = hasPermission('page', 'read'); + +// At the bottom of setup(): +return { ..., permission }; +``` + +Is `permission` is true, the user has access. In the template, you can add a new view and the `v-info` component for +when the permission constant is `false`: + +```vue + + + You do not have permission to access this page + + + + + // Existing Content + +``` + +![Large unauthorized error page](https://product-team.directus.app/assets/e04a2d6e-6633-4606-9cb5-709168bbc629.webp) + +::callout{type="info" title="Other Slots"} + +The `navigation`, `actions`, and `sidebar` slots have not been rendered because it’s using a separate `private-view`. +Consider using the `permissions` variable to also prevent the related functions from running as well. This will improve +the performance of your application. + +:: + +You can also create multiple variables that check permissions in different collections or access levels such as +`create`, `read`, `update`, and `delete`. You could use those variables to hide or show various sections in your view +using `v-if`. This works well when you use a collection to feed the content of your module. + +## Summary + +With this guide, you have learned how to expand your module to utilize the various slots available in the private view +and restrict your module using the permissions store. Utilizing the built-in components will provide a consistent +experience for users when moving into your module and ultimately improve usability. diff --git a/content/tutorials/extensions/use-dynamic-values-in-custom-email-templates.md b/content/tutorials/extensions/use-dynamic-values-in-custom-email-templates.md new file mode 100644 index 00000000..e9731f85 --- /dev/null +++ b/content/tutorials/extensions/use-dynamic-values-in-custom-email-templates.md @@ -0,0 +1,210 @@ +--- +id: ca326a34-f92f-4967-96d8-4c9be74a23fd +slug: use-dynamic-values-in-custom-email-templates +title: Use Dynamic Values in Custom Email Templates +authors: [] +--- +Email templates allow you to design your own email look and feel, then populate the template with data from Directus +before sending it to the recipient. This guide will introduce you to the basics of LiquidJS and how to render data +inside your email template. + +Unlike many extensions types, email templates are not included in the Directus Extensions SDK so all you will need to +get started is your favorite text editor and some knowledge of [LiquidJS](https://liquidjs.com/). A useful feature of +LiquidJS is the ability to split the template into blocks such as base, header, footer, content etc then import the base +and overwrite what needed. + +## Use a Base Template + +For the base template, start with the raw essentials: + +```liquid + + + + + + + {% block header %}{% endblock %} + {% block content %}{% endblock %} + {% block footer %}{% endblock %} + + +``` + +You can use a free responsive email template and adjust it to fit your brand. Be aware that images cannot be uploaded +alongside your template and must be hosted. If you host them in Directus, make sure the image permission is set to +public and you use the full URL in the template. + +## Extend the Template + +Once you have your base template, you can create smaller templates with a specific purpose that reference your base +template. + +```liquid +{% layout "my-custom-base" %} +{% block content %} +

Content Here

+{% endblock %} +``` + +In this example, anything inside this content block will replace the content block in the base template. + +## Variables in Templates + +There are a few predefined variables available to email templates. They are: + +| Variable | Description | Default | +| -------------- | ----------- | ---------- | +| `projectName` | String | `Directus` | +| `projectColor` | Hex Color | `#546e7a` | +| `projectLogo` | Image URL | | +| `projectUrl` | URL | | + +Beyond this, you can inject whatever data you need. If you are using an extension, you can include information inside +the data section: + +```js +await mailService.send({ + to: 'name@example.com', + subject: 'This is an example email', + template: { + name: 'my-custom-email-template', + data: { + firstname: user.firstname, + }, + }, +}); +``` + +If you are using Flows, you can also inject data into emails: + +![Type template. Template name 'my custom template' and data is a JSON object with a property named first name and a value of trigger.payload.firstname.](https://product-team.directus.app/assets/36562249-81e0-483e-9943-54c88db33ae1.webp) + +In your template, you can use the `firstname` variable like this: + +```liquid +{% layout "my-custom-base" %} +{% block content %} +

Hi {{ firstname }},

+{% endblock %} +``` + +You may also provide a fallback if this variable is not provided. + +```liquid +{% layout "my-custom-base" %} +{% block content %} +

Hi{% if firstname %}{{ firstname }}{% endif %},

+{% endblock %} +``` + +## Items and For Loops + +You can provide an array of data to a template and use a for loop to render the items. + +```liquid +{% layout "my-custom-base" %} +{% block content %} +
+ {% for item in items %} + + {% endfor %} +
+{% endblock %} +``` + +## Real-World Example + +A team needs a weekly update of how many new subscriptions were created in the last week. The company has a base +template called `example-base` and looks like this: + +![A designed boilerplate email with clear placeholders for header text and content](https://product-team.directus.app/assets/b3415f15-7272-40f3-aea2-e670ce4d22bc.webp) + +Using Flows, create a Schedule trigger with the value `0 8 * * 1` to send the email every Monday at 8am, then add a Read +Data operation with the following filters: + +![A query on the customers collections showing a filter of active users in the last 7 days, aggregated by customer ID and grouped by subscription name.](https://product-team.directus.app/assets/dfb9b9b9-afb2-4b0e-9527-af35ce175a7c.webp) + +The response may look like this: + +```json +[ + { + "subscription": { + "name": "Premium" + }, + "count": { + "customer_id": 10 + } + }, + { + "subscription": { + "name": "Standard" + }, + "count": { + "customer_id": 23 + } + }, + { + "subscription": { + "name": "Free" + }, + "count": { + "customer_id": 143 + } + } +] +``` + +Create an operation to Send an Email and change the type to Template. In the Data field, add the results of `{{$last}}` +to a variable such as `report`. + +![An email showing the custom template and passing in an object with one property - report - and the value of last.](https://product-team.directus.app/assets/db1c8ac4-1173-4907-8e68-a799fcf3ffc8.webp) + +For this report, the template uses a for loop to generate a table of results and capitalize the name for better +appearance: + +```liquid +{% layout "example-base" %} +{% block header %} +

Weekly Subscription Report

+{% endblock %} +{% block content %} + + + + + + + + + {% for item in report %} + + + + + {% endfor %} + +
SubscriptionNew Members
{{ item.subscription.name | capitalize }}{{ item.count.customer_id }}
+{% endblock %} +``` + +## Add Template to Directus + +Custom email templates are stored in the configured `EMAIL_TEMPLATES_PATH` location, which defaults to the `./templates` +folder relative to your project. + +1. Inside the templates directory, copy and paste the required liquid files for your email. These cannot go in a + subdirectory. +2. Restart Directus. + +The template is now available to Directus. + +Make sure to keep a reference of what templates you have available because Directus will not provide a selection list +for templates. You must type the filename of the template without the extension. + +## Summary + +With this guide you have learned how to create your own email templates using LiquidJS and how to include data from +Directus in your emails. Make sure to read up on the various +[documentation about LiquidJS](https://shopify.github.io/liquid/basics/introduction/) to see what it’s fully capable of. diff --git a/content/tutorials/extensions/use-npm-packages-in-custom-operations.md b/content/tutorials/extensions/use-npm-packages-in-custom-operations.md new file mode 100644 index 00000000..e6da2a80 --- /dev/null +++ b/content/tutorials/extensions/use-npm-packages-in-custom-operations.md @@ -0,0 +1,175 @@ +--- +id: 9e2efc14-27b4-4fa0-9632-71d922947fa2 +slug: use-npm-packages-in-custom-operations +title: Use npm Packages in Custom Operations +authors: [] +--- +This guide will show you how to expose an NPM package as a custom operation in Flows. We will use `lodash` here, but the +process should be the same for any package. + +## Install Dependencies + +Open a console to your preferred working directory and initialize a new extension, which will create the boilerplate +code for your operation. + +```shell +npx create-directus-extension@latest +``` + +A list of options will appear (choose operation), and type a name for your extension (for example, +`directus-operation-lodash`). For this guide, select JavaScript. + +Now the boilerplate has been created, install the lodash package, and then open the directory in your code editor. + +```shell +cd directus-operation-lodash +npm install lodash +``` + +## Build the Operation UI + +Operations have 2 parts - the `api.js` file that performs logic, and the `app.js` file that describes the front-end UI +for the operation. + +Open `app.js` and change the `id`, `name`, `icon`, and `description`. + +```js +id: 'operation-lodash-camelcase', +name: 'Lodash Camel Case', +icon: 'electric_bolt', +description: 'Use Lodash Camel Case Function.', +``` + +Make sure the `id` is unique between all extensions including ones created by 3rd parties - a good practice is to +include a professional prefix. You can choose an icon from the library [here](https://fonts.google.com/icons). + +With the information above, the operation will appear in the list like this: + +Custom operation featuring the previously set icon, name and description + +### Accepting Inputs + +The default `options` object in `app.js` has a single text interface called `text`: + +```js +options: [ + { + field: 'text', + name: 'Text', + type: 'string', + meta: { + width: 'full', + interface: 'input', + }, + }, +], +``` + +If your NPM package function requires multiple inputs, you can add them here. The `overview` array defines what is shown +on the card when the operation is not selected. + +## Build the API Function + +Open the `api.js` file and update the `id` to match the one used in the `app.js` file. Import from your NPM package, +execute your logic, and finish by return any data from the operation into the data chain. + +```js +import { defineOperationApi } from '@directus/extensions-sdk'; +import { camelCase } from 'lodash'; // [!code ++] + +export default defineOperationApi({ + id: 'operation-lodash-camelcase', + handler: ({ text }) => { + console.log(text); // [!code --] + return { // [!code ++] + text: camelCase(text) // [!code ++] + }; // [!code ++] + }, +}); +``` + +Both files are now complete. Build the operation with the latest changes. + +``` +npm run build +``` + +## Add Operation to Directus + +When Directus starts, it will look in the `extensions` directory for any subdirectory starting with +`directus-extension-`, and attempt to load them. + +To install an extension, copy the entire directory with all source code, the `package.json` file, and the `dist` +directory into the Directus `extensions` directory. Make sure the directory with your extension has a name that starts +with `directus-extension`. In this case, you may choose to use `directus-extension-operation-lodash`. + +Restart Directus to load the extension. + +::callout{type="info" title="Required files"} + +Only the `package.json` and `dist` directory are required inside of your extension directory. However, adding the source +code has no negative effect. + +:: + +## Use the Operation + +In the Directus Data Studio, open the Flows section in Settings. Create a new flow with a manual trigger. Select the +collection(s) to include this button on. + +Add a new step (operation) by clicking the tick/plus on the card, then choose **Lodash Camel Case** from the list. In +the text box, you can use any of the values from the trigger, or a hardcoded string. + +Save the operation, save the Flow, and then trigger the flow by opening the chosen collection, then trigger the manual +flow from the right side toolbar. + +## Summary + +This operation takes an NPM package (`lodash`) and exposes it as a custom operation extension. You can use the same +technique for other packages to extend on the features of Directus Flows. + +## Complete Code + +`app.js` + +```js +export default { + id: 'operation-lodash-camelcase', + name: 'Lodash Camel Case', + icon: 'electric_bolt', + description: 'Use Lodash Camel Case Function.', + overview: ({ text }) => [ + { + label: 'Text', + text: text, + }, + ], + options: [ + { + field: 'text', + name: 'Text', + type: 'string', + meta: { + width: 'full', + interface: 'input', + }, + }, + ], +}; +``` + +`api.js` + +```js +import { defineOperationApi } from '@directus/extensions-sdk'; +import { camelCase } from 'lodash'; + +export default defineOperationApi({ + id: 'operation-lodash-camelcase', + handler: ({ text }) => { + return { + text: camelCase(text), + }; + }, +}); +``` diff --git a/content/tutorials/extensions/validate-phone-numbers-with-twilio-in-a-custom-hook.md b/content/tutorials/extensions/validate-phone-numbers-with-twilio-in-a-custom-hook.md new file mode 100644 index 00000000..a47e2480 --- /dev/null +++ b/content/tutorials/extensions/validate-phone-numbers-with-twilio-in-a-custom-hook.md @@ -0,0 +1,176 @@ +--- +id: 20e049fb-529f-4a71-8a34-1e4e61d34af1 +slug: validate-phone-numbers-with-twilio-in-a-custom-hook +title: Validate Phone Numbers with Twilio in a Custom Hook +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- +Hooks allow you to trigger your own code when events are emitted from Directus. This guide will show you how to prevent +a record from saving if a phone number is not valid using the Twilio Lookup API. + +## Install Dependencies + +Open a console to your preferred working directory and initialize a new extension, which will create the boilerplate +code for your display. + +```shell +npx create-directus-extension@latest +``` + +A list of options will appear (choose hook), and type a name for your extension (for example, +`directus-hook-phone-validation`). For this guide, select JavaScript. + +Now the boilerplate has been created, install the twilio package, and then open the directory in your code editor. + +``` +cd directus-hook-phone-validation +npm install twilio @directus/errors +``` + +## Build the Hook + +Create a collection called Customers with a text field called `phone_number`. This hook will be used to validate the +item when a record is saved. + +Open the `index.js` file inside the src directory. Delete all the existing code and start with the import of the Twilio +library and the invalid payload error: + +```js +import twilio from 'twilio'; +import { InvalidPayloadError } from "@directus/errors"; +``` + +Create an initial export. This hook will need to intercept the save function with `filter` and include `env` for the +environment variables: + +```js +export default ({ filter }, { env }) => {}; +``` + +Next, capture the `items.create` stream using `filter` and include the `input` and `collection` associated with the +stream: + +```js +filter('items.create', async (input, { collection }) => {}); +``` + +When using filters and actions, it’s important to remember this will capture **all** events so you should set some +restrictions. Inside the filter, exclude anything that’s not in the customers collection. + +```js +filter('items.create', async (input, { collection }) => { + if (collection !== 'customers') return input; // [!code ++] +}); +``` + +Prevent saving an event if the `phone_number` is `undefined`, by reporting this back to the user. Add this line +underneath the collection restriction. + +```js +if (input.phone_number === undefined) { + throw new InvalidPayloadError({ reason: 'No Phone Number has been provided' }); +} +``` + +Set up your Twilio phone number lookup: + +```js +const accountSid = env.TWILIO_ACCOUNT_SID; +const authToken = env.TWILIO_AUTH_TOKEN; +const client = new twilio(accountSid, authToken); + +client.lookups.v2 + .phoneNumbers(input.phone_number) + .fetch() + .then((phoneNumber) => {}); +``` + +`env` looks inside the Directus environment variables for `TWILIO_ACCOUNT_SID` and `TWILIO_AUTH_TOKEN`. In order to +start using this hook, these variables must be added to the `.env` file. + +The lookup is performed with the `phone_number` from the input object. + +Inside the callback, provide a response for when the phone number is invalid, otherwise continue as normal. Twilio +provides a very helpful boolean response called `valid`. + +Use this to throw an error if `false`, or return the input to the stream and end the hook if `true`: + +```js +client.lookups.v2 + .phoneNumbers(input.phone_number) + .fetch() + .then((phoneNumber) => { + if (!phoneNumber.valid) { // [!code ++] + throw new InvalidPayloadError({ reason: 'Phone Number is not valid' }); // [!code ++] + } // [!code ++] +// [!code ++] + return input; // [!code ++] + }); +``` + +Build the hook with the latest changes. + +``` +npm run build +``` + +## Add Hook to Directus + +When Directus starts, it will look in the `extensions` directory for any subdirectory starting with +`directus-extension-`, and attempt to load them. + +To install an extension, copy the entire directory with all source code, the `package.json` file, and the `dist` +directory into the Directus `extensions` directory. Make sure the directory with your extension has a name that starts +with `directus-extension`. In this case, you may choose to use `directus-extension-hook-phone-validation`. + +Ensure the `.env` file has `TWILIO_ACCOUNT_SID` and `TWILIO_AUTH_TOKEN` variables. + +Restart Directus to load the extension. + +::callout{type="info" title="Required files"} + +Only the `package.json` and `dist` directory are required inside of your extension directory. However, adding the source +code has no negative effect. + +:: + +## Summary + +With Twilio now integrated in this hook, whenever a record attempts to save for the first time, this hook will validate +the phone number with Twilio and respond with true or false. If false, the record is prevented from saving until a valid +phone number is supplied. Now that you know how to interact with the Twilio API, you can investigate other endpoints +that Twilio has to offer. + +## Complete Code + +`index.js` + +```js +import { InvalidPayloadError } from "@directus/errors"; + +export default ({ filter }, { env }) => { + filter('items.create', async (input, { collection }) => { + if (collection !== 'customers') return input; + + if (input.phone_number === undefined) { + throw new InvalidPayloadError({ reason: 'No Phone Number has been provided' }); + } + + const accountSid = env.TWILIO_ACCOUNT_SID; + const authToken = env.TWILIO_AUTH_TOKEN; + const client = new twilio(accountSid, authToken); + + client.lookups.v2 + .phoneNumbers(input.phone_number) + .fetch() + .then((phoneNumber) => { + if (!phoneNumber.valid) { + throw new InvalidPayloadError({ reason: 'Phone Number is not valid' }); + } + + return input; + }); + }); +}; +``` diff --git a/content/tutorials/getting-started/create-reusable-blocks-with-many-to-any-relationships.md b/content/tutorials/getting-started/create-reusable-blocks-with-many-to-any-relationships.md new file mode 100644 index 00000000..64bc0e49 --- /dev/null +++ b/content/tutorials/getting-started/create-reusable-blocks-with-many-to-any-relationships.md @@ -0,0 +1,376 @@ +--- +id: 9176d1b6-c530-44f3-92b3-71f35ae80902 +slug: create-reusable-blocks-with-many-to-any-relationships +title: Create Reusable Blocks with Many-to-Any Relationships +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- +Many websites are made of common, repeating sections or groups of content. + +A common use case when using Directus as a Headless CMS is creating individual blocks that can be re-used on many +different pages. + +This enables your content team create unique page layouts from re-usable components. + +To achieve this, you will: + +- Map your data model +- Create individual page blocks +- Create your page collection +- Build pages with blocks +- Fetch page data from the API +- Learn tips to work with your front-end + +## How-To Guide + +:: callout {type="info" title="Requirements"} + +You’ll need to have either a Directus Cloud project configured and running or a self-hosted instance of Directus up and +running. + +:: + +### Map Out Your Data Model + +Before creating Collections inside Directus, it’s helpful to map out your data model (schema). + +Consider this sample page below. + +![Website wireframe that shows three different sections. A hero block with a headline and image, a group of content cards, and a block of rich text.](https://product-team.directus.app/assets/a979b0c2-08e8-4813-9ef2-c8bdd1cc2a3e.webp) + +There are three main “blocks” that could be broken down into separate components. + +1. A hero block at the top of the page that includes a strong headline, an image, and some copy with a call to action. +2. A block of cards that could link out to blog posts or other content. +3. A block of rich text or HTML content. + +Let’s break down the data model for each section. + +--- + +![Simple wireframe of a hero section on a sample website.](https://product-team.directus.app/assets/21ee8ffb-89c7-48b8-8817-6c9342af8f62.webp) + +**Hero** + +- `headline` - short text that grabs attention (string) +- `content` - longer text that explains the product or service (string) +- `buttons` - group of buttons that link out (array of objects) + - `label` - call to action like Learn More (string) + - `href` - URL to link to (string) + - `variant` - type of button like 'default', 'primary’, or 'outline' (string) +- `image` - supporting image (file) + +--- + +![Simple wireframe of a group of content cards on a sample website.](https://product-team.directus.app/assets/766eb3aa-31e5-4fc8-b8c4-c02bca94a406.webp) + +**Card Group** + +- `headline` - short text that describes the section (string) +- `content` - supporting text (textarea) +- `card` - array of objects + + - `image` - featured image of a blog post or content (file) + - `content` - text summary of a blog post or content (string) + +--- + +![Simple wireframe of a block of rich text on a sample website.](https://product-team.directus.app/assets/3ec7c067-0ca5-46dd-860a-617a6fc94bc2.webp) + +**Rich Text** + +- `headline` - short text that describes the content (string) +- `content` - rich text / HTML content (string) + +--- + +Now let's create a Collection for each inside Directus. + +::: tip + +To keep things organized, we recommend that you namespace each collection with a prefix like `block`. + +::: + +### Create the Rich Text Block + +1. [Create a new Collection](/data-modeling/collections) named `block_richtext` and add the + following fields. + + ```md + block_richtext + + - id (uuid) + - headline (Type: String, Interface: Input) + - content (Type: Text, Interface: WYSIWYG) + ``` + +### Create the Hero Block + +2. [Create a new Collection](/data-modeling/collections) named `block_hero` and add the following + fields. + + ```md + block_hero + + - id (uuid) + - headline (Type: String, Interface: Input) + - content (Type: Text, Interface: WYSIWYG) + - buttons (Type: JSON, Interface: Repeater) + - label (Type: String, Interface: Input) + - href (Type: String, Interface: Input) + - variant (Type: String, Interface: Input) + - image (Type: uuid / single file, Interface: Image) + ``` + +### Create the Card Group Block + +1. [Create a new Collection](/data-modeling/collections) named `block_cardgroup` and add the + following fields. + + ```md + block_cardgroup + + - id (uuid) + - headline (Type: String, Interface: Input) + - content (Type: Text, Interface: WYSIWYG) + - group_type (Type: String, Interface: Radio, Options: ['posts', 'custom'] ) + - posts (Type: M2M, Conditions: Hide Field on Detail IF group_type === 'posts', Related Collection: posts) + - cards (Type: O2M, Conditions: Hide Field on Detail IF group_type === 'custom', Related Collection: block_cardgroup_cards) + ``` + +### Create the Pages Collection + +4. [Create a new Collection](/data-modeling/collections) named `pages` and add the following + fields. + + ```md + pages + + - id (uuid) + - title (Type: String, Interface: Input) + - slug (Type: String, Interface: Input, URL Safe: true) + ``` + +### Configure a Many-To-Any (M2A) Relationship Inside the `pages` Collection. + +5. Create a new Builder (M2A) field inside the `pages` data model. + + ![In the data model settings for the pages collection, a new Many-To-Any relationship is being created. The key is named blocks. There are 3 related collections selected - Block Cardgroup, Block Hero, and Block Rich text.](https://product-team.directus.app/assets/611b2dcb-b30a-427a-8876-10fa585a5dac.webp) + + a. For the **Key**, use `blocks`. + + b. For **Related Collections**, choose the following: + + - Hero + - Gallery / Cards + - Article + + c. Enter the Advanced Field Creation Mode. In the Relationship section add a Sort Field (you can just type the word + 'sort'). This will allow you to sort the blocks in the editor. + + d. Save the field. Directus will create a new, hidden + [junction collection](/data-modeling/relationships) for you automatically. + +::: tip + +If you want more control over the name of the junction table and its fields, use the Continue in Advanced Field Creation +Mode option. + +::: + +### Create Your Page Content + +6. [Create a new item](/content/editor) in the `pages` collection + + + + a. Enter the page **Title** and **Slug**. + + b. Under the Blocks field, click Create New and choose the collection type to create new blocks. Or click Add + Existing to re-use existing blocks from other pages. Use the drag handle on the left side of each item to re-order + blocks. + +### Fetching Page Data From the APIs + +Next, you'll want to access these with the API. If you try to use `/items/pages` then `blocks` returns an array of IDs. +Instead, you'll want to add a [field parameter](/data-modeling/relationships) to get nested relational data. + +::callout{type="info"} + +Study the [Global Query Parameters > Fields > Many-To-Any](/data-modeling/relationships) article to learn +how to properly fetch nested relational M2A data without over-fetching data that you might not need. + +:: + +**Sample Request** + +```js +import { createDirectus, rest, readItems } from '@directus/sdk'; + +// Initialize the SDK. +const directus = createDirectus('https://directus.example.com').with(rest()); + +// Write some code here in your front-end framework that gets the slug from the current URL. +const slug = 'the-ultimate-guide-to-rabbits'; + +// Fetch page data using the SDK. +const pages = await directus.request( + readItems('pages', { + filter: { + slug: { _eq: slug }, + }, + fields: [ + '*', + { + blocks: [ + '*', + { + item: { + block_hero: ['*'], + block_cardgroup: ['*'], + block_richtext: ['*'], + }, + }, + ], + }, + ], + limit: 1, + }) +); +const page = page[0]; +``` + +::callout{type="details" title="Toggle Open to See Sample Response"} + +```json +{ + "data": [ + { + "id": "079bf3c0-6f73-4725-b4c3-9d1a6cb58a05", + "status": "published", + "date_created": "2023-02-08T20:54:15", + "user_updated": "9fdd1ca5-982e-422d-bced-640e3a98a339", + "date_updated": "2023-02-13T17:36:38", + "user_created": "9fdd1ca5-982e-422d-bced-640e3a98a339", + "title": "The Ultimate Guide to Rabbits", + "slug": "the-ultimate-guide-to-rabbits", + "blocks": [ + { + "id": 1, + "pages_id": "079bf3c0-6f73-4725-b4c3-9d1a6cb58a05", + "sort": 1, + "collection": "block_hero", + "item": { + "id": "1fa9065d-39a0-479a-a8ae-9ccd31429c98", + "headline": "Learn everything about rabbits", + "content": "This guide will teach you everything you need to know about those wascally wabbits.", + "buttons": [ + { + "label": "Learn More", + "href": "learn-more", + "variant": "primary" + } + ], + "image": "12e02b82-b4a4-4aaf-8ca4-e73c20a41c26" + } + }, + { + "id": 3, + "pages_id": "079bf3c0-6f73-4725-b4c3-9d1a6cb58a05", + "sort": 2, + "collection": "block_cardgroup", + "item": { + "id": "52661ac6-f134-4fbf-9084-17cf3fc4e256", + "headline": "Our Best Blog Posts on Rabbits", + "content": "Here's the latest and greatest from our rabid writers.", + "group_type": "posts", + "cards": [], + "posts": [1, 2, 3] + } + }, + { + "id": 2, + "pages_id": "079bf3c0-6f73-4725-b4c3-9d1a6cb58a05", + "sort": 3, + "collection": "block_richtext", + "item": { + "id": "6c5df396-be52-4b1c-a144-d55b229e5a34", + "headline": "The Benefits of Rabbits", + "content": "

Rabbits are a great source of environmental benefit. They help to keep grasslands and other ecosystems in check. Rabbits are herbivores, meaning they eat only plants, which helps to keep vegetation in balance. Additionally, rabbits are crucial to the food chain, providing sustenance for predators in their environment.

\n

Rabbits also help to improve the quality of soil by digging burrows and depositing their waste in them. This helps to aerate the soil, improving its quality and allowing for better plant growth. Additionally, the waste from rabbits is a rich source of nutrients for plants and other animals in the area. This helps to keep the soil healthy and support the overall ecosystem.

" + } + } + ] + } + ] +} +``` + +:: + +### Structuring Your Front End + +We have [integration guides](/tutorials) for many popular front-end frameworks. But there are far too +many to cover in this recipe. + +Here’s some general advice on how to structure your front end to display page blocks / Many-To-Any (M2A) Relationship +data. + +**Create a single component for each individual page_builder collection.** + +- Hero +- Gallery +- Article + +**Create a dynamic route that does the following:** + +- Imports your page builder components. +- Calls your `pages` collection via the API. Add a filter rule to match the requested page’s `slug`. +- Maps all the possible `page.pages_blocks.collection` names to your page block components. +- Loops through the `page.blocks` array and passes the correct data (props) that each page_builder component needs to + render properly. + +## Final Tips + +This guide has quite a few steps and involves several different collections. Here are some helpful tips to consider. + +### Study the API Response + +To prevent frustration when building your front-end, it’s important you understand the structure of the JSON data that +Directus returns for Many To Any (M2A) relationships. + +- Complete your page builder data model inside Directus. +- Add some content. +- Test your API calls. + +### Check Your Permissions + +If you notice you aren't receiving the data that you expect, check the Permissions settings for your Public or chosen +role. You'll have to enable Read access for each collection using in the Pages > Blocks Many-To-Any field. + +### Use Typescript + +We recommend adding types for each of your different collections to your frontend framework. + +### Organize Your Data Model with Folders + +Consider using [data model folders](/data-modeling/collections) to keep things nicely organized and +your collections easy to find. + +![In the data model settings, a folder is highlighted. It is named blocks. There is a caption that reads "Data Model Folders help you keep collections well-organized and easy to find."](https://product-team.directus.app/assets/8eee6c8d-cdcc-40b1-ab54-93911f8d494f.webp) + +### Use Translations for Collection Names + +When [setting up Collections](/data-modeling/collections) within your data model, use the Collection +Naming Translations to create names that easier for the Data Studio users to understand. + +![In the data model settings for the hero collection a section is highlighted. It reads "Collection naming translations" with a single item called "Hero".](https://product-team.directus.app/assets/0e6e47ee-554d-4a9c-8a79-d704eaecb121.webp) + +For example: + +- `block_richtext` becomes `Rich Text` +- `block_hero` becomes `Hero` or `Hero Block` +- `block_cardgroup` becomes `Card Group` diff --git a/content/tutorials/getting-started/fetch-data-from-directus-in-android-with-kotlin.md b/content/tutorials/getting-started/fetch-data-from-directus-in-android-with-kotlin.md new file mode 100644 index 00000000..230007ae --- /dev/null +++ b/content/tutorials/getting-started/fetch-data-from-directus-in-android-with-kotlin.md @@ -0,0 +1,796 @@ +--- +id: 3444d6c8-f336-4d0b-aa02-bc665f4ad3b5 +slug: fetch-data-from-directus-in-android-with-kotlin +title: Fetch Data from Directus in Android with Kotlin +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- +In this tutorial, you will learn how to set up an Android project with Kotlin and Directus. We'll cover initializing the project, creating a helper library for the Directus SDK, setting up global configurations, and creating dynamic pages, including a blog listing and a blog single view. + +## Before You Start +You will need: +- A Directus project - follow our [quickstart guide](/getting-started/create-a-project) if you don't already have one. +- knowledge of Kotlin +- [Android Studio](https://developer.android.com/studio) installed on your computer + + +## Initialize a Project + +Open your Android Studio and create a new project by clicking **Start a new Android Studio project** from the welcome screen, or click on **File -> New -> New Project** if you created a project on Android Studio before. Select `Empty Activity`, name your project `DirectusApp` and, click the **Finish** button. + +Open your `build.gradule` module file and add the following dependencies in the dependencies section: + +```groovy +dependencies { + // [!code ++] + implementation("androidx.navigation:navigation-fragment-ktx:2.3.5") + implementation("androidx.navigation:navigation-ui-ktx:2.3.5") + implementation("com.squareup.retrofit2:retrofit:2.9.0") + implementation("com.squareup.retrofit2:converter-gson:2.9.0") + implementation("org.jetbrains:markdown:0.7.3") +} +``` + +Once the changes are made, a modal will appear suggesting you sync the project. Click on the **Sync** button to install the dependencies. + +## Create a Helper Library for the Directus SDK + +Right-click on the `com.example.directusapp` directory and select **New -> New Kotlin FIle/Class -> File** and name it `Constants`. This is where you will define all the constants for this app like your Directus URL. Add the code to the `Constants.kt` file: + +```kotlin +package com.example.directusapp + +object Constants { + const val BASE_URL = "https://directus.example.com" +} +``` + +Then right-click on the `com.example.directusapp` directory and select **New -> Package** to create a network package. In your network package, create a new Kotlin file named `DirectusHelper` and define the Directus API service: + +```kotlin +package com.example.directusapp.network +import com.example.directusapp.Constants +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +interface DirectusApiService { + companion object { + + fun create(): DirectusApiService { + val retrofit = Retrofit.Builder() + .baseUrl(Constants.BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .build() + return retrofit.create(DirectusApiService::class.java) + } + } +} +``` + +The above code defines a `DirectusAPIService` that includes a `create()` function to set up a Retrofit instance. This function creates a `Retrofit.Builder` object, imports the `Constants` object and sets the base URL using `baseUrl(Constants.BASE_URL)`, adds the `GsonConverterFactory` for handling JSON data conversion, builds the Retrofit instance with `build()`, and creates an implementation of the `DirectusApiService` interface using `create(DirectusApiService::class.java)`. + +Similarly to the network package, create a model and create a new Kotlin file named `Models` in the model package and define the app models: + +```kotlin +package com.example.directusapp.model + +data class Author( + val id: Int, + val name: String, +) + +data class Blog( + val id: Int, + val title: String, + val content: String, + val dateCreated: String, + val author: Author +) + +data class Page( + val slug: String, + val title: String, + val content: String, +) + +data class Global( + val id: Int, + val title: String, + val description: String, +) + +data class BlogResponse( + val data: Blog +) + +data class BlogsResponse( + val data: List +) + +data class PageResponse( + val data: List +) + +data class GlobalResponse( + val data: Global +) +``` + +The above code defines data classes for different Directus collections and their respective response models. + +## Using Global Metadata and Settings + +In your Directus Data Studio, click on **Settings -> Data Model** and create a new collection named `global`. Select 'Treat as a single object' under the Singleton option because this will only have a single entry containing the app's global metadata. Create two text input fields - one with the key `title` and one with `description`. + +Dirctus collections are not accessible to the public by default, click on **Settings -> Access Policies -> Public** and give **Read** access to the `global` collection. + +Then click on the content module and select the global collection. A collection would normally display a list of items, but since this is a singleton, it will launch directly into the one-item form. Enter information in the title and description field and hit save. + +![Creating global collection](https://product-team.directus.app/assets/d8c92df8-63c3-404e-8e0f-b086d27d960a.webp) + +Update the code in your `DirectusHelper.kt` file in your network package to define a Get endpoint to fetch the global metadata from Directus: + +```kotlin +package com.example.directusapp.network +import com.example.directusapp.Constants +import com.example.directusapp.model.GlobalResponse +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.GET + +interface DirectusApiService { + @GET("items/global") + suspend fun getGlobal(): GlobalResponse + + companion object { + + fun create(): DirectusApiService { + val retrofit = Retrofit.Builder() + .baseUrl(Constants.BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .build() + return retrofit.create(DirectusApiService::class.java) + } + } +} +``` + +Right-click on your ui package, and create a new Kotlin file named `HomePageScreen`: + +```kotlin +package com.example.directusapp.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import com.example.directusapp.model.GlobalResponse +import com.example.directusapp.network.DirectusApiService + +@Composable +fun BlogHomeScreen() { + var globalResponse by remember { mutableStateOf(null) } + var errorMessage by remember { mutableStateOf(null) } + + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + scope.launch { + try { + val apiService = DirectusApiService.create() + globalResponse = apiService.getGlobal() + + } catch (e: Exception) { + errorMessage = e.message + } + } + } + + if (errorMessage != null) { + Text(text = "Error: $errorMessage", color = MaterialTheme.colorScheme.error) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + globalResponse?.let { response -> + Text(text = response.data.title, style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = response.data.description, style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} +``` + +Update your `MainActivity` class in the `MainActivity.kt` file to render the `BlogHomeScreen` screen. + +```kotlin +package com.example.directusapp + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.example.directusapp.ui.BlogHomeScreen +import com.example.directusapp.ui.theme.DirectusAppTheme + +class directusapp : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + DirectusAppTheme { + // A surface container using the 'background' color from the theme + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + BlogHomeScreen() + } + } + } + } +} +``` + +Update your `AndroidManifest.xml` file in `app/src/main/` directory and grant your application access to the internet. + +```xml + + + + + + + + + + + + + + // [!code ++] + +``` + +Now click on the Run icon at the top of your Android Studio Window to run the application. + +![Showing metadata from Directus global collection](https://product-team.directus.app/assets/1e118359-6727-45c4-90e3-17c412ab0ef2.webp) + +## Creating Pages With Directus +Create a new collection called `pages` - give it a text input field called `slug`, which will correlate with the URL for the page. For example, `about` will later correlate to the page `localhost:3000/about`. + +Create a text input field called `title` and a `WYSIWYG` input field called `content`. In Access Policies, give the Public role read access to the new collection. Create 3 items in the new collection - [here's some sample data](https://github.com/directus-labs/getting-started-demo-data). + +Then update your `DirectusHelper` file to add another endpoint to fetch the page data from Directus: + +```kotlin +package com.example.directusapp.network +import com.example.directusapp.Constants +import com.example.directusapp.model.GlobalResponse +import com.example.directusapp.model.PageResponse +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.GET + + +interface DirectusApiService { + @GET("items/global") + suspend fun getGlobal(): GlobalResponse + + @GET("items/pages") + suspend fun getPages(): PageResponse + + companion object { + + fun create(): DirectusApiService { + val retrofit = Retrofit.Builder() + .baseUrl(Constants.BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .build() + return retrofit.create(DirectusApiService::class.java) + } + } +} +``` + +Update your `BlogHomeScreen` to display the pages data: + +```kotlin +package com.example.directusapp.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import kotlinx.coroutines.launch +import androidx.compose.foundation.lazy.LazyColumn +import com.example.directusapp.ui.MarkdownView + +@Composable +fun BlogHomeScreen(navController: NavController) { + var globalResponse by remember { mutableStateOf(null) } + var pagesResponse by remember { mutableStateOf(null) } + + var errorMessage by remember { mutableStateOf(null) } + + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + scope.launch { + try { + val apiService = DirectusApiService.create() + globalResponse = apiService.getGlobal() + pagesResponse = apiService.getPages() + + } catch (e: Exception) { + errorMessage = e.message + } + } + } + + if (errorMessage != null) { + Text(text = "Error: $errorMessage", color = MaterialTheme.colorScheme.error) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + pagesResponse?.let { response -> + Text(text = response.data[0].title, style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.height(8.dp)) + MarkdownView(markdownText = response.data[0].content.trimIndent()) + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} +``` + +Create another file named `MarkdownView` and create a `MarkdownView` composable function to render the `WYSIWYG` content from the collection of the pages: + +```kotlin +package com.example.directusapp.ui + +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.runtime.Composable +import androidx.compose.ui.viewinterop.AndroidView +import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor +import org.intellij.markdown.html.HtmlGenerator +import org.intellij.markdown.parser.MarkdownParser + +@Composable +fun MarkdownView(markdownText: String) { + val htmlContent = markdownToHtml(markdownText) + + AndroidView(factory = { context -> + WebView(context).apply { + webViewClient = WebViewClient() + loadDataWithBaseURL(null, htmlContent, "text/html", "UTF-8", null) + } + }, update = { + it.loadDataWithBaseURL(null, htmlContent, "text/html", "UTF-8", null) + }) +} + +fun markdownToHtml(markdownText: String): String { + val flavour = GFMFlavourDescriptor() + val parser = MarkdownParser(flavour) + val parsedTree = parser.buildMarkdownTreeFromString(markdownText) + val htmlGenerator = HtmlGenerator(markdownText, parsedTree, flavour) + return htmlGenerator.generateHtml() +} +``` + +Refresh the app to see the changes. + +![Displaying the pages](https://product-team.directus.app/assets/c9ded927-e28a-43da-9432-31d383e54da0.webp) + +## Creating Blog Posts With Directus +Back to your Directus Data studio, create a collection to store and manage your user's blog posts. First, create a collection named `author` with a single text input field named `name`. Add one or more authors to the collection. + +Create another collection called `blogs` and add the following fields: + +- `slug`: Text input field +- `title`: Text input field +- `content`: WYSIWYG input field +- `image`: Image relational field +- `author`: Many-to-one relational field with the related collection set to authors + +Add 3 items in the posts collection - [here's some sample data](https://github.com/directus-labs/getting-started-demo-data). + + +Then update your `DirectusHelper` file to add another endpoint to fetch the blog data: + +```kotlin +package com.example.directusapp.network +import com.example.directusapp.Constants +import com.example.directusapp.model.BlogsResponse +import com.example.directusapp.model.GlobalResponse +import com.example.directusapp.model.PageResponse +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.GET + + +interface DirectusApiService { + @GET("items/global") + suspend fun getGlobal(): GlobalResponse + + @GET("items/pages") + suspend fun getPages(): PageResponse + + @GET("items/blogs?fields=*,author.name") + suspend fun getBlogs(): BlogsResponse + + companion object { + + fun create(): DirectusApiService { + val retrofit = Retrofit.Builder() + .baseUrl(Constants.BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .build() + return retrofit.create(DirectusApiService::class.java) + } + } +} +``` + +Update your `BlogHomeScreen` to render the blogs: + +```kotlin +package com.example.directusapp.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import kotlinx.coroutines.launch +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.ui.graphics.BlendMode.Companion.Screen +import com.example.directusapp.ui.MarkdownView +import com.example.directusapp.model.GlobalResponse +import com.example.directusapp.model.PageResponse +import com.example.directusapp.model.BlogsResponse +import com.example.directusapp.model.Blog +import com.example.directusapp.network.DirectusApiService + +@Composable +fun BlogHomeScreen(navController: NavController) { + var blogsResponse by remember { mutableStateOf(null) } + var pagesResponse by remember { mutableStateOf(null) } + var globalResponse by remember { mutableStateOf(null) } + var errorMessage by remember { mutableStateOf(null) } + + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + scope.launch { + try { + val apiService = DirectusApiService.create() + blogsResponse = apiService.getBlogs() + pagesResponse = apiService.getPages() + globalResponse = apiService.getGlobal() + println(pagesResponse) + println(globalResponse) + + } catch (e: Exception) { + errorMessage = e.message + } + } + } + + if (errorMessage != null) { + Text(text = "Error: $errorMessage", color = MaterialTheme.colorScheme.error) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + // Display the page title and content + pagesResponse?.let { response -> + Text(text = response.data[0].title, style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.height(8.dp)) + MarkdownView(markdownText = response.data[0].content.trimIndent()) + Spacer(modifier = Modifier.height(16.dp)) + } + Text(text = "Blog Posts", style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.height(10.dp)) + blogsResponse?.let { response -> + LazyColumn { + items(response.data.size) { index -> + BlogItem(response.data[index], navController) + } + } + } + } + } +} + +@Composable +fun BlogItem(blog: Blog, navController: NavController) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { + navController.navigate(Screen.BlogDetail.createRoute(blog.id)) + println(blog.id) + } + .padding(16.dp) + ) { + + Text(text = "${blog.title} - ${blog.author}", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = blog.dateCreated, style = MaterialTheme.typography.bodyMedium) + } +} +``` + +Refresh your application to see the updates. + +![Display the blog listing page](https://product-team.directus.app/assets/7e427b9d-2f8e-4a7e-a3fb-86d81afcddd8.webp) + +## Create Blog Post Listing +Each blog post links to a screen that does not yet exist. Right-click the `ui` package and create a new Kotlin file named `BlogDetailScreen`: + +```kotlin +package com.example.directusapp.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.example.directusapp.network.DirectusApiService +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import kotlinx.coroutines.launch +import com.example.directusapp.model.BlogResponse + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BlogDetailScreen(blogId: Int, navController: NavController) { + var blogResponse by remember { mutableStateOf(null) } + var errorMessage by remember { mutableStateOf(null) } + + LaunchedEffect(blogId) { + launch { + try { + val apiService = DirectusApiService.create() + blogResponse = apiService.getBlogById(blogId) + } catch (e: Exception) { + errorMessage = e.message + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Blog Detail") }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + } + ) { + if (errorMessage != null) { + Text(text = "Error: $errorMessage", style = MaterialTheme.typography.bodyLarge) + } else { + if (blogResponse != null) { + // Render content using `blogResponse.data` + val blog = blogResponse!!.data + Column( + modifier = Modifier + .fillMaxSize() + .padding(it) + .padding(16.dp) + ) { + Text(text = blog.title, style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = blog.dateCreated, style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.height(16.dp)) + MarkdownView(markdownText = blog.content.trimIndent()) + } + } else{ + Text(text="Loading") + } + } + } +} +``` + + +The above code defines a composable function called `BlogDetailScreen` that displays the details of a blog post retrieved from an API. It uses the Scaffold component with a `TopAppBar` that has a back button to navigate up the screen hierarchy. The screen fetches blog data from an API service using a coroutine and stores it in the `blogResponse` state variable. If there is an error, the `errorMessage` state variable is set. If the blog data is successfully fetched, it renders the blog title, date created, and content using the custom `MarkdownView` composable function. + +Then update your `DirectusHelper` file to add an endpoint to fetch blogs by their id: + +```kotlin +package com.example.directusapp.network +import com.example.directusapp.Constants +import com.example.directusapp.model.BlogsResponse +import com.example.directusapp.model.BlogResponse +import com.example.directusapp.model.GlobalResponse +import com.example.directusapp.model.PageResponse +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.GET +import retrofit2.http.Path + +interface DirectusApiService { + @GET("items/global") + suspend fun getGlobal(): GlobalResponse + + @GET("items/pages") + suspend fun getPages(): PageResponse + + @GET("items/blog?fields=*,author.name") + suspend fun getBlogs(): BlogsResponse + + @GET("items/blog/{id}?fields=*,author.name") + suspend fun getBlogById(@Path("id") id: Int): BlogResponse + + companion object { + + fun create(): DirectusApiService { + val retrofit = Retrofit.Builder() + .baseUrl(Constants.BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .build() + return retrofit.create(DirectusApiService::class.java) + } + } +} +``` + +## Add Navigation +To allow your users to navigate the `BlogDetailScreen` and back to the `BlogHomeScreen` you need to implement navigation in the app. In the ui package, create a new Kotlin file named `NavGraph`: + +```kotlin +package com.example.directusapp.ui + +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable + +sealed class Screen(val route: String) { + object BlogList : Screen("blogList") + object BlogDetail : Screen("blogDetail/{blogId}") { + fun createRoute(blogId: Int) = "blogDetail/$blogId" + } +} + +@Composable +fun NavGraph(navController: NavHostController) { + NavHost(navController, startDestination = Screen.BlogList.route) { + composable(Screen.BlogList.route) { + BlogHomeScreen(navController) + } + composable(Screen.BlogDetail.route) { backStackEntry -> + val blogIdString = backStackEntry.arguments?.getString("blogId") + val blogId = blogIdString?.toIntOrNull() + if (blogId != null) { + BlogDetailScreen(blogId, navController) + } + } + } +} +``` + +For the navigation, update your `MainActivity` file to render the `NavGraph`. + +```kotlin +package com.example.directusapp + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.navigation.compose.rememberNavController +import com.example.directusapp.ui.NavGraph +import com.example.directusapp.ui.theme.DirectusAppTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + DirectusAppTheme { + // A surface container using the 'background' color from the theme + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + val navController = rememberNavController() + NavGraph(navController = navController) + } + } + } + } +} +``` + +Now click on any of the blogs to navigate to the details page. + +![Show the blog details pages](https://product-team.directus.app/assets/f41ab897-09f9-407e-a940-6bbaea37225a.webp) + +## Next Steps +Throughout this guide, you have set up an Android project, created a Directus plugin, and set up an Android project with Kotlin to interact with Directus, covering project initialization, creating a helper library for the Directus SDK, global configurations, dynamic pages, and navigation setup. + +If you want to see the code for this project, you can find it on [GitHub](https://github.com/directus-labs/blog-example-getting-started-android-kotlin). \ No newline at end of file diff --git a/content/tutorials/getting-started/fetch-data-from-directus-in-i-os-with-swift.md b/content/tutorials/getting-started/fetch-data-from-directus-in-i-os-with-swift.md new file mode 100644 index 00000000..c5bf54ea --- /dev/null +++ b/content/tutorials/getting-started/fetch-data-from-directus-in-i-os-with-swift.md @@ -0,0 +1,252 @@ +--- +id: fbb96512-b46a-4a0c-bb39-b7d11ec0dc7f +slug: fetch-data-from-directus-in-i-os-with-swift +title: Fetch Data from Directus in iOS with Swift +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- +In this tutorial, you will learn how to configure an iOS project to fetch and showcase posts in your SwiftUI-based app. + +## Before You Start + +You will need: + +1. To have Xcode installed on your macOS machine. +2. Knowledge of the Swift programming language. +3. A Directus project - follow our [quickstart guide](/getting-started/create-a-project) if you don't already have one. + +## Create Post Structs and Helpers + +Create a new file in your Xcode project and name it `Post.swift` you can do this by: + +1. Right-click on the project navigator in the root of the project. +2. Choose "New File..." from the context menu. +3. In the template chooser, select "Swift File" under the "Source" section. +4. Name the file as "Post.swift". +5. Click "Create." + +In the `Post.swift` file, create a Swift `struct` named `Post` to represent the data structure of the posts you'll be fetching from the Directus API. This `struct` should conform to the `Codable` and `Identifiable` protocols. + +```swift +struct Post: Codable, Identifiable { + var id: Int + var title: String + var content: String + var status: String + var image: String? +} +``` +Below the` image` variable, create an `imageURL` computed property to calculates the image URL by appending the image UUID to the base URL of your Directus instance's assets: + +```swift +var imageURL: String? { + guard let imageUUID = image else { return nil } + return "https://directus-project-url/assets/\(imageUUID)" +} +``` + +Finally, create a `stripHTML()` function to remove any HTML markup and leaving only the text content: + +```swift +func stripHTML() -> String { + return content.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil) +} +``` + +## Create a ContentView + +Create a `ContentView.swift` file if you haven't got one already you can do this by: + +1. Right-click on the project navigator in the root of the project. +2. Choose "New File...". +3. Select "SwiftUI View" and name it "ContentView.swift". +4. Click "Create". + +`ContentView` is a SwiftUI view that serves as the main interface for displaying a list of posts. Users can interact with individual posts, view truncated content, and access detailed information about a selected post. The view leverages SwiftUI's navigation and sheet presentation capabilities to create a consistent user experience. + +![App screenshot showing three posts - each with a title and a description](https://marketing.directus.app/assets/b1b92c40-0ffb-4d00-9b90-5d952d4321cd) + +In your `ConentView.swift` file add the following two properties: + +```swift +struct ContentView: View { + @State private var posts = [Post]() // [!code ++] + @State private var selectedPost: Post? = nil // [!code ++] +} +``` + +- `@State private var posts = [Post]()` is state property holding an array of `Post` objects. The `@State` property wrapper indicates that the value can be modified and that changes to it should trigger a re-render of the corresponding view. +- `@State private var selectedPost: Post? = nil` is a state property that represents the currently selected `Post` object. It is initially set to `nil` because no post is selected at launch. + +Add a `body`: + +```swift +var body: some View { + NavigationView { + VStack(alignment: .leading) { + List(posts) { post in + VStack(alignment: .leading) { + Text(post.title) + .font(.headline) + Text(post.stripHTML().prefix(100) + "...") + .font(.body) + .onTapGesture { + selectedPost = post + } + } + } + .sheet(item: $selectedPost) { post in + PostDetailView(selectedPost: $selectedPost, fetchPost: postAPIcall) + } + } + .navigationTitle("Posts") + .task { + await fetchPosts() + } + } +} +``` + +The `body` property is the main content of the view. In SwiftUI, views are constructed by combining smaller views: + +1. `NavigationView`: Wraps the entire content and provides a navigation interface. +2. `VStack`: A vertical stack that arranges its children views in a vertical line. +3. `List(posts) { post in ... }`: Creates a list of `Post` objects, where each post is represented by a vertical stack containing the post's title and a truncated version of its content. +4. Inside the list, a `Text` view displays the post's title, and another `Text` view displays a truncated version of the post's content. `onTapGesture` is used to detect when a user taps on a post, setting the `selectedPost` property to the tapped post. + +The `.navigationTitle()` method in a `NavigationView` sets the title of the navigation bar, and the `task` fetches posts asynchronously when the view is first loaded. + +## Fetch Posts List + +I na previous step, you have called the `fetchPosts()` function, and now it's time to implement it. The function will get data from a remote API, decode the JSON response, and update the `@State` property `posts` with the retrieved data. Any errors encountered during this process are printed to the console. + +Inside `ContentView.swift`, add the following function: + +```swift +func fetchPosts() async { + guard let url = URL(string: "https://ios-author-demo.directus.app/items/posts") else { + print("Invalid URL") + return + } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + let decoder = JSONDecoder() + let result = try decoder.decode([String: [Post]].self, from: data) + + if let posts = result["data"] { + self.posts = posts + } + } catch { + print("Error: \(error)") + } +} +``` + +## Fetch a Single Post + +When the user clicks a post in the list, a new request will be made to fetch details of a specific post. If successful, the `selectedPost` property is updated with the retrieved post details: + +```swift +func postAPIcall(postId: Int) async { + let uuid = UUID().uuidString + var components = URLComponents( + string: "https://directus-project-url/items/posts/\(postId)")! + components.queryItems = [URLQueryItem(name: "uuid", value: uuid)] + + guard let url = components.url else { + print("Invalid URL") + return + } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + let decoder = JSONDecoder() + + struct ApiResponse: Decodable { + let data: Post + } + + let result = try decoder.decode(ApiResponse.self, from: data) + + selectedPost = result.data + } catch { + print("Error: \(error)") + + } +} +``` + +## Display a Single Post + +This SwiftUI view is designed to present detailed information about a selected post. It includes the post title, image (if available), content, a dismiss button to clear the selected post, and the post status. + +Create a new `PostDetailView.swift` file and add the following code: + +```swift +import SwiftUI + +struct PostDetailView: View { + @Binding var selectedPost: Post? + var fetchPost: (Int) async -> Void + var body: some View { + if let post = selectedPost { + VStack { + Text(post.title) + .font(.headline) + .padding() + + if let imageURL = post.imageURL { + AsyncImage(url: URL(string: imageURL)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: 200) + case .failure(_): + Text("Failed to load image") + case .empty: + Image(systemName: "photo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: 200) + .foregroundColor(.gray) + default: + EmptyView() + } + } + .padding() + } + + Text(post.stripHTML()) + .font(.body) + .padding() + + Spacer() + + Button("Dismiss") { + selectedPost = nil + } + + Text("Status: \(post.status)") + .font(.subheadline) + .foregroundColor(.gray) + .padding() + } + .task { + await fetchPost(post.id) + } + } + } +} +``` + +After checking that `selectedPost` has a value, various values are rendered to the view. `AsyncImage` asynchronously loads and displays the post image, handling different loading phases and displaying a placeholder or an error message if necessary. The `Button` clears the `selectedPost` which hides the view. + +Take note that the `fetchPost` function is also run with the ID of the post. During this request, you can ask or more data and only load what's absolutely needed in the list view. + +## Summary + +By following this tutorial, you've learned to integrate Directus API calls into a SwiftUI iOS app. You have loaded a list of data, and implemented a post detail view which asynchronously displays an image and further post information. diff --git a/content/tutorials/getting-started/fetch-data-from-directus-with-angular.md b/content/tutorials/getting-started/fetch-data-from-directus-with-angular.md new file mode 100644 index 00000000..d413cf6d --- /dev/null +++ b/content/tutorials/getting-started/fetch-data-from-directus-with-angular.md @@ -0,0 +1,457 @@ +--- +id: 01d87344-02d8-45fc-a6e3-f13777cdab83 +slug: fetch-data-from-directus-with-angular +title: Fetch Data from Directus with Angular +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- +[Angular](https://angular.dev/) is a popular front-end web framework. In this tutorial, you will use the framework to implement the front-end for the Directus headless CMS. You will implement a blog that loads blog posts dynamically and also serves global metadata. + +## Before You Start + +- Some knowledge of TypeScript and [Angular](https://angular.io/) +- A Directus project. Follow the [Quickstart guide](/getting-started/create-a-project) to create one. +- [Node.js](https://nodejs.org/en/download) and a development environment of your choice +- Install the Angular CLI - use the Angular [guide](https://angular.io/guide/setup-local) to achieve this. + +::callout{type="info" title="Compatibility"} + +Note that Angular and TypeScript versions must be compatible. Since the SDK requires a minimum TypeScript version of 5.0, you need to use Angular version 17 for your project. + +:: + +## Initialize Project +To create a new Angular project, use the following command. + +```bash +ng new directus-with-angular +? Which stylesheet format would you like to use? CSS +? Do you want to enable Server-Side Rendering (SSR) and Static Site Generation (SSG/Prerendering)? (y/N) No +``` +Next, run the following command to install the Directus SDK: + +```bash +npm install @directus/sdk +``` +Once the project has been created, open it in your code editor and replace the code in the `src/app/app.component.html` file with the following: + +```html + +``` +Angular will dynamically fill the [RouterOutlet](https://angular.io/api/router/RouterOutlet) placeholder based on the current router state. + +You should also disable strict checking in `./tsconfig.json` file under `compilerOptions`. + +```json +"strict": false +``` + +Navigate to your project directory in a terminal and start the development server at `http://localhost:4200`: + +```bash +ng serve +``` + +## Create an instance of Directus SDK +For every Directus model that you define, you need to create a TypeScript type for that model. The type will help to map the JSON data to TypeScript objects. + +In addition, you should expose an instance of the Directus SDK that you will use to make different requests to the Directus CMS. + +In your project, create a file named `./directus.ts` with the following code: + +```ts +import {createDirectus, rest} from "@directus/sdk"; + +type Global = { + slug: string; + title: string; + description: string; +} + +type Author = { + slug: string; + name: string; +} + +type Page = { + slug: string; + title: string; + content: string; +} + +type Post = { + slug: string; + image: string; + title: string; + content: string; + author: Author; + published_date: string; +} + +type Schema = { + global: Global; + posts: Post[]; + pages: Page[]; +} + +const directus = + createDirectus("YOUR_DIRECTUS_URL") + .with(rest()); + +export {directus, Global, Post, Page} +``` + +The Schema contains three types which match the data model we will create in Directus throughout this tutorial - each property being a field in the collection. As `global` is a singleton, we do not define it as an array in the Schema. If you add new fields, or rename them, they will also need updating in the type definitions. + +## Using Global Metadata and Settings +In your Directus project, go to **Settings > Data Model** and create a singleton collection named `global` with the Primary ID Field as a "Manually Entered String" called `slug`. Next, add the fields `title` and `description`. + +To ensure the collection is a singleton, select the **Singleton** checkbox. This collection's fields match the `Global` type you created when defining the Schema for the Directus SDK. + +Once the collection is defined go to the **Content** section and add the title and description for the metadata. Go to **Settings > Access Policies > Public** and allow read permissions for the global collection. + +## Create a Component for the Global Metadata +Navigate to your project directory in a terminal and create a `global` component: + +```bash +ng g c component/global +``` +This command will generate four files under the *component* directory. + +Replace the code in the `src/app/component/global/global.component.ts` file with the following code: + +```ts +import {Component, OnInit} from '@angular/core'; +import {directus, Global} from "../../../../directus"; +import {CommonModule} from "@angular/common"; +import {readSingleton} from "@directus/sdk"; + +@Component({ + selector: 'app-global', + standalone: true, + imports: [ + CommonModule + ], + templateUrl: './global.component.html', + styleUrl: './global.component.css' +}) +export class GlobalComponent implements OnInit{ + global: Global; + ngOnInit(): void { + this.getGlobal(); + } + + async getGlobal(){ + //@ts-ignore + this.global = await directus + .request(readSingleton("global")) + } + +} +``` +When this component is initialized, it will retrieve the singleton and store it in the `global` object. + +To display the contents of the object, replace the code in the `src/app/component/global/global.component.html` file with the following code: + +```ts +
+

{{global.title}}

+

{{global.description}}

+
+``` + +### Add Routing for the Global Metadata +In `app.routes.ts` replace the code in the file with the following code: + +```ts +import { Routes } from '@angular/router'; +import {GlobalComponent} from "./component/global/global.component"; + +export const routes: Routes = [ + {path: '', component: GlobalComponent} +]; +``` +Open the application in your browser (`http://localhost:4200`) and the global component containing the data from Directus will be shown. + +## Creating Pages with Directus + +### Configure Directus +In your Directus project, create a new collection named `pages` - make a text input field called `slug`, which will +correlate with the URL for the page. For example `about` will later correlate to the page `localhost:3000/about`. + +Create another text input field called `title` and a text area input field called `content`. In the Access Policies settings, give the Public role read access to the new collection. + +Create some items in the new collection - [here is some sample data](https://github.com/directus-community/getting-started-demo-data). + +### Dynamic Routes in Angular +Navigate to your project directory in a terminal and generate the page component: + +```bash +ng g c component/page +``` +Replace the code in the `src/app/component/page/page.component.ts` file with the following code: + +```ts +import {Component, OnInit} from '@angular/core'; +import {directus, Page} from "../../../../directus"; +import {ActivatedRoute} from "@angular/router"; +import {CommonModule} from "@angular/common"; +import {readItems} from "@directus/sdk"; + +@Component({ + selector: 'app-page', + standalone: true, + imports: [CommonModule], + templateUrl: './page.component.html', + styleUrl: './page.component.css' +}) +export class PageComponent implements OnInit{ + page: Page; + + constructor(private route: ActivatedRoute) { + } + + ngOnInit(): void { + this.route.paramMap.subscribe(params => { + const slug = params.get("slug"); + if (slug){ + this.getPageBySlug(slug); + } + }) + } + + async getPageBySlug(slug: string){ + //@ts-ignore + this.page = await directus + .request(readItems("pages", [{slug}]))[0]; + } + +} +``` +When the component is initialized, the `slug` path parameter is retrieved using `ActivatedRoute` and passed to the `readItems()` function to get a page with that slug. + +The retrieved page is stored in the object named `page`. To display the contents of the page, replace the code in the `src/app/component/page/page.component.html` file with the following code: + +```html +
+

{{page.title}}

+

{{page.content}}

+
+``` + +### Add Routing for the Pages +In `src/app/app.routes.ts` add the following route in the `Routes` array: + +```ts +{path: ':slug', component: PageComponent}, +``` +Visit `http://localhost:4200/about` to view the about page. Replace the `slug` path parameter with `privacy` and `conduct` to view the content of about and conduct pages held in Directus. + +## Creating Blog Posts with Directus +In your Directus project, create a new collection called `authors` with a single text input field called `name`. Add some authors to the collection. + + +Next, create a new collection named `posts` - add a text input field called `slug`, +which will correlate with the URL for the page. For example `hello-world` will later correlate to the page +`localhost:3000/blog/hello-world`. + +Create the following additional fields in your `posts` data model: + +- a text input field called `title` +- an image relational field called `image` +- a text area input field called `content` +- a datetime selection field called `published_date` of type date. +- a many-to-one relational field called `author` with the related collection set to `authors` + +In Settings -> Access Policies, give the Public role read access to the `authors`, `posts`, and `directus_files` collections. + +Create some items in the posts collection - [here's some sample data](https://github.com/directus-community/getting-started-demo-data). + +### Create Blog Post Listing +Navigate to your project directory in a terminal and generate the posts component: + +```bash +ng g c component/posts +``` +Replace the code in the `src/app/component/posts/posts.component.ts` file with the following code: + +```ts +import {Component, OnInit} from '@angular/core'; +import {directus, Post} from "../../../../directus"; +import {RouterLink} from "@angular/router"; +import {CommonModule} from "@angular/common"; +import {readItems} from "@directus/sdk"; + +@Component({ + selector: 'app-posts', + standalone: true, + imports: [CommonModule, RouterLink], + templateUrl: './posts.component.html', + styleUrl: './posts.component.css' +}) +export class PostsComponent implements OnInit{ + posts: Post[]; + + ngOnInit(): void { + this.getAllPosts(); + } + + async getAllPosts(){ + //@ts-ignore + this.posts = await directus + .request(readItems("posts", { + fields: ["slug","title", "published_date", {author: ["name"]}] + })) + } +} + +``` +When the component is initialized, it will retrieve all the posts using the `readItems()` function and store them in the `posts` array. + +To list the posts, replace the code in the `src/app/component/posts/posts.component.html` file with the following code: + +```html +

Blog Posts

+
    +
  1. + +

    {{post.title}}

    +
    + + {{post.published_date}} • {{post.author.name}} + +
  2. +
+``` + +### Add Routing for Posts +Go to `src/app/app.routes.ts` file and add the following route in the `Routes` array: + +```ts +{path: 'blog', component: PostsComponent}, +``` +Once the application reloads, go to `http://localhost:4200/blog` and the list of posts will be displayed on the page. + +:::info Navigation + +In Angular, the order in which you put the routes in the `Routes` array will affect how components are loaded in your application. In this case, you don't want the path to `blog` to be consumed as a `slug`. As a result, ensure the blog route is put before slug in the Routes array. + +::: + +![blog post listing](https://marketing.directus.app/assets/fa4a4af1-13bc-4357-9dd2-4c06a9583ce6) + +## Create Blog Post Pages +You have learned how to create dynamic pages in a previous section, you will leverage the skill in this section to display individual post pages for the blog post listing. + +### Create a Component for Gallery Detail +Navigate to your project directory in a terminal and create the post component: + +```bash +ng g c component/post +``` +Replace the code in the `src/app/component/post/post.component.ts` file with the following code. + +```ts +import {Component, OnInit} from '@angular/core'; +import {directus, Post} from "../../../../directus"; +import {ActivatedRoute} from "@angular/router"; +import {CommonModule} from "@angular/common"; +import {readItems} from "@directus/sdk"; + +@Component({ + selector: 'app-post', + standalone: true, + imports: [CommonModule], + templateUrl: './post.component.html', + styleUrl: './post.component.css' +}) +export class PostComponent implements OnInit{ + post: Post; + baseUrl = "YOUR_DIRECTUS_URL"; + constructor(private route: ActivatedRoute) { + } + ngOnInit(): void { + this + .getPostBySlug(+this + .route + .snapshot + .paramMap.get('slug')) + } + + async getPostBySlug(slug: string){ + //@ts-ignore + this.post = await directus + .request(readItems("posts", [{slug}]))[0]; + } + +} +``` +When the component is initialized, it will retrieve the path variable using the `ActivatedRoute` and pass it to the `readItems()` function to get the post with that slug. + +Note that this will happen when you click on a blog post from the list of blog posts. + +The retrieved post is stored in the `post` object. To display the contents of the object, replace the code in the `src/app/component/post/post.component.html` file with the following code: + +```html +
+
+ {{post.title}} +
+

{{post.title}}

+

{{post.content}}

+
+``` + +## Add a Method to Handle a Click on the Blog Posts +In `src/app/component/posts/posts.component.ts` file add the following code. + +```ts + constructor(private router: Router) {} + + goToPost(slug: string){ + this.router.navigate(['/blog', slug]); + } +``` +This method will redirect you to `/blog/slug` using `Route` when you click on an post on the blog post listing. The `slug` path variable will be associated with the clicked item. + +As a result, a post will be loaded dynamically depending on which post you click. + +### Add a Click Listener for the Blog Posts +In `src/app/component/posts/posts.component.html` add the method you have created in the previous section in the following line. + +```ts + +

{{post.title}}

+
+``` +Since the method expects the `slug` parameter, pass `post.slug` as the argument of the method. As a result, this will bind the method with the current slug of a post at runtime. + +### Add Routing for the Blog Post Page +In `src/app/app.routes.ts` file add the following route in the `Routes` array: + +```ts +{path: 'blog/:slug', component: PostComponent} +``` +Once the application reloads, go to `http://localhost:4200/blog` and click on a post. As a result, the individual post will be displayed on the page via the path `http://localhost:4200/blog/slug`. + +![blog post pages](https://marketing.directus.app/assets/ab38e8ac-93b0-495d-8c91-343ad42dcadc) + +## Add Navigation +While not strictly Directus-related, there are now several pages that aren't linked to each other. Open `src/app/app.component.html` and add the following code before the `` tag: + +```html + +``` + +## Summary +In this tutorial, you have learned how to integrate directus with Angular. You have covered how to use global metadata and settings, how to create pages, how to create a post listing, how to show blog post pages, and lastly how to add navigation in your application. \ No newline at end of file diff --git a/content/tutorials/getting-started/fetch-data-from-directus-with-astro.md b/content/tutorials/getting-started/fetch-data-from-directus-with-astro.md new file mode 100644 index 00000000..f11ff77f --- /dev/null +++ b/content/tutorials/getting-started/fetch-data-from-directus-with-astro.md @@ -0,0 +1,311 @@ +--- +id: 970eaaf3-d033-43d6-800e-c168e28c6d8f +slug: fetch-data-from-directus-with-astro +title: Fetch Data from Directus with Astro +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- +[Astro](https://astro.build/) is a web framework used for building content-heavy websites. In this tutorial, you will learn how to build a website using Directus as a headless CMS. You will store, retrieve, and use global metadata such as the site title, create new pages dynamically based on Directus items, and build a blog. + +## Before You Start + +You will need: + +- To install [Node.js](https://nodejs.org/en/) and a code editor on your computer. +- A Directus project - you can use [Directus Cloud](https://directus.cloud/) or [run it yourself](/getting-started/create-a-project). +- Some knowledge of TypeScript and Astro framework + +## Initializing Astro + +Open your terminal to run the following command to create a new Astro project: + +```bash +npm create astro@latest +``` + +During installation, when prompted, choose the following configurations: + +```bash +Where should we create your new project? ./astro-directus +How would you like to start your new project? Include sample files +Install dependencies? Yes +Do you plan to write TypeScript? Yes +How strict should TypeScript be? Strict +``` + +Once completed, navigate into the new directory and delete all the contents in the `pages/index.astro` file so you can build the project from scratch and install the Directus JavaScript SDK: + +```bash +cd astro-directus +npm i @directus/sdk +``` + +Open the `astro-directus` directory in a text editor of your choice and run `npm run dev` in the terminal to start the development server at `http://localhost:4321`. + +## Creating a Helper for the SDK + +To create an instance of the Directus SDK that multiple pages in the project will use, create a new directory called `lib` and a new file called `directus.ts` inside of it with the following content: + +```ts +import { createDirectus, rest, } from '@directus/sdk'; + +type Global = { + title: string; + description: string; +} + +type Author = { + name: string +} + +type Page = { + title: string; + content: string; + slug: string; +} + +type Post = { + image: string; + title: string; + author: Author; + content: string; + published_date: string + slug: string; +} + +type Schema = { + posts: Post[]; + global: Global; + pages: Page[]; +} + +const directus = createDirectus('YOUR_DIRECTUS_URL').with(rest()); + +export default directus; +``` + +Ensure your Directus URL is correct when initializing the Directus JavaScript SDK. Also note that the type definitions match the structure of the data that will be fetched from your Directus project. + +## Using Global Metadata and Settings + +In your Directus project, navigate to Settings -> Data Model and create a new collection called `global`. Under the Singleton option, select 'Treat as a single object', as this collection will have just a single entry containing global website metadata. + +Create two text input fields - one with the key of `title` and one `description`. + +Navigate to the content module and enter the global collection. Collections will generally display a list of items, but as a singleton, it will launch directly into the one-item form. Enter information in the title and description field and hit save. + +![A form named Global has two inputs - a title and a description, each filled with some text.](https://product-team.directus.app/assets/d8c92df8-63c3-404e-8e0f-b086d27d960a.webp) + +By default, new collections are not accessible to the public. Navigate to Settings -> Access Policies -> Public and give Read access to the Global collection. + + In your `pages/index.astro` file, add the following to fetch the data from Directus and display it: + +```ts +--- +import Layout from "../layouts/Layout.astro"; +import directus from "../lib/directus"; +import { readSingleton } from "@directus/sdk"; + +const global = await directus.request(readSingleton("global")); +--- + + +
+
+

{global.title}

+

{global.description}

+
+
+
+``` + +Refresh your browser. You should see the data from your Directus Global collection displayed in the index page. + +## Creating Pages With Directus + +### Setting Up Directus + +Create a new collection called `pages` - make a text input field called `slug`, which will correlate with the URL for the page. For example `about` will later correlate to the page `localhost:4321/about`. + +Create a text input field called `title` and a WYSIWYG input field called `content`. In Access Policies settings, give the Public role read access to the new collection. + +Create some items in the new collection - [here is some sample data](https://github.com/directus-community/getting-started-demo-data). + +### Setting Up Dynamic Routes in Astro + +Inside of the `pages` directory, create a new file called `[slug].astro`. Astro can use dynamic route parameters in a filename to generate multiple, matching pages. + +```ts +--- +import Layout from "../layouts/Layout.astro"; +import directus from "../lib/directus"; +import { readItems } from "@directus/sdk"; + +export async function getStaticPaths() { + const pages = await directus.request(readItems("pages")); + return pages.map((page) => ({ + params: { slug: page.slug }, + props: page, + })); +} +const page = Astro.props; +--- + + +
+

{page.title}

+
+
+
+``` + +Because all routes must be determined at build time in Astro, a dynamic route must export a `getStaticPaths()` function that returns an array of objects with a params property. Each of these objects will generate a corresponding route. + +Go to `http://localhost:4321/about`, replacing `about` with any of your item slugs. Using the Directus JavaScript SDK, the item with that slug is retrieved, and the page should show your data. + +::callout{type="warning" title="404s and Trusted Content"} + +Non-existing slugs will result in a 404 error. Additionally, +[`set:html` should only be used for trusted content.](https://docs.astro.build/en/reference/directives-reference/#sethtml)_ + +:: + +## Creating Blog Posts With Directus + +Create a new collection called `authors` with a single text input field called `name`. Create one or more authors. + +Then, create a new collection called `posts` - make a text input field called `slug`, which will correlate with the URL for the page. For example `hello-world` will later correlate to the page `localhost:4321/blog/hello-world`. + +Create the following fields in your `posts` data model: + +- a text input field called `title` +- a WYSIWYG input field called `content` +- an image relational field called `image` +- a datetime selection field called `published_date` - set the type to 'date' +- a many-to-one relational field called `author` with the related collection set to `authors` + +In Access Policies, give the Public role read access to the `authors`, `posts`, and `directus_files` collections. + +Create some items in the posts collection - [here is some sample data](https://github.com/directus-community/getting-started-demo-data). + +### Create Blog Post Listing + +Inside of the `pages` directory, create a new directory called `blog` and a new file called `index.astro` inside of it. + +```ts +--- +import Layout from "../../layouts/Layout.astro"; +import directus from "../../lib/directus"; +import { readItems } from "@directus/sdk"; + +const posts = await directus.request( + readItems("posts", { + fields: [ + "slug", + "title", + "published_date", + { author: ["name"] }, + ], + sort: ["-published_date"], + }) +); +--- + + +
+

Blog Posts

+
+
+``` + +This query will retrieve the first 100 items (default), sorted by publish date (descending order, which is latest first). It will only return the specific fields we request - `slug`, `title`, `published_date`, and the `name` from the related `author` item. + +Display the fetched data in HTML: + +```ts + + +
+

Blog Posts

+
    + { + posts.map((post) => ( +
  • + +

    {post.title}

    +
    + + {post.published_date} • {post.author.name} + +
  • + )) + } +
+
+
+ +``` + +Visit `http://localhost:4321/blog` and you'll find a blog post listing, with the latest items first. + +![A page with a title of "Blog". On it is a list of three items - each with a title, author, and date. The title is a link.](https://product-team.directus.app/assets/5811ee82-f600-4855-9620-bafca0bb98d8.webp) + +### Create Blog Post Pages + +Each blog post links to a page that does not yet exist. In the `pages/blog` directory, create a new file called `[slug].astro` with the content: + +```ts +--- +import Layout from "../../layouts/Layout.astro"; +import directus from "../../lib/directus"; +import { readItems, readItem } from "@directus/sdk"; + +export async function getStaticPaths() { + const posts = await directus.request(readItems("posts", { + fields: ['*', { relation: ['*'] }], + })); + return posts.map((post) => ({ params: { slug: post.slug }, props: post })); +} +const post = Astro.props; +--- + + +
+ +

{post.title}

+
+
+
+``` + +Some key notes about this code snippet. + +- The `width` attribute demonstrates Directus' built-in image transformations. +- Once again, `set:html` should only be used if all content is trusted. +- Because almost-all fields are used in this page, including those from the image relational field, the `fields` property when using the Directus JavaScript SDK can be set to `*.*`. + +Click on any of the blog post links, and it will take you to a blog post page complete with a header image. + +![A blog post page shows an image, a title, and a number of paragraphs.](https://product-team.directus.app/assets/5811ee82-f600-4855-9620-bafca0bb98d8.webp) + +## Add Navigation + +While not strictly Directus-related, there are now several pages that aren't linked to each other. Update the `Layout.astro` file to include a navigation. Don't forget to use your specific page slugs. + +```ts + + + + +``` + +## Next Steps + +Through this guide, you have set up an Astro project, created a Directus instance, and used it to query data. You have used a singleton collection for global metadata, dynamically created pages, as well as blog listing and post pages. diff --git a/content/tutorials/getting-started/fetch-data-from-directus-with-django.md b/content/tutorials/getting-started/fetch-data-from-directus-with-django.md new file mode 100644 index 00000000..d5bbbeaa --- /dev/null +++ b/content/tutorials/getting-started/fetch-data-from-directus-with-django.md @@ -0,0 +1,332 @@ +--- +id: ab19694b-b7e5-44d1-994c-f7a5e5025829 +slug: fetch-data-from-directus-with-django +title: Fetch Data from Directus with Django +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- +Django is a popular Python framework known for its "battery included" philosophy. In this tutorial, you will learn how to integrate Django with Directus, and build an application that uses the Django templating engine to display data from the API. + +## Before You Start + +You will need: + +- Python installed and a code editor on your computer. +- A Directus project - Use the [quickstart guide](/getting-started/create-a-project) to create a project if you dont already have one. + + +## Create a Django Project + +Open your terminal and run the following commands to set up a Django project: + +```bash +mkdir my_django_site && cd my_django_site +django-admin startproject config . +python -m venv env +source env/bin/activate # On Windows use `env\Scripts\activate` +pip install django requests +``` + +Open the new Django project in your code editor of choice and activate your virtual environment and start your Django development server to run the application at `http://localhost:8000`: + + ```bash + python manage.py runserver + ``` + +After you've started your server, create a Django app that will contain your views, integrations and URLs. Run the following command in your project directory: + + ```bash + python manage.py startapp blog +``` + +Open the `config/settings.py`, add your new app to the `INSTALLED_APPS` list, and configure a templates directory: + +```python +INSTALLED_APPS = [ + ... # Other installed apps + 'blog', # Add this line +] + +TEMPLATES = [ + { + ... + "DIRS": [BASE_DIR / "templates"], + ... + }, +] +``` + +## Using Global Metadata and Settings + +In your Directus project, navigate to Settings -> Data Model and create a new collection called global. Under the Singleton option, select 'Treat as a single object', as this collection will have just a single entry containing global website metadata. + +Create two text input fields - one with the key of title and one description. + +Navigate to the content module and enter the global collection. Collections will generally display a list of items, but as a singleton, it will launch directly into the one-item form. Enter information in the title and description field and hit save. + +![Global metadata edit view, showing a title and description field](https://marketing.directus.app/assets/e7795159-ba26-46a5-b3bd-1fe96198132c) + +By default, new collections are not accessible to the public. Navigate to Settings -> Access Policies -> Public and give Read access to the Global collection. + +In your Django project, create a file named `directus_integration.py` in the blog app directory to handle data fetching: + +```python +import requests + +DIRECTUS_API_ENDPOINT = "YOUR_DIRECTUS_INSTANCE_API_ENDPOINT" + +def get_global_settings(): + response = requests.get(f"{DIRECTUS_API_ENDPOINT}/items/global") + return response.json() + +def get_collection_items(collection): + response = requests.get(f"{DIRECTUS_API_ENDPOINT}/items/{collection}") + return response.json() +``` + +With the functions in place, you can now fetch global settings and pass them to your Django templates. + +Lets now create a view for the home page. Django automatically creates a `views.py` file after starting an app. Update the file: + +```python +from django.shortcuts import render +from .directus_integration import get_global_settings + +def home_page(request): + global_settings = get_global_settings() + context = { + 'title': global_settings['data']['title'], + 'description': global_settings['data']['description'] + } + return render(request, 'home.html', context) +``` + +Create a `templates` directory in the root directory of our Django project (the root directory is where you have the manage.py file). + +Create a `home.html` file in your templates directory: + +```python + + + + + {{ title }} + + +
+

{{ title }}

+
+
+

{{ description }}

+
+ + +``` + +## Creating Pages With Directus + +In your Django project, set up a system to serve pages stored in a Directus collection called `pages`. Each page in Directus will have a unique identifier that corresponds to its URL path. + +In your Directus dashboard, navigate to Settings -> Data Model and create a new collection named `pages`. Add an input field called `slug` for the URL of each page. Add a text field named `title` and a Rich Text field for the `content`. + +In the Access Policies settings allow the Public role to read the `pages` collection. + +In your `views.py`, utilize the `get_collection_items` function to get the content and serve it through a Django view: + +```python +from django.shortcuts import render +from django.http import JsonResponse +# Import the get_collection_items function from your integration script +from .directus_integration import get_collection_items + +def page_view(request, slug): + pages = get_collection_items('pages') + page = next((p for p in pages['data'] if p['slug'] == slug), None) + if page: + return render(request, 'page.html', {'page': page}) + else: + return JsonResponse({'error': 'Page not found'}, status=404) +``` + +Now you can create a Django template to render the page content. In your templates directory, create a file named `page.html`: + +```html + + + + + + {{ page.title }} + + +
+ {{ page.content|safe }} +
+ + +``` + +Now, when you visit `http://localhost:8000/your-page-slug`, replacing `your-page-slug` with any slug from your Directus pages collection, Django will serve the content of that page. + +## Creating Blog Posts + +In the Directus Data Studio, create two collections: + +- authors: with a field for the author's `name`. +- posts: with fields for: + - `slug` - a text input + - `title` - a text input + - `content` - a rich text field + - `publish_date` - a date field + - `authors` - a many-to-one relationship linking to the authors collection + +Adjust Directus permissions to allow public reading of the `authors` and `posts` collections. + +### Create Listing + +In the `directus_integration.py` file, create the data fetching function: + +```python +def fetch_blog_posts(): + response = requests.get(f"{DIRECTUS_API_ENDPOINT}/items/posts?fields=*,author.name&sort=-publish_date") + return response.json() +``` + +In the `views.py` file, create a function that imports and uses the `fetch_blog_posts` function to display the list of posts: + +```python +from .directus_integration import get_collection_items,fetch_blog_posts + +def blog_posts(request): + posts_data = fetch_blog_posts() + return render(request, 'blog_list.html', {'posts': posts_data['data']}) +``` + +Within the the `templates` directory, create a `blog_list.html` file: + +```html + + + + + Blog Posts + + +

Blog

+
    + {% for post in posts %} +
  • + {{ post.title }} +

    {{ post.publish_date }} by {{ post.author.name }}

    +
  • + {% endfor %} +
+ + +``` + +![Blog listing](https://marketing.directus.app/assets/7037dd09-7d5f-4620-b84f-6a6b2ea7e140) + +### Create Single Post Page + +Create another view in `views.py` to handle individual blog posts: + +```python +def blog_post_detail(request, slug): + posts_data = fetch_blog_posts() + post = next((p for p in posts_data['data'] if p['slug'] == slug), None) + + if post is not None: + return render(request, 'blog_detail.html', {'post': post}) + else: + return JsonResponse({'error': 'Post not found'}, status=404) +``` + +Still within the `templates` directory, create the `blog_detail.html` template: + +```html + + + + + {{ post.title }} + + +
+
+

{{ post.title }}

+ +

Published on: {{ post.publish_date }} by {{ post.author.name }}

+
+
+ {{ post.content | safe }} +
+
+ + + +``` + +Create a `urls.py` file within the blog app directory and update it to include URL patterns for all views: + +```python +from django.urls import path +from .views import blog_posts, blog_post_detail + +urlpatterns = [ + path('', home_page, name='home'), + path('blog/', blog_posts, name='blog_list'), + path('blog//', blog_post_detail, name='blog_detail'), + + # ... other URL patterns ... +] +``` + +Include the app's URLs in the main project's `config/urls.py`: + +```python +from django.urls import path, include + +urlpatterns = [ + path('', include('blog.urls')), +] +``` + +## Add Navigation + +In Django, the website's navigation is usually integrated into a base template that other templates extend. Let's add a navigation menu to your base Django template to link together the different pages of your site. + +The navigation menu typically resides in a base template that other templates extend. Update your base template (`base.html`) to include the navigation: + +```python + + +{% block content %} +{% endblock %} +``` + +In your individual page templates, extend the base.html to inherit the navigation: + +```python +{% extends 'base.html' %} + +{% block content %} +{% endblock %} +``` + +Utilize Django's URL names instead of hardcoded paths for navigation links: + +```html +Home +``` + +## Next steps + +Through this guide, you have established a Django project and integrated it with Directus to manage and serve content dynamically. Utilizing the rich features of Django's web framework and Directus's flexible CMS, you've created a system that not only handles global website settings but also powers a blog with listings and detailed post pages. + +As you progress, you might consider refining the accessibility of your content. To achieve this, delve into Directus's permissions and roles to define more granular access control, ensuring that only appropriate data is available for each user role. Additionally, you can fine-tune your Django views and templates to render content based on the user's permissions, providing a secure and customized experience. \ No newline at end of file diff --git a/content/tutorials/getting-started/fetch-data-from-directus-with-eleventy-3.md b/content/tutorials/getting-started/fetch-data-from-directus-with-eleventy-3.md new file mode 100644 index 00000000..0a3a1628 --- /dev/null +++ b/content/tutorials/getting-started/fetch-data-from-directus-with-eleventy-3.md @@ -0,0 +1,259 @@ +--- +id: b847e493-5a35-49e1-80b1-3dc2657a0f7d +slug: fetch-data-from-directus-with-eleventy-3 +title: Fetch Data from Directus with Eleventy 3 +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- +Eleventy (sometimes referred to 11ty) is a lightweight and unopinionated static site generator. You can use any templating language, and it ships with zero client-side JavaScript by default. In this guide, you will learn how to build a website with Directus as a Headless CMS. + +## Before You Start + +You will need: + +- Node.js and a code editor. +- A Directus project - [follow our quickstart guide](/getting-started/create-a-project) if you don't already have one. + +Open your terminal and run the following commands to create a new 11ty project and the Directus JavaScript SDK: + +``` +mkdir my-website && cd my-website +npm init -y +npm install @11ty/eleventy@3.0.0-alpha.2 @directus/sdk +``` + +::callout{type="info" title="Eleventy 3.0 in Alpha"} + +When Eleventy 3.0 leaves alpha, we'll update this post with any changes required. + +:: + +Open `my-website` in your code editor. Add `"type": "module"` to the object in your `package.json` file, and type `npx @11ty/eleventy --serve --watch` in your terminal to start the 11ty development server and open in your browser. + +Create a new directory in your 11ty project called `_includes`. Inside of it, another directory called `layouts`. And, finally, a file called `base.njk`: + +```njk + + + + + + {{ title }} + + +
+ {{ content | safe }} +
+ + +``` + +## Create a Directus Helper + +Create a `_data` directory in your 11ty project, and inside of it a `directus.js` file, being sure to provide your full Directus project URL: + +```js +import { createDirectus, rest } from '@directus/sdk'; + +const directus = createDirectus('YOUR_DIRECTUS_PROJECT_URL').with(rest()); + +export default directus; +``` + +## Using Global Metadata and Settings + +In your Directus project, navigate to **Settings -> Data Model** and create a new collection called `global`. Under the Singleton option, select 'Treat as a single object', as this collection will have just a single entry containing global website metadata. + +Create two text input fields - one with the key of `title` and one `description`. + +Navigate to the content module and enter the global collection. Collections will generally display a list of items, but as a singleton, it will launch directly into the one-item form. Enter information in the title and description field and hit save. + +![A form named Global has two inputs - a title and a description, each filled with some text.](https://product-team.directus.app/assets/d8c92df8-63c3-404e-8e0f-b086d27d960a.webp) + +By default, new collections are not accessible to the public. Navigate to **Settings -> Access Policies -> Public** and give Read access to the Global collection. + +Inside of your `_data` directory, create a new file called `global.js`: + +```js +import directus from './directus.js'; +import { readSingleton } from '@directus/sdk'; + +export default async () => { + return await directus.request(readSingleton('global')) +} +``` + +Data from the global collection in Directus will now be available throughout your 11ty project as `global`. + +Create a new file in the root directory of your 11ty project called `index.njk`: + +```njk +--- +layout: layouts/base.njk +eleventyComputed: + title: "{{ global.title }}" +--- + +

{{ title }}

+

{{ global.description }}

+``` + +`eleventyComputed` is being used so there is a `title` key, which is used by the main layout created at the start of this tutorial to populate the `` element in the `<head>`. + +Refresh your browser. You should see data from your Directus Global collection in your page. + +## Creating Pages With Directus + +Create a new collection called `pages` - make an input field titled `slug`, which will correlate with the URL for the page. For example `about` will later correlate to the page `localhost:3000/about`. + +Create an additional text input field called `title` and a WYSIWYG input field called `content`. In Roles & Permissions, give the Public role read access to the new collection. Create 3 items in the new collection - [here's some sample data](https://github.com/directus-community/getting-started-demo-data). + +Inside of your `_data` directory, create a new file called `pages.js`: + +```js +import directus from './directus.js'; +import { readItems } from '@directus/sdk'; + +export default async () => { + return await directus.request(readItems('pages')) +} +``` + +Create a new file in the root directory of your 11ty project called `_page.njk`: + +```njk +--- +layout: layouts/base.njk +pagination: + data: pages + size: 1 + alias: page +permalink: "{{ page.slug }}/index.html" +eleventyComputed: + title: "{{ page.title }}" +--- + +<h1>{{ title }}</h1> +{{ page.content | safe }} +``` + +Go to http://localhost:8080/about, replacing `about` with any of your item slugs. One page is created per page returned in the `pages.js` data file. + +_Note that only pages that match the permalink structure, and exist in Directus, are generated. This means your application will return a 404 if the page does not exist. Please also note that the `safe` filter should only be used for trusted content as it renders unescaped content._ + +## Creating Blog Posts With Directus + +Create a new collection called `authors` with a single text input field called `name`. Create one or more authors. + +Then, create a new collection called `posts` - add a text input field called `slug`, which will correlate with the URL for the page. For example `hello-world` will later correlate to the page `localhost:3000/blog/hello-world`. + +Create the following additional fields in your `posts` data model: + +- a text input field called `title` +- a WYSIWYG input field called `content` +- an image relational field called `image` +- a datetime selection field called `publish_date` - set the type to 'date' +- a many-to-one relational field called `author` with the related collection set to `authors` + +In your Access Policies settings, give the Public role read access to the `authors`, `posts`, and `directus_files` collections. + +Create 3 items in the posts collection - +[here's some sample data](https://github.com/directus-community/getting-started-demo-data). + +### Create Blog Post Listing + +Inside of your `_data` directory, create a new file called `posts.js`: + +```js +import directus from './directus.js'; +import { readItems } from '@directus/sdk'; + +export default async () => { + return await directus.request( + readItems("posts", { + fields: ["*", { author: ["name"] }], + sort: ["-publish_date"], + }) + ); +} +``` + +This data file will retrieve the first 100 items (default), sorted by publish date (descending order, which is latest first). It will only return the specific fields we request - `slug`, `title`, `publish_date`, and the `name` from the related `author` item. + +Create a new file in the root directory of your 11ty project called `blog.njk`: + +```njk +--- +layout: layouts/base.njk +permalink: "blog/index.html" +title: Blog +--- + +<h1>{{ title }}</h1> +<ul> + {% for post in posts %} + <a href="/posts/{{ post.slug }}"> + <h2>{{ post.title }}</h2> + </a> + <span> + {{ post.publish_date }} • {{ post.author.name }} + </span> + {% endfor %} +</ul> +``` + +Visit http://localhost:3000 and you should now see a blog post listing, with latest items first. + +![A page with a title of "Blog". On it is a list of three items - each with a title, author, and date. The title is a link.](https://product-team.directus.app/assets/5811ee82-f600-4855-9620-bafca0bb98d8.webp) + +### Create Blog Post Page + +Each blog post links to a page that does not yet exist. Create a new file in the root directory of your 11ty project called `_post.njk`: + +```njk +--- +layout: layouts/base.njk +pagination: + data: posts + size: 1 + alias: post +permalink: "blog/{{ post.slug }}/index.html" +eleventyComputed: + title: "{{ post.title }}" +--- + +<img src="{{ directus.url }}assets/{{ post.image }}?width=600" /> +<h1>{{ title }}</h1> +{{ post.content | safe }} +``` + +Some key notes about this code snippet. + +- In the `<img>` tag, `directus.url` is the value provided when creating the Directus data file. +- The `width` attribute demonstrates Directus' built-in image transformations. +- Once again, the `safe` filter should only be used if all content is trusted. + +Click on any of the blog post links, and it will take you to a blog post page complete with a header image. + +![A blog post page shows an image, a title, and a number of paragraphs.](https://product-team.directus.app/assets/5811ee82-f600-4855-9620-bafca0bb98d8.webp) + +## Add Navigation + +While not strictly Directus-related, there are now several pages that aren't linked to each other. In `_includes/layouts/base.njk`, above the `<main>` component, add a navigation. Don't forget to use your specific page slugs. + +```vue-html +<nav> + <a to="/">Home</a> + <a to="/about">About</a> + <a to="/conduct">Code of Conduct</a> + <a to="/privacy">Privacy Policy</a> + <a to="/blog">Blog</a> +</nav> +``` + +## Next Steps + +Through this guide, you have set up an 11ty project, initialized the Directus JavaScript SDK, and used it to query data. You have used a singleton collection for global metadata, dynamically created pages, as well as blog listing and post pages. + +If you want to change what is user-accessible, consider setting up more restrictive roles and accessing only valid data at build-time. diff --git a/content/tutorials/getting-started/fetch-data-from-directus-with-flask.md b/content/tutorials/getting-started/fetch-data-from-directus-with-flask.md new file mode 100644 index 00000000..904bab96 --- /dev/null +++ b/content/tutorials/getting-started/fetch-data-from-directus-with-flask.md @@ -0,0 +1,359 @@ +--- +id: c8562b3b-fbe0-4fd5-ade1-2d9a9e986f6d +slug: fetch-data-from-directus-with-flask +title: Fetch Data from Directus with Flask +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- + +[Flask](https://flask.palletsprojects.com/en/3.0.x/) is a minimal Python framework used to build web applications. In this tutorial, you will store, retrieve, and use global metadata, pages, and posts based on a Directus project. + +## Before You Start + +You will need: + +- To have Python installed on your machine +- A Directus project - [follow our quickstart guide](/getting-started/create-a-project) if you don't already have one. +- Knowledge of Python and Flask + +### Creating Page Templates + +First of all, you have to create a base template to be used by all your pages. Create a `templates` directory and a file called `base.html` in it with the following content: + +```jinja +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>{% block title %}Directus x Flask{% endblock %} + + +
{% block content %}{% endblock %}
+ + +``` + +## Setting Up A Flask Project + +To create a new Flask project using `venv`, create your project directory and enter it, then run the following commands: + +```sh +python3 -m venv .venv +source .venv/bin/activate # On Windows you should use `.venv\Scripts\activate` +pip install Flask requests python-dotenv +``` + +To make `.env` file variables available on the project, create a `config.py` file: + +```py +from dotenv import load_dotenv + +load_dotenv() +``` + +Then create an `app.py` file to start the Flask app: + +```py +from flask import Flask + +app = Flask(__name__) + + +@app.get("/") +def home(): + return "Hello world" +``` + +And run the following command to run your flask server, which will start a server at `http://localhost:3000`: + +```sh +flask run --debug +``` + +## Creating Global Metadata And Settings Collection + +In your Directus project, navigate to **Settings** -> **Data Model** and create a new collection called `global`. Under the **Singleton** option, select 'Treat as a single object', as this collection will have just a single entry containing global website metadata. + +Create two text input fields - one with the key of `title` and one `description`. + +Navigate to the content module and enter the `global` collection. Collections will generally display a list of items, but as a singleton, it will launch directly into the one-item form. Enter information in the title and description field and hit save. + +![Global metadata fields filled with custom text](https://product-team.directus.app/assets/d8c92df8-63c3-404e-8e0f-b086d27d960a.webp) + +By default, new collections are not accessible to the public. Navigate to **Settings** -> **Access Policies** -> **Public** and give Read access to the Global collection. + +## Creating a Directus Module + +Since your data will be fetched via the Directus REST API, you will need to create a module that encapsulates all that logic and exposes an interface to the outside world. + +To accomplish this, create a `directus.py` file and add the following content to it: + +```py +import requests +import os + +DIRECTUS_BASE_URL = os.environ.get("DIRECTUS_BASE_URL") + +def get_global_data(): + response = requests.get(f"{DIRECTUS_BASE_URL}/items/global") + return response.json().get("data") +``` + +By now this is all you need, but in the following sections, you will also create a new function to fetch data from other collections. + +## Rendering The Home Page + +To render the site home page, create a new route that uses the directus module to get the global data and use it on a page template. + +### Creating Page Templates + +Create a `templates/home.html` file that will extend the base template and display additional data: + +```jinja +{% extends "base.html" %} +{% block content %} +
+

{% block title %}{{ title }}{% endblock %}

+

{{ description }}

+
+{% endblock %} +``` + + +### Updating Home Route + +Update the `app.py` file: + +```py +from flask import Flask // [!code --] +from flask import Flask, render_template // [!code ++] +import directus // [!code ++] + +app = Flask(__name__) + +@app.get("/") +def home(): + return "Hello world" // [!code --] + global_data = directus.get_global_data() // [!code ++] +// [!code ++] + return render_template( // [!code ++] + "home.html", title=global_data["title"], description=global_data["description"] // [!code ++] + ) // [!code ++] +``` + +Then go to `http://localhost:3000` in your browser and you will see a page like this: + +![Home page displaying configured global data](https://marketing.directus.app/assets/a57351dd-2788-416f-8c06-5a2cc2c3dccc) + +## Creating Pages With Directus + +Create a new collection called pages - add a text input field called `slug`, which will correlate with the URL for the page. For example, about will later correlate to the page `localhost:3000/about`. + +Create a text input field called `title` and a WYSIWYG input field called `content`. In the Access Policies settings, give the Public role read access to the new collection. Create 3 items in the new collection - [here's some sample data](https://github.com/directus-community/getting-started-demo-data). + +### Rendering Dynamic Pages + +To get data of pages registered on the `pages` collection you will need to add the following code at the end of the `directus.py` file: + +```py +def get_page_by_slug(slug): + response = requests.get(f"{DIRECTUS_BASE_URL}/items/pages?filter[slug][_eq]={slug}")[0] + return response.json().get("data") +``` + +Create the `templates/dynamic-page.html` file with the following content: + +```jinja +{% extends "base.html" %} +{% block content %} +

{% block title %}{{ title }}{% endblock %}

+
{{ content | safe }}
+{% endblock %} +``` + +Then, on the `app.py` file import `render_template_string` from `Flask` and define a new app route with the following code at the end of the file: + +```py +@app.get("/") +def dynamic_page(slug): + page = directus.get_page_by_slug(slug) + + if not page: + return render_template_string( + "{% extends 'base.html' %}{% block content %}This page does not exists{% endblock %}" + ) + + return render_template( + "dynamic-page.html", title=page["title"], content=page["content"] + ) +``` + +This route fetches page data using the `directus.get_page_by_slug` method and then renders a simple not found page (defined as an inline template string) if the page does not exist, and if it exists it renders the `dynamic-page.html` template with page data on it. + +Navigate to `http://localhost:3000/about` and see the result + +![About page displaying configured data](https://product-team.directus.app/assets/199fab96-daa7-4342-a601-b0e28c60af35.webp) + +# Creating Blog Posts With Directus + +Create a new collection called `authors` with a single text input field called `name`. Create one or more authors. + +Then, create a new collection called `posts` - make a text input field called `slug`, which will correlate with the URL for the page. For example `hello-world` will later correlate to the page `localhost:3000/blog/hello-world`. + +Create the following additional fields in your `posts` data model: + +- a text input field called `title` +- a WYSIWYG input field called `content` +- an image relational field called `image` +- a datetime selection field called `publish_date` - set the type to 'date' +- a many-to-one relational field called `author` with the related collection set to `authors` + +In Access Policies, give the Public role read access to the `authors`, `posts`, and `directus_files` collections. + +Create 3 items in the posts collection - [here's some sample data](https://github.com/directus-community/getting-started-demo-data). + +### Create Blog Post Listing Page + +To fetch the blog post data add this function at the end of the `directus.py` file: + +```py +def get_posts(): + response = requests.get( + f"{DIRECTUS_BASE_URL}/items/posts?fields=slug,title,description,publish_date,author.name&sort=-publish_date" + ) + return response.json().get("data") +``` + +::callout{type="info" title="Directus Query Parameters"} + +The `fields` parameter tells Directus to return only the specified fields. The `sort` parameter tells Directus to return the most recent posts first. + +:: + +Then create a `templates/blog.html` file to display the posts data to users. + +```jinja +{% extends "base.html" %} +{% block content %} +
+

Blog posts

+
    + {% for post in posts %} +
  1. +
    +

    {{ post["title"] }}

    + + {{ post["publish_date"] }} • {{ post["author"]["name"] }} + +
    + Read post +
    +
  2. + {% endfor %} +
+
+{% endblock %} +``` + +And add the following route at the end of `app.py`: + +```py +@app.get("/blog") +def blog_page(): + posts = directus.get_posts() + + return render_template("blog.html", posts=posts) +``` + +Now navigate to`http://localhost:5000/blog` and you will see this result: + +![Blog page displaying data stored in Directus collection](https://product-team.directus.app/assets/caa2f7e9-1c5c-471e-b53b-ea9807cfe97c.webp) + +### Create Blog Post Page + +For this page you will need to add the following function at the end of `directus.py` file: + +```py +def get_post_by_slug(slug): + response = requests.get( + f"{DIRECTUS_BASE_URL}/items/posts/?filter[slug][_eq]={slug}&fields=*,author.name" + )[0] + post = response.json().get("data") + post["image"] = f'{DIRECTUS_BASE_URL}/assets/{post["image"]}' + + return post +``` + +::callout{type="info" title="File ID"} + +Note that this code is reassigning `post["image"]`, this is because Directus returns the image ID, and you need to explicitly say where it is placed in your code, following this structure: `/assets/`. +You can read more about it [in the files reference](/files/quickstart). + +:: + +Then create the page template on the `templates/post.html` file: + +```jinja +{% extends "base.html" %} +{% block content %} +
+

{% block title %}{{ post["title"] }}{% endblock %}

+ {{ post["publish_date"] }} • {{ post["author"]["name"] }} +
+
+
+ +
{{ post["content"] | safe }}
+
+{% endblock %} +``` +::callout{type="info" title="Images transformation"} + +Note that the template code appends a query string to the image URL, it is used to dynamically convert the image to the webp format and set a width of 400px to it, allowing you to prevent users from loading an excessively large image. +You can learn more about this [in the files reference](/files/transform). + +:: + +Lastly, create the page route handler at the end of `app.py`: + +```py +@app.get("/blog/") +def post_page(slug): + post = directus.get_post_by_slug(slug) + + return render_template("post.html", post=post) +``` + +Now navigate to one of your posts listed on the previous page an see the result. + +![Post page displaying post data that came from Directus posts collection](https://product-team.directus.app/assets/6465a004-0e06-43b6-adbd-dbcd2aff62e0?cache-buster=2024-11-14T10:18:16.419Z&key=system-large-contain) + +## Add Navigation + +While not strictly Directus-related, there are now several pages that aren't linked to each other. In `templates/base.html`, above the `
` tag, add a navigation. Don't forget to use your specific page slugs. + +```jinja +
+ +
+``` + +## Next steps + +Through this guide, you have set up a Flask project, created a Directus module, and used it to query data. You have used a singleton collection for global metadata, dynamically created pages, as well as blog listing and post pages. + +If you want to change what is user-accessible, consider setting up more restrictive roles and accessing only valid data at build-time. + +If you want to build more complex dynamic pages made out of reusable components, [check out our recipe on doing just this](https://docs.directus.io/guides/headless-cms/reusable-components.html). + +If you want to see the code for this project, you can find it [on GitHub](https://github.com/directus-labs/blog-example-flask). \ No newline at end of file diff --git a/content/tutorials/getting-started/fetch-data-from-directus-with-flutter.md b/content/tutorials/getting-started/fetch-data-from-directus-with-flutter.md new file mode 100644 index 00000000..a33ffe36 --- /dev/null +++ b/content/tutorials/getting-started/fetch-data-from-directus-with-flutter.md @@ -0,0 +1,506 @@ +--- +id: 28299d88-04e5-4cb8-83b1-3916696088e3 +slug: fetch-data-from-directus-with-flutter +title: Fetch Data from Directus with Flutter +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- +## Before You Start +You will need: + +- Flutter SDK: Follow the official [Flutter installation guide](https://docs.flutter.dev/get-started/install) for your operating system (Windows, macOS, or Linux). This will also install the Dart programming language, which is required for Flutter development. +- A Directus project - [follow our quickstart guide](/getting-started/create-a-project) if you don't already have one. +- A code editor installed. +- Knowledge of Dart. + +# Initialize Project +On your terminal, navigate to the directory where you want to create your project, and run the following command: + +```bash +flutter create my_directus_app +``` + +Navigate to the project directory, after the project has been created and run the application with the command: + +```bash +cd my_directus_app && flutter run +``` + +This will launch the app on an emulator or connected device. If everything is set up correctly, you should see the default Flutter app running. + +Add these dependencies to your `pubspec.yaml` file under the dependencies section: + +```yaml +dependencies: + http: ^0.13.5 + flutter_dotenv: ^5.0.2 + flutter_html: ^3.0.0-alpha.6 +``` + +Create an `.env` file in an `assets` directory of your Flutter project. This file will store your Directus Project URL. + +``` +DIRECTUS_API_URL=https://your-directus-project.com +``` + +In your `main.dart` file, import the `flutter_dotenv` package and load the environment variables: + +```dart +import 'package:flutter_dotenv/flutter_dotenv.dart'; +Future main() async { + await dotenv.load(fileName: ".env"); + runApp(MyApp()); +} +``` + +# Using Global Metadata and Settings + +In your Directus project, navigate to Settings -> Data Model and create a new collection called `global`. Under the Singleton option, select 'Treat as a single object', as this collection will have just a single entry containing global website metadata. + +Create two text input fields - one with the key of `title` and one `description`. + +Navigate to the content module and enter the global collection. Collections will generally display a list of items, but as a singleton, it will launch directly into the one-item form. Enter information in the title and description field and hit save. + +By default, new collections are not accessible to the public. Navigate to Settings -> Access Policies -> Public and give Read access to the Global collection. + +Set up a `DirectusService` class to retrieve all the global settings and use them in your project. Create a new file named `directus_service.dart` in your `lib` directory and add the code snippets: + +```dart +import 'dart:async'; +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +class DirectusService { + final String _baseUrl = dotenv.env['DIRECTUS_API_URL']!; + Future> getGlobalMetadata() async { + final response = await http.get(Uri.parse('$_baseUrl/global')); + if (response.statusCode == 200) { + return jsonDecode(response.body)['data']; + } else { + throw Exception('Failed to load global metadata'); + } + } +} +``` + +The above code creates a method to fetch the global metadata settings from Directus. + +Import the `DirectusService` class and use it to retrieve global settings and metadata: + +```dart +import 'package:flutter/material.dart'; +import 'services/directus_service.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +Future main() async { + await dotenv.load(fileName: ".env"); + runApp(MyApp()); +} +class MyApp extends StatelessWidget { + final DirectusService _directusService = DirectusService(); + MyApp({super.key}); + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: Future.wait([ + _directusService.getGlobalMetadata(), + ]), + builder: (context, + AsyncSnapshot>> settingsSnapshot) { + if (settingsSnapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } else if (settingsSnapshot.hasError) { + return Text('Error: ${settingsSnapshot.error}'); + } else { + final metadata = settingsSnapshot.data![0]; + return MaterialApp( + title: metadata['title'], + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: Scaffold( + appBar: AppBar( + title: Text(metadata['title'] ?? 'My App'), + ), + body: Center( + child: + Text(metadata['description'] ?? 'No description provided'), + ), + ), + ); + } + }, + ); + } +} +``` + +This will use the `FutureBuilder` to fetch the global metadata from Directus. Once the data is loaded, you will use it throughout your application for the app `title`, and `description` of your application. + +## Creating Pages With Directus + +Create a new collection called pages - add a text input field called `slug`, which will correlate with the URL for the page. For example about will later correlate to the page localhost:3000/about. + +Create a text input field called `title` and a WYSIWYG input field called `content`. In Access Policies, give the Public role read access to the new collection. Create 3 items in the new collection - [here's some sample data](https://github.com/directus-labs/getting-started-demo-data). + +Add a new method to fetch pages in your `DirectusService` class from Directus in the `directus_service.dart` file: + +``` + ... + Future> getPages() async { + final response = await http.get(Uri.parse('$_baseUrl/pages')); + if (response.statusCode == 200) { + return jsonDecode(response.body)['data']; + } else { + throw Exception('Failed to load pages'); + } + } + ... +``` +Create a page widget to display a single page using the data returned from the page collection. Create a `screens` directory in the `lib` directory. In the `screens` directory, create a `home_screen.dart` file and add the following code snippet: + +``` +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +class PageWidget extends StatelessWidget { + final Map page; + const PageWidget({ + super.key, + required this.page, + }); + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(page['title']), + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Html( + data: page['content'], + ), + ], + ), + ), + ), + ); + } +} +``` + +This will render the content of your `pages` collection and use the `flutter_html` package to convert the WYSIWYG content to HTML. Update the the code in your `main.dart` file to use the page widget: + +``` +import 'package:flutter/material.dart'; +import 'services/directus_service.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'screens/home_screen.dart'; +Future main() async { + await dotenv.load(fileName: ".env"); + runApp(MyApp()); +} +class MyApp extends StatelessWidget { + final DirectusService _directusService = DirectusService(); + MyApp({super.key}); + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: Future.wait([ + _directusService.getGlobalMetadata(), + ]), + builder: (context, + AsyncSnapshot>> settingsSnapshot) { + if (settingsSnapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } else if (settingsSnapshot.hasError) { + return Text('Error: ${settingsSnapshot.error}'); + } else { + final metadata = settingsSnapshot.data![0]; + return MaterialApp( + title: metadata['title'], + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: FutureBuilder>( + future: _directusService.getPages(), + builder: (context, pagesSnapshot) { + if (pagesSnapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } else if (pagesSnapshot.hasError) { + return Text('Error: ${pagesSnapshot.error}'); + } else { + final pages = pagesSnapshot.data!; + return pages.isNotEmpty + ? PageWidget( + page: pages, + ) + : const Text('No pages found'); + } + }, + ), + ); + } + }, + ); + } +} +``` + +![Showing the contents from the pages collection in flutter application](https://product-team.directus.app/assets/93903c78-1437-49e9-9c7c-2f7fa17bd367.webp) + +## Creating Blog Posts With Directus +Similar to creating pages, you can also create and manage blog posts using Directus CMS. Create a new collection called `authors` with a single text input field called `name`. Add one or more authors to the collection. + +Create another collection called `posts` and add the following fields: + +- **slug**: Text input field +- **title**: Text input field +- **content**: WYSIWYG input field +- **image**: Image relational field +- **author**: Many-to-one relational field with the related collection set to `authors` + +Add 3 items in the `posts` collection - [here's some sample data](https://github.com/directus-community/getting-started-demo-data). + +## Create Blog Post Listing + +Add a new method to your `DirectusService` class to fetch blog posts from Directus: + +``` + ... + Future> getBlogPosts() async { + final response = await http.get(Uri.parse('$_baseUrl/posts')); + if (response.statusCode == 200) { + return jsonDecode(response.body)['data']; + } else { + throw Exception('Failed to load blog posts'); + } + } + ... +``` + +Update the code in your `lib/screens/home_screen.dart` file to render the blog posts in the `PageWidget`: + +``` +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +class PageWidget extends StatelessWidget { + final Map pages; + final List blogPosts; + const PageWidget({ + super.key, + required this.pages, + required this.blogPosts, + }); + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(pages['title']), + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Html( + data: pages['content'], + ), + const SizedBox(height: 32), + Text( + 'Blog Posts', + style: Theme.of(context).textTheme.headline6, + ), + const SizedBox(height: 16), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: blogPosts.length, + itemBuilder: (context, index) { + final blogPost = blogPosts[index]; + return BlogPostItem(blogPost: blogPost); + }, + ), + ], + ), + ), + ), + ); + } +} +class BlogPostItem extends StatelessWidget { + final dynamic blogPost; + const BlogPostItem({ + super.key, + required this.blogPost, + }); + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + blogPost['title'], + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), + Html( + data: blogPost['content'], + ), + ], + ), + ); + } +}` +``` + +The `PageWidget` accepts `blogPost` which are the blog posts from Directus as a required parameter. Update the code in your `main.dart` file to pass it from the `DirectusService` class instance: + +``` +import 'package:flutter/material.dart'; +import 'services/directus_service.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'screens/home_screen.dart'; +Future main() async { + await dotenv.load(fileName: ".env"); + runApp(MyApp()); +} +class MyApp extends StatelessWidget { + final DirectusService _directusService = DirectusService(); + MyApp({super.key}); + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: Future.wait([ + _directusService.getGlobalMetadata(), + _directusService.getBlogPosts(), + ]), + builder: (context, AsyncSnapshot> settingsSnapshot) { + if (settingsSnapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } else if (settingsSnapshot.hasError) { + return Text('Error: ${settingsSnapshot.error}'); + } else { + final metadata = settingsSnapshot.data![0]; + final blogPosts = settingsSnapshot.data![1]; + return MaterialApp( + title: metadata['title'], + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: FutureBuilder>( + future: _directusService.getPages(), + builder: (context, pagesSnapshot) { + if (pagesSnapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } else if (pagesSnapshot.hasError) { + return Text('Error: ${pagesSnapshot.error}'); + } else { + final pages = pagesSnapshot.data!; + return pages.isNotEmpty + ? PageWidget(pages: pages, blogPosts: blogPosts) + : const Text('No pages found'); + } + }, + ), + ); + } + }, + ); + } +} +``` + +![Display the contents from the posts collection](https://product-team.directus.app/assets/033e07f5-122d-4457-b026-5a96a45b711b.webp) + +## Create Blog Post Detail + +Create a new file called `post_single.dart` file in the `lib/screens` directory. Then create a `BlogPostWidget`: + +``` +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +class BlogPostWidget extends StatelessWidget { + final Map post; + const BlogPostWidget({super.key, required this.post }); + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(post['title']), + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Html( + data: post['content'], + ), + ], + ), + ), + ), + ); + } +} +``` + +The `BlogPostWidget` serves as the single blog post view. When a user clicks on a blog post from the listing, the app navigates to this widget, displaying the full content of the selected post. + +# Add Navigation + +Update the `BlogPostItem` class in the `lib/screens/home_screen.dart` file to add navigation to the project: + +``` +class BlogPostItem extends StatelessWidget { + final dynamic blogPost; + const BlogPostItem({ + super.key, + required this.blogPost, + }); + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BlogPostWidget(post: blogPost), + ), + ); + }, + child: Container( + margin: const EdgeInsets.only(bottom: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + blogPost['title'], + style: Theme.of(context).textTheme.labelLarge, + ), + ], + ), + ), + ); + } +} +``` + +With the above code, when the user taps on the `BlogPostItem`, it triggers the `onTap` callback function. Inside this function, the `Navigator.push` will navigate to a new screen. `MaterialPageRoute` will define the widget to be displayed on the new screen as `BlogPostWidget`. Also, the `blogPost` data is passed as a parameter to the `BlogPostWidget` widget. This will allow you to display detailed information about the selected `blog` post on the new screen. + +![Navigating to the blog single page](https://product-team.directus.app/assets/c17cbbe5-174a-4b37-a3bd-2eb31a518bfa.webp) + +## Summary + +Throughout this tutorial, you've learned how to build a Flutter application that uses data from a Directus project. You started by creating a new project, set up environment variables and everything you need to call Directus. You then created pages and posts collections in Directus and integrated them with the the Flutter. diff --git a/content/tutorials/getting-started/fetch-data-from-directus-with-laravel.md b/content/tutorials/getting-started/fetch-data-from-directus-with-laravel.md new file mode 100644 index 00000000..40888864 --- /dev/null +++ b/content/tutorials/getting-started/fetch-data-from-directus-with-laravel.md @@ -0,0 +1,383 @@ +--- +id: 9a8f3c14-f969-4d2a-9b00-5b22f5dcdc33 +slug: fetch-data-from-directus-with-laravel +title: Fetch Data from Directus with Laravel +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- +In this tutorial, you will learn how to build a website using Directus as a Headless CMS. You will store, retrieve, and use global metadata such as the site title, create new pages dynamically based on Directus items, and build a blog. + +## Before You Start + +You will need: + +- [PHP 7.4](https://www.php.net/releases/7_4_0.php) or higher +- [Composer](https://getcomposer.org/) +- A code editor on your computer. +- A Directus project - follow our [quickstart guide](/getting-started/create-a-project) if you don't already have one. +- Some knowledge of Laravel. + +The code for this tutorial is available on my [GitHub repository](https://github.com/directus-labs/blog-example-getting-started-laravel). + +Set up a new Laravel project move into the project directory by running the following commands: + +```shell +composer create-project laravel/laravel directus-laravel-blog +cd directus-laravel-blog +``` + +## Creating a Directus Module + +Create a new service provider with the following command: + +```shell +php artisan make:provider DirectusServiceProvider +``` + +Update the `app/Providers/DirectusServiceProvider.php` file with the following code: + +```php +app->singleton('directus', function ($app) { + return new class { + protected $baseUrl; + + public function __construct() + { + $this->baseUrl = rtrim(env('DIRECTUS_URL'), '/'); } + + public function request($method, $endpoint, $data = []) + { + $url = "{$this->baseUrl}/items/{$endpoint}"; + return Http::$method($url, $data); + } + + public function get($endpoint, $params = []) + { + return $this->request('get', $endpoint, $params); + } + }; + }); + } +} +``` + +This defines a `DirectusServiceProvider` class which creates a singleton instance for interacting with a Directus API. It provides methods to make HTTP requests to the API, with the base URL set from environment variables. + +## Using Global Metadata and Settings + +Create a new collection named `global` in your Directus project by navigating to **Settings -> Data Model**. Choose 'Treat as a single object' under the Singleton option since this collection will have just one item with global website metadata in it. + +Create two text input fields, one with the key `title` and the other with `description`. + +Navigate to the content module and enter the global collection. Collections will generally display a list of items, but as a singleton, it will launch directly into the one-item form. Enter information in the title and description field and hit save. + +By default, new collections are not accessible to the public. Navigate to Settings -> Access Policies -> Public and give Read access to the `global` collection. + +Create a `HomeController` with the command: + +```shell +php artisan make:controller HomeController +``` + +Open the `app/Http/Controllers/HomeController.php` that was created with the above command, and use the `DirectusServiceProvider` class instance to a call to the Directus backend to fetch global metadata. + +```php +get('global'); + $settings = $settingsResponse['data']; + return view('home', compact('settings')); + } +} +``` + +The `DirectusServiceProvider` registers a singleton instance of Directus API, which can be accessed throughout the application using `app('directus')`. The `HomeController` uses this instance to fetch global settings from the Directus backend and pass them to the view. + +Create a `home.blade.php` file in the `resources/views` directory and add the following code to render the global metadata settings: + +```html + + + + + + {{ $settings['site_title'] }} + + +

{{ $settings['site_title'] }}

+

{{ $settings['site_description'] }}

+ + +``` + +Edit the code in your `routes/web.php` file to add a new route for the `HomeController` view: + +```php +get('pages', [ + 'filter' => ['slug' => $slug] + ]); + $page = $pageResponse['data'][0]; + return view('page', compact('page')); + } +} +``` +The above code uses the Directus instance to fetch the page data from the Directus backend and pass them to the view. + +Create a new blade view file named `page.blade.php` in your `resources/views` directory and add the following code: + +```html + + + + + + {{ $page['title'] }} + + +

{{ $page['title'] }}

+ {!! $page['content'] !!} + + +``` + +Edit the `routes/web.php` file to add a new route for the `PageController` view: + +```php +use Illuminate\Support\Facades\Route; +use App\Http\Controllers\HomeController; +use App\Http\Controllers\PostController; + +Route::get('/page/{slug}', [PageController::class, 'show']); +Route::get('/', [HomeController::class, 'index']); +``` + +Navigate to `http://127.0.0.1:8000/page/about` to view the About page. + +![dynamic about page](https://product-team.directus.app/assets/3eaa88b8-b18c-4f81-9a33-17f178f80c2f.webp) + +### Creating Blog Posts With Directus + +Create a new collection called `authors` and include a single `name` text input field. Create one or more authors. + +Create another collection called `posts` and add the following fields: + +- title (Type: String) +- slug (Type: String) +- content (Type: WYSIWYG) +- image (Type: Image relational field) +- author (Type: Many-to-one relational field with the related collection set to authors) + +Add 3 items in the posts collection - [here's some sample data](https://github.com/directus-community/getting-started-demo-data). + +Create a `app/Http/Controllers/PageController.php` file by running the command: + +```shell +php artisan make:controller PageController +``` + +Update the `app/Http/Controllers/PageController.php` file with the following code: + +```php +get('posts', [ + 'sort' => ['-date_created'], + 'limit' => 10 + ]); + $posts = $postsResponse['data']; + + return view('posts.index', compact('posts')); + } + + public function show($id) + { + $directus = app('directus'); + $postResponse = $directus->get('posts', $id); + $post = $postResponse['data']; + return view('posts.show', compact('post')); + } +} +``` + +The above code fetches the blogs from the Directus backend and passes them to the posts view. + +Create a `resources/views/page.blade.php` file for the page blade view and add the following code. + +```html + + + + + + Blog Posts + + +

Blog Posts

+ @foreach($posts as $post) + + @endforeach + + +``` + +Create another view file `resources/views/posts/show.blade.php` for the blog single page: + +```html + + + + + + {{ $post['title'] }} + + +

{{ $post['title'] }}

+

Posted on: {{ date('F j, Y', strtotime($post['date_created'])) }}

+ {!! $post['content'] !!} + Back to Blog + + +``` + +Add the following routes to your `routes/web.php` file: + +```php +name('posts.index'); +Route::get('/blog/{id}', [PostController::class, 'show'])->name('posts.show'); +Route::get('/page/{slug}', [PageController::class, 'show']); +Route::get('/', [HomeController::class, 'index']); +``` +Navigate to `http://127.0.0.1:8000/blog` to access the blogs page. + +![blog list page](https://product-team.directus.app/assets/37562bf0-164e-436d-a7e1-6f42ef1afe9a.webp) + +## Add Navigation + +Run the commmand below to create a new service provider: + +```shell +php artisan make:provider ViewServiceProvider +``` + +Then update `app/Providers/ViewServiceProvider.php` file with the following code: + +```php + '/', 'label' => 'Home'], + ['url' => '/blog', 'label' => 'Blog Posts'], + ['url' => '/page/about', 'label' => 'About'], + ]; + + View::composer('*', function ($view) use ($navigation) { + $view->with('navigation', $navigation); + }); + } +} +``` + +The `ViewServiceProvider` provider service class registers an array of navigations for your application and will be used across your views to allow your users to navigate throughout the application. + +Update all your views files in the **views** directory to add the navigation: + +```html + + +``` + +![A content page with three navigation links at the top.](https://product-team.directus.app/assets/e55b9b9f-a74f-44d8-926e-29a274c8a41e.webp) + +## Summary + +Throughout this tutorial, you've learned how to build a Laravel application that uses data from a Directus project. You started by creating a new project, setting up environment variables, and everything you need to call Directus. You then created pages and post collections in Directus and integrated them with the Laravel project. \ No newline at end of file diff --git a/content/tutorials/getting-started/fetch-data-from-directus-with-nextjs.md b/content/tutorials/getting-started/fetch-data-from-directus-with-nextjs.md new file mode 100644 index 00000000..ff948253 --- /dev/null +++ b/content/tutorials/getting-started/fetch-data-from-directus-with-nextjs.md @@ -0,0 +1,337 @@ +--- +id: 66f569cd-d9cd-42ee-b064-b46cdf380c62 +slug: fetch-data-from-directus-with-nextjs +title: Fetch Data from Directus with Next.js +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- +[Next.js](https://nextjs.org/) is a popular JavaScript framework based on React.js. In this tutorial, you will learn how +to build a website using Directus as a [Headless CMS](https://directus.io/solutions/headless-cms). You will store, +retrieve, and use global metadata such as the site title, create new pages dynamically based on Directus items, and +build a blog. + +## Before You Start + +You will need: + +- To install Node.js and a code editor on your computer. +- To sign up for a Directus Cloud account. +- Some knowledge of React.js and Next. + +Create a new Directus Cloud project - any tier and configuration is suitable for this tutorial. + +Open your terminal and run the following command to create a new Next project: + +```shell +# The options below is what is recommended for a completion of this guide. +# See https://nextjs.org/docs/pages/api-reference/create-next-app +# for all possible options. + +npx create-next-app \ + my-website \ + --js \ + --app \ + --eslint \ + --no-src-dir \ + --no-tailwind \ + --no-turbopack \ + --import-alias "@/*" +``` + +Once finished, navigate into the new directory, delete all of the files in `app` so you can build this project from +scratch and install the Directus JavaScript SDK: + +```shell +cd my-website +rm app/* +npm install @directus/sdk +``` + +Now, open `my-website` in your code editor for the following steps. + +## Create a Helper for the SDK + +To share a single instance of the Directus JavaScript SDK between multiple pages in this project, create a single helper +file that can be imported later. Create a new directory called `lib` and a new file called `directus.js` inside of it. + +```js +import { createDirectus, rest } from '@directus/sdk'; + +const directus = createDirectus('https://directus.example.com').with(rest()); + +export default directus; +``` + +::callout{type="info" title="Next.js Caching"} + +Next.js extends the native fetch API with a `force-cache` configuration by default. This means you may sometimes run +into scenarios where Next.js returns stale data. To fix this, update the `rest()` composable as follows: + +```js +const directus = createDirectus('https://directus.example.com').with( + rest({ + onRequest: (options) => ({ ...options, cache: 'no-store' }), + }) +); +``` + +:: + +Ensure your Project URL is correct when initializing the Directus JavaScript SDK. + +## Using Global Metadata and Settings + +In your Directus project, navigate to Settings -> Data Model and create a new collection called `global`. Under the +Singleton option, select 'Treat as a single object', as this collection will have just a single entry containing global +website metadata. + +Create two text input fields - one with the key of `title` and one `description`. + +Navigate to the content module and enter the global collection. Collections will generally display a list of items, but +as a singleton, it will launch directly into the one-item form. Enter information in the title and description field and +hit save. + +![A form named "Global" has two inputs - a title and a description, each filled with some text.](https://product-team.directus.app/assets/7ea2d6b3-d7ca-4a71-bdaa-cd2ce8c75ec1.webp) + +By default, new collections are not accessible to the public. Navigate to Settings -> Access Policies -> Public and give +Read access to the Global collection. + +Inside of the `app` directory, create a new file called `page.jsx`. + +```jsx +import directus from '@/lib/directus'; +import { readItems } from '@directus/sdk'; + +async function getGlobals() { + return directus.request(readItems('global')); +} + +export default async function HomePage() { + const global = await getGlobals(); + return ( +
+

{global.title}

+

{global.description}

+
+ ); +} +``` + +Type `npm run dev` in your terminal to start the Next development server and open http://localhost:3000 in your browser. +You should see data from your Directus Global collection in your page. Some additional files will be created by Next +that it expects, but do not yet exist - these can be safely ignored for now. + +## Creating Pages With Directus + +Create a new collection called `pages` - make a text input field called `slug`, which will +correlate with the URL for the page. For example `about` will later correlate to the page `localhost:3000/about`. + +Create another text input field called `title` and a WYSIWYG input field called `content`. In Access Policies, give the Public +role read access to the new collection. Create 3 items in the new collection - +[here's some sample data](https://github.com/directus-community/getting-started-demo-data). + +Inside of `app`, create a new directory called `[slug]` with a file called `page.jsx`. This is a dynamic route, so a +single file can be used for all of the top-level pages. + +```jsx +import directus from '@/lib/directus'; +import { notFound } from 'next/navigation'; +import { readItem } from '@directus/sdk'; + +async function getPage(slug) { + try { + const pages = await directus.request(readItems('pages', { + fields: [{ slug }], + })); + return pages[0]; + } catch (error) { + notFound(); + } +} + +export default async function DynamicPage({ params }) { + const page = await getPage(params.slug); + return ( +
+

{page.title}

+
+
+ ); +} +``` + +Go to http://localhost:3000/about, replacing `about` with any of your item slugs. Using the Directus JavaScript SDK, the +first item with that slug is retrieved, and the page should show your data. `readItems()` allows you to specify the +`slug` Field. + +_Note that we check if a returned value exists, and return a 404 if not. Please also note that +[`dangerouslySetInnerHTML` should only be used for trusted content](https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml)._ + +## Creating Blog Posts With Directus + +Create a new collection called `authors` with a single text input field called `name`. Create one or more authors. + +Then, create a new collection called `posts` - add a text input field called `slug`, +which will correlate with the URL for the page. For example `hello-world` will later correlate to the page +`localhost:3000/blog/hello-world`. + +Create the following additional fields in your `posts` data model: + +- a text input field called `title` +- a WYSIWYG input field called `content` +- an image relational field called `image` +- a datetime selection field called `publish_date` - set the type to 'date' +- a many-to-one relational field called `author` with the related collection set to `authors` + +In Access Policies, give the Public role read access to the `authors`, `posts`, and `directus_files` collections. + +Create 3 items in the posts collection - +[here's some sample data](https://github.com/directus-community/getting-started-demo-data). + +### Create Blog Post Listing + +Inside of the `app` directory, create a new subdirectory called `blog` and a new file called `page.jsx` inside of it. + +```jsx +import directus from '@/lib/directus'; +import { readItems } from '@directus/sdk'; + +async function getPosts() { + return directus.request( + readItems('posts', { + fields: ['slug', 'title', 'publish_date', { author: ['name'] }], + sort: ['-publish_date'], + }) + ); +} + +export default async function DynamicPage() { + const posts = await getPosts(); + return ( +
+

Blog

+
+ ); +} +``` + +This query will retrieve the first 100 items (default), sorted by publish date (descending order, which is latest +first). It will only return the specific fields we request - `slug`, `title`, `publish_date`, and the `name` from the +related `author` item. + +Update the returned HTML: + +```jsx +
+

Blog

+
    + {posts.map((post) => { + return ( +
  • +

    + + {post.title} + +

    + + {post.publish_date} • {post.author.name} + +
  • + ); + })} +
+
+``` + +Visit http://localhost:3000/blog and you should now see a blog post listing, with latest items first. + +![A page with a title of "Blog". On it is a list of three items - each with a title, author, and date. The title is a link.](https://product-team.directus.app/assets/bcbab1ac-e4b3-4614-b4b5-2be97e71a2ae.webp) + +### Create Blog Post Pages + +Each blog post links to a page that does not yet exist. In the `app/blog` directory, create a new directory called +`[slug]`, and within it a `page.jsx` file: + +```jsx +import directus from '@/lib/directus'; +import { readItems } from '@directus/sdk'; +import { notFound } from 'next/navigation'; + +async function getPost(slug) { + try { + const posts = await directus.request( + readItems('posts', { + fields: ['*', { slug, image: ['filename_disk'], author: ['name'] }], + }) + ); + + return posts[0]; + } catch (error) { + notFound(); + } +} + +export default async function DynamicPage({ params }) { + const post = await getPost(params.slug); + return ( + <> + +

{post.title}

+
+ + ); +} +``` + +Some key notes about this code snippet. + +- In the `` tag, `directus.url` is the value provided when creating the Directus plugin. +- The `width` attribute demonstrates Directus' built-in image transformations. +- Once again, `dangerouslySetInnerHTML` should only be used if all content is trusted. + +Click on any of the blog post links, and it will take you to a blog post page complete with a header image. + +![A blog post page shows an image, a title, and a number of paragraphs.](https://product-team.directus.app/assets/5603-4992-9c5b-b08765a9186a.webp) + +## Add Navigation + +While not strictly Directus-related, there are now several pages that aren't linked to each other. Create the file +`app/layout.jsx` to add a navigation above the main content. Don't forget to use your specific page slugs. + +```jsx +import Link from 'next/link'; + +export default function RootLayout({ children }) { + return ( + + + +
{children}
+ + + ); +} +``` + +Make sure to remove the automatically generated `layout.js` files in your project. + +## Next Steps + +Through this guide, you have set up a Next project, created a Directus helper, and used it to query data. You have used +a singleton collection for global metadata, dynamically created pages, as well as blog listing and post pages. + +If you want to change what is user-accessible, consider setting up more restrictive roles and accessing only valid data +at build-time. + +If you want to build more complex dynamic pages made out of reusable components, check out +[our recipe on doing just this](/guides/headless-cms/reusable-components). + +If you want to see the code for this project, you can find it +[on GitHub](https://github.com/directus/examples/blob/main/website-next13). diff --git a/content/tutorials/getting-started/fetch-data-from-directus-with-nuxt.md b/content/tutorials/getting-started/fetch-data-from-directus-with-nuxt.md new file mode 100644 index 00000000..9a61c049 --- /dev/null +++ b/content/tutorials/getting-started/fetch-data-from-directus-with-nuxt.md @@ -0,0 +1,284 @@ +--- +id: bf074448-1967-4c83-8926-9f2005029974 +slug: fetch-data-from-directus-with-nuxt +title: Fetch Data from Directus with Nuxt +authors: + - name: Kevin Lewis + title: Director Developer Experience +--- +[Nuxt](https://nuxt.com/) is a popular JavaScript framework based on Vue.js. In this tutorial, you will learn how to +build a website using Directus as a [Headless CMS](https://directus.io/solutions/headless-cms). You will store, +retrieve, and use global metadata such as the site title, create new pages dynamically based on Directus items, and +build a blog. + +## Before You Start + +You will need: + +- To install Node.js and a code editor on your computer. +- To sign up for a Directus Cloud account. +- Some knowledge of Vue.js and Nuxt. + +Create a new Directus Cloud project - any tier and configuration is suitable for this tutorial. + +Open your terminal and run the following commands to create a new Nuxt project and the Directus JavaScript SDK: + +``` +npx nuxt init my-website +cd my-website +npm install +npm install @directus/sdk +``` + +Open `my-website` in your code editor and type `npm run dev` in your terminal to start the Nuxt development server and +open http://localhost:3000 in your browser. + +## Create a Plugin for the SDK + +To expose an Node.js package available globally in your Nuxt project you must create a plugin. Create a new directory +called `plugins` and a new file called `directus.js` inside of it. + +```js +import { createDirectus, rest, readItem, readItems } from '@directus/sdk'; + +const directus = createDirectus('https://directus.example.com').with(rest()); + +export default defineNuxtPlugin(() => { + return { + provide: { directus, readItem, readItems }, + }; +}); +``` + +Ensure your Project URL is correct when initializing the Directus JavaScript SDK. + +Inside of your `app.vue` entry file, add the following to the bottom to test that your plugin works: + +```vue + +``` + +Refresh your browser, and check the console. You should see the Directus instance logged, which means you have access to +all of the Directus JavaScript SDK methods by using the `useNuxtApp()` composable in any page or component. + +Once you've confirmed this, remove the ` +``` + +Refresh your browser. You should see data from your Directus Global collection in your page. + +## Creating Pages With Directus + +Create a new collection called `pages` - make a text input field called `slug`, which will +correlate with the URL for the page. For example `about` will later correlate to the page `localhost:3000/about`. + +Create another text input field `title` and a WYSIWYG input field called `content`. In Access Policies, give the Public +role read access to the new collection. Create 3 items in the new collection - +[here's some sample data](https://github.com/directus-community/getting-started-demo-data). + +Inside of `pages`, create a new file called `[slug].vue`. This is a dynamic route, so a single file can be used for all +of the top-level pages. + +```vue + + + +``` + +Go to http://localhost:3000/about, replacing `about` with any of your item slugs. Using the Directus JavaScript SDK, the + item with that slug is retrieved, and the page should show your data. `readItems()` checks all pages that have the specified `slug` field. + +_Note that we check if a returned value exists, and return a 404 if not. Please also note that +[`v-html` should only be used for trusted content](https://vuejs.org/api/built-in-directives.html#v-html)._ + +## Creating Blog Posts With Directus + +Create a new collection called `authors` with a single text input field called `name`. Create one or more authors. + +Then, create a new collection called `posts` - add a text input field called `slug`, +which will correlate with the URL for the page. For example `hello-world` will later correlate to the page +`localhost:3000/blog/hello-world`. + +Create the following additional fields in your `posts` data model: + +- a text input field called `title` +- a WYSIWYG input field called `content` +- an image relational field called `image` +- a datetime selection field called `publish_date` - set the type to 'date' +- a many-to-one relational field called `author` with the related collection set to `authors` + +In Access Policies, give the Public role read access to the `authors`, `posts`, and `directus_files` collections. + +Create 3 items in the posts collection - +[here's some sample data](https://github.com/directus-community/getting-started-demo-data). + +### Create Blog Post Listing + +Inside of the `pages` directory, create a new subdirectory called `blog` and a new file called `index.vue` inside of it. + +```vue + + + +``` + +This query will retrieve the first 100 items (default), sorted by publish date (descending order, which is latest +first). It will only return the specific fields we request - `slug`, `title`, `publish_date`, and the `name` from the +related `author` item. + +Update the ` + + +``` + +### Load `upsert.vue` in `router.js` + +```js +import Upsert from "../views/upsert.vue"; +``` + +```js + { + path: "/note/:id", + name: "upsert", + meta: { public: false }, + component: Upsert, + }, +``` + +![Create Note](https://product-team.directus.app/assets/0f21f1a5-ee69-4e45-8535-2200bf985184.webp) + +![Edit Note](https://product-team.directus.app/assets/a5946d5b-75cd-45b6-9f8b-fcded5eb8916.webp) + +## Summary + +In this tutorial, you've learnt how to build a Chrome Extension that authenticates with Directus and allows the user to manage data. There's still some more polish and functionality you can build, but a lot of it will be based on the same concepts we've worked through here. diff --git a/content/tutorials/projects/build-a-testimonial-widget-with-svelte-kit-and-directus-.md b/content/tutorials/projects/build-a-testimonial-widget-with-svelte-kit-and-directus-.md new file mode 100644 index 00000000..70894924 --- /dev/null +++ b/content/tutorials/projects/build-a-testimonial-widget-with-svelte-kit-and-directus-.md @@ -0,0 +1,556 @@ +--- +id: 95197ebe-fc70-4f80-a053-a894f3b0b00a +slug: build-a-testimonial-widget-with-svelte-kit-and-directus- +title: Build a Testimonial Widget with SvelteKit and Directus +authors: [] +--- +In this tutorial, we will setup a testimonial widget using SvelteKit and Directus as a backend. + +## Before You Start + +You will need: + +- To install Node.js and a code editor on your computer. +- A Directus project - follow our [quickstart guide](/getting-started/create-a-project) if you don't already have one. +- Some knowledge of Svelte and SvelteKit. + +## Setting Up Your Directus Project + +Create a `testimonials` collection with the following fields: + +- `full_name` (Type: String, Interface: Input): To capture the user's full name. +- `email_address` (Type: String, Interface: Input): To store the user's email address. +- `review` (Type: Text, Interface: TextArea): To store the user's testimonials. + +Then give the public role full access to create and read items in the `testimonials` collection. + +Create 3 example testimonials from the content module. + +## Initializing a Svelte project + +Initialize a new Svelte project by running the following command: + +```bash +npm create svelte@latest testimonial-frontend # Choose Skeleton project +cd testimonial-frontend +npm install +npm install @directus/sdk +``` + +Type `npm run dev` in your terminal to start the Vite development server and open [http://localhost:5173](http://localhost:5173) in your browser to access the Svelte website. + +## Setting Up the Directus SDK + +To make the Directus SDK available to your project, you need to setup a wrapper for the Directus SDK. + +Add a `directus.js` file to the `./src/lib` directory and add the following to the file. + +```js +import { createDirectus, rest } from '@directus/sdk'; +import { PUBLIC_API_URL } from '$env/static/public'; + + +function getDirectusInstance(fetch) { + const options = fetch ? { globals: { fetch } } : {}; + const directus = createDirectus(PUBLIC_API_URL, options).with(rest()); + return directus; +} + +export default getDirectusInstance; +``` + +Add a `hooks.server.js` file to your `./src` directory, and add the following to the file. + +```js +export async function handle({ event, resolve }) { + return await resolve(event, { + filterSerializedResponseHeaders: (key, value) => { + return key.toLowerCase() === 'content-type'; + }, + }); +} +``` + +The `hooks.server.js` ensures that request headers required by the Directus backend are added to every request sent from your frontend to the Directus server. + +Create a `.env` file in your project’s root directory and add the following to the file + +```bash +PUBLIC_API_URL='directus_server_url' +``` + +Change `directus_server_url` to the URL of your Directus project. + +### Fetching Data From Directus + +Add a `+page.js` file to your `./src/routes` directory, and add the following content to the file. + +```js +/** @type {import('./$types').PageLoad} */ +import getDirectusInstance from "$lib/directus"; +import { error } from "@sveltejs/kit"; +import { readItems } from "@directus/sdk"; + +export async function load({ fetch }) { + const directus = getDirectusInstance(fetch); + try { + return { + testimonials: await directus.request(readItems("testimonials")), + }; + } catch (err) { + error(err); + } +} +``` + +The `load` function fetch data from your testimonials collection on every page load. Update your `+page.svelte` file to the following. + +```js + + +
+
{data.testimonials[0].full_name}
+
{data.testimonials[0].email_address}
+
{data.testimonials[0].review}
+
+``` + +Your page should contain information from your testimonials collection. + +## Create a Testimonial Carousel + +Add a `TestimonialCard.svelte` and `TestimonialCarousel.svelte` file to your `./src/lib` directory. Add the following to your `TestiomonialCard.svelte` file: + +```js + + +
+
{review}
+
+
+ {full_name} {email_address} +
+
+
+ + +``` + +This code displays individual testimonial data in a Card. Add the following to your `TestimonialCarousel.svelte` file to implement the testimonial carousel: + +```js + + + + + + + +``` + +Update your `+page.svelte` file: + +```js + + +
+

Product testimonials

+
+ +
+ +
+ + +``` + +Your page should change to something similar to the following. + +![Svelte Testimonial Carousel](https://product-team.directus.app/assets/155ded4b-87c7-445b-b1c9-4cb9024ba464.webp) + +## Creating the Add Testimonial Form + +The final step is to implement your Add Testimonial form. This form will allow users add data to your Testimonials collection directly from your svelte website. + +Add a `TestimonialCreate.svelte` file your `./src/lib` directory and add the following code to the file. + +```js + + +
+
+

Add your Testimonial

+ + + + + +