From dbb7026f7e1557d20dbcb132f626393c38d80a3a Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 10 Jun 2024 15:38:07 +0200 Subject: [PATCH] feat(docs): import docs --- docs/blog/2019-05-28-first-blog-post.md | 12 - docs/blog/2019-05-29-long-blog-post.md | 44 - docs/blog/2021-08-01-mdx-blog-post.mdx | 20 - .../docusaurus-plushie-banner.jpeg | Bin 96122 -> 0 bytes docs/blog/2021-08-26-welcome/index.md | 25 - docs/blog/authors.yml | 17 - docs/blog/tags.yml | 16 - docs/docs/cloud/permissions.md | 61 + docs/docs/guides/acl-definition.md | 134 + docs/docs/guides/deploy-contember.md | 53 + docs/docs/guides/deploy-github-actions.md | 60 + docs/docs/guides/self-hosted-contember.md | 144 + docs/docs/guides/seo.md | 188 + docs/docs/guides/superface.md | 147 + docs/docs/intro.md | 47 - docs/docs/intro/actions.mdx | 72 + docs/docs/intro/deployment.mdx | 44 + docs/docs/intro/glossary.md | 33 + docs/docs/intro/graphql.mdx | 198 + docs/docs/intro/how-it-works.md | 138 + docs/docs/intro/installation.mdx | 81 + docs/docs/intro/interface.mdx | 246 + docs/docs/intro/introduction.mdx | 87 + docs/docs/intro/quickstart.mdx | 112 + docs/docs/intro/studio-quickstart.mdx | 204 + docs/docs/reference/cli.md | 41 + .../reference/engine/actions/definition.md | 265 + .../reference/engine/actions/invocation.md | 251 + .../docs/reference/engine/actions/managing.md | 160 + .../docs/reference/engine/actions/overview.md | 74 + .../content/advanced/assume-identity.md | 36 + .../content/advanced/assume-membership.md | 77 + .../engine/content/advanced/caching.md | 62 + .../content/advanced/request-debugging.md | 17 + .../reference/engine/content/event-log.md | 116 + .../reference/engine/content/mutations.md | 164 + .../docs/reference/engine/content/overview.md | 19 + docs/docs/reference/engine/content/queries.md | 425 + docs/docs/reference/engine/content/s3.md | 100 + .../docs/reference/engine/content/transfer.md | 163 + .../advanced/development-commands.md | 62 + .../advanced/skipping-validations.md | 56 + .../advanced/writing-schema-migrations.md | 112 + .../reference/engine/migrations/basics.md | 55 + .../engine/migrations/content-migrations.md | 156 + .../reference/engine/migrations/overview.md | 47 + docs/docs/reference/engine/schema/acl.md | 276 + docs/docs/reference/engine/schema/columns.md | 206 + docs/docs/reference/engine/schema/overview.md | 58 + .../reference/engine/schema/relationships.md | 295 + .../reference/engine/schema/tenant-acl.md | 87 + .../reference/engine/schema/validations.md | 25 + docs/docs/reference/engine/schema/views.md | 113 + docs/docs/reference/engine/tenant/api-keys.md | 117 + docs/docs/reference/engine/tenant/idp.md | 213 + docs/docs/reference/engine/tenant/invites.md | 47 + .../reference/engine/tenant/mail-templates.md | 106 + .../reference/engine/tenant/memberships.md | 92 + docs/docs/reference/engine/tenant/overview.md | 41 + docs/docs/reference/engine/tenant/sessions.md | 83 + .../data-binding/custom-components.md | 121 + .../interface/data-binding/overview.md | 95 + .../interface/data-binding/query-language.md | 253 + .../data-binding/relationship-components.md | 120 + .../interface/data-binding/value-rendering.md | 97 + docs/docs/reference/interface/introduction.md | 28 + .../interface/pages/_Example/slots/define.tsx | 24 + .../interface/pages/_Example/slots/layout.tsx | 26 + .../interface/pages/_Example/slots/usage.tsx | 7 + docs/docs/reference/interface/pages/links.md | 78 + .../reference/interface/pages/overview.md | 24 + .../docs/reference/interface/pages/routing.md | 89 + docs/docs/reference/interface/pages/slots.mdx | 84 + docs/docs/tutorial-basics/_category_.json | 8 - docs/docs/tutorial-basics/congratulations.md | 23 - .../tutorial-basics/create-a-blog-post.md | 34 - .../docs/tutorial-basics/create-a-document.md | 57 - docs/docs/tutorial-basics/create-a-page.md | 43 - docs/docs/tutorial-basics/deploy-your-site.md | 31 - .../tutorial-basics/markdown-features.mdx | 152 - docs/docs/tutorial-extras/_category_.json | 7 - .../img/docsVersionDropdown.png | Bin 25427 -> 0 bytes .../tutorial-extras/img/localeDropdown.png | Bin 27841 -> 0 bytes .../tutorial-extras/manage-docs-versions.md | 55 - .../tutorial-extras/translate-your-site.md | 88 - docs/docusaurus.config.ts | 285 +- docs/package.json | 1 + docs/sidebars.ts | 213 +- .../src/components/HomepageFeatures/index.tsx | 70 - .../HomepageFeatures/styles.module.css | 11 - docs/src/components/global/DocsCard/index.tsx | 70 + .../global/DocsCard/styles.module.css | 192 + .../src/components/global/DocsCards/cards.css | 32 + .../src/components/global/DocsCards/index.tsx | 9 + docs/src/components/propsTable.tsx | 51 + docs/src/components/propsType.tsx | 7 + docs/src/css/custom.css | 30 - docs/src/css/index.css | 290 + docs/src/css/liveCode.css | 41 + docs/src/pages/index.module.css | 23 - docs/src/pages/index.tsx | 43 - docs/src/pages/markdown-page.md | 7 - .../webpack-configuration-plugin/index.js | 34 + docs/static/.nojekyll | 0 docs/static/assets/actions-enable.png | Bin 0 -> 47414 bytes docs/static/assets/availability-tracking.webp | Bin 0 -> 78386 bytes .../static/assets/cloud-create-project-v2.png | Bin 0 -> 386041 bytes .../static/assets/cloud-project-detail-v2.png | Bin 0 -> 669630 bytes docs/static/assets/components/field-text.png | Bin 0 -> 6011 bytes .../assets/contember-admin-create-page.png | Bin 0 -> 132908 bytes .../assets/contember-admin-edit-page.png | Bin 0 -> 141424 bytes .../assets/contember-admin-list-page.png | Bin 0 -> 131460 bytes .../assets/contember-admin-menu-pages.png | Bin 0 -> 130208 bytes .../static/assets/contember-admin-running.png | Bin 0 -> 122424 bytes docs/static/assets/contember-diagram.drawio | 1 + docs/static/assets/contember-diagram.svg | 3 + docs/static/assets/contember-screenshot.png | Bin 0 -> 509980 bytes docs/static/assets/content-datagrid.webp | Bin 0 -> 40222 bytes docs/static/assets/content-repeater.webp | Bin 0 -> 75096 bytes docs/static/assets/databinding.svg | 4 + docs/static/assets/datagrid-example.png | Bin 0 -> 83316 bytes docs/static/assets/github-actions-secret.png | Bin 0 -> 35017 bytes docs/static/assets/insomnia-quickstart.json | 1 + docs/static/assets/insomnia-screen.png | Bin 0 -> 97032 bytes docs/static/assets/layout-kit.svg | 1 + docs/static/assets/mangeko-newproject.webp | Bin 0 -> 102706 bytes docs/static/assets/mangeko-status.webp | Bin 0 -> 130328 bytes docs/static/assets/many-has-many.drawio | 1 + docs/static/assets/many-has-many.svg | 4 + docs/static/assets/one-has-many.drawio | 1 + docs/static/assets/one-has-many.svg | 4 + docs/static/assets/one-has-one.drawio | 1 + docs/static/assets/one-has-one.svg | 4 + docs/static/assets/quickstart-final.png | Bin 0 -> 427594 bytes docs/static/assets/quickstart-intro.png | Bin 0 -> 408010 bytes docs/static/assets/s3.drawio | 1 + docs/static/assets/s3.svg | 3 + docs/static/assets/single-instance.drawio | 1 + docs/static/assets/single-instance.svg | 26 + docs/static/img/contember-for-twitter.png | Bin 0 -> 99675 bytes docs/static/img/contember-horizontal-blue.svg | 37 + .../static/img/contember-horizontal-white.svg | 37 + docs/static/img/contember-logo.png | Bin 0 -> 59701 bytes docs/static/img/docusaurus-social-card.jpg | Bin 55746 -> 0 bytes docs/static/img/docusaurus.png | Bin 5142 -> 0 bytes docs/static/img/favicon.ico | Bin 3626 -> 0 bytes docs/static/img/favicon.png | Bin 0 -> 11137 bytes docs/static/img/logo.svg | 1 - .../static/img/undraw_docusaurus_mountain.svg | 171 - docs/static/img/undraw_docusaurus_react.svg | 170 - docs/static/img/undraw_docusaurus_tree.svg | 40 - docs/yarn.lock | 20170 +++++++++------- 152 files changed, 20270 insertions(+), 9765 deletions(-) delete mode 100644 docs/blog/2019-05-28-first-blog-post.md delete mode 100644 docs/blog/2019-05-29-long-blog-post.md delete mode 100644 docs/blog/2021-08-01-mdx-blog-post.mdx delete mode 100644 docs/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg delete mode 100644 docs/blog/2021-08-26-welcome/index.md delete mode 100644 docs/blog/authors.yml delete mode 100644 docs/blog/tags.yml create mode 100644 docs/docs/cloud/permissions.md create mode 100644 docs/docs/guides/acl-definition.md create mode 100644 docs/docs/guides/deploy-contember.md create mode 100644 docs/docs/guides/deploy-github-actions.md create mode 100644 docs/docs/guides/self-hosted-contember.md create mode 100644 docs/docs/guides/seo.md create mode 100644 docs/docs/guides/superface.md delete mode 100644 docs/docs/intro.md create mode 100644 docs/docs/intro/actions.mdx create mode 100644 docs/docs/intro/deployment.mdx create mode 100644 docs/docs/intro/glossary.md create mode 100644 docs/docs/intro/graphql.mdx create mode 100644 docs/docs/intro/how-it-works.md create mode 100644 docs/docs/intro/installation.mdx create mode 100644 docs/docs/intro/interface.mdx create mode 100644 docs/docs/intro/introduction.mdx create mode 100644 docs/docs/intro/quickstart.mdx create mode 100644 docs/docs/intro/studio-quickstart.mdx create mode 100644 docs/docs/reference/cli.md create mode 100644 docs/docs/reference/engine/actions/definition.md create mode 100644 docs/docs/reference/engine/actions/invocation.md create mode 100644 docs/docs/reference/engine/actions/managing.md create mode 100644 docs/docs/reference/engine/actions/overview.md create mode 100644 docs/docs/reference/engine/content/advanced/assume-identity.md create mode 100644 docs/docs/reference/engine/content/advanced/assume-membership.md create mode 100644 docs/docs/reference/engine/content/advanced/caching.md create mode 100644 docs/docs/reference/engine/content/advanced/request-debugging.md create mode 100644 docs/docs/reference/engine/content/event-log.md create mode 100644 docs/docs/reference/engine/content/mutations.md create mode 100644 docs/docs/reference/engine/content/overview.md create mode 100644 docs/docs/reference/engine/content/queries.md create mode 100644 docs/docs/reference/engine/content/s3.md create mode 100644 docs/docs/reference/engine/content/transfer.md create mode 100644 docs/docs/reference/engine/migrations/advanced/development-commands.md create mode 100644 docs/docs/reference/engine/migrations/advanced/skipping-validations.md create mode 100644 docs/docs/reference/engine/migrations/advanced/writing-schema-migrations.md create mode 100644 docs/docs/reference/engine/migrations/basics.md create mode 100644 docs/docs/reference/engine/migrations/content-migrations.md create mode 100644 docs/docs/reference/engine/migrations/overview.md create mode 100644 docs/docs/reference/engine/schema/acl.md create mode 100644 docs/docs/reference/engine/schema/columns.md create mode 100644 docs/docs/reference/engine/schema/overview.md create mode 100644 docs/docs/reference/engine/schema/relationships.md create mode 100644 docs/docs/reference/engine/schema/tenant-acl.md create mode 100644 docs/docs/reference/engine/schema/validations.md create mode 100644 docs/docs/reference/engine/schema/views.md create mode 100644 docs/docs/reference/engine/tenant/api-keys.md create mode 100644 docs/docs/reference/engine/tenant/idp.md create mode 100644 docs/docs/reference/engine/tenant/invites.md create mode 100644 docs/docs/reference/engine/tenant/mail-templates.md create mode 100644 docs/docs/reference/engine/tenant/memberships.md create mode 100644 docs/docs/reference/engine/tenant/overview.md create mode 100644 docs/docs/reference/engine/tenant/sessions.md create mode 100644 docs/docs/reference/interface/data-binding/custom-components.md create mode 100644 docs/docs/reference/interface/data-binding/overview.md create mode 100644 docs/docs/reference/interface/data-binding/query-language.md create mode 100644 docs/docs/reference/interface/data-binding/relationship-components.md create mode 100644 docs/docs/reference/interface/data-binding/value-rendering.md create mode 100644 docs/docs/reference/interface/introduction.md create mode 100644 docs/docs/reference/interface/pages/_Example/slots/define.tsx create mode 100644 docs/docs/reference/interface/pages/_Example/slots/layout.tsx create mode 100644 docs/docs/reference/interface/pages/_Example/slots/usage.tsx create mode 100644 docs/docs/reference/interface/pages/links.md create mode 100644 docs/docs/reference/interface/pages/overview.md create mode 100644 docs/docs/reference/interface/pages/routing.md create mode 100644 docs/docs/reference/interface/pages/slots.mdx delete mode 100644 docs/docs/tutorial-basics/_category_.json delete mode 100644 docs/docs/tutorial-basics/congratulations.md delete mode 100644 docs/docs/tutorial-basics/create-a-blog-post.md delete mode 100644 docs/docs/tutorial-basics/create-a-document.md delete mode 100644 docs/docs/tutorial-basics/create-a-page.md delete mode 100644 docs/docs/tutorial-basics/deploy-your-site.md delete mode 100644 docs/docs/tutorial-basics/markdown-features.mdx delete mode 100644 docs/docs/tutorial-extras/_category_.json delete mode 100644 docs/docs/tutorial-extras/img/docsVersionDropdown.png delete mode 100644 docs/docs/tutorial-extras/img/localeDropdown.png delete mode 100644 docs/docs/tutorial-extras/manage-docs-versions.md delete mode 100644 docs/docs/tutorial-extras/translate-your-site.md delete mode 100644 docs/src/components/HomepageFeatures/index.tsx delete mode 100644 docs/src/components/HomepageFeatures/styles.module.css create mode 100644 docs/src/components/global/DocsCard/index.tsx create mode 100644 docs/src/components/global/DocsCard/styles.module.css create mode 100644 docs/src/components/global/DocsCards/cards.css create mode 100644 docs/src/components/global/DocsCards/index.tsx create mode 100644 docs/src/components/propsTable.tsx create mode 100644 docs/src/components/propsType.tsx delete mode 100644 docs/src/css/custom.css create mode 100644 docs/src/css/index.css create mode 100644 docs/src/css/liveCode.css delete mode 100644 docs/src/pages/index.module.css delete mode 100644 docs/src/pages/index.tsx delete mode 100644 docs/src/pages/markdown-page.md create mode 100644 docs/src/plugins/webpack-configuration-plugin/index.js delete mode 100644 docs/static/.nojekyll create mode 100644 docs/static/assets/actions-enable.png create mode 100644 docs/static/assets/availability-tracking.webp create mode 100644 docs/static/assets/cloud-create-project-v2.png create mode 100644 docs/static/assets/cloud-project-detail-v2.png create mode 100644 docs/static/assets/components/field-text.png create mode 100644 docs/static/assets/contember-admin-create-page.png create mode 100644 docs/static/assets/contember-admin-edit-page.png create mode 100644 docs/static/assets/contember-admin-list-page.png create mode 100644 docs/static/assets/contember-admin-menu-pages.png create mode 100644 docs/static/assets/contember-admin-running.png create mode 100644 docs/static/assets/contember-diagram.drawio create mode 100644 docs/static/assets/contember-diagram.svg create mode 100644 docs/static/assets/contember-screenshot.png create mode 100644 docs/static/assets/content-datagrid.webp create mode 100644 docs/static/assets/content-repeater.webp create mode 100644 docs/static/assets/databinding.svg create mode 100644 docs/static/assets/datagrid-example.png create mode 100644 docs/static/assets/github-actions-secret.png create mode 100644 docs/static/assets/insomnia-quickstart.json create mode 100644 docs/static/assets/insomnia-screen.png create mode 100644 docs/static/assets/layout-kit.svg create mode 100644 docs/static/assets/mangeko-newproject.webp create mode 100644 docs/static/assets/mangeko-status.webp create mode 100644 docs/static/assets/many-has-many.drawio create mode 100644 docs/static/assets/many-has-many.svg create mode 100644 docs/static/assets/one-has-many.drawio create mode 100644 docs/static/assets/one-has-many.svg create mode 100644 docs/static/assets/one-has-one.drawio create mode 100644 docs/static/assets/one-has-one.svg create mode 100644 docs/static/assets/quickstart-final.png create mode 100644 docs/static/assets/quickstart-intro.png create mode 100644 docs/static/assets/s3.drawio create mode 100644 docs/static/assets/s3.svg create mode 100644 docs/static/assets/single-instance.drawio create mode 100644 docs/static/assets/single-instance.svg create mode 100644 docs/static/img/contember-for-twitter.png create mode 100644 docs/static/img/contember-horizontal-blue.svg create mode 100644 docs/static/img/contember-horizontal-white.svg create mode 100644 docs/static/img/contember-logo.png delete mode 100644 docs/static/img/docusaurus-social-card.jpg delete mode 100644 docs/static/img/docusaurus.png delete mode 100644 docs/static/img/favicon.ico create mode 100644 docs/static/img/favicon.png delete mode 100644 docs/static/img/logo.svg delete mode 100644 docs/static/img/undraw_docusaurus_mountain.svg delete mode 100644 docs/static/img/undraw_docusaurus_react.svg delete mode 100644 docs/static/img/undraw_docusaurus_tree.svg diff --git a/docs/blog/2019-05-28-first-blog-post.md b/docs/blog/2019-05-28-first-blog-post.md deleted file mode 100644 index 02f3f81bd2..0000000000 --- a/docs/blog/2019-05-28-first-blog-post.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -slug: first-blog-post -title: First Blog Post -authors: - name: Gao Wei - title: Docusaurus Core Team - url: https://github.com/wgao19 - image_url: https://github.com/wgao19.png -tags: [hola, docusaurus] ---- - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet diff --git a/docs/blog/2019-05-29-long-blog-post.md b/docs/blog/2019-05-29-long-blog-post.md deleted file mode 100644 index 26ffb1b1f6..0000000000 --- a/docs/blog/2019-05-29-long-blog-post.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -slug: long-blog-post -title: Long Blog Post -authors: endi -tags: [hello, docusaurus] ---- - -This is the summary of a very long blog post, - -Use a `` comment to limit blog post size in the list view. - - - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet diff --git a/docs/blog/2021-08-01-mdx-blog-post.mdx b/docs/blog/2021-08-01-mdx-blog-post.mdx deleted file mode 100644 index c04ebe323e..0000000000 --- a/docs/blog/2021-08-01-mdx-blog-post.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -slug: mdx-blog-post -title: MDX Blog Post -authors: [slorber] -tags: [docusaurus] ---- - -Blog posts support [Docusaurus Markdown features](https://docusaurus.io/docs/markdown-features), such as [MDX](https://mdxjs.com/). - -:::tip - -Use the power of React to create interactive blog posts. - -```js - -``` - - - -::: diff --git a/docs/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg b/docs/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg deleted file mode 100644 index 11bda0928456b12f8e53d0ba5709212a4058d449..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 96122 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 diff --git a/docs/blog/2021-08-26-welcome/index.md b/docs/blog/2021-08-26-welcome/index.md deleted file mode 100644 index 9455168f17..0000000000 --- a/docs/blog/2021-08-26-welcome/index.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -slug: welcome -title: Welcome -authors: [slorber, yangshun] -tags: [facebook, hello, docusaurus] ---- - -[Docusaurus blogging features](https://docusaurus.io/docs/blog) are powered by the [blog plugin](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-blog). - -Simply add Markdown files (or folders) to the `blog` directory. - -Regular blog authors can be added to `authors.yml`. - -The blog post date can be extracted from filenames, such as: - -- `2019-05-30-welcome.md` -- `2019-05-30-welcome/index.md` - -A blog post folder can be convenient to co-locate blog post images: - -![Docusaurus Plushie](./docusaurus-plushie-banner.jpeg) - -The blog supports tags as well! - -**And if you don't want a blog**: just delete this directory, and use `blog: false` in your Docusaurus config. diff --git a/docs/blog/authors.yml b/docs/blog/authors.yml deleted file mode 100644 index bcb2991563..0000000000 --- a/docs/blog/authors.yml +++ /dev/null @@ -1,17 +0,0 @@ -endi: - name: Endilie Yacop Sucipto - title: Maintainer of Docusaurus - url: https://github.com/endiliey - image_url: https://github.com/endiliey.png - -yangshun: - name: Yangshun Tay - title: Front End Engineer @ Facebook - url: https://github.com/yangshun - image_url: https://github.com/yangshun.png - -slorber: - name: Sébastien Lorber - title: Docusaurus maintainer - url: https://sebastienlorber.com - image_url: https://github.com/slorber.png diff --git a/docs/blog/tags.yml b/docs/blog/tags.yml deleted file mode 100644 index f71dd73931..0000000000 --- a/docs/blog/tags.yml +++ /dev/null @@ -1,16 +0,0 @@ -facebook: - label: Facebook - permalink: /facebook - description: Facebook tag description -hello: - label: Hello - permalink: /hello - description: Hello tag description -docusaurus: - label: Docusaurus - permalink: /docusaurus - description: Docusaurus tag description -hola: - label: Hola - permalink: /hola - description: Hola tag description diff --git a/docs/docs/cloud/permissions.md b/docs/docs/cloud/permissions.md new file mode 100644 index 0000000000..664bffbf1b --- /dev/null +++ b/docs/docs/cloud/permissions.md @@ -0,0 +1,61 @@ +--- +title: Cloud memberships +--- + +## Organization Member Roles and Permissions + +In the Contember Cloud Selfcare, we provide different roles that you can assign to members within your organization. Each role comes with specific permissions that determine what actions members can perform. It's essential to understand these roles and their associated permissions to effectively manage your organization. + +### 1. Roles for the Organization + +#### Owner + +The **Owner** role is the highest level of authority within the organization. Owners have full control over the organization, including its settings, billing, and member management. There must be at least one Owner in the organization at all times. + +#### Admin + +The **Admin** role is the second-highest authority in the organization. Admins have broad permissions, enabling them to manage most aspects of the organization, except billing and some specific member-related actions. + +#### Billing + +The **Billing** role is responsible for managing the organization's billing settings and payment methods. Users with this role can handle billing-related tasks but have limited access to other aspects of the organization. + +#### Developer + +The **Developer** role is for members involved in project development. They have permissions to create and manage, as well as start and stop project activities. They cannot delete a project. + +#### Guest + +The **Guest** role is for providing limited access to external collaborators. Guests have viewing privileges for projects and metrics, but they cannot perform any modifications within the organization. + +### 2. Project-Specific Roles + +#### Project Developer + +The **Project Developer** role is specifically assigned at the project level. Members with this role have permissions to work on a particular project, allowing them to create, manage, and start/stop project activities. + +#### Project Guest + +Similar to the Project Developer role, the **Project Guest** role is specific to individual projects. Members with this role are granted viewing access to a specific project, without the ability to make any changes. + +It's important to note that the Project Developer and Project Guest roles are distinct from the organization-wide roles and are managed separately for each project. + +### Permissions matrix + +
+ +| | Owner | Admin | Billing | Developer | Guest | Project Developer | Project Guest | +|-------------------------------|--------------------------------|--------------------------------|--------------------------------|--------------------------------|--------------------------------|--------------------------------|--------------------------------| +| View projects | YES | YES | YES | YES | YES | YES | YES | +| View metrics | YES | YES | YES | YES | YES | YES | YES | +| View members | YES | YES | YES | YES | NO | NO | NO | +| Manage owner / billing member | YES | NO | NO | NO | NO | NO | NO | +| Manage other members | YES | YES | NO | NO | NO | NO | NO | +| Create projects | YES | YES | NO | YES | NO | NO | NO | +| Start / stop project | YES | YES | NO | YES | NO | YES | NO | +| Edit project settings | YES | YES | NO | YES | NO | YES | NO | +| Delete project | YES | YES | NO | NO | NO | NO | NO | +| View billing | YES | NO | YES | NO | NO | NO | NO | +| Manage billing | YES | NO | YES | NO | NO | NO | NO | + +
diff --git a/docs/docs/guides/acl-definition.md b/docs/docs/guides/acl-definition.md new file mode 100644 index 0000000000..b5aa82aa1c --- /dev/null +++ b/docs/docs/guides/acl-definition.md @@ -0,0 +1,134 @@ +--- +title: Roles and ACL definition +description: How to write custom roles definition. +--- + +Let's say we are defining two roles: a public role, which can see all visible comments, and a moderator role, who can edit and hide comments of given article in assigned category. + +A simplified model definition for this example will look like this: + +```typescript +export class Category { + // .... +} + +export class Article { + category = def.manyHasOne(Category) + // .... +} + +export class Comment { + article = def.manyHasOne(Article) + content = def.stringColumn() + hiddenAt = def.dateTimeColumn() +} +``` + +> We are using high level ACL definition API. For some cases, you might prefer a [low level definition API](/reference/engine/schema/acl.md#low-level-definition) + +### Public role definition + +First, we create a public role using [createRole](/reference/engine/schema/acl.md#create-role) function. + +There is only single mandatory argument - a role identifier. In second argument, we can define various role options, as described [here](/reference/engine/schema/acl.md#create-role). + +```typescript +import { AclDefinition as acl } from '@contember/schema-definition' + +export const publicRole = acl.createRole('public') +``` + +Second, we assign an access rule to a `Comment` entity using [allow](/reference/engine/schema/acl.md#allow) function. + +In a first argument of the function we pass previously defined role. Second argument is an object with the access definition itself. +In `when` we define a predicate. In `read` there is an array of accessible fields. You can also use `true` instead of an array to make all fields accessible: + +```typescript +// highlight-start +@acl.allow(publicRole, { + read: ['content'], + when: { hiddenAt: { isNull: true } }, +}) +// highlight-end +export class Comment { + // ... +} +``` + +That's all. Now, if you access the API with `public` role, you can see not hidden `Comment` rows, and you can access its `content` field. + +### Moderator role definition + +Now, we define a second mentioned role - a `moderator`. Again, we define a role: + +```typescript +export const moderatorRole = acl.createRole('moderator') +``` + +Now it gets a bit more tricky, as we want to allow to only moderate comments in given category. + +Let's define an [entity variable](#entity-variable), where a category ID (or a list of categories) will be stored for given user. + +```typescript +export const categoryIdVariable = acl.createEntityVariable('categoryId', 'Category', moderatorRole) +``` + +You can manage this variable [on memberships using Tenant API](/reference/engine/tenant/memberships.md) using its name - `categoryId`. + +Now we attach another ACL definition to our `Comment` entity: + +```typescript +// highlight-start +@acl.allow(moderatorRole, { + update: ['hiddenAt', 'content'], + when: { article: { category: { id: categoryIdVariable } } }, +}) +// highlight-end +// other ACL definitions +export class Comment { + // ... +} +``` + +As you can see, you can traverse through relations. Our definition says, that `moderator` can update fields `hiddenAt` and `content` of any `Comment` of an `Article` in a `Category` defined in `categoryId` variable. + +:::note migrations +Don't forget to [create a migration](/reference/engine/migrations/basics.md) to apply changes: +```bash +npm run contember migrations:diff my-blog setup-acl +``` +::: + +#### Full example: +```typescript +import { SchemaDefinition as def, Acldefinition as acl } from '@contember/schema-definition' + +export const publicRole = acl.createRole('public') + +export const moderatorRole = acl.createRole('moderator') +export const categoryIdVariable = acl.createEntityVariable('categoryId', 'Category', moderatorRole) + +export class Category { + // .... +} + +export class Article { + category = def.manyHasOne(Category) + // .... +} + +@acl.allow(moderatorRole, { + when: { article: { category: { id: categoryIdVariable } } }, + update: ['hiddenAt', 'content'], +}) +@acl.allow(publicRole, { + when: { hiddenAt: { isNull: true } }, + read: ['content'], +}) +export class Comment { + article = def.manyHasOne(Article) + content = def.stringColumn() + hiddenAt = def.dateTimeColumn() +} + +``` diff --git a/docs/docs/guides/deploy-contember.md b/docs/docs/guides/deploy-contember.md new file mode 100644 index 0000000000..4d9f3ce0e2 --- /dev/null +++ b/docs/docs/guides/deploy-contember.md @@ -0,0 +1,53 @@ +--- +title: Deploy to Contember Cloud +description: From local project to production in 15 minutes. +--- + +This guide will take you through the deployment of Contember from your local machine to the Contember Cloud. + +## Before you start + +You should have a project on your machine - complete [quickstart tutorial](/intro/quickstart.mdx) or use one of our ready-made [starter-kits](https://github.com/contember/starter-kits). + +## 1. Setup project in Contember Cloud + +Go to https://contember.cloud and create an account if you don't have one. After sign up create your first project. Choose a unique name for it. + +![Contember Cloud: create project](/assets/cloud-create-project-v2.png) + +You should receive an email with a link to sign into administration. You don't need it right now - we will come back to it. + + +On your project's page in Contember Cloud console click on "Deploy token" to create a deploy command with your deploy secret token. + +![Contember Cloud: project detail](/assets/cloud-project-detail-v2.png) + +It will look something like this: + +```bash +npm run deploy contember://example:4faef77592845fbeaf390c5e86989b1ea493e5d0@example.eu.contember.cloud +``` + +## 2. Deploy + +Copy it and run this command in your project's directory. It will build your project, package the assets and prompt you to confirm details - type `y` (for "yes") to continue. We will apply your project's schema and copy the administration's assets. + +``` +$ npm run deploy contember://example:4faef77592845fbeaf390c5e86989b1ea493e5d0@example.eu.contember.cloud +... +Deployment successful +``` + +## 3. Sign in to the administration + +When you created the project, you received an email with a link to create an administration account. This account is separate from your Contember Cloud console account. + +## Next steps + +You can change your local project however you like, it's completely independent from the deployed version. After you make any changes in the project's code, test it locally and are ready to deploy it, build it and deploy it again as we did in steps two and three. + +You can also setup [deployment from GitHub Actions](./deploy-github-actions). + +## Troubleshooting + +If you encounter a problem, feel free to open [an issue on GitHub](https://github.com/contember/admin/issues/new). Don't forget to attach screenshots and terminal output. diff --git a/docs/docs/guides/deploy-github-actions.md b/docs/docs/guides/deploy-github-actions.md new file mode 100644 index 0000000000..e24332c477 --- /dev/null +++ b/docs/docs/guides/deploy-github-actions.md @@ -0,0 +1,60 @@ +--- +title: Deploy using GitHub Actions +description: Automate your Contember Cloud deployment. +--- + +This guide will take you through the deployment of Contember from your GitHub repository to the Contember Cloud. + +## Before you start + +You should have a project in a GitHub repository - complete [quickstart tutorial](/intro/quickstart.mdx) or use one of our ready-made [starter-kits](https://github.com/contember/starter-kits). + + +Setup automatic deployment to Contember Cloud after push to GitHub using GitHub Actions. + +## 1. Create Contember DSN + +You need to get Contember DSN which specifies which project to deploy to and includes secret token. In Contember Cloud console on your project's page click a "Create new deploy token" button. You will get a deploy command, but we will need only the part after the `deploy` word - starting with `contember://`. It should look like this: + +```txt title="Contember DSN example" +contember://example:4faef77592845fbeaf390c5e86989b1ea493e5d0@example.eu.contember.cloud +``` + +## 2. Create repository secret + +On GitHub go to your repository settings and select Security → Secrets → Actions. Click on "New repository secret" button, name your new secret `CONTEMBER_DEPLOY_DSN` and input the Contember DSN you got from Cloud Console. + +![GitHub actions create secret screen](/assets/github-actions-secret.png) + + +## 3. Commit workflow file + +After you created the repository secret, you can create a workflow that will run after each push to `main` branch. Create file `.github/workflows/deploy.yml` with following contents. + +```yaml title=".github/workflows/deploy.yml" +name: Deploy + +on: + push: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install dependencies + run: npm ci + + - name: Build and deploy Contember to production + run: npm run deploy ${{ secrets.CONTEMBER_DEPLOY_DSN }} -- --yes +``` + +Now, when you push to `main` branch, your code should be automatically deployed to Contember Cloud. + +## Troubleshooting + +If you encounter a problem, feel free to open [an issue on GitHub](https://github.com/contember/admin/issues/new) or ask us in [our Discord server](https://discord.gg/EkhsuAK2Fg). Don't forget to attach screenshots and terminal output. diff --git a/docs/docs/guides/self-hosted-contember.md b/docs/docs/guides/self-hosted-contember.md new file mode 100644 index 0000000000..8b3f7ae76b --- /dev/null +++ b/docs/docs/guides/self-hosted-contember.md @@ -0,0 +1,144 @@ +--- +title: Self-host Contember +description: Advanced guide to run Contember on your own infrastructure. +--- + +:::caution +This is an advanced guide. You don't need this - skip the hassle and [deploy to Contember Cloud](./deploy-contember.md). To complete this guide, you are expected to know how to run Docker containers in production. +::: + +## What you'll need + +1. PostgreSQL database (e.g. [AWS RDS for PostgreSQL](https://aws.amazon.com/rds/postgresql/), [DigitalOcean Managed Database](https://www.digitalocean.com/products/managed-databases/)) +1. S3-compatible storage for file uploads (optional if you don't need file upload capability; e.g. [AWS S3](https://aws.amazon.com/s3/), [DigitalOcean Spaces](https://www.digitalocean.com/products/spaces/), [MinIO](https://min.io/), [CEPH](https://ceph.io/), [Zenko CloudServer](https://www.zenko.io/cloudserver/)) +1. SMTP server to send emails from (optional) +1. Some way to run Docker image. + +## Deploy Contember Engine + +### 1. Setup PostgresSQL database + +Required PostgresSQL version is 10 or newer. + +Contember needs the following databases: +- tenant (for information about users and their roles) and +- content database for each deployed project. + +Contember itself manages these databases and runs migrations upon startup. You don't need to create content database if the PostgresSQL user given to Contember has permission to create it on its own. + + +### 2. Setup S3 bucket + +This bucket will be used to store user uploads. You will need a key and secret with permissions to create objects. + + +### 3. Setup SMTP server + +Get SMTP credentials for your mailing server - used for example for password reset. + + +### 4. Run Contember Engine Docker container + +Contember Engine is deployed as a Docker container. It's [published on Docker Hub](https://hub.docker.com/r/contember/engine/tags) as `contember/engine`. + +Configure the following environment variables: + +```yaml +NODE_ENV: "production" +CONTEMBER_PORT: "4000" # Port on which the service will be available +CONTEMBER_LOGIN_TOKEN: "..." # Login token used (20 random [0-9a-f] characters; can be generated by `openssl rand -hex 20` command) +CONTEMBER_ROOT_TOKEN: "..." # Root token used (20 random [0-9a-f] characters; can be generated by `openssl rand -hex 20` command) +CONTEMBER_ROOT_EMAIL: "admin@example.com" # Superadmin user's e-mail +CONTEMBER_ROOT_PASSWORD: "my-secret-password" # Superadmin user's password + +# Credentials for database: +DEFAULT_DB_HOST: "your-db-server.com" +DEFAULT_DB_PORT: "5432" +DEFAULT_DB_USER: "postgres" +DEFAULT_DB_PASSWORD: "postgres" +DEFAULT_DB_SSL: "true" +TENANT_DB_NAME: "tenant" # Name of tenant database - see first step +DEFAULT_DB_NAME: "my-project" # Optional - name of database to +# or BLOG_DB_NAME: "blog" where blog is slug of the project. + +# S3 credentials - see step 2 +DEFAULT_S3_REGION: "rgn1" +DEFAULT_S3_KEY: "..." +DEFAULT_S3_SECRET: "..." +DEFAULT_S3_ENDPOINT: "https://rgn1.yours3provider.com" +DEFAULT_S3_BUCKET: "your-bucket" + +# SMTP credentials - see step 3 +TENANT_MAILER_HOST: "send.your-smtp.com" +TENANT_MAILER_PORT: "2525" +TENANT_MAILER_FROM: "noreply@example.com" +``` + +HTTP server should start on the specified port. You can add a load-balancer in front of the app. The container is stateless and therefore horizontally scalable. + +After the server is running Contember CLI can be used to set up other API tokens and invite users. Deploy your projects to this instance by running `contember deploy blog --yes` command with `CONTEMBER_API_URL` and `CONTEMBER_API_TOKEN` environment variables set. You can use either superadmin token or create token with `deployer` role (using `contember tenant:create-api-key` command). + + +## Deploy Contember Interface + +Contember Interface can be deployed as single page application. It's bundled using [Vite](https://vitejs.dev/). + +You will need to modify `admin/index.tsx` and `admin/vite.config.ts` files based on following examples: + +```tsx title="admin/index.tsx" +import * as React from 'react' +import { ApplicationEntrypoint, LoginEntrypoint, Pages, runReactApp } from '@contember/admin' +import { Layout } from './components/Layout' +import '@contember/admin/index.css' + +const apiBaseUrl = import.meta.env.VITE_CONTEMBER_ADMIN_API_BASE_URL as string +const loginToken = import.meta.env.VITE_CONTEMBER_ADMIN_LOGIN_TOKEN as string +const projectSlug = import.meta.env.VITE_CONTEMBER_ADMIN_PROJECT_NAME as string + +if (window.location.pathname === '/') { + // Login page + runReactApp( + `/${it.slug}/`} + />, + ) +} else if (window.location.pathname.startsWith('/' + projectSlug)) { + // Project interface itself page + runReactApp( + } + /> + ) +} else { + // Page not found - redirect to login + window.location.href = '/' +} +``` + +```ts title="admin/vite.config.ts" +import { defineConfig } from 'vite' + +export default defineConfig(({ command }) => ({ + base: '/', +})) +``` + +Furthermore your `.env` files (`.env.development` and `.env.production`) should contain referenced variables: + +```txt title="admin/.env.production" +# URL where Engine is running +VITE_CONTEMBER_ADMIN_API_BASE_URL=http://localhost:1481 +# Login token needed to perform login - configured in env variable of Engine +VITE_CONTEMBER_ADMIN_LOGIN_TOKEN=1111111111111111111111111111111111111111 +# Your project's slug (used to connect to correct GraphQL endpoint) +VITE_CONTEMBER_ADMIN_PROJECT_NAME=headless-cms +``` + +After these changes you can run `npm run build-admin` command. It will create bundle in `admin/dist` folder, which you can deploy to any hosting, which needs to serve the `index.html` file for any request that doesn't match assets. diff --git a/docs/docs/guides/seo.md b/docs/docs/guides/seo.md new file mode 100644 index 0000000000..ca2d1de05b --- /dev/null +++ b/docs/docs/guides/seo.md @@ -0,0 +1,188 @@ +--- +title: SEO +--- + +In this guide, we will present one of the ways you can work with SEO fields in Contember. You can take it as a continuation of the quickstart tutorial. + + +## 1. Schema definition + +First, we have to define the schema. Here we have chosen four fields we want to edit. + +1. `title` (for `` tag) +2. `description` (for `<meta name="description" />`) +3. `ogTitle` (for `<meta property="og:title" />`) +4. `ogDescription` (`<meta property="og:description " />`) + + +For this, we define an entity in our schema with fields. We are making only the `title` field required (using the `.notNull()` call). We save it in a new file. + +```tsx title="api/model/Seo.ts" +import { SchemaDefinition as def } from '@contember/schema-definition' + +export class Seo { + title = def.stringColumn().notNull() + description = def.stringColumn() + ogTitle = def.stringColumn() + ogDescription = def.stringColumn() +} +``` + +Let's say we have just one type of page represented by an existing `Article` entity. + +:::note Multiple page-like entities + +In our headless CMS starter kit, we have two entities representing a "page" on the front-end of your site, which need SEO attributes. These are `Page` (a page that presents static information - such as "Contact" or "About us") and `Article` (a blog-like article). You may have even more. + +To add SEO fields to all of these pages you can create a single `Seo` entity and then create relations for all of your page-like entities. Just repeat the steps below for each of these entities. + +::: + +To connect it to the `Article` entity we add the relation to the entity: + +```tsx title="api/model/Article.ts" +import { SchemaDefinition as def } from '@contember/schema-definition' +// highlight-next-line +import { Seo } from './Seo' + +export class Article { + // some specific fields… + + // highlight-next-line + seo = def.oneHasOne(Seo, 'article').notNull().removeOrphan() +} +``` + +We specify that the field is called `Seo` and one Article can have just one Seo and vice versa (thus `oneHasOne` relation). The `notNull()` call marks it as non-nullable, so `Article` must always have a `Seo` entity connected. To delete a `Seo` when we delete an `Article` we add the `removeOrphan` call. + +Note, that if you have already created some articles you can't mark this field as `notNull`, because the migration would fail. Just remove the `notNull` call in that case. + +To specify the other side of the relation we add a field to the `Seo` entity we created earlier. + +```tsx title="api/model/Seo.ts" +import { SchemaDefinition as def } from '@contember/schema-definition' +// highlight-next-line +import { Article } from './Article' + +export class Seo { + title = def.stringColumn().notNull() + description = def.stringColumn() + ogTitle = def.stringColumn() + ogDescription = def.stringColumn() + + // highlight-next-line + article = def.oneHasOneInverse(Article, 'seo') +} +``` + +To create migration for this we run `npm run contember migration:diff . add-seo` command and then choose `Yes and execute immediately` to create the migration file and execute it on our local machine. + +## 2. Add fields to the administration + +Now, we have these fields in our database and API, but we need to add them to our administration. To easily reuse them in different parts of our administration we can create a component that will encapsulate all the fields. We create a new file for it: + +```tsx title="admin/components/Seo.tsx" +import { + Component, + TextAreaField, + TextField, +} from '@contember/admin' + +export const Seo = Component( + () => ( + <> + <TextField field="seo.title" label="Title" /> + <TextAreaField field="seo.description" label="Description" /> + <TextField field="seo.ogTitle" label="Open Graph title (for facebook)" /> + <TextAreaField field="seo.ogDescription" label="Open Graph description (for facebook)" /> + </> + ), +) +``` + +Then we can use use the newly created component in existing administration pages like this: + +```tsx title="admin/pages/articleEdit.tsx" +import * as React from 'react' +import { EditPage, RichTextField, TextField } from '@contember/admin' +// highlight-next-line +import { Seo } from '../components/Seo' + +export default () => ( + <EditPage entity="Article(id = $id)" rendererProps={{ title: 'Edit Article' }}> + <TextField field="title" label="Title" /> + <RichTextField field="content" label="Content" /> + // highlight-next-line + <Seo /> + </CreatePage> +) +``` + +Congratulations - you have just created your first custom component and used it! + +## Optional: Using the article's title as a SEO title + +Most of the time your article or page has a title that you use as a title in SEO fields. This can be achieved using the `DerivedFieldLink` component. + +First, let's modify our `Seo` component to take the name of the field we should copy the title from. + +```tsx title="admin/components/Seo.tsx" +import { + Component, + TextAreaField, + TextField, +} from '@contember/admin' + +/* highlight-start */ +interface SeoProps { + titleField?: string +} +/* highlight-end */ + +/* highlight-start */ +export const Seo = Component<SeoProps>( + ({ titleField }) => ( +/* highlight-end */ + <> + <TextField field="seo.title" label="Title" /> + <TextAreaField field="seo.description" label="Description" /> + <TextField field="seo.ogTitle" label="Open Graph title (for facebook)" /> + <TextAreaField field="seo.ogDescription" label="Open Graph description (for facebook)" /> + </> + ), +) +``` + +And then, when the prop is passed, we use the `DerivedFieldLink` component. Thus when the source field (title of the article) is edited, the change is mirrored in the derived fields (`seo.title` and `seo.ogTitle`). + +```tsx title="admin/components/Seo.tsx" +import { + Component, + TextAreaField, + TextField, +} from '@contember/admin' + +interface SeoProps { + titleField?: string +} + +export const Seo = Component<SeoProps>( + ({ titleField }) => ( + <> + <TextField field="seo.title" label="Title" /> + <TextAreaField field="seo.description" label="Description" /> + <TextField field="seo.ogTitle" label="Open Graph title (for facebook)" /> + <TextAreaField field="seo.ogDescription" label="Open Graph description (for facebook)" /> + + {/* highlight-start */} + {titleField && ( + <> + <DerivedFieldLink sourceField={titleField} derivedField="seo.title" /> + <DerivedFieldLink sourceField={titleField} derivedField="seo.ogTitle" /> + </> + )} + {/* highlight-end */} + </> + ), +) +``` diff --git a/docs/docs/guides/superface.md b/docs/docs/guides/superface.md new file mode 100644 index 0000000000..333f65d4f6 --- /dev/null +++ b/docs/docs/guides/superface.md @@ -0,0 +1,147 @@ +--- +title: Using Contember Actions with Superface AI +--- + +import DocsCard from '../../src/components/global/DocsCard'; +import DocsCards from '../../src/components/global/DocsCards'; + +In this example, we'll set up notifications for new articles to be sent to Slack. Assuming you've already prepared an Action as per our [Actions tutorial](/intro/actions), the next step is to construct a simple Node.js app to receive the payload from the Action and then send a notification to Slack. + +We'll be following the [guide provided by Superface AI](https://superface.ai/docs/api-examples/slack) step by step. One of the key advantages of Superface AI is that it generates the code we need, meaning there's no need to delve into the Slack API documentation. + +Superface AI is designed to aid in building your integration, but it doesn't limit where you can run it. You can operate it anywhere you like and manage version control via Git. + +Start by installing their CLI and specifying Slack as the platform to work with. + +```bash +npm i -g @superfaceai/cli@latest +``` + +```bash +superface prepare https://raw.githubusercontent.com/slackapi/slack-api-specs/master/web-api/slack_web_openapi_v2.json slack +``` + +Next, let's specify our objective: sending messages to a Slack channel. + +```bash +superface new slack "send message to channel" +``` + +The CLI will then instruct us to run the map command. Please note your file names may differ from ours and running the commands the CLI outputs is the way to go. + +```bash +superface map slack chat-communication/post-message +``` + +Once that's done, you'll need the Slack API token. This part can be a bit tricky. You can find a guide on Slack's website that explains the process, but in brief, you need to create a testing app, install it to your workspace, and then return to the tutorial. Your token, which should begin with xoxb-, will be displayed there. Add this token to your .ENV file. + +Now, in the generated file `chat-communication.post-message.slack.mjs`, update the channel name to the one where you want to send your test message and run: + +```bash +superface execute slack chat-communication/post-message +``` + +And there you have it, your message appears in Slack as if by magic! + + +The final step is to replace the dummy data in your file with actual variables. Your finished code might look like this: + +```javascript +import { config } from 'dotenv'; +import express from 'express'; +import { OneClient, PerformError, UnexpectedError } from '@superfaceai/one-sdk/node/index.js'; + +// Load environment variables from .env file +config(); + +const client = new OneClient({ + token: process.env.SUPERFACE_ONESDK_TOKEN, + assetsPath: '/superface-slack/superface' +}); + +const profile = await client.getProfile('chat-communication/post-message'); +const useCase = profile.getUseCase('SendChannelMessage') + +// Setup Express server +const app = express(); +app.use(express.json()); // for parsing application/json + +// POST endpoint for receiving payload +app.post('/payload', async (req, res) => { + const { events } = req.body; + + for (const event of events) { + const { id, entity, values, operation } = event; + + if (operation === 'create' && entity === 'Article') { + try { + const result = await useCase.perform( + { + channel: 'random', + text: `New article with ${values.title} created`, + as_user: true, + attachments: [], + blocks: [], + }, + { + provider: 'slack', + parameters: {}, + security: { slackBearerToken: { token: process.env.SLACK_TOKEN } } + } + ); + + console.log("RESULT:", JSON.stringify(result, null, 2)); + + } catch (e) { + if (e instanceof PerformError) { + console.log('ERROR RESULT:', e.errorResult); + res.status(500).send({ message: 'Perform Error', error: e.errorResult }); + return; + } else if (e instanceof UnexpectedError) { + console.error('ERROR:', e); + res.status(500).send({ message: 'Unexpected Error', error: e.message }); + return; + } else { + throw e; + } + } + } else { + res.status(400).send({ message: 'Invalid payload' }); + return; + } + } + + res.status(200).send({ message: 'Messages sent successfully' }); +}); + +app.listen(3000, () => { + console.log('Server is running on port 3000'); +}); +``` + +In this updated code, an Express server is set up with a POST endpoint `/payload`. When you send a POST request to this endpoint with the payload, it will check if the operation is `create` and entity is `Article`. If it is, it sends a message to the `random` channel with the text `New article with {values.title} created`. If the operation is not `create` or the entity is not `Article`, it will return a 400 status code with a message `Invalid payload`. + +--- + +This was a very simple example and you might want to solve it differently. Where we think Superface AI is powerful is that it'll allow you to quickly integrate many different APIs. Some of the examples in their documentations: + +<DocsCards> + <DocsCard header="Hubspot" href="https://superface.ai/docs/api-examples/hubspot"> + <p>Produce a working use case that lists the companies in a HubSpot CRM</p> + </DocsCard> + <DocsCard header="Infobip" href="https://superface.ai/docs/api-examples/infobip"> + <p>Produce a working use case that can send an SMS message to any number.</p> + </DocsCard> + <DocsCard header="Lob" href="https://superface.ai/docs/api-examples/lob"> + <p>Produce a working use case that adds a new address that can be used to send postcards, letters, and even checks.</p> + </DocsCard> + <DocsCard header="Notion" href="https://superface.ai/docs/api-examples/notion"> + <p>Produce a working use case that allows you to list all of the users in your Notion workspace.</p> + </DocsCard> + <DocsCard header="PagerDuty" href="https://superface.ai/docs/api-examples/pagerduty"> + <p>Produce a working use case that lists the most recent reported incidents.</p> + </DocsCard> + <DocsCard header="Resend" href="https://superface.ai/docs/api-examples/resend"> + <p>Produce a working use case that allows you to send an email from an application.</p> + </DocsCard> +</DocsCards> \ No newline at end of file diff --git a/docs/docs/intro.md b/docs/docs/intro.md deleted file mode 100644 index 45e8604c8b..0000000000 --- a/docs/docs/intro.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -sidebar_position: 1 ---- - -# Tutorial Intro - -Let's discover **Docusaurus in less than 5 minutes**. - -## Getting Started - -Get started by **creating a new site**. - -Or **try Docusaurus immediately** with **[docusaurus.new](https://docusaurus.new)**. - -### What you'll need - -- [Node.js](https://nodejs.org/en/download/) version 18.0 or above: - - When installing Node.js, you are recommended to check all checkboxes related to dependencies. - -## Generate a new site - -Generate a new Docusaurus site using the **classic template**. - -The classic template will automatically be added to your project after you run the command: - -```bash -npm init docusaurus@latest my-website classic -``` - -You can type this command into Command Prompt, Powershell, Terminal, or any other integrated terminal of your code editor. - -The command also installs all necessary dependencies you need to run Docusaurus. - -## Start your site - -Run the development server: - -```bash -cd my-website -npm run start -``` - -The `cd` command changes the directory you're working with. In order to work with your newly created Docusaurus site, you'll need to navigate the terminal there. - -The `npm run start` command builds your website locally and serves it through a development server, ready for you to view at http://localhost:3000/. - -Open `docs/intro.md` (this page) and edit some lines: the site **reloads automatically** and displays your changes. diff --git a/docs/docs/intro/actions.mdx b/docs/docs/intro/actions.mdx new file mode 100644 index 0000000000..ea485a1e18 --- /dev/null +++ b/docs/docs/intro/actions.mdx @@ -0,0 +1,72 @@ +--- +title: Automating workflows with Contember Actions +--- + +import DocsCard from '../../src/components/global/DocsCard'; +import DocsCards from '../../src/components/global/DocsCards'; + +# <span className="version">Engine EE 1.3+</span> Automating workflows with Contember Actions + +In this section, we'll explore the usage of Contember Actions to automate Slack notifications. You'll discover how to set up an Action, track changes, and trigger Slack notifications. + +While there's an extensive range of potential applications for Actions, we're going to focus on a simple showcase for now. More complex usage is covered in our advanced reference guide. + +### Defining our first Action + +From our [previous steps](/intro/quickstart), we already have the schema. Imagine you want to send a Slack notification every time a new article is created. Here's how you'd modify the schema: + +```javascript +import { SchemaDefinition as def, ActionsDefinition as actions } from '@contember/schema-definition' + +@actions.trigger({ + name: 'sent_to_slack', + create: true, + selection: `title`, + webhook: 'https://example.com/send_to_slack', // we'll get to this later +}) +export class Article { + title = def.stringColumn() + content = def.stringColumn() +} +``` + +With this Trigger Action setup, the webhook is invoked whenever a new Article entity is created. The dispatched webhook payload includes the title field, allowing you to alert external systems about the creation event. + +Before this can work, though, you'll need to apply the changes using migrations. You can generate a migration with the following command: + +```bash +npm run contember migrations:diff "my-first-action" +``` + +(Learn more about [migrations](/reference/engine/migrations/overview.md)) + +That's all there is to the Contember setup. To test this locally, refer to our [guide to Actions](/reference/engine/actions/overview). If you're using Contember Cloud, you'll need to enable Actions for your project in the interface. + +![Contember Cloud Actions](/assets/actions-enable.png) + +### What comes next? + +The Action you've just configured will send the specified payload wherever you direct it. It supports numerous advanced parameters, such as headers for authorization. Here's an example of the payload you might receive: + +```json +{ + "events": [ + { + "id": "f4f0a97d-7850-4add-8946-a1ce016306ce", + "entity": "Article", + "values": { + "title": "Article Title", + }, + "operation": "create", + } + ] +} +``` + +The next step is to use this payload for your specific needs. In our case, we want to send it to Slack. While there are several no-code solutions like [Make](https://www.make.com/) that could be used, we'll illustrate an example using [Superface AI](https://superface.ai/), as it allows us to integrate AI capabilities and maintain everything in code. + +<DocsCards> + <DocsCard header="Easy integrations with Actions and Superface AI" href="/guides/superface"> + <p>Quick tutorial on how to quickly use Action with Superface AI.</p> + </DocsCard> +</DocsCards> diff --git a/docs/docs/intro/deployment.mdx b/docs/docs/intro/deployment.mdx new file mode 100644 index 0000000000..1de0a82043 --- /dev/null +++ b/docs/docs/intro/deployment.mdx @@ -0,0 +1,44 @@ +--- +title: Going live with Contember +--- + +import DocsCard from '../../src/components/global/DocsCard'; +import DocsCards from '../../src/components/global/DocsCards'; + +When you're ready to launch your application, you have two choices with Contember: using our managed hosting service, Contember Cloud, or self-hosting. + +## Contember Cloud + +For beginners, Contember Cloud is a great choice. It simplifies deployment by handling complex tasks, like scaling and security. You also get professional support from us, so you won't be left in the dark if problems arise. It's an easy and cost-effective solution that lets you focus on building your application. + +## Self-hosting + +If you want more control, you can self-host. Contember is open-source, so you can manage your own deployment. But remember, with more control comes more responsibility. You'll have to handle server maintenance and security. This choice is best for advanced users or teams with IT support. + +<DocsCards> + <DocsCard header="Deploy project to Contember Cloud" href="/guides/deploy-contember"> + <p>Step-by-step guide to deploy project to our managed Cloud.</p> + </DocsCard> + <DocsCard header="Self-host Contember" href="/guides/self-hosted-contember"> + <p>An advanced guide to deploy Contember to your own servers.</p> + </DocsCard> +</DocsCards> + +--- + +This concludes our first project. We've only just scratched the surface of what you can build with Contember, so let's see where we go next: + +<DocsCards> + <DocsCard header="Roles and access control" href="/guides/acl-definition"> + <p>Powerful declarative way to control access to your data.</p> + </DocsCard> + <DocsCard header="Contember Actions" href="/reference/engine/actions/overview"> + <p>Keep track of entity changes and trigger webhooks in response.</p> + </DocsCard> + <DocsCard header="Input validations" href="/reference/engine/schema/validations"> + <p>Add more constraints to a field.</p> + </DocsCard> + <DocsCard header="Understand data binding" href="/reference/interface/data-binding/overview"> + <p>Understand how automatic data binding in Contember works.</p> + </DocsCard> +</DocsCards> diff --git a/docs/docs/intro/glossary.md b/docs/docs/intro/glossary.md new file mode 100644 index 0000000000..ceace5f895 --- /dev/null +++ b/docs/docs/intro/glossary.md @@ -0,0 +1,33 @@ +--- +title: Glossary +--- + +## Basics + +| Term | Definition | +| ----------------------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Contember | An open-source developer platform designed to help developers create web applications quickly and efficiently. | +| Interface | In the context of Contember, this refers to the user interface components used to build bespoke user interfaces for web applications. | +| Engine | The part of Contember that handles the backend functionalities, including the data model and the GraphQL API. | +| Contember Cloud | A managed hosting service offered by Contember. It handles deployment-related tasks such as scaling and security, and provides professional support. | +| Project | Every project contains Contember Schema definition for your simple website, blog or any other content-based platform or database. Optionally any project can have its [Contember Interface](/reference/interface/introduction.md). | +| Instance | A running Contember Engine server hosting as many Contember projects as you like (and providing their Content API). Each instance has a single Tenant API, so you can store and manage access from a single point. | +| Entity | Entity is a basic unit in model schema. Each entity can have fields. | +| ACL rules | Access control rules for some entity. | + +## Advanced + +| Term | Definition | +|----------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------| +| Workspace | Your (git) repository with one or more Contember projects. | +| [Content API](/reference/engine/content/overview.md) | This is the primary GraphQL API for your project, which is automatically generated from your schema definition. | +| System API | This is a supplementary API for your project. It's primarily used to manage schema migrations. You need it only in really advanced usecases. | +| [Tenant API](/reference/engine/tenant/overview.md) | This API allows you to manage users, API keys, and project memberships on an instance. | +| [Project Schema](/reference/engine/schema/overview.md) | This is the definition of your model, ACL rules, and input validation rules. | +| [Project Migrations](/reference/engine/migrations/overview.md) | These are chronologically sorted, files that contain all schema or content changes. They serve as the source of truth for a schema. | +| Event | Every operation performed on your data is stored in an event log. This log can be utilized to track history. | +| Superadmin | This is a special user role within Contember. The Superadmin has the highest level of system access and control. | + +<!-- +ADD MODEL SCHEMA, INPUT VALIDATION RULES +--> diff --git a/docs/docs/intro/graphql.mdx b/docs/docs/intro/graphql.mdx new file mode 100644 index 0000000000..795410a19d --- /dev/null +++ b/docs/docs/intro/graphql.mdx @@ -0,0 +1,198 @@ +--- +title: Data handling with GraphQL +toc_max_heading_level: 4 +--- + +import DocsCard from '../../src/components/global/DocsCard'; +import DocsCards from '../../src/components/global/DocsCards'; + +[GraphQL](https://graphql.org/) is a query language for APIs and a runtime for executing those queries with your existing data. It's designed to give the client the power to ask for exactly what they need and nothing more. + +Here's why we think GraphQL is great: + +- Precise Data Fetching: With GraphQL, you can specify exactly what data you need, which can significantly decrease the amount of data being transferred over the network and speed up your applications. + +- Single Request: Unlike traditional REST APIs where you might need to make multiple requests to different endpoints to gather related data, GraphQL allows you to gather that same data in a single request. + +- Type Safety: GraphQL schemas are strongly typed. This means each level of your query has a specific type associated with it, which helps eliminate errors and bugs that are typically harder to find. + +- Evolving API: GraphQL can be version-free, meaning it is easy to add fields and types to your API without breaking existing queries. + +In this section of the tutorial, we will cover the basics of working with GraphQL within your Contember project, helping you understand how to fetch and manipulate data efficiently. + +## Content and Tenant GraphQL APIs + +Contember offers two distinct APIs: the Content API for managing and accessing your project's content, and the Tenant API for handling projects, tokens, users, and roles. + +### Content API + +The Content API, a GraphQL API, is tailored to the structure of your project's content. It's available at the URL formatted as `https://engine-hostname/content/{project}/{stage}`, for example, `http://localhost:1481/content/my-blog/live`. Access requires an access token with adequate permissions, obtained from the Tenant API. This token, appearing something like `44d7dd8ae4a45c33eaa309716e41e1a8476cda4f`, should be passed in the Authorization header as a Bearer token. + +The Content API's GraphQL schema respects your project's ACL rules, meaning it will only include types, fields, and mutations permissible for the user linked to the access token. For instance, a read-only role for a mobile app would result in the API excluding all mutations for this role. + +Each entity allows for 3 types of queries (`get`, `list`, `paginate`) and for 4 mutations (`create`, `update`, `delete`, `upsert`). + +#### `get` for fetching single records by a unique field + ```graphql + query { + getPost(by: { id: "c4ae3a0f-d91b-42a8-ad3c-5ca6b9f407c2" }) { + title + publishedAt + } + } + ``` +#### `list` for a straightforward list + ```graphql + query { + listPost( + filter: { + publishedAt: { lte: "2019-12-20" } + category: { name: { eq: "Graphql" } } + } + orderBy: [{ publishedAt: asc }] + limit: 10 + ) { + title + publishedAt + } + } + ``` +#### `paginate` for Relay-style pagination. + ```graphql + query { + paginatePost( + skip: 1 + first: 2 + filter: { author: { name: { eq: "John Doe" } } } + orderBy: [{ publishedAt: asc }] + ) { + pageInfo { + totalCount + } + edges { + node { + id + title + author { + name + } + } + } + } + } + ``` +#### `create` mutation to create new records. + ```graphql + mutation { + createPost(data: {title: "Hello world", publishedAt: "2019-12-20"}) { + ok + node { + id + } + } + } + ``` +#### `update` mutation to change existing record + ```graphql + mutation { + updatePost( + by: {id: "97644abb-0671-486b-9c51-b72b377ec1d9"} + data: {title: "Hello Contember"} + ) { + ok + node { + id + } + } + } + ``` +#### `delete` mutation to delete records + ```graphql + mutation { + deletePost( + by: {id: "97644abb-0671-486b-9c51-b72b377ec1d9"} + ) { + ok + node { + title + } + } + } + ``` +#### `upsert` mutation to update an existing row or a create a new one when the requested row does not exist + ```graphql + mutation { + upsertPost( + by: {slug: "hello-contember"} + create: {title: "Hello Contember"} + update: {title: "Hello Contember again!"} + ) { + ok + node { + id + } + } + } + ``` + +The `get` query requires a `by` parameter for filtering by a primary column or any unique columns, while `list` provides options such as complex condition filtering, result ordering, and paging via a limit and offset. + +For a more in-depth look at the Content API, here are some advanced resources: + +<DocsCards> + <DocsCard header="GraphQL Queries" href="/reference/engine/content/queries"> + <p>Everything you can do with queries and Contember.</p> + </DocsCard> + <DocsCard header="GraphQL Mutations" href="/reference/engine/content/mutations"> + <p>Advanced mutations including using transactions.</p> + </DocsCard> + <DocsCard header="Storing files on S3" href="/reference/engine/content/s3"> + <p>Mutations related to S3 compatible storage.</p> + </DocsCard> + <DocsCard header="Assume membership" href="/reference/engine/content/advanced/assume-membership"> + <p>Allow identities to temporarily assume a different set of memberships for a single request.</p> + </DocsCard> +</DocsCards> + +### Tenant API + +The Tenant API, accessible via the URL `https://engine-hostname/tenant`, necessitates a Bearer token in the Authorization header. + +The Tenant API facilitates user logins and registrations, membership management, and more. This basic guide won't cover this, but if you'd like to, here are the sources. + +<DocsCards> + <DocsCard header="How Tenant API works" href="/reference/engine/tenant/overview"> + <p>Basic explanation of Tenant API functionality.</p> + </DocsCard> + <DocsCard header="Managing users" href="/reference/engine/tenant/invites"> + <p>Invite users to your projects.</p> + </DocsCard> + <DocsCard header="Memberships" href="/reference/engine/tenant/memberships"> + <p>Explaining relationship between users and projects.</p> + </DocsCard> + <DocsCard header="Identity providers (IdP)" href="/reference/engine/tenant/idp"> + <p>Authenticate users using external accounts (Apple ID, Facebook, or other).</p> + </DocsCard> +</DocsCards> + +## Querying the Content API + +:::note important + +For this part we assume that you already have a Contember project with your data model running on your machine. + +::: + +You can interact with GraphQL API anywhere. But for basic exploring we suggest using the [Insomnia](https://insomnia.rest/) application. If you created a project in previous step, here's an Insomnia project for quick exploration Just <a href="/assets/insomnia-quickstart.json" target="_blank">download the project</a> and import it into Insomnia by creating new collection and in Export/Import choose Import and choose the file you just downloaded. + +![Insomnia](/assets/insomnia-screen.png) + +Try creating a few articles with the `Create Articles` mutation and then list them with the `List Articles` query. + +Now let's build the UI for our data. + +<DocsCards> + <DocsCard header="Building the UI" href="/intro/interface"> + <p>A short introduction to the power of Contember Interface.</p> + </DocsCard> +</DocsCards> diff --git a/docs/docs/intro/how-it-works.md b/docs/docs/intro/how-it-works.md new file mode 100644 index 0000000000..0a1fb5ae7e --- /dev/null +++ b/docs/docs/intro/how-it-works.md @@ -0,0 +1,138 @@ +--- +title: "How Contember Works: Streamlining Web Application Development" +--- + +import DocsCard from '../../src/components/global/DocsCard'; +import DocsCards from '../../src/components/global/DocsCards'; + +**Contember** is an open-source platform that significantly streamlines the process of building and managing data-driven web applications, providing developers with a comprehensive suite of tools to simplify both front-end and back-end operations. + +![contember diagram](/assets/contember-diagram.svg) + +## Contember Engine: Simplified Backend Operations + +**Contember Engine** empowers developers to define data models using TypeScript, subsequently generating a corresponding GraphQL API. This engine functions as a standalone server, offering two main components: + +1. **Content API**: A comprehensive GraphQL API for your data. +2. **Tenant API**: An authentication and authorization system to control data access. + +The Contember Engine doesn't just facilitate defining data structures and implementing APIs, it also manages your data in a well-structured PostgreSQL database, handling all database-related tasks, including automatic migrations. + +## Contember Interface: Efficient UI Building + +**Contember Interface** is a React-based SDK, designed to expedite the process of building custom management interfaces. Developers can define their user interfaces using high-level React components which then automatically connect to the GraphQL API provided by Contember Engine. + +Automated data binding from the GraphQL server to React components eliminates the need for manual data fetching or updates, simplifying the process of creating a custom interface. + +## Contember Cloud: Managed Hosting Service + +If you prefer not to host Contember Engine yourself, Contember Cloud is a managed hosting service that allows you to deploy your projects with ease. It handles tasks related to deployment, scaling, security, and offers professional support, freeing you to focus purely on development. + +## A Quick Look at the Contember Workflow + +Here's a basic workflow of using Contember: + +### 1. Data Model Definition + +Define your project's schema, which is then passed to the Contember Engine. Contember Engine then creates a table in a PostgreSQL database, where it stores your data and instantly provides you with GraphQL API. For example, a simple blog schema could look like this: + +```typescript +// Post.ts +import { SchemaDefinition as def } from '@contember/schema-definition' + +export const publicRole = acl.createRole('public') + +@acl.allow(publicRole, { + when: { publishedAt: { gte: 'now' } }, + read: ['content'], +}) +export class Post { + title = def.stringColumn().notNull() + publishedAt = def.dateTimeColumn() + content = def.stringColumn().notNull() +} +``` +### 2. GraphQL API Generation + +Once the schema is defined, the Contember Engine creates a corresponding table in a PostgreSQL database, and a GraphQL API is generated for your use. + +To save a post using GraphQL API, just fire this mutation: + +```graphql +mutation { + createPost( + data: { + title: "Hello world", + content: "first article stored in Contember!", + publishedAt: "2019-12-11T16:35:06" + } + ) { + ok + errorMessage + node { + id + } + } +} +``` + +To list all published posts, you can use this query: + +```graphql +query { + listPost(filter: {publishedAt: {isNull: false}}) { + title + publishedAt + } +} +``` + +### 3. Data Interactions + +You can use GraphQL mutations and queries to interact with your data. + +### 4. Building the UI + +The Contember Interface enables the creation of custom management interfaces. A post edit page, for example, can be simply created as: + +```typescript jsx +export const postEdit = ( + <EditPage entity="Post(id = $id)"> + <TextField field="title" label="Title" /> + <TextAreaField field="content" label="Content" /> + <DateTimeField field="publishedAt" label="Published at" /> + </EditPage> +) +``` + +With Contember, the process of web application development becomes more efficient and straightforward, allowing developers to focus more on crafting solutions and less on managing infrastructure and setup. + +Whether you're using the open-source platform or the managed Contember Cloud, you'll experience accelerated development timelines and a more streamlined workflow. + +<DocsCards> + <DocsCard header="Installation Guide" href="/intro/installation"> + <p>Step-by-step guide to start a new project with Contember.</p> + </DocsCard> + + <DocsCard header="Starter Kits" href="https://github.com/contember/starter-kits"> + <p>Check out our ready-to-use examples of what you can do with Contember.</p> + </DocsCard> + + <DocsCard header="Roles and access control" href="/guides/acl-definition"> + <p>Powerful declarative way to control access to your data.</p> + </DocsCard> + + <DocsCard header="Understand data binding" href="/reference/interface/data-binding/overview"> + <p>Understand how automatic data binding in Contember works.</p> + </DocsCard> +</DocsCards> + +<!-- +TODO: +MISSING PROJECTS +PICTURE OF CONTEMBER API SERVER, POSTGRES DB AND MULTIPLE CLIENTS +THE CONTEMBER API SERVER SQUARE CONTAINS SUB-SQUARES = PROJECTS & TENANT API +PROJECTS SUB SQUARE CONTAINS BLOG SUBSQUARE +BLOG SUB SQUARE CONTAINS CONTENT API AND SYSTEM API +POSSIBLE INCLUDE WALL AS AUTHORIZATION LAYER`` +--> diff --git a/docs/docs/intro/installation.mdx b/docs/docs/intro/installation.mdx new file mode 100644 index 0000000000..4272c8f5a8 --- /dev/null +++ b/docs/docs/intro/installation.mdx @@ -0,0 +1,81 @@ +--- +title: Installation +--- + +import DocsCard from '../../src/components/global/DocsCard'; +import DocsCards from '../../src/components/global/DocsCards'; + +:::note System requirements + +- [NPM 7](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) or later. +- [Docker](https://docs.docker.com/install/) with [Docker Compose](https://docs.docker.com/compose/install/) version 1.27+. +- macOS, Windows (including WSL), and Linux are supported. If you're using Apple Silicon, make sure to enable Use Rosetta for x86/amd64 emulation in your Docker Desktop settings/Features in development. + +::: + +## Pre-Requisite Knowledge + +While our documentation is designed with beginners in mind, we do assume a foundational knowledge base to keep our focus on Contember's features and capabilities. Rest assured, we'll provide links to appropriate resources whenever we introduce new concepts. + +To get the most out of Contember, we will assume that you have a basic understanding of Docker, React and TypeScript. If you don't, we suggest following resources: + +- [React: Official documentation](https://react.dev/learn) +- [Docker: Official documentation](https://docs.docker.com/get-started/) +- [TypeScript: The Basics](https://www.typescriptlang.org/docs/handbook/2/basic-types.html) + +## Create a new project with Contember + +We'll begin by creating a new Contember project using the `@contember/create` tool. Run the following command in your terminal: + +```bash +npm exec "@contember/create@latest" quickstart +``` + +This command creates a new folder called "quickstart" that contains a basic Contember setup. + +After the command completes, navigate to the newly created "quickstart" directory: + +```bash +cd quickstart +``` + +Next, we'll install the project dependencies. These will install Contember Interface and other libraries it needs to run properly. Install them using npm: + +```bash +npm install +``` + +Once the installation is complete, you're ready to start your Contember project. The npm start command will initiate Docker containers and get Contember up and running. + +``` +npm start +``` + +The npm start command executes two processes: + +- `start-engine` runs `docker-compose up --detach` to start the Docker containers, followed by `npm run contember migrations:execute --yes` to execute database migrations. <span class="smallNote">(There's nothing in your model at the moment so no migration will be executed. We'll show you how to create your first model in a minute.)</span> +- `start-admin` runs the Vite server for Contember Interface on port 1480 and listens to all network interfaces. + +At this point, you should be able to access your Contember Interface on your local machine at [http://localhost:1480](http://localhost:1480). + +To stop your Contember project, just press ctrl+c to exit the process. This will stop the Contember interface (which is not running in Docker for faster development). You will also need to run `docker-compose down` to stop all the Docker containers as well. + +**Here's a summary of the services that are now running on your machine:** + +- Your Contember application at [http://localhost:1480](http://localhost:1480) +- API endpoints at [http://localhost:1481](http://localhost:1481) (you can authorize with token `0000000000000000000000000000000000000000`) + +**For advanced use-cases, there are also:** + +- Adminer database management tool at [http://localhost:1485](http://localhost:1485) to access database created by Contember. +- Minio local S3 provider at [http://localhost:1483](http://localhost:1483) (you can sign in with contember / contember credentials). Contember uses object storage to store files and images uploaded by your users. +- Mailhog - tool for testing emails at [http://localhost:1484](http://localhost:1484). +- And of course PostgreSQL database at [localhost:1482](localhost:1482) (you can sign in with `contember` / `contember` credentials). + +Congratulations! You've successfully created your first Contember project. Now let's build something. + +<DocsCards> + <DocsCard header="Build your first project with Contember" href="/intro/quickstart"> + <p>Step-by-step guide to create your first model and user interface with Contember.</p> + </DocsCard> +</DocsCards> diff --git a/docs/docs/intro/interface.mdx b/docs/docs/intro/interface.mdx new file mode 100644 index 0000000000..f59cc26a86 --- /dev/null +++ b/docs/docs/intro/interface.mdx @@ -0,0 +1,246 @@ +--- +title: Building the UI +--- + +import DocsCard from '../../src/components/global/DocsCard'; +import DocsCards from '../../src/components/global/DocsCards'; + +Contember Interface is a React framework for building fast custom UIs. Contember Engine can work separately as an **headless GraphQL API**, but together with Interface you can quickly build interface for users that need to work with data right away. + +In this example we will create an UI for managing articles. You can use any React components alongside the Interface components. Data binding makes this very easy and fast to build almost any **single page application (SPA)** imaginable. + +::warning +This guide is outdated. We are working on updating it. If you have any questions, please contact us. +::: + +## Building user interfaces + +:::note important + +Before we start, make sure you already have a Contember project running on your computer. This should be a project with your data model that we made in the [designing your model](/intro/quickstart) guide. + +::: + + +### List all articles + +First, we want to create a display for all articles present in our project. To achieve this, we're using the `DataGridScope` component. This component generates a table, including filters for sorting and searching data. + +To start, navigate to admin/pages and create a new file named articleList.tsx. + +```tsx title="admin/pages/articleList.tsx" +import * as React from 'react' +import { DataGridScope, TextCell } from '@contember/admin' +import { SlotSources } from '../components/Slots' + +const { Title } = SlotSources + +export default () => ( + <> + <Title>Articles + + + + +) +``` + +What we've created here is a page that renders a table listing all the articles in our project. The table includes a column for the title of each article. Let's break down what's happening: + +1. We begin by importing the `@contember/admin` package. This package contains all necessary components and also supports TypeScript autocompletion. +2. We also import the `SlotSources` component namespace from `../components/Slots` used to define the title of our page. To learn more about this, see the [Slots](/reference/interface/pages/slots#2-creating-your-own-slots) documentation. +2. Next, we export the page component as the default export. This is necessary for our routing and navigation. +3. We implement the `DataGridScope` component to display our data in a simple, easy-to-read grid format. +4. We specify the entity we want to work with, which in this case is `Article`. This entity name should match what we defined in our model. +5. Lastly, we use the TextCell component to add a text column, designated for the title of each article. + +When you navigate to localhost:1480/article-list, you should now see a list of your articles. Keep in mind, this list will be empty if no articles have been added to the data model yet. + +### Routing + +In addition to the steps above, it's important to note how routing works within this framework: + +The naming of pages (and by extension, URL paths) is automated. The name given to a page is determined by the name of the file and the function, with slashes ('/') used as separators. + +For example, if you have a default export from a file named `post.tsx`, the resulting page name would be `post`. If there's a function within the same file that's exported as edit, the page name would be `post/edit`. + + + +

