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.
+
+
+
+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)
+
+
+
+- `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)
+
+
+
+- there is no `order` fields as there is only one relation per pair.
+
+### One way relation (Restaurants <-> Categories)
+
+Where a restaurant has many categories:
+
+
+
+- `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
+
+
+
+### Complex example
+
+
+
+### 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({
+ 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',
+ },
+ ],
+ },
+ },
+ });
+ });
+});