diff --git a/docs/docs/core/database/intro.md b/docs/docs/core/database/intro.md new file mode 100644 index 0000000000..c97f4fac1a --- /dev/null +++ b/docs/docs/core/database/intro.md @@ -0,0 +1,17 @@ +--- +title: Introduction +slug: /database +tags: + - database +--- + +# Database + +This section is an overview of all the features related to the Database package: + +```mdx-code-block +import DocCardList from '@theme/DocCardList'; +import { useCurrentSidebarCategory } from '@docusaurus/theme-common'; + + +``` diff --git a/docs/docs/core/database/relations/reordering.mdx b/docs/docs/core/database/relations/reordering.mdx new file mode 100644 index 0000000000..e136088482 --- /dev/null +++ b/docs/docs/core/database/relations/reordering.mdx @@ -0,0 +1,119 @@ +--- +title: Relations +slug: /database/relations/reordering +description: Conceptual guide to relations reordering in the Database +tags: + - database + - relations + - reordering +--- + +Strapi allows you to reorder a relation list. + +An example of reordering in the CM + +This reordering feature is available in the Content Manager and the API. + +## Code location + +`packages/core/database/lib/entity-manager/relations-orderer.js` + +## How is the order stored in DB? + +- We store the order value of the relation in an `order` field. +- For bidirectional relations, we store the order value of the other side in an `inverse_order` field. + +We store order values for all type of relations, except for: + +- Polymorphic relations (too complicated to implement). +- One to one relations (as there is only one relation per pair) + +### Many to many (Addresses <-> Categories) + +many to many relation + +- `category_order` is the order value of the categories relations in an address entity. +- `address_order` is the order value of the addresses relations in a category entity. + +### One to one (Kitchensinks <-> Tags) + +one to one relation + +- there is no `order` fields as there is only one relation per pair. + +### One way relation (Restaurants <-> Categories) + +Where a restaurant has many categories: + +many way relation + +- `category_order` is the order value of the categories relations in a restaurant entity. +- There is no `restaurant_order` as it is a one way relation. + +## How to reorder relations in the DB layer + +See more on [Strapi Docs](https://docs.strapi.io/dev-docs/api/rest/relations#connect) + +The database layer should receive a payload shown below: + +```js + category: { + connect: [ + { id: 6, position: { after: 1} }, // It should be after relation id=1 + { id: 8, position: { end: true }}, // It should be at the end + ], + disconnect: [ + { id: 4 } + ] + } +``` + +## How does relations reordering work? + +We use fractional indexing. This means that we use decimal numbers to order the relations. See the following diagrams below for a more detailed understanding. + +### Simple example + +An example of reordering in the CM + +### Complex example + +An example of reordering in the CM + +### Algorithm steps + +From the `connect` array: + +- For every element, **load relations by id**, **from fields `after` or `before`**. +- Start computing based on the `after` and `before` relations: + - **Initialize** with after/before relations (**step 1**). Let's call these ones **init relations.** + - **Apply the updates** from the `connect` array, **sequentially**. + - If the update is of type `before`: + - Place the element with the given `id` **before** the specified element in the list. + - If the specified element is an `init relation`, place the element in between that relation and the one before it. + - To determine the order value, **order = beforeRelation.order - 0.5**. This ensures the element is placed before the `before` relation and after the one before it. + - Else **order = beforeRelation.order** + - If the update is of type `after`: + - Place the element with the given `id` **after** the specified element in the list. + - If the specified element is an `init relation`, place the element in between that relation and the one after it. + - To determine the order value, **order = beforeRelation.order + 0.5**. This ensures the element is placed before the `after` relation and before the one after it. + - Else **order = beforeRelation.order** + - If the update is of type `end`: + - Place at the **end** + - If placing after an init relation: **order = lastRelation.order + 0.5** + - Else **order = lastRelation.order** + - If the update is of type `start`: + -Place at the **start** + - **order = 0.5** + - `before/after`: If the **id does not exist in the current array**, **throw an error** + - If an **id** was **already in this array, remove the previous one** +- **Grouping by the order value**, and ignoring init relations + - Recalculate order values for each group, so there are no repeated numbers & they keep the same order. + - Example : [ {id: 5 , order: 1.5}, {id: 3, order: 1.5 } ] → [ {id: 5 , order: 1.33}, {id: 3, order: 1.66 } ] + - **Insert values in the database** + - **Update database order based on their order position.** (using ROW_NUMBER() clause) + +From the disconnect array: + +- Delete the relations from the database. +- Reorder the remaining elements in the database based on their position, using ROW_NUMBER() clause. diff --git a/docs/static/img/database/m2m-example.png b/docs/static/img/database/m2m-example.png new file mode 100644 index 0000000000..506114eb7b Binary files /dev/null and b/docs/static/img/database/m2m-example.png differ diff --git a/docs/static/img/database/mw-example.png b/docs/static/img/database/mw-example.png new file mode 100644 index 0000000000..62b410ebc5 Binary files /dev/null and b/docs/static/img/database/mw-example.png differ diff --git a/docs/static/img/database/o2o-example.png b/docs/static/img/database/o2o-example.png new file mode 100644 index 0000000000..18aa545dec Binary files /dev/null and b/docs/static/img/database/o2o-example.png differ diff --git a/docs/static/img/database/reordering-algo-1.png b/docs/static/img/database/reordering-algo-1.png new file mode 100644 index 0000000000..298122e233 Binary files /dev/null and b/docs/static/img/database/reordering-algo-1.png differ diff --git a/docs/static/img/database/reordering-algo-2.png b/docs/static/img/database/reordering-algo-2.png new file mode 100644 index 0000000000..6354e1ec14 Binary files /dev/null and b/docs/static/img/database/reordering-algo-2.png differ diff --git a/docs/static/img/database/reordering.png b/docs/static/img/database/reordering.png new file mode 100644 index 0000000000..adfcbf3d5d Binary files /dev/null and b/docs/static/img/database/reordering.png differ diff --git a/packages/core/admin/admin/src/pages/HomePage/CloudBox.js b/packages/core/admin/admin/src/pages/HomePage/CloudBox.js new file mode 100644 index 0000000000..c05aada847 --- /dev/null +++ b/packages/core/admin/admin/src/pages/HomePage/CloudBox.js @@ -0,0 +1,83 @@ +import React from 'react'; +import styled from 'styled-components'; +import { useIntl } from 'react-intl'; +import { useTracking, pxToRem } from '@strapi/helper-plugin'; +import { Box, Flex, Typography } from '@strapi/design-system'; +import cloudIconBackgroundImage from './assets/strapi-cloud-background.png'; +import cloudIcon from './assets/strapi-cloud-icon.svg'; +import cloudFlagsImage from './assets/strapi-cloud-flags.svg'; + +const BlockLink = styled.a` + text-decoration: none; +`; + +const CloudCustomWrapper = styled(Box)` + background-image: url(${({ backgroundImage }) => backgroundImage}); +`; + +const CloudIconWrapper = styled(Flex)` + background: rgba(255, 255, 255, 0.3); +`; + +const CloudBox = () => { + const { formatMessage } = useIntl(); + const { trackUsage } = useTracking(); + + return ( + { + trackUsage('didClickOnTryStrapiCloudSection'); + }} + > + + + + {formatMessage({ + + + + + + {formatMessage({ + id: 'app.components.BlockLink.cloud', + defaultMessage: 'Strapi Cloud', + })} + + + + {formatMessage({ + id: 'app.components.BlockLink.cloud.content', + defaultMessage: + 'A fully composable, and collaborative platform to boost your team velocity.', + })} + + + + + + ); +}; + +export default CloudBox; diff --git a/packages/core/admin/admin/src/pages/HomePage/ContentBlocks.js b/packages/core/admin/admin/src/pages/HomePage/ContentBlocks.js index 0a44e61705..17d63fe300 100644 --- a/packages/core/admin/admin/src/pages/HomePage/ContentBlocks.js +++ b/packages/core/admin/admin/src/pages/HomePage/ContentBlocks.js @@ -4,6 +4,7 @@ import { useIntl } from 'react-intl'; import { ContentBox, useTracking } from '@strapi/helper-plugin'; import { Stack } from '@strapi/design-system'; import { InformationSquare, CodeSquare, PlaySquare, FeatherSquare } from '@strapi/icons'; +import CloudBox from './CloudBox'; const BlockLink = styled.a` text-decoration: none; @@ -19,6 +20,7 @@ const ContentBlocks = () => { return ( + \ No newline at end of file diff --git a/packages/core/admin/admin/src/pages/HomePage/assets/strapi-cloud-icon.svg b/packages/core/admin/admin/src/pages/HomePage/assets/strapi-cloud-icon.svg new file mode 100644 index 0000000000..0cea9bf9ce --- /dev/null +++ b/packages/core/admin/admin/src/pages/HomePage/assets/strapi-cloud-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/admin/admin/src/pages/HomePage/tests/index.test.js b/packages/core/admin/admin/src/pages/HomePage/tests/index.test.js index 82dc331e08..473bc47991 100644 --- a/packages/core/admin/admin/src/pages/HomePage/tests/index.test.js +++ b/packages/core/admin/admin/src/pages/HomePage/tests/index.test.js @@ -65,6 +65,7 @@ describe('Homepage', () => { }); test.each([ + 'strapi cloud a fully composable, and collaborative platform to boost your team velocity.', 'documentation discover the essential concepts, guides and instructions.', 'code example learn by using ready-made starters for your projects.', 'tutorials follow step-by-step instructions to use and customize strapi.', diff --git a/packages/core/admin/admin/src/translations/en.json b/packages/core/admin/admin/src/translations/en.json index 1373e19d0a..ba1671a867 100644 --- a/packages/core/admin/admin/src/translations/en.json +++ b/packages/core/admin/admin/src/translations/en.json @@ -353,6 +353,8 @@ "app.components.BlockLink.documentation.content": "Discover the essential concepts, guides and instructions.", "app.components.BlockLink.tutorial": "Tutorials", "app.components.BlockLink.tutorial.content": "Follow step-by-step instructions to use and customize Strapi.", + "app.components.BlockLink.cloud": "Strapi Cloud", + "app.components.BlockLink.cloud.content": "A fully composable, and collaborative platform to boost your team velocity.", "app.components.Button.cancel": "Cancel", "app.components.Button.confirm": "Confirm", "app.components.Button.reset": "Reset", diff --git a/packages/core/admin/package.json b/packages/core/admin/package.json index 3c87cd614b..b7f8b7a423 100644 --- a/packages/core/admin/package.json +++ b/packages/core/admin/package.json @@ -165,4 +165,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/core/helper-plugin/lib/src/components/ContentBox/index.js b/packages/core/helper-plugin/lib/src/components/ContentBox/index.js index ab5bf29cc8..985f43a803 100644 --- a/packages/core/helper-plugin/lib/src/components/ContentBox/index.js +++ b/packages/core/helper-plugin/lib/src/components/ContentBox/index.js @@ -5,6 +5,7 @@ import { Flex, Stack, Typography } from '@strapi/design-system'; const IconWrapper = styled(Flex)` margin-right: ${({ theme }) => theme.spaces[6]}; + svg { width: ${32 / 16}rem; height: ${32 / 16}rem; diff --git a/packages/core/strapi/lib/services/entity-validator/index.js b/packages/core/strapi/lib/services/entity-validator/index.js index 062a97f2ff..81fe1ca781 100644 --- a/packages/core/strapi/lib/services/entity-validator/index.js +++ b/packages/core/strapi/lib/services/entity-validator/index.js @@ -159,11 +159,27 @@ const createScalarAttributeValidator = (createOrUpdate) => (metas, options) => { return validator; }; +const createMediaAttributeValidator = (createOrUpdate) => (metas, options) => { + let validator; + + if (metas.attr.multiple) { + validator = yup.array().of(yup.mixed()).min(1); + } else { + validator = yup.mixed(); + } + + validator = addRequiredValidation(createOrUpdate)(validator, { + attr: { required: !options.isDraft && metas.attr.required }, + }); + + return validator; +}; + const createAttributeValidator = (createOrUpdate) => (metas, options) => { let validator; if (isMediaAttribute(metas.attr)) { - validator = yup.mixed(); + validator = createMediaAttributeValidator(createOrUpdate)(metas, options); } else if (isScalarAttribute(metas.attr)) { validator = createScalarAttributeValidator(createOrUpdate)(metas, options); } else { diff --git a/packages/core/strapi/tests/api/basic-media.test.api.js b/packages/core/strapi/tests/api/basic-media.test.api.js new file mode 100644 index 0000000000..80ad8c06ae --- /dev/null +++ b/packages/core/strapi/tests/api/basic-media.test.api.js @@ -0,0 +1,167 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { createStrapiInstance } = require('../../../../../test/helpers/strapi'); +const { createTestBuilder } = require('../../../../../test/helpers/builder'); +const { createContentAPIRequest } = require('../../../../../test/helpers/request'); + +const builder = createTestBuilder(); +let strapi; +let rq; + +const productWithMedia = { + attributes: { + name: { + type: 'string', + }, + description: { + type: 'text', + }, + media: { + type: 'media', + multiple: false, + required: true, + allowedTypes: ['images', 'files', 'videos', 'audios'], + }, + multipleMedia: { + type: 'media', + multiple: true, + required: true, + allowedTypes: ['images', 'files', 'videos', 'audios'], + }, + }, + displayName: 'product-with-media', + singularName: 'product-with-media', + pluralName: 'product-with-medias', + description: '', + collectionName: '', +}; + +describe('Core API - Basic + required media', () => { + let file; + + beforeAll(async () => { + await builder.addContentType(productWithMedia).build(); + strapi = await createStrapiInstance(); + rq = await createContentAPIRequest({ strapi }); + + // Create file + const result = await rq({ + method: 'POST', + url: '/upload', + formData: { + files: fs.createReadStream(path.join(__dirname, 'basic-media.test.api.js')), + }, + }); + file = result.body[0]; + }); + + afterAll(async () => { + await strapi.destroy(); + await builder.cleanup(); + }); + + test('Create entry without required multiple media', async () => { + const product = { + name: 'product', + description: 'description', + media: file.id, + }; + + const res = await rq({ + method: 'POST', + url: '/product-with-medias', + body: { data: product }, + qs: { populate: true }, + }); + + expect(res.statusCode).toBe(400); + expect(res.body).toMatchObject({ + data: null, + error: { + status: 400, + name: 'ValidationError', + message: 'multipleMedia must be defined.', + details: { + errors: [ + { + path: ['multipleMedia'], + message: 'multipleMedia must be defined.', + name: 'ValidationError', + }, + ], + }, + }, + }); + }); + + test('Create entry with required multiple media as an empty array', async () => { + const product = { + name: 'product', + description: 'description', + multipleMedia: [], + media: file.id, + }; + + const res = await rq({ + method: 'POST', + url: '/product-with-medias', + body: { data: product }, + qs: { populate: true }, + }); + + expect(res.statusCode).toBe(400); + expect(res.body).toMatchObject({ + data: null, + error: { + status: 400, + name: 'ValidationError', + message: 'multipleMedia field must have at least 1 items', + details: { + errors: [ + { + path: ['multipleMedia'], + message: 'multipleMedia field must have at least 1 items', + name: 'ValidationError', + }, + ], + }, + }, + }); + }); + + test('Create entry without required single media', async () => { + const product = { + name: 'product', + description: 'description', + multipleMedia: [{ id: file.id }], + }; + + const res = await rq({ + method: 'POST', + url: '/product-with-medias', + body: { data: product }, + qs: { populate: true }, + }); + + expect(res.statusCode).toBe(400); + expect(res.body).toMatchObject({ + data: null, + error: { + status: 400, + name: 'ValidationError', + message: 'media must be defined.', + details: { + errors: [ + { + path: ['media'], + message: 'media must be defined.', + name: 'ValidationError', + }, + ], + }, + }, + }); + }); +});