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 { 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 (
|
||||
<Stack spacing={5}>
|
||||
<CloudBox />
|
||||
<BlockLink
|
||||
href="https://strapi.io/resource-center"
|
||||
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([
|
||||
'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.',
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|