Understand how pages and routing works.

+
+
+ +### Create an article + +Now we're moving on to creating an article. For this, we'll make a new file called `articleCreate.tsx`. + +```tsx title="admin/pages/articleCreate.tsx" +import * as React from 'react' +import { CreateScope, PersistButton, RichTextField, TextField } from '@contember/admin' +import { SlotSources } from '../components/Slots' + +const { Title, Actions } = SlotSources + +export default () => ( + <> + Create Article + + + + + + + + +) +``` + +1. First, we're creating a new file named articleCreate.tsx in the admin/pages directory. +2. We're using the `CreateScope` component. This component allows us to create new entries for our specified entity - in this case, an Article. +3. We're defining the entity we're adding, which is Article again. +4. We're employing two components to capture user input: `TextField` and `RichTextField`. These components are connected to the `title` and `content` fields of our `Article` entity respectively. + +By navigating to [localhost:1480/article-create](localhost:1480/article-create), you can now create a new article. When you return to the list of articles, you'll see that your new article has been added. + +However, you'll notice the user experience isn't perfect yet. For instance, after creating an article, the application doesn't automatically switch to edit mode. To improve this, let's add an edit page next: + +### Edit article + +Now we're going to tackle editing an article. To do this, we'll create a new page named `articleEdit`. This page will look quite similar to the create page, but it will be designed to edit an existing article: + +```tsx title="admin/pages/articleEdit.tsx" +import * as React from 'react' +import { EditScope, PersistButton, RichTextField, TextField } from '@contember/admin' +import { SlotSources } from '../components/Slots' + +const { Title, Actions } = SlotSources + +export default () => ( + <> + Edit Article + + + + + + + + +) +``` + +Here's the breakdown of this code: + +1. We start by creating a new file named articleEdit.tsx in the `admin/pages` directory. +2. We're using the `EditScope` component this time. This component is similar to `CreateScope` but it is used for modifying existing entities. +3. We specify which Article entity we want to edit by providing an id: `Article(id = $id)`. This id will be dynamically populated based on the article selected for editing. +4. Like before, we're using `TextField` and `RichTextField` components to handle editing for the title and content fields respectively. + +Now, with this new edit page in place, you'll be able to modify existing articles. So let's use it. We'll redirect users from our create page to the edit page after the article is successfully created. + +```tsx title="admin/pages/articleCreate.tsx" +import * as React from 'react' +import { CreateScope, PersistButton, RichTextField, TextField } from '@contember/admin' +import { SlotSources } from '../components/Slots' + +const { Title, Actions } = SlotSources + +export default () => ( + <> + Create Article + + + + + + + + + +) +``` + +This is done with `redirectOnSuccess` prop where we specify link to page where user should be redirected. This is our first encounter with Contember Interface query language. Now if you create a new article you're automatically redirected to the edit page. + + + +

