Merge branch 'main' into releases/4.7.0
17
docs/docs/core/database/intro.md
Normal file
@ -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';
|
||||||
|
|
||||||
|
<DocCardList items={useCurrentSidebarCategory().items} />
|
||||||
|
```
|
119
docs/docs/core/database/relations/reordering.mdx
Normal file
@ -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.
|
||||||
|
|
||||||
|
<img src="/img/database/reordering.png" alt="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)
|
||||||
|
|
||||||
|
<img src="/img/database/m2m-example.png" alt="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)
|
||||||
|
|
||||||
|
<img src="/img/database/o2o-example.png" alt="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:
|
||||||
|
|
||||||
|
<img src="/img/database/mw-example.png" alt="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
|
||||||
|
|
||||||
|
<img src="/img/database/reordering-algo-1.png" alt="An example of reordering in the CM" />
|
||||||
|
|
||||||
|
### Complex example
|
||||||
|
|
||||||
|
<img src="/img/database/reordering-algo-2.png" alt="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.
|
BIN
docs/static/img/database/m2m-example.png
vendored
Normal file
After Width: | Height: | Size: 85 KiB |
BIN
docs/static/img/database/mw-example.png
vendored
Normal file
After Width: | Height: | Size: 127 KiB |
BIN
docs/static/img/database/o2o-example.png
vendored
Normal file
After Width: | Height: | Size: 290 KiB |
BIN
docs/static/img/database/reordering-algo-1.png
vendored
Normal file
After Width: | Height: | Size: 384 KiB |
BIN
docs/static/img/database/reordering-algo-2.png
vendored
Normal file
After Width: | Height: | Size: 1.3 MiB |
BIN
docs/static/img/database/reordering.png
vendored
Normal file
After Width: | Height: | Size: 177 KiB |
83
packages/core/admin/admin/src/pages/HomePage/CloudBox.js
Normal file
@ -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 (
|
||||||
|
<BlockLink
|
||||||
|
href="https://cloud.strapi.io"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
onClick={() => {
|
||||||
|
trackUsage('didClickOnTryStrapiCloudSection');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
shadow="tableShadow"
|
||||||
|
hasRadius
|
||||||
|
padding={6}
|
||||||
|
background="neutral0"
|
||||||
|
position="relative"
|
||||||
|
gap={6}
|
||||||
|
>
|
||||||
|
<CloudCustomWrapper backgroundImage={cloudIconBackgroundImage} hasRadius padding={3}>
|
||||||
|
<CloudIconWrapper
|
||||||
|
width={pxToRem(32)}
|
||||||
|
height={pxToRem(32)}
|
||||||
|
justifyContent="center"
|
||||||
|
hasRadius
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={cloudIcon}
|
||||||
|
alt={formatMessage({
|
||||||
|
id: 'app.components.BlockLink.cloud',
|
||||||
|
defaultMessage: 'Strapi Cloud',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</CloudIconWrapper>
|
||||||
|
</CloudCustomWrapper>
|
||||||
|
<Flex gap={1} direction="column" alignItems="start">
|
||||||
|
<Flex>
|
||||||
|
<Typography fontWeight="semiBold" variant="pi">
|
||||||
|
{formatMessage({
|
||||||
|
id: 'app.components.BlockLink.cloud',
|
||||||
|
defaultMessage: 'Strapi Cloud',
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
</Flex>
|
||||||
|
<Typography textColor="neutral600">
|
||||||
|
{formatMessage({
|
||||||
|
id: 'app.components.BlockLink.cloud.content',
|
||||||
|
defaultMessage:
|
||||||
|
'A fully composable, and collaborative platform to boost your team velocity.',
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
<Box src={cloudFlagsImage} position="absolute" top={0} right={0} as="img" />
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</BlockLink>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CloudBox;
|
@ -4,6 +4,7 @@ import { useIntl } from 'react-intl';
|
|||||||
import { ContentBox, useTracking } from '@strapi/helper-plugin';
|
import { ContentBox, useTracking } from '@strapi/helper-plugin';
|
||||||
import { Stack } from '@strapi/design-system';
|
import { Stack } from '@strapi/design-system';
|
||||||
import { InformationSquare, CodeSquare, PlaySquare, FeatherSquare } from '@strapi/icons';
|
import { InformationSquare, CodeSquare, PlaySquare, FeatherSquare } from '@strapi/icons';
|
||||||
|
import CloudBox from './CloudBox';
|
||||||
|
|
||||||
const BlockLink = styled.a`
|
const BlockLink = styled.a`
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@ -19,6 +20,7 @@ const ContentBlocks = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={5}>
|
<Stack spacing={5}>
|
||||||
|
<CloudBox />
|
||||||
<BlockLink
|
<BlockLink
|
||||||
href="https://strapi.io/resource-center"
|
href="https://strapi.io/resource-center"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
After Width: | Height: | Size: 9.6 KiB |
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="15" fill="none"><path fill="#fff" fill-rule="evenodd" d="M4.39453 13.8298C1.93859 13.6455 0 11.468 0 8.80884 0 6.0289 2.11876 3.7753 4.73238 3.7753c.46775 0 .91964.07218 1.34638.20664C7.21234 1.62909 9.66469 0 12.5073 0c2.5102 0 4.7161 1.27036 5.9782 3.18766a4.54297 4.54297 0 0 1 .6132-.04144C21.8056 3.14622 24 5.54066 24 8.49436c0 2.89194-2.1036 5.24784-4.7323 5.34504v.0031l-1.8948.278a38.18054 38.18054 0 0 1-11.08354 0l-1.89483-.278v-.0127Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 534 B |
@ -65,6 +65,7 @@ describe('Homepage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
|
'strapi cloud a fully composable, and collaborative platform to boost your team velocity.',
|
||||||
'documentation discover the essential concepts, guides and instructions.',
|
'documentation discover the essential concepts, guides and instructions.',
|
||||||
'code example learn by using ready-made starters for your projects.',
|
'code example learn by using ready-made starters for your projects.',
|
||||||
'tutorials follow step-by-step instructions to use and customize strapi.',
|
'tutorials follow step-by-step instructions to use and customize strapi.',
|
||||||
|
@ -353,6 +353,8 @@
|
|||||||
"app.components.BlockLink.documentation.content": "Discover the essential concepts, guides and instructions.",
|
"app.components.BlockLink.documentation.content": "Discover the essential concepts, guides and instructions.",
|
||||||
"app.components.BlockLink.tutorial": "Tutorials",
|
"app.components.BlockLink.tutorial": "Tutorials",
|
||||||
"app.components.BlockLink.tutorial.content": "Follow step-by-step instructions to use and customize Strapi.",
|
"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.cancel": "Cancel",
|
||||||
"app.components.Button.confirm": "Confirm",
|
"app.components.Button.confirm": "Confirm",
|
||||||
"app.components.Button.reset": "Reset",
|
"app.components.Button.reset": "Reset",
|
||||||
|
@ -5,6 +5,7 @@ import { Flex, Stack, Typography } from '@strapi/design-system';
|
|||||||
|
|
||||||
const IconWrapper = styled(Flex)`
|
const IconWrapper = styled(Flex)`
|
||||||
margin-right: ${({ theme }) => theme.spaces[6]};
|
margin-right: ${({ theme }) => theme.spaces[6]};
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: ${32 / 16}rem;
|
width: ${32 / 16}rem;
|
||||||
height: ${32 / 16}rem;
|
height: ${32 / 16}rem;
|
||||||
|
@ -159,11 +159,27 @@ const createScalarAttributeValidator = (createOrUpdate) => (metas, options) => {
|
|||||||
return validator;
|
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) => {
|
const createAttributeValidator = (createOrUpdate) => (metas, options) => {
|
||||||
let validator;
|
let validator;
|
||||||
|
|
||||||
if (isMediaAttribute(metas.attr)) {
|
if (isMediaAttribute(metas.attr)) {
|
||||||
validator = yup.mixed();
|
validator = createMediaAttributeValidator(createOrUpdate)(metas, options);
|
||||||
} else if (isScalarAttribute(metas.attr)) {
|
} else if (isScalarAttribute(metas.attr)) {
|
||||||
validator = createScalarAttributeValidator(createOrUpdate)(metas, options);
|
validator = createScalarAttributeValidator(createOrUpdate)(metas, options);
|
||||||
} else {
|
} else {
|
||||||
|
167
packages/core/strapi/tests/api/basic-media.test.api.js
Normal file
@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|