Learn query language to specify entities, fields, and filters.

+
+
+ +### More cells in datagrid + +Next, let's enhance our data grid with additional cells to open detail, create new or delete an existing article. + +```tsx title="admin/pages/articleList.tsx" +import * as React from 'react' +import { DataGridScope, DeleteEntityButton, GenericCell, Link, LinkButton, TextCell } from '@contember/admin' +import { SlotSources } from '../components/Slots' + +const { Title, Actions } = SlotSources + +export default () => ( + Articles + {/* highlight-start */} + + Add article + + {/* highlight-end */} + + + {/* highlight-start */} + Edit + + {/* highlight-end */} + +) +``` + +Let's break this down: + +1. We've added two new `GenericCell` components to our data grid. It's just a generic column without any functionality. +2. The first `GenericCell` includes a [Link component](/reference/interface/pages/links/). This links to the `articleEdit` page and passes the id of the current row's entity as a parameter. This lets us navigate directly to the edit page for a specific article. +3. The second `GenericCell` incorporates a `DeleteEntityButton`. This allows users to delete a specific article directly from the data grid. +4. We're using the `shrunk` property on both `GenericCell` components. This keeps these cells as small as possible, ensuring that they don't take up unnecessary space in the data grid. +5. We've also added a new `LinkButton` component to the `Actions` slot. This button links to the `articleCreate` page, allowing users to create a new article directly from the data grid. + +Now, with these additions, our data grid is more functional, providing quick access to both editing, creating and deleting articles. + +### Add pages to side menu + +The final step is to add our pages to the sidebar navigation. This provides easy access to all of our pages: + +```tsx title="admin/components/Navigation.tsx" +import * as React from 'react' +import { Menu } from '@contember/admin' + +export const Navigation = () => ( + + + + + + + +) +``` + +Here's what this code does: + +1. We create a new `Navigation` component in the `admin/components` directory. +2. We use the `Menu` component from `@contember/admin` to build a sidebar menu. +3. Inside this `Menu`, we create three `Menu.Item` components. Each of these represents a link to one of our pages. The first item leads to the "Dashboard", or `index` page. The second item leads to the "Articles", or `articleList` page. The third item leads to the "Create new article", or `articleCreate` page. + +And that's it! You have just created a simple data model and created custom interface, so you can edit the data. + +![administration is running](/assets/quickstart-final.png) + + + +

How to deploy Contember project.

+
+
diff --git a/docs/docs/intro/introduction.mdx b/docs/docs/intro/introduction.mdx new file mode 100644 index 0000000000..0a5772c418 --- /dev/null +++ b/docs/docs/intro/introduction.mdx @@ -0,0 +1,87 @@ +--- +title: Introduction +slug: / +--- + +import DocsCard from '../../src/components/global/DocsCard'; +import DocsCards from '../../src/components/global/DocsCards'; + +Contember is an open source platform that empowers developers to quickly build and manage data-driven web applications. + +## What is Contember + +With Contember, you can structure your data models using TypeScript and Contember Engine will instantly provide a GraphQL API for your data. Meanwhile, Contember Interface, a React framework, enables you to build bespoke user interfaces with high-level React components in hours. + +Behind the scenes, Contember takes care of technical aspects like generating a well-structured PostgreSQL database, handling automatic database migrations, and providing a built-in authentication and powerful role-based access control list. This means you can concentrate on building your application, rather than getting lost in setup and configurations. + +Whether you're developing on your own or as part of a team, Contember makes it easy to build custom backends, headless CMSes, customer relationship systems, employee portals, or other management apps. + +:::tip New: Contember Studio + +Meet Contember AI Studio. Describe your ideal backend and AI and Contember will build and launch it for you in minutes. No coding required. You will get a Postgres database, authentication, GraphQL API, and user interface. All custom-built for your project in minutes. [Try it today](https://www.contember.com/open/studio) + +::: + + + +

Step-by-step guide to start a new project with Contember.

+
+ + +

Quickly understand Contember's core mechanics and structure.

+
+
+ +## Main features + +| Feature | Description | +|---|---| +| **[Declarative role-based access control](/reference/engine/schema/acl.md)** | Build secure apps with numerous roles and granular data access controls. This helps you create secure apps with complex access requirements. | +| **[Instant GraphQL API](/reference/engine/content/overview.md)** | Automatically get high-performant, role-specific GraphQL APIs, accelerating feature development and allowing easy data utilization with any framework or language. | +| **Data Binding in Contember Interface** | Automated data binding from GraphQL server to React components removes manual data fetching or updating work. This makes creating a custom interface a breeze. | +| **Actions for data changes** (new in Engine v1.3) | Automate workflows, integrate with external systems, and enhance performance with Actions. | +| **Extendable with your React.js components** | Beyond out-of-the-box React components, Contember Interface lets you use React's full power to customise your applications. | +| **[Built-in authentication and authorization](/reference/engine/tenant/overview.md)** | Skip integrating third-party auth solutions and spend more time developing. | +| **[Automatic database migrations](/reference/engine/migrations/overview.md)** | Database schema changes are handled smoothly, reducing update risks. | +| **Well-structured PostgreSQL database** | Focus on development, not database setup. We ensure that your data is organized and efficient, making it easier to manage and maintain. | +| **Everything is code** | Manage and deploy your entire application with version control systems like Git, simplifying multi-environment setups. | +| **Multi-language, translatable** | Build global-ready apps with ease thanks to multi-language support. | +| **History API** | A detailed event log automatically tracks data changes. | + +## Why we build Contember + +Building custom web apps is more challenging than necessary, particularly for small teams or individual developers tasked with maintaining complex code bases over time. At Contember, we're challenging this status quo. + +We envision a world where everyone can become a full-stack developer, and where backend and frontend specializations aren't prerequisites for creating robust applications. Our declarative framework is central to this vision. But we're pushing boundaries even further with Contember Studio, an innovative AI tool that allows users to craft a functional first version of their app in mere minutes. This allows developers to focus on what really matters: creating apps that solve problems and delight users. + +Although the expanding complexity of web development allows for building remarkable things, it comes with a cost. Our goal at Contember is to simplify, streamline, and democratize web app development. We're here to empower you to build the web apps you need, and to bring the joy back into the building process. + +## License + +The Contember framework is open source under the Apache 2 license, so you can use it freely when self-hosting. This ensures you'll always have the ability to move your data wherever you need and continue using the applications you've built with the Contember framework. + +In addition to the core features, we offer an Enterprise Edition of Contember, which comes with several advanced capabilities. Access to these features requires a license fee, or you can utilize them as part of our managed hosting solution, Contember Cloud. + +## Questions and support + +If you have questions about anything related to Contembre, you're always welcome to ask our us on [Github Discussions](https://github.com/orgs/contember/discussions) or [Twitter](https://twitter.com/contember). + +## Where to go from here + + + +

Step-by-step guide to start a new project with Contember.

+
+ + +

Build your first project from scratch with Contember.

+
+ + +

Check out our ready-to-use examples of what you can do with Contember.

+
+ + +

Quickly understand Contember's core mechanics and structure.

+
+
diff --git a/docs/docs/intro/quickstart.mdx b/docs/docs/intro/quickstart.mdx new file mode 100644 index 0000000000..e9b8a9c22e --- /dev/null +++ b/docs/docs/intro/quickstart.mdx @@ -0,0 +1,112 @@ +--- +title: Designing your data model +--- + +import DocsCard from '../../src/components/global/DocsCard'; +import DocsCards from '../../src/components/global/DocsCards'; + +Your data model forms the backbone of your Contember application. From this model, Contember automatically generates a well structured PostgreSQL database for your and instantly provides a ready-to-use GraphQL API. + +Contember gives you the flexibility to shape your data model according to your application's specific needs. It offers a wide variety of [supported data types](https://docs.contember.com/reference/engine/schema/columns) and allows you to define [relationships between your entities](https://docs.contember.com/reference/engine/schema/relationships). There are no mandatory structures imposed on you, which means you can design your data model in the most effective way for your project. + +:::important important + +Make sure you have already followed our [installation guide](/intro/installation) and have a new project running on your local machine. + +::: + +After following our installation guide and having your project running on your local machine, you will notice the folder structure, which serves specific purposes: + +- The `admin` folder houses the user intefrace of your application. It contains all the scripts, styles, and assets required to create your application's interface. +- The `api` folder is where you define your data model, user roles, access control rules and Actions. + +``` +your_project_name/ + ├── admin/ + │ ├── components/ + │ ├── pages/ + │ ├── index.html + │ ├── index.tsx + │ ├── tsconfig.json + │ ├── vite-env.d.ts + │ └── vite.config.ts + ├── api/ + │ ├── migrations/ + │ ├── model/ + │ ├── index.ts + │ └── tsconfig.json + ├── docker-compose.yaml + ├── package.json + └── tsconfig.json +``` + +## Create your first data model + +### Define data model + +When we installed Contember it already created the structure above. Let's go to `api/model/index.ts` file. It looks like this: + +```ts +import { SchemaDefinition as def } from '@contember/schema-definition' + +// export your model definition here + +export { } // you can delete this line once you export your first entity +``` + +Let's define our first entity. + +:::note What is entity + +An entity represents a real-world object that is relevant to the system or application you are building. Each entity has properties that define its characteristics. + +For instance, if you were building a blog platform, some of the entities could be `Article`, `Author`, `Comment`, etc. An `Article` entity might have properties like `title`, `content`, `published_date`, while an `Author` might have `name`, `email`, `bio`, and so on. + +::: + +In this example, we'll define entity `Article` with two properties: `title` and `content`, which are both of type string. In real life we would obviously want it a bit different, but this is a very basic example designed to quickly show you how to work with Contember. + +```ts title="api/model/index.ts" +import { SchemaDefinition as def } from '@contember/schema-definition' + +export class Article { + title = def.stringColumn() + content = def.stringColumn() +} +``` + +### Generate and run migration + +After defining your data model, you need to generate and execute a migration for Contember Engine. Until migration is executed, no changes are applied to your database and GraphQL API. + +:::note What are migrations + +Migrations are a way of version controlling your database schema. They provide a systematic approach to evolve your database schema over time, instead of making ad-hoc changes. This makes it easier to coordinate changes to the schema across a development team, especially when multiple developers are working on the same project and need to keep their local databases in sync. [Migrations in detail →](/reference/engine/migrations/overview.md) + +::: + +To generate a new database migration is simple. Just run command: + +```bash +npm run contember migrations:diff "my-custom-describe-of-this-migration-in-few-words" +``` + +Execute the following command and select the Yes and execute immediately option. This action will generate your migration and, upon your confirmation, execute it. What actually happens behind the scenes is that a new JSON file detailing the changes in the database is created in the api/migrations directory. By opting to "execute", the changes are automatically applied on your local machine. As you deploy your project to various environments, the system will sequentially implement all migrations from this directory. For projects that are already live and functional, only the newly added migrations will be executed. + +Now if you would look into your database, you would see there a table `article` with three columns: `id`, `title`, `content`. Nice. + +:::note Contember CLI + +`npm run contember` is a Contember CLI, if you call this without any arguments you'll see all the available commands. +We'll use `migrations:diff` command. It goes through your schema and generates migration - instructions for Contember how to get from previous state to your new one. +This command needs two parameters: first is name of your project (`quickstart` in our example) and then name your migration. It can be anything you want. + +::: + +Now we have a structure in our database and GraphQL API is available. + + + +

Getting started with GraphQL in Contember.

+
+
diff --git a/docs/docs/intro/studio-quickstart.mdx b/docs/docs/intro/studio-quickstart.mdx new file mode 100644 index 0000000000..abcb1ad152 --- /dev/null +++ b/docs/docs/intro/studio-quickstart.mdx @@ -0,0 +1,204 @@ +--- +title: Walkthrough for Studio projects +--- + +You've just downloaded your Contember project and are eager to dive in and make changes. This tutorial is designed to guide you through the major concepts and help you understand the structure of your Contember project. By the end of this tutorial, you'll have a clear idea of where to find what you need and how to make modifications to your project with confidence. + +We'll cover essential topics such as project structure, data modeling, user interfaces, and more. Whether you're a seasoned developer or just starting out with Contember, this tutorial will equip you with the knowledge and skills needed to make the most of your new project. + +So, let's get started and unlock the full potential of your Contember project! + +
+ +## Project downloaded + +When you download your project, you'll see this structure: + +``` +your_project_name/ + ├── admin/ + │ ├── components/ + │ ├── pages/ + │ ├── .env + │ ├── index.html + │ ├── index.tsx + │ └── vite-env.d.ts + ├── api/ + │ ├── migrations/ + │ ├── model/ + │ ├── acl.ts + │ └── index.ts + ├── docker/ + ├── node_modules/ + ├── docker-compose.yaml + ├── package.json + ├── package-lock.json + └── tsconfig.json +``` + +:::note For running Contember locally you'll need following: + +- Installed [NPM](https://www.npmjs.com/) version 7+ +- Installed [Docker](https://docs.docker.com/install/) with [Docker Compose](https://docs.docker.com/compose/install/) version 1.27+ + +::: + +Go to the project folder: + +```bash +cd your_project_name +``` + +First step is to install dependencies. + +```bash +npm install +``` + +Now you're ready to start your project locally. Please make sure Docker is running and run: + +```bash +npm start +``` + +It will take about a minute and your project will be running on your computer. Any change you make is on your local machine only so feel free to experiement as much as you'd like. + +Services that are now running on your local machine: + +- User interface at: [http://localhost:1480](http://localhost:1480). +- API endpoints at [http://localhost:1481](http://localhost:1481) (you can authorize with token `0000000000000000000000000000000000000000`) + +For advanced use-cases there is also: + +- Adminer database management tool at [http://localhost:1485](http://localhost:1485). +- Minio local S3 provider at [http://localhost:1483](http://localhost:1483) (you can sign in with contember / contember credentials). +- Mailhog testing SMTP at [http://localhost:1484](http://localhost:1484). +- PostgreSQL database at [localhost:1482](localhost:1482) (you can sign in with contember / contember credentials). + +## Your data model + +In `api/model/index.ts` file you can find your application data model that AI built. + +Really simple example looks like this but you'll obviously have a more complicated one. + +```ts title="api/model/index.ts" +import { SchemaDefinition as def } from '@contember/schema-definition' + +@acl.allow(publicRole, { + read: ['content'], + when: { hiddenAt: { isNull: true } }, +}) +export class Article { + title = def.stringColumn() + content = def.stringColumn() + hiddenAt = def.dateTimeColumn() +} +``` + +Few notes: + +1. Thanks to `import { SchemaDefinition as def }` you get TypeScript autocompletion. Just press ctrl+space in your editor and you'll what you can use for each entity. It will also underline any errors. +2. Schema consists of Entities. Each entity has columns with many supported types. +3. Above Entities you can find access control decorators which define what role can access what. + +When you make changes, it won't get applied right away. You need to generate and execute database migration. Just run: + +```bash +npm run contember migrations:diff name-of-your-migration +``` + +Run this command and choose an option `Yes and execute immediately`. It will create your migration and after confirmation execute it. + +:::note Contember CLI + +`npm run contember` is a Contember CLI, if you call this without any arguments you'll see all the available commands. +We'll use `migrations:diff` command. It goes through your schema and generates migration - instructions for Contember how to get from previous state to your new one. +This command needs two parameters: first is name of your project (`quickstart` in our example) and then name your migration. It can be anything you want. + +::: + +## Your user interface + +Go to `admin/pages` where you can find all the pages you have in your project. You'll mostly have listing pages, create pages and edit pages. + +### Listing pages + +```tsx title="admin/pages/articleList.tsx" +import * as React from 'react' +import { DataGridPage, TextCell } from '@contember/admin' + +export default () => ( + + + +) +``` + +Few notes: + +1. There's import from `@contember/admin` package for TypeScript autocompletion. +2. Each page need to be exported component as default export. +3. In our example we use `DataGridPage` component to show the data in a simple datagrid. But there's many more components you can use. +4. Tell it which entities you'd like to edit. In our case it's `Article` (it has to be the same name you have in the model). +5. Another component we use here is a `TextCell` which is used to show `title` of each article. + +If you go to [localhost:1480/article-list](http://localhost:1480/article-list) you'll see list of your articles. Which is empty as we didn't add any data there yet. + +### Create page + +```tsx title="admin/pages/articleCreate.tsx" +import * as React from 'react' +import { CreatePage, RichTextField, TextField } from '@contember/admin' + +export default () => ( + + + + +) +``` + +1. There's a `CreatePage` component. +2. We tell it what we want to add (`Article`). +3. There are two components - `TextField` and `RichTextField` and each has fields which to edit. + +### Edit page + +```tsx title="admin/pages/articleEdit.tsx" +import * as React from 'react' +import { EditPage, RichTextField, TextField } from '@contember/admin' + +export default () => ( + + + + +) +``` + +It's pretty much the same as Create page but edits a specific Entity ID. + +### Navigation + +```tsx title="admin/components/Navigation.tsx" +import * as React from 'react' +import { Menu } from '@contember/admin' + +export const Navigation = () => ( + + + + + + + +) +``` + +## Fetch data via GraphQL API + +We recommend reading [Content API topic](/reference/engine/content/overview) however if you're looking to quickly play with the API, we've prepared [Insomnia](https://insomnia.rest/) project for you to import and quickly try it out. To use it download it here and just drag&drop it to Insomnia. + +## Deploy updates to Contember Cloud + +Your project is already deployed to Contember Cloud. Therefore all you need to do is to setup a Github Action (or similar if you're not using Github). Take a look at our guide on how to [deploy your project to Contember Cloud](/guides/deploy-contember.md) diff --git a/docs/docs/reference/cli.md b/docs/docs/reference/cli.md new file mode 100644 index 0000000000..e01ab7ebd2 --- /dev/null +++ b/docs/docs/reference/cli.md @@ -0,0 +1,41 @@ +--- +title: Contember CLI +--- + +There is a command line tool, which helps you with developing Contember projects and running them. + +Run it without any args to see a help +```text +npm run contember +``` +Which will print the available commands +```text +Contember CLI version X.Y.Z +Usage: + deploy Deploy Contember project + migrations:amend Amends latest migration + migrations:describe Describes a migration + migrations:diff Creates schema migration diff for given project + migrations:execute Executes migrations on an instance + migrations:rebase Rebase migrations on filesystem and in local instance + migrations:status Shows status of executed migrations on an instance & sync status + project:create Creates a new Contember project + project:print-schema Prints project schema + project:validate Validates project schema + tenant:create-api-key Creates an API key + tenant:invite Invites a user by an email + tenant:reset-password Resets user password + tenant:sign-in Signs in a user + version Prints Contember CLI version + workspace:update:api Updates Contember API version and all related packages +``` + +## Commands +:::tip +You can use non-ambiguous abbreviation for commands, +for example `npm run contember migr:exe`, which runs `migrations:execute` command +::: + +### migrations:diff + +See [migrations chapter](/reference/engine/migrations/overview.md). diff --git a/docs/docs/reference/engine/actions/definition.md b/docs/docs/reference/engine/actions/definition.md new file mode 100644 index 0000000000..80ac83d971 --- /dev/null +++ b/docs/docs/reference/engine/actions/definition.md @@ -0,0 +1,265 @@ +--- +title: Actions definition +--- + +# Engine EE 1.3+ Actions definition + +This section of the documentation will guide you through the syntax and definition of Actions, facilitating their optimal utilization within your applications. + +:::note +Keep in mind, whenever you add or modify Actions in Contember, it's essential to create and apply schema migrations. This ensures your changes are correctly integrated. You can learn more about [migrations](../migrations/overview.md). +::: + +## Defining a Watch Action + +The watch action in Contember Actions allows you to track and monitor specific changes within an entity. When you define a watch action, Contember automatically keeps track of the creation, deletion, and updates of the entity, as well as any changes made to the watched fields and relations. + +To define a Watch Action, you employ decorator syntax. The `@watch` decorator is attached to the entity class you aim to observe for changes. + +#### Example: Basic structure of a Watch Action definition: + +```javascript +import { SchemaDefinition as def, ActionsDefinition as actions } from '@contember/schema-definition' + +@actions.watch({ + name: 'action_name', + watch: `fields_to_watch`, + webhook: 'webhook_url', + selection: 'optional_selection_for_payload', +}) +export class YourEntity { + // Entity properties and relationships +} +``` + +- `name`: Assigns a unique name for the Action, which can serve for reference or identification. +- `watch`: Determines the fields and relations to track for changes. A GraphQL-like syntax can be used to designate the fields and relations to observe. This includes both direct fields of the entity as well as fields within related entities. +- `webhook`: Provides the URL to which the webhook notification will be directed when changes occur. [see advanced configuration](#webhook-configuration) +- `selection` (optional): Specifies the selection that will be dispatched in a payload. + +## Defining a Trigger Action + +Trigger Actions in Contember serve as a lower-level alternative to watch-based Actions, giving you the ability to selectively observe specific operations on an entity. These operations can include creation, deletion, or updates to specified fields. By providing precise control over which operations initiate your webhook, Trigger Actions let you concentrate on tracking the specific changes that matter to your application. + +To define a Trigger Action, you attach the `@trigger` decorator to the entity class you wish to monitor. + +#### Example: Basic structure of a Trigger Action definition: + +```typescript +import { SchemaDefinition as def, ActionsDefinition as actions } from '@contember/schema-definition' + +@actions.trigger({ + name: 'action_name', + create: true, + delete: true, + update: ['field_to_watch'], // or "true" + selection: 'optional_selection_for_payload', + webhook: 'webhook_url' +}) +export class YourEntity { + // Entity properties and relationships +} +``` + +- `name`: Provides a unique name for the Trigger Action, usable for reference or identification purposes. +- `create` (optional): Indicates whether the webhook should be triggered when a new entity is created. +- `delete` (optional): Specifies whether the webhook should be triggered when an entity is deleted. +- `update` (optional): Determines whether the webhook should be triggered when an entity is updated. This can be set to `true` to trigger on any update, or to an array of field names to trigger only when those specific fields are modified. +- `webhook`: Provides the URL to which the webhook notification will be directed when changes occur. [see advanced configuration](#webhook-configuration) +- `selection` (optional): Lets you include specific fields in the payload sent to the webhook, allowing fine-grained control over the data included in the notification. + +#### Example: Defining a Trigger Action + +```javascript +import { SchemaDefinition as def, ActionsDefinition as actions } from '@contember/schema-definition' + +@actions.trigger({ + name: 'book_created', + create: true, + selection: ` + title + author { + name + } + `, + webhook: 'https://example.com/book/created', +}) +export class Book { + title = def.stringColumn(); + author = def.manyHasOne(Author); +} +``` + +With the provided Trigger Action configuration, the webhook will be invoked whenever a new `Book` entity is created. The payload dispatched to the webhook will include the defined fields (`title` and `author.name`), allowing you to execute custom actions or alert external systems about the creation event. + +## Payload and Selection + +By default, the payload that is sent to the webhook encapsulates the changes that triggered the Action. However, you can personalize the payload by defining a `selection` property within both `@watch` and `@trigger` decorators. The `selection` property enables you to incorporate specific fields in the payload, offering fine-grained control over the data dispatched to the webhook. + +Example: Defining a selection within a watch + +```typescript +import { SchemaDefinition as def, ActionsDefinition as actions } from '@contember/schema-definition' + +@actions.watch({ + name: 'order_watch', + watch: ` + status + customer { + name + email + } + `, + webhook: 'https://example.com/order/updated', + selection: ` + status + customer { + name + } + `, +}) +export class Order { + // Entity properties and relationships +} +``` + +## Webhook Configuration + +The `webhook` property determines the URL where the webhook notification will be dispatched. This could be an external service or an endpoint within your own application that processes the webhook payload. + +### Advanced Webhook Options + +Instead of defining a simple string for the `webhook` property, you have the option to pass an object that allows for a more detailed configuration of the webhook. This feature gives you the ability to set additional headers, specify timeouts, manage retry attempts, and adjust the batching of webhook requests. Below is an example demonstrating how to leverage these advanced options: + +```javascript +import { SchemaDefinition as def, ActionsDefinition as actions } from "@contember/schema-definition" + +@actions.watch({ + name: 'book_watch', + watch: ` + title + tags { + name + } + `, + webhook: { + url: 'https://example.com/book/updated', + headers: { + 'Authorization': 'Bearer YOUR_API_KEY', + 'Content-Type': 'application/json', + }, + timeoutMs: 5000, + maxAttempts: 5, + initialRepeatIntervalMs: 2000, + batchSize: 10, + }, +}) +export class Book { + // Entity properties and relationships +} +``` + +In the provided example, the `webhook` property is defined as an object containing the following advanced options: + +- `url`: Defines the URL where the webhook notification should be dispatched. +- `headers` (optional): Lets you add custom headers to the webhook request. You can provide an object containing key-value pairs that represent header names and their corresponding values. +- `timeoutMs` (optional): Sets the timeout duration in milliseconds for the webhook request. If the request lasts longer than the set timeout, it will be marked as unsuccessful. +- `maxAttempts` (optional): Determines the maximum number of attempts to dispatch the webhook request in case of failures or errors. +- `initialRepeatIntervalMs` (optional): Establishes the initial interval duration in milliseconds between repeated attempts to dispatch the webhook request. This interval utilizes an exponential backoff strategy, doubling with each attempt until the maximum attempts are reached. +- `batchSize` (optional): Lets you manage the maximum number of events included in a single payload when sending batched requests. + +By making use of these advanced webhook options, you can tailor the behavior, reliability, and efficiency of your webhook integration in Contember. Feel free to adjust these options based on your unique requirements and the specifications of your target webhook endpoint. + +### Using Variables in Webhook URLs and Headers + +Variables can be employed within the webhook URLs and header values of your Actions, facilitating dynamic and adaptable configurations tailored to different environments or specific requirements. Variables are enclosed in double curly braces (e.g., `{{variableName}}`) and can be inserted in both the webhook URL and header values. + +#### Example: Using variables in URL and headers: + +```javascript +import {SchemaDefinition as def, ActionsDefinition as actions} from "@contember/schema-definition" + +@actions.watch({ + name: 'book_watch', + watch: ` + title + tags { + name + } + `, + webhook: { + url: `{{baseWebhookUrl}}/book/updated`, + headers: { + 'Authorization': 'Bearer {{apiKey}}', + 'Content-Type': 'application/json', + }, + }, +}) +export class Book { + // Entity properties and relationships +} +``` + +In this example, the `baseWebhookUrl` variable is employed to denote the base URL for the webhook endpoint. By including the variable in the `url` property of the `webhook` object, you can effortlessly alter the base URL as needed, which enhances flexibility and maintainability. + +The example also demonstrates the usage of the `{{apiKey}}` variable within the `headers` object. This feature enables you to dynamically assign the authorization token for the webhook request, making it adaptable to a variety of scenarios. + +The managementof these variables are explained in greater detail in the upcoming section, [Managing Actions](./managing.md), where you'll learn how to handle variables and set their values. + +### Using separate targets for shared configuration + +Contember enables the use of an alternative syntax that separates webhook targets for shared webhook configurations. This method promotes the reuse of identical webhook configurations across multiple Actions, granting flexibility for enabling or disabling watches, while preserving the target definition. Here's an example illustrating this syntax: + +```javascript +import {SchemaDefinition as def, ActionsDefinition as actions} from "@contember/schema-definition" + +export const myOrderUpdateTarget = actions.createTarget({ + name: 'my_order_update_target', + type: 'webhook', + url: 'http://localhost', +}); + +@actions.watch({ + target: myOrderUpdateTarget, + name: '...', + watch: '...', +}) +export class Foo { + // Entity properties and relationships +} +``` + +In this sample, a separate webhook target named `myOrderUpdateTarget` is defined using the `createTarget()` function. The target configuration includes properties such as `name`, `type`, and `url`. The `type` property is assigned `'webhook'`, signifying that this target corresponds to a webhook endpoint. + +To link the webhook target with an Action, the `target` property within the `@watch` decorator instead of the traditional `webhook` property. This facilitates referencing the shared webhook target for the specified Action. The remainder of the configuration, including the `name` and `watch` properties, stays consistent. + +This method also permits the disabling of the watch while retaining the target definition, enabling any pending events to be dispatched even when the watch is inactive. + +## Events Priority + +Contember Actions facilitates event prioritization in `watch` and `trigger` actions. Considering that events from various actions are stored and processed in a single queue, the ability to assign priority levels to certain events is significant. Higher priority ensures these events are processed ahead of others, which is especially beneficial for critical operations or time-sensitive tasks. + +To assign priority for a `watch` or `trigger` action, include the `priority` property in the action configuration. The `priority` value should be a positive integer, with higher values indicating higher priority. Consequently, events associated with a higher priority value will precede those with lower priority values during processing. + +Below is an example of assigning priority to a `watch` action: + +```typescript +import { SchemaDefinition as def, ActionsDefinition as action } from "@contember/schema-definition" + +@action.watch({ + name: 'book_watch', + watch: ` + title + tags { + name + } + `, + webhook: 'https://example.com/webhook', + priority: 2 +}) +export class Book { + // Entity definition +} +``` + +In this example, the `watch` action 'book_watch' is assigned a priority level of 2. Therefore, events triggered by changes in the 'title' or 'tags' fields of the 'Book' entity will have a processing priority level of 2 in the event queue. diff --git a/docs/docs/reference/engine/actions/invocation.md b/docs/docs/reference/engine/actions/invocation.md new file mode 100644 index 0000000000..5975a8ffcc --- /dev/null +++ b/docs/docs/reference/engine/actions/invocation.md @@ -0,0 +1,251 @@ +--- +title: Webhook invocation +--- + +# Engine EE 1.3+ Webhook Invocation in Actions + +After a watched event on a monitored entity is registered, it is queued within the actions queue. The Actions worker waits for these events to be dispatched and triggers the corresponding webhooks configured for the events. + +## Batching + +To optimize efficiency, events targeting the same webhook can be batched together, allowing for processing multiple events in a single invocation. By default, each batch contains a single event, and the event payload is wrapped in an array. However, you can adjust the `batchSize` property in the webhook configuration to specify the maximum number of events per batch. + +It's important to note that when processing a batch, all events within the batch are considered either successful or failed based on the HTTP response code. Currently, partial success for individual events within a batch is not supported. If any event in the batch fails, the entire batch is considered failed. + +## Fetching events + +The worker identifies events ready for processing based on their visibility, selecting events with the lowest visibility first. It then groups together events with the same target to form a batch. The payload for each event is already constructed when it is saved to the queue, simplifying the worker's role to dispatching the batched payloads. + +## Payload Construction and Variable Replacement + +In the webhook URL and header values, variables can be used using double curly braces (`{{variableName}}`). These variables are replaced with their corresponding values at the time of webhook invocation. It provides flexibility and customization by allowing dynamic content based on the context of the +event. + +By default, Contember appends the following headers to the webhook request: + +- `User-Agent: Contember Actions` +- `Content-Type: application/json` + +These headers identify the source of the webhook request and specify the format of the payload. + +## Request payload + +When a batch of events is dispatched and the corresponding webhook is invoked, the payload sent to the webhook contains a field named `events`. This field holds an array of event payloads representing the batched events. Each event payload follows a specific structure based on the type of event triggered. + + +#### Example: webhook body +```json5 +{ + "events": [ + { + "id": "...", + "entity": "...", + "type": "watch", + // ... + }, + /// other events + ] +} +``` + +:::caution +Even when a batch is configured to contain only a single event, it is still sent in the `events` field as an array with a single item. This consistent structure allows for unified handling of batched events, ensuring consistent processing logic regardless of the number of events in the batch. +::: + +### Watch Event Payload + +A `watch` event payload represents a change in the watched entity and provides detailed information about the change. Here is the structure of a `watch` event payload: + +- `id` (string or int): The unique identifier of the entity event. +- `entity` (string): The entity type of the watched entity. +- `events` (array of objects): An array of event payloads representing the changes in the watched entity. Each event payload has the following properties: + - `id` (string): The unique identifier for the event. + - `path` (array of string): The path to this entity relative to the watched entity. + - `entity` (string): The entity type of the event. + - `operation` (string): The type of operation performed on the entity, such as `create`, `update`, `delete`, `junction_connect`, or `junction_disconnect`. + - [other fields based on operation type](#basic-events) +- `trigger` (string): The name of the trigger associated with the event. +- `operation` (string): The operation type of the event, which is set to `watch` for a watch event. +- `selection` (object): Custom payload defined by `selection` on a watch definition +- `meta` (object): [see event meta](#event-metadata) + +#### Example: a body payload with a `watch` event payload for a `Book` entity: + +```json +{ + "events": [ + { + "id": "f4f0a97d-7850-4add-8946-a1ce016306ce", + "entity": "Book", + "events": [ + { + "id": "f4f0a97d-7850-4add-8946-a1ce016306ce", + "entity": "Book", + "values": { + "title": "Sample Book Title" + }, + "operation": "update" + } + ], + "trigger": "book_updated_watch", + "operation": "watch", + "selection": {}, + "meta": { + "eventId": "73bbf733-36a5-4e5c-8798-1ba1b7900b39", + "transactionId": "53e3790d-5485-490c-9420-a1e79b19b5d3", + "createdAt": "2023-07-25T15:30:17.937Z", + "lastStateChange": "2023-07-25T15:30:17.937Z", + "numRetries": 0, + "trigger": "book_updated_watch", + "target": "book_updated_watch_target" + } + } + ] +} +``` + +In this example, the `watch` event payload represents an update operation on a `Book` entity. It includes the updated value of the `title` property. The `operation` is set to `update`, and the associated trigger is `book_updated_watch`. Additional information, such as the selection of fields, can be included in the `selection` property if specified in the watch configuration. + +### Trigger Event Payload + +Trigger event payloads represent payloads for events invoked by `trigger` and contains individual basic events. + +- `id` (string or int): The unique identifier for the entity. +- `entity` (string): The entity type associated with the event. +- `operation` (string): The type of operation performed on the entity. Possible values are `create`, `update`, `delete`, `junction_connect`, or `junction_disconnect`. +- `selection` (object, optional): Additional information about the selected fields in the event, if specified in the event configuration. +- `meta` (object): [see event meta](#event-metadata) +- [other fields based on operation type](#basic-events) + +Here's an example of a basic event payload for an `update` operation on a `Book` entity: + +#### Example: a body payload with a `update` event payload for a `Book` entity: +```json +{ + "events": [ + { + "id": "f4f0a97d-7850-4add-8946-a1ce016306ce", + "entity": "Book", + "values": { + "title": "Updated Book Title", + "author": "John Doe" + }, + "operation": "update", + "selection": {}, + "trigger": "book_updated_watch", + "meta": { + "eventId": "73bbf733-36a5-4e5c-8798-1ba1b7900b39", + "transactionId": "53e3790d-5485-490c-9420-a1e79b19b5d3", + "createdAt": "2023-07-25T15:30:17.937Z", + "lastStateChange": "2023-07-25T15:30:17.937Z", + "numRetries": 0, + "trigger": "book_updated_watch", + "target": "book_updated_watch_target" + } + } + ] +} +``` + +In this example, the basic event payload represents an `update` operation on a `Book` entity. It includes the updated values of the `title` and `author` properties. The `operation` is set to `update`, and the `id` identifies the specific book entity. The `selection` and `path` properties are optional and provide additional context or information about the event within the entity graph. + +## Event Metadata + +Each event within the webhook payload contains metadata that provides essential information about the event. The event metadata, available under the `meta` field, includes the following properties: + +- `eventId` (UUID string): A unique identifier for the event. This identifier can be used to track and reference the event throughout your system. +- `transactionId` (UUID string): The identifier for the transaction associated with the event. This can be helpful for managing the event within a transactional context. +- `createdAt` (ISO 8601 string): The timestamp indicating when the event was created. It represents the moment when the event was initially recorded. +- `lastStateChange` (ISO 8601 string): The timestamp of the last state change for the event. It indicates when the event's state was last modified or updated. +- `numRetries` (int): The number of times the event has been retried. This count can help you track the number of retry attempts made for the event. +- `trigger` (string): The name of the trigger or watch that caused the event. +- `target` (string): The name of target associated with the event. + +### Basic events + +Basic event payloads represent individual operations performed on entities. They provide information about the specific operation and the changes made to the entity. + +```typescript +export type UpdateEvent = { + operation: 'update' + entity: string + id: PrimaryValue + values: JSONObject + old?: JSONObject +} + +export type CreateEvent = { + operation: 'create' + entity: string + id: PrimaryValue + values: JSONObject +} + +export type DeleteEvent = { + operation: 'delete' + entity: string + id: PrimaryValue +} + +export type JunctionConnectEvent = { + operation: 'junction_connect' + entity: string + id: PrimaryValue + relation: string + inverseId: PrimaryValue +} + +export type JunctionDisconnectEvent = { + operation: 'junction_disconnect' + entity: string + id: PrimaryValue + relation: string + inverseId: PrimaryValue +} +``` + +## Processing timeout + +A timeout is enforced for webhook completion to ensure timely processing. By default, the timeout is set to 30 seconds, but it can be adjusted in the webhook configuration (using `timeoutMs` prop). If a webhook fails to respond within the specified timeout, all events in the batch are marked as "retrying." + +## Response Processing + +When processing the response received from a webhook invocation, Contember follows specific rules to determine the success or failure of the batched events. Here's an overview of the response processing rules: + +1. **Not-OK Response Status**: If the HTTP response status falls outside the 2xx range (i.e., it is not considered OK), the entire batch is considered unsuccessful. Detailed information about the failure is stored in the `log` field of each event. + +2. **OK Response Status with Specific Non-Empty Body**: If the HTTP response status is within the 2xx range (OK) and the response body contains a valid JSON object with a `failures` key, the response is further analyzed: + - **Invalid Structure or unknown `eventId`**: If the structure of the response doesn't adhere to the required format (`failures` must contain an array of objects containing an `eventId` (UUID format) and optionally an `error` (string format)) or if the `eventId` present in the response does not correspond with any event in the batch, the entire batch is considered unsuccessful. + - **Valid Structure and `eventId`**: If the structure of the response is as expected and the `eventId` in the response matches an event in the batch, only the events specified in the `failures` field are marked as unsuccessful, while the remaining events are deemed successful. + +3. **OK Response Status with Empty or Other Non-Specific Body**: If the HTTP response status is OK (2xx) and the response body is either empty or contains anything other than a valid JSON object with a `failures` key, the entire batch is treated as successful, ignoring the specifics of the response content. + + +Following the processing of the response, the standard retry mechanism is applied to the events that were marked as unsuccessful. This ensures that the unsuccessful events are retried according to the configured retry logic, allowing for subsequent attempts to process them successfully. + +#### Example: webhook response payload with the `failures` field indicating event failures: + +```json +{ + "failures": [ + { + "eventId": "f4f0a97d-7850-4add-8946-a1ce016306ce", + "error": "Invalid input" + }, + { + "eventId": "a2b1c3d4-5678-90e1-2345-678f9g0h12i", + "error": "Service not available" + } + ] +} +``` + +In this example: + +- Two events within the batch are marked as failures. +- The `eventId` field uniquely identifies each failed event. This id matches the `eventId` in a `meta`. +- The `error` field provides additional information about the cause of the failure, such as an error message or a specific reason for the event processing failure. + +## Retries + +Contember follows a retry strategy for events marked as "retrying." By default, the initial repeat interval between retry attempts is set to 5,000 milliseconds (can be changed using `initialRepeatIntervalMs` webhook prop), and it follows an exponential backoff strategy for subsequent retries. The interval between retries doubles with each attempt until the maximum number of attempts is reached. The maximum number of attempts is set to 10 (`maxAttempts` prop in webhook configuration), meaning Contember will attempt to send the webhook request a maximum of 10 times before considering it as a failure. diff --git a/docs/docs/reference/engine/actions/managing.md b/docs/docs/reference/engine/actions/managing.md new file mode 100644 index 0000000000..ad090d4b78 --- /dev/null +++ b/docs/docs/reference/engine/actions/managing.md @@ -0,0 +1,160 @@ +--- +title: Actions management +--- + +# Engine EE 1.3+ Actions management + +The Actions Management API in Contember provides a GraphQL interface for managing Action events and variables in your project. You can access the API by making GraphQL requests to the `/actions/` endpoint, where `project-name` represents the name of your Contember project. + +## Permissions +You must include a Bearer token in Authorization header. To view and setup variables, you need either `admin` or `deployer` role. To interact with other Actions Management APIs, the `admin` role is required. It is not yet possible to configure these permissions. + +## Managing Variables + +Variables are dynamic values that can be used to customize the behavior of your Actions. These variables can be referenced in your Actions' configurations using double curly brackets `{{}}`, e.g. `{{baseUrl}}`. + +### Retrieving Variables: +To retrieve the variables defined for your Contember project, you can make a GraphQL query to the `variables` field. This will provide you with a list of variables, including their names and current values. + +Example: + +```graphql +query { + variables { + name + value + } +} +``` + +### Updating Variables + +To update the values of variables, you can use the `setVariables` mutation. This allows you to modify one or more variables simultaneously, depending on your requirements. The mutation accepts an array of `VariableInput` objects, where each object includes the variable name and the new value. These updated values can then be referenced in your Actions' configurations. + +Example: + +```graphql +mutation { + setVariables(args: { + variables: [ + { name: "apiKey", value: "new-api-key-value" }, + { name: "baseUrl", value: "http://localhost/foo-bar" } + ] + # mode: MERGE + }) { + ok + } +} +``` + +In this example, we are updating two variables, `apiKey` and `baseUrl`, with their respective new values. The mutation will return a boolean `ok` field indicating whether the update was successful. + +#### Variable update mode + +The `mode` field in the `SetVariablesArgs` input of `setVariables` mutation allows you to specify how the mutation should handle the original variables when updating their values. + +1. MERGE (Default Behavior): + - When `mode` is set to `MERGE`, the new variable values provided in the mutation will be merged with the existing variable values. + - Existing variables that are not included in the mutation will retain their current values. + - If a variable is included in the mutation, its value will be updated with the new value provided. + +2. SET: + - When `mode` is set to `SET`, all existing variables will be replaced with the new variable values provided in the mutation. + - This means that any variables not included in the mutation will be removed. + - Use caution when using the `SET` mode, as it completely replaces all existing variables. + +3. APPEND_ONLY_MISSING: + - When `mode` is set to `APPEND_ONLY_MISSING`, the new variable values provided in the mutation will be appended to the existing variables only if they do not already exist. + - Existing variables that are not included in the mutation will retain their current values. + - If a variable is included in the mutation and it already exists, its value will not be updated. + + +## Event queries + +The Actions API provides several queries to retrieve information about events triggered by Actions. These queries allow you to monitor the status, details, and progress of events within your project. Here are the available queries for retrieving events: + +### failedEvents query + +- Retrieves a list of failed events (failed or retrying). +- Events are sorted based on the last state change in descending order. +- Returns an array of `Event` objects that represent events with a failed state. + +#### Example: retrieving failed events + +```graphql +query { + failedEvents { + id + target + log + } +} +``` + +Explore the `log` field to extract any relevant error messages and gain further insights into the issues encountered during event processing. + +### eventsToProcess query + +- The query returns events that are visible and awaiting processing (processing, created and retrying). +- The events are sorted based on visibility in ascending order. + +:::note +Visibility refers to the point in time when an event becomes eligible for processing. Events with a timeout after failure may not immediately become visible. +::: + +#### Example: retrieving events ready to be processed + +```graphql +query { + eventsToProcess { + id + createdAt + lastStateChange + numRetries + state + target + } +} +``` + +### eventsInProcessing: query + +- The query returns events that are in the "processing" state. +- The events are sorted based on the lastStateChange property in ascending order. + +#### Example: retrieving events in processing + +```graphql +query { + eventsInProcessing { + id + createdAt + lastStateChange + numRetries + state + target + } +} +``` + + +### Pagination + +- The args input object allows you to specify the offset and limit parameters for pagination. +- Adjust the offset value to retrieve events from a specific position in the result set. +- Modify the limit (default 100) value to control the maximum number of events to be returned in a single query. + +## processBatch mutation + +The `processBatch` mutation in the Actions API allows you to manually trigger a batch processing of events within your Contember project. This mutation is an alternative to starting the Actions worker using the `CONTEMBER_APPLICATION_WORKER: 'all'` configuration. The `processBatch` mutation returns a simple response object indicating the success of the batch processing. Here is an overview of the `processBatch` mutation: + +Mutation: + +```graphql +mutation { + processBatch { + ok + } +} +``` + diff --git a/docs/docs/reference/engine/actions/overview.md b/docs/docs/reference/engine/actions/overview.md new file mode 100644 index 0000000000..b2581c2955 --- /dev/null +++ b/docs/docs/reference/engine/actions/overview.md @@ -0,0 +1,74 @@ +--- +title: Actions +--- + +# Engine EE 1.3+ Actions overview + +Actions in Contember provide developers with a powerful way to keep track of entity changes and trigger webhooks in response. With the use of Actions, developers can automate workflows, interface with external systems, and optimize their application's overall performance. + +To configure an Action, you need to employ the decorator syntax provided by Contember. Below is an example of an Action definition to demonstrate its structure: + +```javascript +import { SchemaDefinition as def, ActionsDefinition as actions } from "@contember/schema-definition" + +@actions.watch({ + name: 'book_watch', + watch: ` + title + tags { + name + } + `, + webhook: 'https://example.com/book/updated', +}) +export class Book { + title = def.stringColumn(); + tags = def.manyHasMany(Tag); + category = def.manyHasOne(Category); +} +``` + +In this example, we've defined an Action for the `Book` entity in Contember. The Action is set up to monitor the creation, deletion, and changes in the `title` field of the `Book` entity, along with modifications in the `tags` relation. It detects when tags are added or removed from a book, or when a tag's name changes. If any of these monitored fields or relations are altered, Contember triggers the assigned webhook. This enables the automation of workflows, integration with external systems, or execution of custom actions. + +Subsequent sections of this documentation will provide a more detailed understanding of Actions, including how to configure them, customize payloads, utilize best practices, and more. Let's tap into the full capabilities of Actions in Contember and streamline your development process. + +- [Defining Actions](./definition.md) +- [Managing Actions](./managing.md) +- [Webhook invocation](./invocation.md) + +:::caution Direct database changes +Contember tracks modifications made to data through the Contember Engine. It's important to be aware that if a user modifies data directly in the database bypassing Contember, such changes will not trigger the associated events. Events are only fired when modifications are performed using the Contember API or other supported mechanisms within the Contember ecosystem. +::: + +## Engine 1.3 Feature {#1-3-feature} + +Please be aware that Actions are part of Contember 1.3, which is currently in the RC stage. + +## Enterprise / Cloud Exclusive Feature + +This feature is not included in the Contember open-source edition but is part of the Contember Enterprise Edition (EE). Actions can also be enabled on Contember Cloud. + +![Contember Cloud Actions](/assets/actions-enable.png) + +## Local Development + +To use Actions in your local Contember projects, the `contember/engine-ee` Docker image is required. The Contember Enterprise Edition is freely available for development and testing purposes. To trigger webhooks and Actions, ensure that the Actions worker is enabled. The `CONTEMBER_APPLICATION_WORKER` environment variable can be set to 'all' in your Docker Compose configuration for the Contember Engine service. + +#### Docker Compose configuration update: + +```yaml +services: + contember-engine: + image: contember/engine-ee:1.3.0-rc + environment: + CONTEMBER_APPLICATION_WORKER: 'all' + # other environment variables +``` + +In this updated configuration, the `image` field has been changed to `contember/engine-ee:1.3.0-rc`, enabling the use of the Contember Enterprise Edition (EE) with Actions support. The `CONTEMBER_APPLICATION_WORKER` environment variable has also been introduced and set to `'all'`. + +You can also manually trigger webhooks using the [Actions management API](./managing.md). + +:::caution Upgrading from previous Contember versions +If you're upgrading from previous versions of Contember, you'll need to change the version in several places. One is the engine as seen above, another is the image for Contember CLI (`contember-cli`) and the last are packages for `@contember/schema` and `@contember/schema-definition`. +::: diff --git a/docs/docs/reference/engine/content/advanced/assume-identity.md b/docs/docs/reference/engine/content/advanced/assume-identity.md new file mode 100644 index 0000000000..a085747d1d --- /dev/null +++ b/docs/docs/reference/engine/content/advanced/assume-identity.md @@ -0,0 +1,36 @@ +--- +title: Assume identity +--- + + +The "assume identity" feature allows you to change the identity associated with your request for the purposes of event log. This can be useful in cases where you want to modify data as a different user, for example when using an admin account to perform actions on behalf of another user. + +:::note +Keep in mind that this does not change your membership or permissions, it only affects the event log. +::: + +### Sending a request + +To assume an identity, you need to add a special request header called x-contember-assume-identity with the UUID of the identity you want to assume. + +##### Example how to assume an identity specified in a header value: + +``` +x-contember-assume-identity: 8b7787d6-d26e-4bf3-8b7c-a9a9c7e2f8e8 +``` + +This will make all changes to data performed by this request to be associated with the identity specified in the header. + +### ACL Definition + +To use this feature, you must first enable it for the role you are using by setting `system.assumeIdentity` to `true` in your ACL definition + +#### Example how to define a role with ability to assume an identity: + +```typescript +export const adminRole = acl.createRole('admin', { + system: { + assumeIdentity: true, + }, +}) +``` diff --git a/docs/docs/reference/engine/content/advanced/assume-membership.md b/docs/docs/reference/engine/content/advanced/assume-membership.md new file mode 100644 index 0000000000..7cc43901e9 --- /dev/null +++ b/docs/docs/reference/engine/content/advanced/assume-membership.md @@ -0,0 +1,77 @@ +--- +title: Assume membership +--- + +Contember's assume membership feature allows identities to temporarily assume a different set of memberships for a single request. This can be useful in certain scenarios where an identity needs to perform an action that requires permissions that they do not have in their current memberships. + +### Sending using `x-contember-assume-membership` header + +The assume membership feature is enabled by sending a special request header called `x-contember-assume-membership` with a JSON encoded object matching the following type: +```typescript +{ + memberships: { + role: string, + variables: { + name: string, + values: string[] + }[] + } +} +``` + +#### Example of the x-contember-assume-membership header in use: +``` +x-contember-assign-membership: {"memberships": [{"role": "editor", "variables": [{"name": "lang", "values": ["en"]}]}]} +``` +This header would allow the identity to temporarily assume the editor role with a `lang` variable set to `en`. + +### Sending in a request body + +You can also enable this feature in a GraphQL request by including additional `assumeMembership` field in the JSON-encoded request body. + +#### Example of how you can structure the request body: + +```json5 +{ + "assumeMembership": { + "memberships": [ + { + "role": "editor", + "variables": [ + { "name": "lang", "values": ["en"] } + ] + } + ] + }, + // other standard GraphQL fields like query, variables or operationName +} +``` + +In this example, the `assumeMembership` field is added to the request body, which contains an object with the same structure as the `x-contember-assume-membership` header. You can specify the role and any variables for the assumed membership in the same way as with the header. + +If you send the `assumeMembership` field in the request body, it will take precedence over `x-contember-assume-membership` header that may also be present in the request. + +### ACL Definition + +To allow an identity to use the assume membership feature, the appropriate permissions must be defined in the ACL under the `content.assumeMembership` field of the role definition. Here is an example of how to do this: + +#### Example how to define a role with ability to assume a membership: + +```typescript +export const authorRole = acl.createRole('author', { + content: { + assumeMembership: {reader: true} + } +) +``` +This would allow identities with the `author` role to assume the `reader` role. + +### Memberships validity + +It's important to note that when a user assumes a membership, their existing memberships are replaced with the assumed memberships. This means that the user will only have the permissions of the assumed role while the membership is assumed. Assumed memberships are only valid for the single request where the x-contember-assume-membership header is present. + + +### Use Case +There are many use cases for this feature. For example, it can be useful in situations where an admin user wants to see how the content appears to an ordinary user. + +Another common use case for the "assume membership" feature is when you have an authentication proxy server in front of your Contember instance. The proxy server can add the `x-contember-assume-membership` header to the request before it reaches Contember, allowing the authenticated user to act as a different user with different permissions. diff --git a/docs/docs/reference/engine/content/advanced/caching.md b/docs/docs/reference/engine/content/advanced/caching.md new file mode 100644 index 0000000000..785907ee3f --- /dev/null +++ b/docs/docs/reference/engine/content/advanced/caching.md @@ -0,0 +1,62 @@ +--- +title: Caching +--- + + +## `x-contember-ref` Header for Caching Responses + +The `x-contember-ref` header allows for efficient caching of Contember responses. It works by providing a way for the client to ask the server whether the current server state has changed since the last time it was requested. + +It works by providing a unique identifier for the latest known event that has occurred in the system. This identifier can be included in subsequent requests as a means of specifying that the client already has the latest data. +If the ID provided in the request matches the latest ID in the server's event log, the server can respond with a `304 Not Modified` status code, indicating that the client already has the latest data and no further action is needed. This can significantly improve performance by reducing the amount of unnecessary queries and transfered data. + +The `x-contember-ref` header can be especially useful in environments where data is expected to change infrequently, such as in a publishing platform where new posts are only added periodically. By using the `x-contember-ref header`, clients can avoid unnecessary data transfer and improve performance. + +### Sending a request + +To use the `x-contember-ref` header, in first request include it in your request with any non-empty value (e.g. `1`). The server will then respond with a `x-contember-ref` header containing the UUID of the latest known event. This value should be saved and included in subsequent requests to take advantage of the caching behavior. + +### Example flow + +#### Initial request + +``` +GET /content/my-project/live +x-contember-ref: 1 +``` + +#### Response + +``` +200 OK +x-contember-ref: 2897c7ef-5407-4c1f-a45b-09f64bd050d8 +``` + +#### Subsequent request with event ID from previous response + +``` +GET /content/my-project/live +x-contember-ref: 2897c7ef-5407-4c1f-a45b-09f64bd050d8 +``` + +#### Response with 304 Not Modified + +``` +304 Not Modified +``` + +If the ID provided in the request does not match the latest known event on the server, the server will execute the query and return the latest data and a new ID value in the `x-contember-ref` header. This allows the client to update its cached data and continue using the ID for efficient caching in the future. + +#### Request with stale ID + +``` +GET /content/my-project/live +x-contember-ref: 2897c7ef-5407-4c1f-a45b-09f64bd050d8 +``` + +#### Response with updated data and new ID + +``` +200 OK +x-contember-ref: 37e7dfd1-5fbb-4c53-a56d-395fb5f2119a +``` diff --git a/docs/docs/reference/engine/content/advanced/request-debugging.md b/docs/docs/reference/engine/content/advanced/request-debugging.md new file mode 100644 index 0000000000..34ba0f359f --- /dev/null +++ b/docs/docs/reference/engine/content/advanced/request-debugging.md @@ -0,0 +1,17 @@ +--- +title: Request debugging +--- + +Debugging information provided in a response can be useful for identifying and troubleshooting issues with your Contember application. It provides details about the executed SQL queries, along with other useful information. + +In development mode, it is always enabled. On production, the `x-contember-debug: 1` header can be used to enable debugging information for a single request. + +### ACL definition + +Note that debugging information is only included in the response if the role used for the request has debugging enabled in its ACL definition. By default, the admin role has debugging enabled, but for other roles it must be explicitly enabled in the ACL definition. For example: + +```typescript +export const editor = acl.createRole('editor', { + debug: true, +}) +``` diff --git a/docs/docs/reference/engine/content/event-log.md b/docs/docs/reference/engine/content/event-log.md new file mode 100644 index 0000000000..95ee55df9b --- /dev/null +++ b/docs/docs/reference/engine/content/event-log.md @@ -0,0 +1,116 @@ +--- +title: Event log and history +--- + +Every operation changing the content is logged in an event log. + +:::note events and migrations +Events in event log are NOT transformed when you execute a migration. +::: + +## Querying the event log + +Anyone with [history permission](/reference/engine/schema/acl.md#history) can query the event log. You can do it by sending GraphQL `events` query to `/system/` endpoint. + +#### Example: listing events + +```graphql +query { + events { + id + # ... + } +} +``` + +### Event log: fields + +There are 3 GraphQL types of events: `UpdateEvent`, `CreateEvent` and `DeleteEvent` with following fields available + +:::note fields availability +Fields `oldValues`, `diffValues` and `newValues` are available only for some event types. See notes in table below. +::: + +| Field | GraphQL Type | Description | +| ------ | ---- | ----------- | +| id | `String!` | Unique identifier of the event | +| type | `EventType!` | One of `UPDATE`, `DELETE` and `CREATE` | +| createdAt | `DateTime!` | When the event was created | +| appliedAt | `DateTime!` | When the event was applied (transaction was committed) | +| identityId | `String!` | Identity ID of the user who performed the operation | +| identityDescription | `String!` | Description of the user who performed the operation | +| tableName | `String!` | Name of the table affected by the event | +| primaryKey | `[String!]!` | Primary key of the row affected by the event (for entities it contains a single element with it's id, for ManyHasMany junction tables it contains IDs of both entities) | +| oldValues | `Json!` | Old value of the row affected by the event (available only on `UpdateEvent` and `DeleteEvent`)| +| diffValues | `Json!` | Diff between old and new values of the row affected by the event (available only on `UpdateEvent`)| +| newValues | `Json!` | New value of the row affected by the event (available only on `CreateEvent`)| + +### Event log: filtering, sorting and pagination + +Using `args` (of type `EventArgs`) argument you can filter, sort and paginate the event log. + + +Here is structure of `EventArgs` type: + +```graphql +input EventsArgs { + stage: String + filter: EventsFilter + order: EventsOrder + offset: Int + limit: Int +} +enum EventType { + UPDATE + DELETE + CREATE +} +enum EventsOrder { + CREATED_AT_ASC + CREATED_AT_DESC + APPLIED_AT_ASC + APPLIED_AT_DESC +} + +input EventsFilter { + types: [EventType!] + rows: [EventFilterRow!] + tables: [String!] + transactions: [String!] + identities: [String!] + createdAt: EventsFilterDate + appliedAt: EventsFilterDate +} + +input EventsFilterDate { + from: DateTime + to: DateTime +} + +input EventFilterRow { + tableName: String! + primaryKey: [String!]! +} +``` + +#### Example: getting last 100 events creating an article + +```graphql +query { + events(args: { + filter: { + types: [CREATE], + tables: ["article"], + }, + limit: 100, + order: APPLIED_AT_DESC, + }) { + id + identityId + appliedAt + ... on CreateEvent { + newValues + } + } +} +``` diff --git a/docs/docs/reference/engine/content/mutations.md b/docs/docs/reference/engine/content/mutations.md new file mode 100644 index 0000000000..657820a1e3 --- /dev/null +++ b/docs/docs/reference/engine/content/mutations.md @@ -0,0 +1,164 @@ +--- +title: GraphQL Mutations +--- + +Contember offers advanced mutations for data modification. For every entity there are 4 operations available - create, update, delete and upsert. A GraphQL schema for your entity Post will look like this: + +```graphql +type Mutation { + createPost(data: PostCreateInput!): PostCreateResult! + deletePost(by: PostUniqueWhere!): PostDeleteResult! + updatePost(by: PostUniqueWhere!, data: PostUpdateInput!): PostUpdateResult! + upsertPost(by: PostUniqueWhere!, update: PostUpdateInput!, create: PostCreateInput!): PostUpsertResult! +} +``` + +## Creating records + +Using a `create` mutation you can create new records. Under `node` field you can fetch inserted record with a generated identifier. + +```graphql +mutation { + createPost(data: {title: "Hello world", publishedAt: "2019-12-20"}) { + ok + node { + id + } + } +} +``` + +## Updating records + +Use an `update` mutation to change existing record. For this operation, you need an unique identifier of the record. It can be either an ID or any other custom unique field. Using `node` field, you can fetch updated record. + +```graphql +mutation { + updatePost( + by: {id: "97644abb-0671-486b-9c51-b72b377ec1d9"} + data: {title: "Hello Contember"} + ) { + ok + node { + id + } + } +} +``` + +## Deleting records + +Use a `delete` mutation you can delete records. You also need a unique identifier for this operation. Using `node` field you can fetch a record before it is actually deleted. + +```graphql +mutation { + deletePost( + by: {id: "97644abb-0671-486b-9c51-b72b377ec1d9"} + ) { + ok + node { + title + } + } +} +``` + +## Upserting records + +`upsert` is a special operation, which updates an existing row or a creates a new one when the requested row does not exist. Beside the unique identifier you must provide two data inputs - one for creating and one for updating existing row. + +Using `node` field, you can fetch updated/created record. + +```graphql +mutation { + upsertPost( + by: {slug: "hello-contember"} + create: {title: "Hello Contember"} + update: {title: "Hello Contember again!"} + ) { + ok + node { + id + } + } +} +``` + +## Mutations on relations + +In a single mutation, you can execute nested mutations on relations using this operations: + +- `connect` - connects a record you specify by unique identifier. +- `disconnect` - disconnects record. For "has many" relations, you have to identify a record using unique identifier +- `create` - creates a record and connects it automatically after that. +- `update` - updates referenced record. For "has many" relations, you have to identify a record using unique identifier +- `delete` - deletes referenced record. For "has many" relations, you have to identify a record using unique identifier +- `upsert` - updates given record or creates a new record when there is nothing to update + +:::note +If you are e.g. connecting a record, which does not exist, the mutation will fail. +::: + +```graphql +mutation { + createPost(data: { + title: "Hello world" + category: {connect: {id: "af86a9a3-349a-412f-b95b-725c4b9061b8"}} + tags: [{create: {name: "graphql"}}] + }) { + ok + } +} +``` + +## Transactions + +Each mutation (with its nested mutations) is wrapped into individual transaction, meaning that for query: +```graphql +mutation { + post1: createPost(data: { + title: "Hello world" + tags: [{create: {name: "graphql"}}] + }) { + ok + } + post2: createPost(data: { + title: "Lorem ipsum" + tags: [{create: {name: "contember"}}] + }) { + ok + } +} +``` + +There will be two database transaction. + +If you need to wrap it into a single transaction, use `transaction` mutation: +```graphql +mutation { + transaction { + post1: createPost(data: { + title: "Hello world" + tags: [{create: {name: "graphql"}}] + }) { + ok + } + post2: createPost(data: { + title: "Lorem ipsum" + tags: [{create: {name: "contember"}}] + }) { + ok + } + ok + } +} +``` + +If anything goes wrong, whole transaction is rolled back. + +As you can see in the example, you can get the result of mutation (`ok` field but also errors, validation etc.) on both levels. + + +## Batch operations + +Currently there are no mutations for batch updates available. But you can always connect to a PostgreSQL and execute an update using SQL. diff --git a/docs/docs/reference/engine/content/overview.md b/docs/docs/reference/engine/content/overview.md new file mode 100644 index 0000000000..fde0e4f51c --- /dev/null +++ b/docs/docs/reference/engine/content/overview.md @@ -0,0 +1,19 @@ +--- +title: Content API overview +--- + +After Contember server starts, there will be more then single API available, but the Content API is the most important right now. + +The Content API is a GraphQL API provided by Contember that is used to manage and access content in your project. It is available at a URL in the format `https://your-hostname/content/{project}/{stage}` (e.g. `http://localhost:1481/content/my-blog/live`). To access the API, you will need to obtain an access token with sufficient permissions from the [Tenant API](../tenant/overview.md). The token will look like `44d7dd8ae4a45c33eaa309716e41e1a8476cda4f`. This token should be passed in the Authorization header as a Bearer token. + +:::tip +When running Contember locally, there is a superadmin token `0000000000000000000000000000000000000000` that can be used to access the Content API. +::: + +The GraphQL schema provided by the Content API is restricted based on the [ACL rules](../schema/acl.md) defined in your project. This means that the API will only include the types, fields, and mutations that are allowed based on the permissions granted to the user associated with the access token. For example, if you have defined a read-only role for a mobile application, the GraphQL API will not include any mutations for this role. + +## GraphQL client + +On [http://localhost:1481/playground](http://localhost:1481/playground) there is Apollo GraphQL playground running. But you can also try more advanced clients. Our choice is [Insomnia](https://insomnia.rest/). + +Now you can discover how to read your data using [GraphQL queries](./queries.md) or modify them using [GraphQL mutations](./mutations.md). Subscriptions for watching data changes are currently not available. diff --git a/docs/docs/reference/engine/content/queries.md b/docs/docs/reference/engine/content/queries.md new file mode 100644 index 0000000000..ca33953047 --- /dev/null +++ b/docs/docs/reference/engine/content/queries.md @@ -0,0 +1,425 @@ +--- +title: GraphQL Queries +--- + + +Contember provides a GraphQL API for fetching and modifying data. There are three types of queries available for each entity: "get" for fetching a single record by a unique field, "list" for simple listing, and "paginate" for pagination similar to the Relay specification. The "get" query requires a "by" parameter that allows filtering by a primary column or any other unique columns. The "list" query offers additional options such as filtering using complex conditions, ordering the result, and paging using a limit and offset. The "paginate" query works similarl to the "list" query but returns a connection object that includes pagination information such as the total count and edges for each record. + +For following entity: + +```typescript +import { SchemaDefinition as def } from "@contember/schema-definition"; + +export class Post { + title = def.stringColumn().notNull(); + publishedAt = def.dateTimeColumn(); +} +``` + +GraphQL schema will be similar to this (some types are omitted in the example for clarity): + +```graphql +query { + getPost(by: PostUniqueWhere!, filter: PostWhere): Post + listPost(filter: PostWhere, orderBy: [PostOrderBy!], offset: Int, limit: Int): [Post!] + paginatePost(filter: PostWhere, orderBy: [PostOrderBy!], skip: Int, first: Int): PostConnection! +} +input PostUniqueWhere { + id: UUID +} +input PostOrderBy { + id: OrderDirection + publishedAt: OrderDirection +} +input PostWhere { + id: UUIDCondition + publishedAt: DateTimeCondition + and: [PostWhere!] + or: [PostWhere!] + not: PostWhere +} +enum OrderDirection { + asc + desc +} +type PostConnection { + pageInfo: PageInfo! + edges: [PostEdge!]! +} + +type PageInfo { + totalCount: Int! +} + +type PostEdge { + node: Post! +} + +``` + +## Fetching a single record + +To fetch a single record using the "get" query in Contember. For example, if you have an entity called "Post", there will be a field called "getPost" with a parameter called "by". To fetch a record, you need to know the unique field that identifies the record. + +#### Example: fetching a single Post with the id "c4ae3a0f-d91b-42a8-ad3c-5ca6b9f407c2": +```graphql +query { + getPost(by: { id: "c4ae3a0f-d91b-42a8-ad3c-5ca6b9f407c2" }) { + title + publishedAt + } +} +``` + +The "by" parameter allows you to filter by any unique column (or columns in case of a compound unique key). By default, the unique field is "id", but you can specify other unique fields using the `.unique()` method on the column or using the `@def.Unique(...)` class annotation. + +You can then specify the fields you want to retrieve in the GraphQL query. In the example above, we are retrieving the "title" and "publishedAt" fields of the Post with the specified id. + +You can also use the "get" query to filter by other unique fields, as long as they are defined as unique in your Contember schema. For example, if you have a unique field called "slug" in your Post entity, you can fetch a single Post using the following query: + +```graphql +query { + getPost(by: { slug: "my-awesome-post" }) { + title + publishedAt + } +} +``` + +The filter argument in a get query allows you to apply additional filters on the result in addition to the unique filter provided by the by argument. You can use the same filter conditions as in the list and paginate queries. + +#### Example of how to use the filter argument in a get query: + +```graphql +query { + getPost( + by: { id: "c4ae3a0f-d91b-42a8-ad3c-5ca6b9f407c2" }, + filter: { + publishedAt: { lte: "2019-12-20" } + category: { name: { eq: "Graphql" } } + } + ) { + title + publishedAt + } +} +``` +This query will fetch a single Post record with specified ID and also filter the result to include only those that were published before "2019-12-20" and are in the category with the name "Graphql". + + +## Fetching a list of records + +You can use the list query to fetch a list of records from a specific entity. The list query takes several arguments, including filter, orderBy, offset, and limit, which allow you to narrow down the results and specify the order and pagination of the returned data. + + +#### Example of how to use the list query to fetch a list of posts: +```graphql +query { + listPost( + filter: { + publishedAt: { lte: "2019-12-20" } + category: { name: { eq: "Graphql" } } + } + orderBy: [{ publishedAt: asc }] + limit: 10 + ) { + title + publishedAt + } +} +``` + +This query will fetch a list of posts published before a certain date, ordered by their publication date in ascending order, with a limit of 10 results. + +You can use the `filter` argument to apply conditions on the returned data. You can use comparison operators, such as `eq`, `lt`, `contains`, etc., to specify the values that a certain field should or should not have. You can also use logical operators, such as `and`, `or`, and `not`, to combine multiple conditions. For details, see [filters section](#filters) bellow. + +The `orderBy` argument allows you to specify the order of the returned data. You can use the `asc` and `desc` values to specify ascending or descending order, respectively. For details, see [sorting results](#sorting-result) bellow. + +The offset and limit arguments allow you to specify the pagination of the returned data. The offset argument specifies the number of records to skip, while the limit argument specifies the maximum number of records to return. + +## Records pagination + +There is an alternative to a list queries with a similar structure - a "paginate" queries. This query aims to be Relay compatible in the future. + +The `skip` parameter determines the number of records to skip before returning results, while the `first` parameter determines the maximum number of records to return. The `filter` and `orderBy` parameters work the same way as they do in the `list` query. + +Cursor based pagination is not yet supported. + +In addition to fields for fetching a list of records, there is a `pageInfo` object with `totalCount` field, which tells you the total number of records that match the filter and sorting criteria. You can use this value to calculate the total number of pages and implement a pagination UI in your application. + +#### Example of how to use the "paginate" query + +```graphql +query { + paginatePost( + skip: 1 + first: 2 + filter: { author: { name: { eq: "John Doe" } } } + orderBy: [{ publishedAt: asc }] + ) { + pageInfo { + totalCount + } + edges { + node { + id + title + author { + name + } + } + } + } +} +``` + +## Filters + +To filter the results of a query in Contember's GraphQL API, you can use the filter argument. This argument takes an input object with fields corresponding to the columns of the entity you are querying. The value of each field can be a condition object specifying how the column should be filtered. + +### Comparison operators + +| GraphQL name | Description | Example | Supported columns +| ------------ | -------------- | ---------------- | ------------ +| isNull | is (not) null | `{isNull: true}` or `{isNull: false}` | Everywhere +| eq | equal to | `{eq: "value"}` | Everywhere but JSON +| notEq | not equals to | `{notEq: "value"}` | Everywhere but JSON +| in | is in list | `{in: ["A", "B"]}` | Everywhere but JSON +| notIn | is not in list | `{in: ["A", "B"]}` | Everywhere but JSON +| lt | less than | `{lt: 100}` | Everywhere but JSON +| lte | less than or equals to | `{lte: 100}` | Everywhere but JSON +| gt | greater than | `{gt: 100}` | Everywhere but JSON +| gte | greater than or equals to | `{gte: 100}` | Everywhere but JSON +| contains | contains a string (case sensitive) | `{contains: "contember"}` | String only +| containsCI | contains a string (case insensitive) | `{containsCI: "contember"}` | String only +| startsWith | starts with a string (case sensitive) | `{startsWith: "contember"}` | String only +| startsWithCI | starts with a string (case insensitive) | `{startsWithCI: "contember"}` | String only +| endsWith | ends with a string (case sensitive) | `{endsWith: "contember"}` | String only +| endsWithCI | ends with a string (case insensitive) | `{endsWithCI: "contember"}` | String only + +#### Example: GraphQL type for String condition + +```graphql +input StringCondition { + and: [StringCondition!] + or: [StringCondition!] + not: StringCondition + null: Boolean + isNull: Boolean + eq: String + notEq: String + in: [String!] + notIn: [String!] + lt: String + lte: String + gt: String + gte: String + contains: String + startsWith: String + endsWith: String + containsCI: String + startsWithCI: String + endsWithCI: String +} +``` + +### Logical operators + +You can also use the and, or, and not operators to combine multiple filters: + +| GraphQL name | Example +| ----------- | -------- +| and | `{and: [{ gte: "2019-12-20" }, { lte: "2019-12-30" }]}` +| or | `{or: [{isNull: true}, {eq: "value"}]}` +| not | `{not: {eq: "value"}}` + +#### Example how to combine two operators using AND + +```graphql +query { + listPost( + filter: { + publishedAt: { and: [{ gte: "2019-12-20" }, { lte: "2019-12-30" }] } + } + ) { + id + title + } +} +``` + +In this example, the `and` logical operator is used to specify that the `publishedAt` field must be greater than or equal to "2019-12-20" and less than or equal to "2019-12-30". This filters the result to only include posts with a `publishedAt` value within this range. The `and` operator can take an array of conditions, which must all be met in order for the overall condition to be `true`. The `or` operator can also be used in a similar manner, with the difference being that at least one of the conditions in the array must be `true` for the overall condition to be `true`. The `not` operator can be used to negate a condition, so that only records that do not match the condition will be included in the result. + +:::note +It is not possible to combine multiple fields in a single object. You have to wrap it using `and` or `or` fields. +::: + + +### Relation filters + +Filtering over relationships allows you to apply filters on related entities in a query. This is useful for narrowing down the results of a query by using the properties of related entities. Relation filters can be used on all types of relationships, including "has many". + +#### Example: filtering over relation + +```graphql +query { + listPost( + filter: { + author: { name: { eq: "John Doe" } } + tag: { caption: { eq: "graphql" } } + } + ) { + id + title + } +} +``` + +In the above example, the listPost query is being filtered by two relationships - author and tag. For the author relationship, the filter is specifying that the name of the related author must be "John Doe". For the tag relationship, the filter is specifying that the caption of the related tag must be "graphql". This will return all posts that have an author with a name of "John Doe" and a tag with a caption of "graphql". + +## Sorting result + +To sort the results of a query, you can use the `orderBy` argument in the query. This argument takes an array of objects, where each object represents a field to sort by and the sorting direction (ascending or descending). You can specify multiple fields to sort by and you can also sort by fields on related entities. + + +#### Example how to sort a list of posts first by the author's name in ascending order and then by the publishedAt field in descending order: +```graphql +query { + listPost( + orderBy: [ + { author: { name: asc } }, + { publishedAt: desc } + ] + ) { + id + title + } +} +``` + +In addition to `asc` and `desc`, there are also `ascNullsFirst` and `descNullsLast` which can be used to specify how to handle null values in the sorting. ascNullsFirst will place null values at the beginning of the sorted list, while descNullsLast will place them at the end. + + +## Nested objects + +When working with GraphQL, it is often necessary to traverse relationships between objects to retrieve the data needed. + +#### Example +```graphql +query { + listPost { + id + title + category { + name + } + author { + name + } + } +} +``` + +Additionally, you can traverse multiple levels of relationships by nesting fields within each other. + +#### Example how to retrieve the author of each post within a category: + +```graphql +query { + listCategory { + id + title + posts { + title + author { + name + } + } + } +} +``` + +On "has many" relations, you can also set a filter, orderBy and limit with an offset. + +#### Example how to use parameters on has-many relations +```graphql +query { + listCategory { + id + title + posts(limit: 3, orderBy: [{ publishedAt: desc }]) { + title + } + } +} +``` + +On both "has many" and "has one" relations, you can apply additional filters using the same syntax as with "list" queries. + +#### Example how to fetch all posts, but category is filtered to only match given filter: +``` +query { + listPost { + id + title + category(filter: { name: { eq: "GraphQL" } }) { + name + } + } +} +``` + + +## Narrowed has many + +You can use the narrowed has many to filter a "has many" relation by a field of a compound unique key, where the second part of the unique key references the entity that you are querying. This allows you to access a specific record within the "has many" relation. + +For example, consider the following schema: +```typescript +export class Category { + translations = d.oneHasMany(CategoryTranslation, 'category')) + internalNote = def.stringColumn() +} + +@d.Unique('category', 'locale') +export class CategoryTranslation { + category = d.manyHasOne(Category, 'translations').cascadeOnDelete().notNull() + locale = d.stringColumn().notNull() + name = d.stringColumn() +} +``` + +With this schema, you can use the following GraphQL query to access a specific translation for a category, filtered by the "locale" field: + +``` +query { + listCategory { + id + translationsByLocale(by: {locale: "en"}) { + name + } +} +``` +This will return single translation for the specified locale for each category in the result set, even though the relation is defined as "has many". + +## Transactions + +It's important to note that transactions are not automatically started for queries. This is done to improve performance, but it also means that you may experience inconsistency in the results. + +The transaction field allows you to wrap multiple queries into a single transaction. This ensures that all the queries are executed atomically. + +#### Example how to use transactions in queries +``` +query { + transaction { + listPost { + title + } + listAuthor { + name + } + } +} +``` + +This will ensure that the `listPost` and `listAuthor` queries are executed within a single transaction, ensuring consistency in the results. diff --git a/docs/docs/reference/engine/content/s3.md b/docs/docs/reference/engine/content/s3.md new file mode 100644 index 0000000000..67f889ecb0 --- /dev/null +++ b/docs/docs/reference/engine/content/s3.md @@ -0,0 +1,100 @@ +--- +title: Storing files on S3 +--- + +Beside operations for your data, there are mutations related to S3 compatible storage. Contember itself doesn't store your files, but it can help you with signing URLs for uploading (or reading) those files. + +## S3 server + +In Contember development stack, there is bundled Minio server, which is S3 compatible storage server, therefore you don't have to setup anything on localhost as it is running out of box. For production [see our guide](guides/self-hosted-contember.md#s3) + +## Signing upload URL + +Use GraphQL `generateUploadUrl` mutation to generate a presigned S3 upload URL. This is a secure way how to access your S3 bucket without exposing credentials to a client application, because only Contember server knows a secret key, using which it can sign one time URL. + +Execute following mutation in your application: +``` +mutation { + signedUpload: generateUploadUrl(contentType: "image/jpeg") { + url + publicUrl + method + headers { + key + value + } + } +} +``` +The `url` and other fields can be used to construct a request, which uploads a file directly to S3 storage from an application: +```typescript +await fetch( + signedUpload.url, + { + method: signedUpload.method, + headers: Object.fromEntries(signedUpload.headers.map(({ key, value }) => [key, value])), + body: content, + } +) +``` + +![S3 diagram](/assets/s3.svg) + +`generateUploadUrl` mutation has few optional arguments +- **expiration** (default 3600) - URL expiration in seconds +- **acl** (default corresponds S3 provider, usually it is "PUBLIC_READ") - object ACL + - PUBLIC_READ: anyone who knows public URL is allowed to read an object + - PRIVATE: object is not accessible using its public URL + - NONE: explicit object ACL is not set, leaving ACL to bucket policies + +:::note +Some S3 providers does not support object level ACL, so this argument will not be available at all. +::: + +## Signing read url + +When you set an object ACL to *PRIVATE*, you can use this mutation to sign a read URL: +``` +mutation { + generateReadUrl(objectKey: "images/5b934fe1-0e30-4761-9e00-d4bfabbddf34.png") { + url + } +} +``` +:::tip +If you store public URL instead of object key, don't worry. Contember will recognize it and parses an object key from it. +::: + +You can also optionally set an expiration of the URL (default 3600 seconds) + + +## Configuring S3 ACL + +To use S3 functionality, each role in the [ACL schema](../schema/acl.md) must have defined ACL rules. This is the most simple rule, which allows both operations on any key: +```typescript +const adminRole = { + s3: { + '**': { + read: true, + upload: true, + } + }, + // ... variables, stage, entities... +} +``` + +In a key you define a glob-like pattern for an S3 object key and in a value you define allowed operations (`read` and `upload`). We use [picomatch](https://github.com/micromatch/picomatch) library for a pattern matching. + +#### Example patterns + +- `**` - matches anything +- `images/**` - matches any object with an `images` key prefix +- `**.jpg` - matches any object with a `jpg` extension + +### ACL evaluation + +Contember will try to find any rule for an object key, which allows given operation. This means that if you first define a specific rule for `private/**`, which does NOT allow an upload, and later you define a generic rule `**` , which allows upload, then this rule will override previous one. + +:::note +`read` option only affects `generateReadUrl` mutation and does not affect upload ACL (`PUBLIC_READ`, `PRIVATE`) nor public read URL in any way. +::: diff --git a/docs/docs/reference/engine/content/transfer.md b/docs/docs/reference/engine/content/transfer.md new file mode 100644 index 0000000000..60d5b7a745 --- /dev/null +++ b/docs/docs/reference/engine/content/transfer.md @@ -0,0 +1,163 @@ +--- +title: Data export and import +--- + +Data transfer API and CLI tools allows exporting and importing data between projects. + +# Data transfer CLI + +The most convenient way to use transfer API are following CLI commands. + +## Data export +The `data:export` allows you to export data from a Contember project to a file. This can be useful for creating backups of your data or for transferring data between projects. + +To use the `data:export` command, open a terminal window and navigate to the root directory of your Contember project. Then, enter the following command: + +``` +npm run contember data:export [source-project] +``` +The source project argument is optional. If you leave it blank, the command will export data from the local project. If you want to export data from a remote project, you can specify the project using a DSN format like `contember://project:token@apiurl`. + +For example: +``` +npm run contember data:export contember://my-blog:0000abcd@api-my-blog.eu.contember.cloud +``` + +You can also use the following options with the data:export command: + +- `--no-gzip`: This option allows you to skip compressing the output file. By default, the output file will be compressed using gzip. + +- `--include-system`: This option includes system schema (with event log) in the export. + +- `--output` This option allows you to specify the name of the output file. By default, the file will be named `project-name.jsonl` (or `project-name.jsonl.gz` if the output is compressed). + +For example, to export data from a local project without compressing the output and including system schema data, you could use the following command: + +``` +npm run contember data:export -- --no-gzip --include-system --output my-project-data.jsonl +``` +This would export the data from the local project to a file named my-project-data.jsonl. + +## Data import + +The `data:import` allows you to import data from a file into a Contember project. This can be useful for restoring data from a backup or for transferring data between projects. + +To use the `data:import` command, open a terminal window and navigate to the root directory of your Contember project. Then, enter the following command: + +``` +npm run contember data:import [target-project] +``` +The input file argument should be the path to the file containing the data you want to import. The target project argument is optional. If you leave it blank, the command will import the data into the local project. If you want to import the data into a remote project, you can specify the project using a DSN format like `contember://project:token@apiurl`. + +For example: +``` +npm run contember data:import ./my-project-data.jsonl contember://my-blog:0000abcd@api-my-blog.eu.contember.cloud +``` +When you run the `data:import` command, you will be prompted to confirm the import. This is because the import process will overwrite any existing data in the target project. You can use the `--yes` option to skip this confirmation prompt. + +For example, to import data from a file into a local project without being prompted to confirm the import, you could use the following command: + +``` +npm run contember data:import ./my-project-data.jsonl -- --yes +``` + +This would import the data from the file `my-project-data.jsonl` into the local project, overwriting any existing data. + +## Data transfer + +The `data:transfer` allows you to transfer data directly between two Contember projects. This can be useful for transferring data between production and staging environments, or for moving data between different teams or projects. + +To use the `data:transfer` command, open a terminal window and navigate to the root directory of your Contember project. Then, enter the following command: +``` +npm run contember data:transfer +``` +The source project and target project arguments should be the projects you want to transfer data between. You can specify a local project using a dot `.`, or a remote project using a DSN format like `contember://project:token@apiurl` + +For example: +``` +npm run contember data:transfer . contember://my-blog:0000abcd@api-my-blog.eu.contember.cloud +``` +This would transfer data from the local project to the remote project specified in the DSN. + +You can also use the following options with the data:transfer command: +- `--include-system` This option includes system schema data in the transfer. +- `--yes` This option skips the confirmation prompt. + +For example, to transfer data from a remote project to a local project without being prompted to confirm the transfer and including system schema, you could use the following command: + +``` +npm run contember data:transfer contember://my-blog:0000abcd@api-my-blog.eu.contember.cloud . -- --include-system --yes +``` +This would transfer data from the remote project specified in the DSN to the local project, overwriting any existing data. + +# Data transfer HTTP API + +HTTP endpoints can be useful if you want to automate data management tasks or integrate Contember with other tools and systems. + +## Data export + +The `/export` endpoint in Contember allows you to export data from a Contember project using an HTTP request. + +To use the `/export` endpoint, you will need to send a POST request to the endpoint with a bearer token in the `Authorization` header and a JSON body containing the list of projects you want to export. The body should be in the following format: + +```json +{ + "projects": [ + { "slug": "project-name", "system": false } + ] +} +``` +To include system schema in the export, set the system field to true. The export will be in "JSON lines" format, with one JSON object per line. + +#### Example how to use the `/export` endpoint with CURL: + +```bash +curl --request POST \ + --url https://your-api/export \ + --header 'Authorization: Bearer your-token' \ + --header 'Content-Type: application/json' \ + --output exported-data.jsonl \ + --data '{ + "projects": [ + { "slug": "project-name", "system": false } + ] + }' +``` +This will send a POST request to the `/export` endpoint with a bearer token in the `Authorization` header and a JSON body specifying the project you want to export. The exported data will be returned in the response body. + +You can also use the `Content-Encoding: gzip` header to enable gzip compression for the export. + +# Data import + +The `/import` endpoint in Contember allows you to import data into a Contember project using an HTTP request. + +To use the `/import` endpoint, you will need to send a POST request to the endpoint with the exported data in the request body and a bearer token in the `Authorization` header. The `Content-Type` header should be set to `application/x-ndjson`. The target project for the import is contained in the exported file, but you can change it by modifying the file or by specifying the `targetSlug` field in the export. + +#### Example of to use the `/import` endpoint with CURL: + +``` +curl --request POST \ + --url https://your-api/import \ + --header 'Authorization: Bearer your-token' \ + --header 'Content-Type: application/x-ndjson' \ + --data exported-data.jsonl +``` +This will send a POST request to the `/import` endpoint with a bearer token in the `Authorization` header and the exported data in the request body. The Content-Type header is set to application/x-ndjson, which is the correct format for the exported data. + +You should also set the `Content-Encoding: gzip` header when the exported file is gzip encoded. + +## Permissions + +The ability to import and export data is controlled by user roles and their permissions. By default, the `admin` and `content_admin` roles have the ability to import and export data. However, you can modify these permissions by setting the `import` and `export` keys to `true` or `false` in the `content` or `system` section of the role configuration. + +For example, to grant the editor role the ability to export data but not import it, you could use following configuration: + +```typescript +export const editorRole = acl.createRole('editor', { + // ... + content: { + export: true, + import: false, + } +}) +``` diff --git a/docs/docs/reference/engine/migrations/advanced/development-commands.md b/docs/docs/reference/engine/migrations/advanced/development-commands.md new file mode 100644 index 0000000000..4d13bc8a76 --- /dev/null +++ b/docs/docs/reference/engine/migrations/advanced/development-commands.md @@ -0,0 +1,62 @@ +--- +title: Commands for development +--- + +During local development, there may be a need to bypass certain checks, even if the migration has already been executed locally. This section details several commands that provide flexibility and control over your local migration process. + +Please be aware that these commands are available exclusively on your local Contember instance and are not meant for production environments. + +### Amending a Migration + +While developing a new feature, you might find yourself needing to adjust an already created and applied schema migration. Instead of creating an entirely new diff, you can utilize the `migrations:amend` command. This command allows you to update the most recent migration both on disk and in your local Contember instance. If you revert the schema changes and run `migrations:amend`, the command effectively removes the migration. + +#### Example: Amending Latest Migration + +```bash +npm run contember migrations:amend +``` + +#### Example: Amending Specific Migration + +You can target a specific migration to amend by providing an additional argument, as shown below: + +```bash +npm run contember migrations:amend 2022-01-17-101806-test +``` + +:::note +If the migration has already been run by someone else or it's been deployed, it won't be possible to execute the amended migration. +::: + +### Rebasing a Migration with `migrations:rebase` + +Before merging a branch with a new migration, you might find that a new migration has been added upstream. The `migrations:rebase` command assists in resolving this by renaming the migrations both on disk and in your local Contember instance. Simply pass the names of the migrations you need to merge. + +#### Example + +```bash +npm run contember migrations:rebase 2022-01-17-101806-test +``` + +### Force Execution of Out-of-Order Migrations + +When you pull code from upstream, there may be a new migration that precedes your local migrations. To bypass this, you can run the `migrations:execute` command with the `--force` flag. + +#### Example: Force Executing + +```bash +npm run contember migrations:execute --force +``` + +### Engine 1.3+ Executing Migrations Until a Specific Point with `--until` + +In your development process, you might need to run a series of migrations up to a certain point. The `migrations:execute` command now allows you to use the `--until` flag for this purpose. This option executes all migrations up to and including the specified migration. + +#### Example: Executing Until a Specific Migration + +```bash +npm run contember migrations:execute --until 2022-01-17-101806-test +``` + + + diff --git a/docs/docs/reference/engine/migrations/advanced/skipping-validations.md b/docs/docs/reference/engine/migrations/advanced/skipping-validations.md new file mode 100644 index 0000000000..48218b33af --- /dev/null +++ b/docs/docs/reference/engine/migrations/advanced/skipping-validations.md @@ -0,0 +1,56 @@ +--- +title: Skipping validation errors +--- + +## Engine 1.2+ Skipping validation errors + +The `skippedErrors` feature in Contember allows users to specify a list of errors that should be ignored during validation of a migration. This can be useful in cases where a migration became invalid due to improvements and new checks in validator, but cannot be changed, because it is already applied. + +To skip errors, open a migration file producing errors and add `skippedErrors` field. It is an array of objects, each of which contains a code and a path field. The code field specifies the error code, and the path field specifies the path to the element in the migration that caused the error. Path field is optional. + +It is important to note that only individual migrations can have skipped errors, and the final migrated state must be valid. This means that any errors that are skipped in one migration must be fixed in a later migration in order for the migration process to be successful. + +#### Example: + +```json5 +{ + "skippedErrors": [ + { + "code": "ACL_INVALID_CONDITION", + "path": "roles.reader.entities.ContentReference.predicates.test" + } + ], + "formatVersion": 3, + "modifications": [ + // Modifications here... + ] +} +``` + +In this example, the `ACL_INVALID_CONDITION` error will be ignored for the test predicate in the ContentReference entity for the reader role. + +### Engine 1.3+ `skipUntil` + +In each error object, you can specify a `skipUntil` allowing to skip given validation until a specificed migration. This feature is useful when more migrations becomes invalid due to changes in the validator or data structure. + +Example: + +```json5 +{ + "skippedErrors": [ + { + "code": "ACL_INVALID_CONDITION", + "path": "roles.reader.entities.ContentReference.predicates.test", + /* highlight-start */ + "skipUntil": "2023-07-01-101530-abcd" + /* highlight-end */ + } + ], + "formatVersion": 3, + "modifications": [ + // Modifications here... + ] +} +``` + +In the above example, the "ACL_INVALID_CONDITION" error is ignored for a specific predicate in the ContentReference entity for the reader role. Additionally, subsequent validations will be skipped until the migration `2023-07-01-101530-abcd`. diff --git a/docs/docs/reference/engine/migrations/advanced/writing-schema-migrations.md b/docs/docs/reference/engine/migrations/advanced/writing-schema-migrations.md new file mode 100644 index 0000000000..41b591cd13 --- /dev/null +++ b/docs/docs/reference/engine/migrations/advanced/writing-schema-migrations.md @@ -0,0 +1,112 @@ +--- +title: Writing or fixing migrations manually +--- + +Typically, you won't need to write migrations from scratch, but there may be occasions when you need to fine-tune or rectify a generated migration. When you open a generated `.json` migration file, you'll find a list of "modifications" that describe the changes made to your database schema. In such cases, you can manually adjust these modifications to tailor your migrations to specific requirements. Below are the available manual adjustments you can make to migrations. + +### `fillValue` and `copyValue` support + +In Contember, migrations can be manually adjusted or fixed when needed. When working with migrations, you may encounter two modifications, `createColumn` and `updateColumnDefinition`, which now support the `fillValue` and `copyValue` features. These options allow you to provide values during the migration process for added columns or modified columns that have been changed to disallow null values. + +#### `createColumn` modification and `copyValue`/`fillValue` + +The `createColumn` modification enables the addition of a new column to an entity. When creating a column that does not allow null values, you can utilize the following options: + +- **fillValue**: Specifies a value that will be used to fill the column during the migration run. This value is distinct from the default value used at runtime. If a new column with a default value is added, the default value will also be used as the fillValue in the generated JSON migration. + +- **copyValue**: Indicates the name of another column from which the value will be copied to the newly created column. + +**Example:** + +```json5 +{ + "modification": "createColumn", + "entityName": "Article", + "field": { + "name": "isPublished", + "columnName": "is_published", + "columnType": "boolean", + "nullable": false, + "type": "Bool" + }, + /* highlight-start */ + "fillValue": false + /* highlight-end */ +} +``` + +#### Engine 1.3+ `updateColumnDefinition` modification and `copyValue`/`fillValue` + +The `updateColumnDefinition` modification allows you to modify the definition of an existing column within an entity. When changing a column to disallow null values, you can make use of the following options: + +- **fillValue**: Specifies a value that will be used to fill the column during the migration run. This option proves useful in populating the modified column with meaningful data when the nullability constraint is enforced. + +- **copyValue**: Indicates the name of another column from which the value will be copied to the modified column. + +**Example:** + +```json5 +{ + "modification": "updateColumnDefinition", + "entityName": "Article", + "fieldName": "isPublished", + "definition": { + "columnType": "boolean", + "nullable": false, + "type": "Bool" + }, + /* highlight-start */ + "copyValue": "existingColumn" + /* highlight-end */ +} +``` + +In this example, the value from an existing column named "existingColumn" will be copied to the modified column "isPublished" during the migration run. + +### Renaming Entities + +In Contember, renaming an entity involves creating a migration that drops the old entity and creates a new one. However, with the `updateEntityName` modification, you can instruct Contember to simply rename an existing entity without recreating it. + +**Arguments:** + +- **entityName**: The current name of the entity. +- **newEntityName**: The desired new name for the entity. +- **tableName**: You can optionally also change the name of the database table. + +**Example:** + +```json +{ + "modification": "updateEntityName", + "entityName": "OldEntity", + "newEntityName": "NewEntity", + "tableName": "new_entity" +} +``` + +In this example, the entity named "OldEntity" will be renamed to "NewEntity" using the `updateEntityName` modification. Also, the table in database will be renamed to `new_entity` + +### Renaming Fields + +Similar to the `updateEntityName` modification, the `updateFieldName` modification allows you to rename a field within an entity. + +**Arguments:** + +- **entityName**: The name of the entity containing the field. +- **fieldName**: The current name of the field. +- **newFieldName**: The desired new name for the field. +- **columnName**: You can optionally change the name of the field in a database. + +**Example:** + +```json5 +{ + "modification": "updateFieldName", + "entityName": "Entity", + "fieldName": "oldField", + "newFieldName": "newField", + "columnName": "new_field" +} +``` + +In this example, the field named "OldField" within the entity "Entity" will be renamed to "NewField" using the `updateFieldName` modification. diff --git a/docs/docs/reference/engine/migrations/basics.md b/docs/docs/reference/engine/migrations/basics.md new file mode 100644 index 0000000000..52a0736328 --- /dev/null +++ b/docs/docs/reference/engine/migrations/basics.md @@ -0,0 +1,55 @@ +--- +title: Migrations workflow +--- + +### Creating a diff using `migrations:diff` + +After you update your schema, you need to create a migration for your change, otherwise Contember won't see it. + +There is a command to rescue you: + +```bash +npm run contember migrations:diff +``` + +#### Example: creating a diff + +```bash +npm run contember migrations:diff add-categories +``` + +:::note +Name of a migration can only contain alphanumeric letters and a dash +::: + +Contember will show you individual migration steps and ask you for confirmation. + +You should check the steps with caution, because Contember cannot detect some changes correctly and it may result in a loss of your data. For example when you rename a field it drops the field and creates a new one. + +If you have chosen to execute migration, you are done for now. If you haven't, you can check created `.json` file and [modify migration](./advanced/writing-schema-migrations.md) file manually describing the change more precisely. + +### Explaining a migration using `migrations:describe` + +You can again verify individual migration steps using `migrations:describe`. You can use `--sql-only` and `--no-sql` to alter output of the command. + +#### Example: explaining migrations steps + +```bash +npm run contember migrations:describe +``` + +### Executing migrations using `migrations:execute` + +If you've pulled new migrations from upstream, or you want to execute a migration, you've created, you can apply all pending migrations using `migrations:execute` + +#### Example: executing migrations + +```bash +npm run contember migrations:execute +``` + +All the changes will be applied to both Contember schema and PostgreSQL database. + +:::note +To execute migrations, you need [appropriate permissions](../schema/acl.md#migrations). +::: diff --git a/docs/docs/reference/engine/migrations/content-migrations.md b/docs/docs/reference/engine/migrations/content-migrations.md new file mode 100644 index 0000000000..b69eb835ae --- /dev/null +++ b/docs/docs/reference/engine/migrations/content-migrations.md @@ -0,0 +1,156 @@ +--- +title: Content Migrations +--- + +# Content Migrations in Contember + +## Introduction + +Content migrations within Contember are designed to manage and transform the existing data in your database. This is achieved using GraphQL queries, which provide a powerful and versatile approach to data manipulation. + +## Setting Up Content Migration Files + +Content migration files should be added to the `api/migrations` directory in your Contember project. The naming convention for these files is `YYYY-MM-DD-HHMMSS-label`, which ensures they are executed in the correct order and are easy to identify. + +Example File Name: `2023-10-26-150000-add-english-locale.js` + + +### Using the CLI to create blank migration files + +You can use the Contember CLI to create blank migration files. Simply run the following command and provide a label for your migration. + +```bash +yarn contember migration:blank