Merge branch 'develop' of github.com:strapi/strapi into front/ui-improvements

Signed-off-by: Virginie Ky <virginie.ky@gmail.com>
This commit is contained in:
Virginie Ky 2020-03-23 08:34:34 +01:00
commit c69a84f91a
25 changed files with 434 additions and 118 deletions

View File

@ -162,6 +162,38 @@ Here is the list of endpoints generated for each of your **Content Types**.
:::
::: tab Contact
`Contact` **Content Type**
<div id="endpoint-table">
| Method | Path | Description |
| :----- | :--------- | :------------------------- |
| GET | `/contact` | Get the contact content |
| PUT | `/contact` | Update the contact content |
| DELETE | `/contact` | Delete the contact content |
</div>
:::
::: tab About
`About` **Content Type**
<div id="endpoint-table">
| Method | Path | Description |
| :----- | :------- | :----------------------- |
| GET | `/about` | Get the about content |
| PUT | `/about` | Update the about content |
| DELETE | `/about` | Delete the about content |
</div>
:::
::::
### Collection Types

View File

@ -15,7 +15,7 @@ It's the origin purpose of the project.
### Custom content structure
With the admin panel of Strapi, You can generate the admin panel in just a few clicks, and get your whole CMS setup in a few minutes.
You can generate the admin panel in a few clicks and get your whole CMS setup in a few minutes.
### Manage content

View File

@ -29,6 +29,10 @@ To create a project head over to the Strapi [listing on the marketplace](https:/
Please note that it may take anywhere from 30 seconds to a few minutes for the droplet to startup, when it does you should see it in your [droplets list](https://cloud.digitalocean.com/droplets).
::: warning
After the droplet has started, it will take a few more minutes to finish the Strapi installation.
:::
From here you will see the public ipv4 address that you can use to visit your Strapi application, just open that in a browser and it should ask you to create your first administrator!
You can also SSH into the virtual machine using `root` as the SSH user and your public ipv4 address, there is no password for SSH as DigitalOcean uses SSH keys by default with password authentication disabled.
@ -101,13 +105,13 @@ upstream strapi {
### Strapi
In the DigitalOcean one-click application a service user is used in which it's home directory is located at `/srv/strapi`. Likewise the actual Strapi application is located within this home directory at `/srv/strapi/strapi`.
In the DigitalOcean one-click application a service user is used in which it's home directory is located at `/srv/strapi`. Likewise the actual Strapi application is located within this home directory at `/srv/strapi/strapi-development`.
Please note that with this application it is intially created and ran in the `development` environment to allow for creating models. **You should not use this directly in production**, it is recommended that you configure a private git repository to commit changes into and create a new application directory within the service user's home (Example: `/srv/strapi/strapi-production`). To run the new `production` or `staging` environments you can refer to the [PM2 Documentation](https://pm2.keymetrics.io/docs/usage/quick-start/#managing-processes).
## Using the Service Account
By default the Strapi application will be running under a "service account", this is an account that is extremely limited into what it can do and access. The purpose of using a service account is to project your system from security threats.
By default the Strapi application will be running under a "service account", this is an account that is extremely limited into what it can do and access. The purpose of using a service account is to help protect your system from security threats.
### Accessing the service account
@ -137,8 +141,6 @@ Strapi will automatically start if the virtual machine is rebooted, you can also
## Changing the PostgreSQL Password
Because of how the virtual machine is created, your database is setup with a long and random password, however for security you should change this password before moving into a production-like setting.
Use the following steps to change the PostgreSQL password and update Strapi's config:
- Make sure you are logged into the `strapi` service user

View File

@ -46,7 +46,7 @@ By default, the [Shadow CRUD](#shadow-crud) feature is enabled and the GraphQL i
Security limits on maximum number of items in your response by default is limited to 100, however you can change this on the following config option `amountLimit`. This should only be changed after careful consideration of the drawbacks of a large query which can cause what would basically be a DDoS (Distributed Denial of Service). And may cause abnormal load on your Strapi server, as well as your database server.
You can also enable the Apollo server tracing feature, which is supported by the playground to track the response time of each part of your query. To enable this feature just change/add the `"tracing": true` option in the GraphQL settings file. You can read more about the tracing feature from Apollo [here](https://www.apollographql.com/docs/engine/features/query-tracing.html).
You can also enable the Apollo server tracing feature, which is supported by the playground to track the response time of each part of your query. To enable this feature just change/add the `"tracing": true` option in the GraphQL settings file. You can read more about the tracing feature from Apollo [here](https://www.apollographql.com/docs/apollo-server/federation/metrics/).
You can edit these configurations by creating following file.

View File

@ -5,14 +5,9 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
const FaIcon = styled(({ small, ...props }) => <FontAwesomeIcon {...props} />)`
position: absolute;
top: calc(50% - 0.9rem + 0.3rem);
left: 1.6rem;
margin-right: 1.2rem;
margin-top: ${({ small }) => (small ? '.3rem' : null)};
font-size: ${({ small }) => (small ? '.9rem' : '1.4rem')};
width: 1.4rem;
padding-bottom: 0.2rem;
text-align: center;
top: ${({ small }) => (small ? 'calc(50% - 0.3rem)' : 'calc(50% - 0.9rem + 0.3rem)')};
left: ${({ small }) => (small ? '2.2rem' : '1.6rem')};
font-size: ${({ small }) => (small ? '.5rem' : '1.2rem')};
`;
const LeftMenuIcon = ({ icon }) => <FaIcon small={icon === 'circle'} icon={icon} />;

View File

@ -19,7 +19,7 @@ const LinkLabel = styled.span`
display: inline-block;
width: 100%;
padding-right: 1rem;
padding-left: 2.6rem;
padding-left: 2.1rem;
`;
const LeftMenuLinkContent = ({

View File

@ -5,6 +5,7 @@ exports[`Admin | containers | ListView should match the snapshot 1`] = `
.c6 button {
width: 100%;
height: 54px;
border: 0;
border-top: 1px solid #aed4fb;
color: #007eff;
font-weight: 500;

View File

@ -661,9 +661,10 @@ module.exports = ({ models, target }, ctx) => {
await createComponentJoinTables({ definition, ORM });
} catch (err) {
strapi.log.error(`Impossible to register the '${model}' model.`);
strapi.log.error(err);
strapi.stop();
if (err instanceof TypeError || err instanceof ReferenceError) {
strapi.stopWithError(err, `Impossible to register the '${model}' model.`);
}
strapi.stopWithError(err);
}
});

View File

@ -20,9 +20,8 @@ const populateFetch = (definition, options) => {
} else if (_.isEmpty(options.withRelated)) {
options.withRelated = populateComponents(definition);
} else {
options.withRelated = formatPopulateOptions(
definition,
options.withRelated
options.withRelated = formatPopulateOptions(definition, options.withRelated).concat(
populateComponents(definition)
);
}
};
@ -173,9 +172,7 @@ const formatPopulateOptions = (definition, withRelated) => {
continue;
}
const assoc = tmpModel.associations.find(
association => association.alias === part
);
const assoc = tmpModel.associations.find(association => association.alias === part);
if (!assoc) return acc;

View File

@ -0,0 +1,15 @@
import React from 'react';
import styled from 'styled-components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import colors from '../../assets/styles/colors';
const LeftMenuIcon = styled(({ ...props }) => <FontAwesomeIcon {...props} icon="circle" />)`
position: absolute;
top: calc(50% - 0.25rem);
left: 1.5rem;
font-size: 0.5rem;
color: ${colors.leftMenu.darkGrey};
`;
export default LeftMenuIcon;

View File

@ -2,9 +2,12 @@ import React, { memo } from 'react';
import PropTypes from 'prop-types';
import { NavLink } from 'react-router-dom';
import Icon from './Icon';
function LeftMenuLink({ children, to }) {
return (
<NavLink to={to}>
<Icon />
<p>{children}</p>
</NavLink>
);

View File

@ -5,8 +5,7 @@ import colors from '../../assets/styles/colors';
const List = styled.ul`
margin-bottom: 0;
padding-left: 0;
max-height: ${props =>
props.numberOfVisibleItems ? `${props.numberOfVisibleItems * 35}px` : null};
max-height: 178px;
overflow-y: scroll;
li {
position: relative;
@ -20,19 +19,6 @@ const List = styled.ul`
padding-left: 30px;
height: 34px;
border-radius: 2px;
&::before {
content: '•';
position: absolute;
top: calc(50% - 2px);
left: 15px;
font-weight: bold;
display: block;
width: 0.5em;
height: 0.5em;
color: ${colors.leftMenu.darkGrey};
line-height: 5px;
font-size: 10px;
}
p {
color: ${colors.leftMenu.black};
font-size: 13px;
@ -46,7 +32,7 @@ const List = styled.ul`
p {
font-weight: 600;
}
&::before {
svg {
color: ${colors.leftMenu.black};
}
}

View File

@ -9,7 +9,7 @@ import LeftMenuHeader from '../LeftMenuHeader';
import List from './List';
import Wrapper from './Wrapper';
function LeftMenuList({ customLink, links, title, searchable, numberOfVisibleItems }) {
function LeftMenuList({ customLink, links, title, searchable }) {
const [search, setSearch] = useState('');
const { formatMessage } = useGlobalContext();
@ -100,9 +100,7 @@ function LeftMenuList({ customLink, links, title, searchable, numberOfVisibleIte
<LeftMenuHeader {...headerProps} />
</div>
<div>
<List numberOfVisibleItems={numberOfVisibleItems}>
{getList().map((link, i) => renderCompo(link, i))}
</List>
<List>{getList().map((link, i) => renderCompo(link, i))}</List>
{Component && isValidElement(<Component />) && <Component {...componentProps} />}
</div>
</Wrapper>
@ -114,7 +112,6 @@ LeftMenuList.defaultProps = {
links: [],
title: null,
searchable: false,
numberOfVisibleItems: null,
};
LeftMenuList.propTypes = {
@ -130,7 +127,6 @@ LeftMenuList.propTypes = {
id: PropTypes.string,
}),
searchable: PropTypes.bool,
numberOfVisibleItems: PropTypes.number,
};
export default LeftMenuList;

View File

@ -11,8 +11,8 @@ const Dropdown = styled.div`
button {
position: relative;
padding: 0 10px 1px 15px;
margin-bottom: 9px;
margin-top: 9px;
margin-bottom: 10px;
margin-top: 8px;
font-weight: 600;
text-transform: capitalize;
&::before {
@ -32,7 +32,7 @@ const Dropdown = styled.div`
padding-left: 10px;
}
.collapse {
margin-bottom: 2px;
margin-bottom: 10px;
}
&:last-of-type {
margin-bottom: 0;

View File

@ -18,9 +18,9 @@ const uploadImg = () => {
describe.each([
[
'CONTENT MANAGER',
'/content-manager/explorer/application::withdynamiczone.withdynamiczone',
'/content-manager/explorer/application::withdynamiczonemedia.withdynamiczonemedia',
],
['GENERATED API', '/withdynamiczones'],
['GENERATED API', '/withdynamiczonemedias'],
])('[%s] => Not required dynamiczone', (_, path) => {
beforeAll(async () => {
const token = await registerAndLogin();
@ -61,17 +61,9 @@ describe.each([
},
});
await modelsUtils.createContentTypeWithType(
'withdynamiczone',
'dynamiczone',
{
components: [
'default.single-media',
'default.multiple-media',
'default.with-nested',
],
}
);
await modelsUtils.createContentTypeWithType('withdynamiczonemedia', 'dynamiczone', {
components: ['default.single-media', 'default.multiple-media', 'default.with-nested'],
});
rq = authRq.defaults({
baseUrl: `http://localhost:1337${path}`,
@ -82,7 +74,7 @@ describe.each([
await modelsUtils.deleteComponent('default.with-nested');
await modelsUtils.deleteComponent('default.single-media');
await modelsUtils.deleteComponent('default.multiple-media');
await modelsUtils.deleteContentType('withdynamiczone');
await modelsUtils.deleteContentType('withdynamiczonemedia');
}, 60000);
describe('Contains components with medias', () => {

View File

@ -8,7 +8,7 @@ import styled from 'styled-components';
const StyledCustomLink = styled.div`
padding-left: 15px;
padding-top: 9px;
padding-top: 10px;
line-height: 0;
margin-left: -3px;

View File

@ -43,7 +43,6 @@ const Wrapper = styled.tr`
}}
p {
font-weight: 500;
text-transform: capitalize;
}
}
td:last-child {

View File

@ -169,7 +169,7 @@ function LeftMenu({ wait }) {
return (
<Wrapper className="col-md-3">
{data.map(list => {
return <LeftMenuList numberOfVisibleItems={5} {...list} key={list.name} />;
return <LeftMenuList {...list} key={list.name} />;
})}
</Wrapper>
);

View File

@ -35,5 +35,6 @@ module.exports = (obj, validNatures) => {
.test(isValidName)
.nullable(),
targetColumnName: yup.string().nullable(),
private: yup.boolean().nullable(),
};
};

View File

@ -60,18 +60,12 @@ function createSchemaBuilder({ components, contentTypes }) {
// init temporary ContentTypes
Object.keys(contentTypes).forEach(key => {
tmpContentTypes.set(
contentTypes[key].uid,
createSchemaHandler(contentTypes[key])
);
tmpContentTypes.set(contentTypes[key].uid, createSchemaHandler(contentTypes[key]));
});
// init temporary components
Object.keys(components).forEach(key => {
tmpComponents.set(
components[key].uid,
createSchemaHandler(components[key])
);
tmpComponents.set(components[key].uid, createSchemaHandler(components[key]));
});
return {
@ -120,12 +114,14 @@ function createSchemaBuilder({ components, contentTypes }) {
columnName,
dominant,
autoPopulate,
private: isPrivate,
} = attribute;
const attr = {
unique: unique === true ? true : undefined,
columnName: columnName || undefined,
configurable: configurable === false ? false : undefined,
private: isPrivate === true ? true : undefined,
autoPopulate,
};

View File

@ -243,7 +243,7 @@ module.exports = {
const inputs = `
input ${inputName} {
${Object.keys(model.attributes)
.map(attributeName => {
return `${attributeName}: ${this.convertType({
@ -271,6 +271,7 @@ module.exports = {
.join('\n')}
}
`;
return inputs;
},

View File

@ -55,6 +55,7 @@ const buildTypeDefObj = model => {
// Change field definition for collection relations
associations
.filter(association => association.type === 'collection')
.filter(association => attributes[association.alias].private !== true)
.forEach(association => {
typeDef[`${association.alias}(sort: String, limit: Int, start: Int, where: JSON)`] =
typeDef[association.alias];

View File

@ -33,7 +33,11 @@ const diffResolvers = (object, base) => {
Object.keys(object).forEach(type => {
Object.keys(object[type]).forEach(resolver => {
if (!_.has(base, [type, resolver])) {
if (type === 'Query' || type === 'Mutation') {
if (!_.has(base, [type, resolver])) {
_.set(newObj, [type, resolver], _.get(object, [type, resolver]));
}
} else {
_.set(newObj, [type, resolver], _.get(object, [type, resolver]));
}
});

View File

@ -9,7 +9,25 @@ let graphqlQuery;
let modelsUtils;
// utils
const selectFields = doc => _.pick(doc, ['id', 'name']);
const selectFields = doc => _.pick(doc, ['id', 'name', 'color']);
const rgbColorComponent = {
attributes: {
name: {
type: 'text',
},
red: {
type: 'integer',
},
green: {
type: 'integer',
},
blue: {
type: 'integer',
},
},
name: 'rgbColor',
};
const documentModel = {
attributes: {
@ -37,6 +55,11 @@ const labelModel = {
target: 'application::document.document',
targetAttribute: 'labels',
},
color: {
type: 'component',
component: 'default.rgb-color',
repeatable: false,
},
},
connection: 'default',
name: 'label',
@ -44,6 +67,41 @@ const labelModel = {
collectionName: '',
};
const carModel = {
attributes: {
name: {
type: 'text',
},
},
connection: 'default',
name: 'car',
description: '',
collectionName: '',
};
const personModel = {
attributes: {
name: {
type: 'text',
},
privateName: {
type: 'text',
private: true,
},
privateCars: {
nature: 'oneToMany',
target: 'application::car.car',
dominant: false,
targetAttribute: 'person',
private: true,
},
},
connection: 'default',
name: 'person',
description: '',
collectionName: '',
};
describe('Test Graphql Relations API End to End', () => {
beforeAll(async () => {
const token = await registerAndLogin();
@ -59,17 +117,24 @@ describe('Test Graphql Relations API End to End', () => {
modelsUtils = createModelsUtils({ rq });
await modelsUtils.createContentTypes([documentModel, labelModel]);
await modelsUtils.createComponent(rgbColorComponent);
await modelsUtils.createContentTypes([documentModel, labelModel, carModel, personModel]);
}, 60000);
afterAll(() => modelsUtils.deleteContentTypes(['document', 'label']), 60000);
afterAll(() => modelsUtils.deleteContentTypes(['document', 'label', 'car', 'person']), 60000);
describe('Test relations features', () => {
let data = {
labels: [],
documents: [],
people: [],
cars: [],
};
const labelsPayload = [{ name: 'label 1' }, { name: 'label 2' }];
const labelsPayload = [
{ name: 'label 1', color: null },
{ name: 'label 2', color: null },
{ name: 'labelWithColor', color: { name: 'tomato', red: 255, green: 99, blue: 71 } },
];
const documentsPayload = [{ name: 'document 1' }, { name: 'document 2' }];
test.each(labelsPayload)('Create label %o', async label => {
@ -79,6 +144,12 @@ describe('Test Graphql Relations API End to End', () => {
createLabel(input: $input) {
label {
name
color {
name
red
green
blue
}
}
}
}
@ -90,10 +161,8 @@ describe('Test Graphql Relations API End to End', () => {
},
});
const { body } = res;
expect(res.statusCode).toBe(200);
expect(body).toEqual({
expect(res.body).toEqual({
data: {
createLabel: {
label,
@ -109,6 +178,12 @@ describe('Test Graphql Relations API End to End', () => {
labels {
id
name
color {
name
red
green
blue
}
}
}
`,
@ -124,52 +199,55 @@ describe('Test Graphql Relations API End to End', () => {
});
// assign for later use
data.labels = res.body.data.labels;
data.labels = data.labels.concat(res.body.data.labels);
});
test.each(documentsPayload)(
'Create document linked to every labels %o',
async document => {
const res = await graphqlQuery({
query: /* GraphQL */ `
mutation createDocument($input: createDocumentInput) {
createDocument(input: $input) {
document {
test.each(documentsPayload)('Create document linked to every labels %o', async document => {
const res = await graphqlQuery({
query: /* GraphQL */ `
mutation createDocument($input: createDocumentInput) {
createDocument(input: $input) {
document {
name
labels {
id
name
labels {
id
color {
name
red
green
blue
}
}
}
}
`,
variables: {
input: {
data: {
...document,
labels: data.labels.map(t => t.id),
},
}
`,
variables: {
input: {
data: {
...document,
labels: data.labels.map(t => t.id),
},
},
});
},
});
const { body } = res;
const { body } = res;
expect(res.statusCode).toBe(200);
expect(res.statusCode).toBe(200);
expect(body).toMatchObject({
data: {
createDocument: {
document: {
...selectFields(document),
labels: expect.arrayContaining(data.labels.map(selectFields)),
},
expect(body).toMatchObject({
data: {
createDocument: {
document: {
...selectFields(document),
labels: expect.arrayContaining(data.labels.map(selectFields)),
},
},
});
}
);
},
});
});
test('List documents with labels', async () => {
const res = await graphqlQuery({
@ -181,6 +259,12 @@ describe('Test Graphql Relations API End to End', () => {
labels {
id
name
color {
name
red
green
blue
}
}
}
}
@ -212,6 +296,12 @@ describe('Test Graphql Relations API End to End', () => {
labels {
id
name
color {
name
red
green
blue
}
documents {
id
name
@ -229,9 +319,7 @@ describe('Test Graphql Relations API End to End', () => {
labels: expect.arrayContaining(
data.labels.map(label => ({
...selectFields(label),
documents: expect.arrayContaining(
data.documents.map(selectFields)
),
documents: expect.arrayContaining(data.documents.map(selectFields)),
}))
),
},
@ -251,6 +339,12 @@ describe('Test Graphql Relations API End to End', () => {
labels {
id
name
color {
name
red
green
blue
}
}
}
}
@ -277,6 +371,12 @@ describe('Test Graphql Relations API End to End', () => {
labels {
id
name
color {
name
red
green
blue
}
}
}
}
@ -316,6 +416,12 @@ describe('Test Graphql Relations API End to End', () => {
label {
id
name
color {
name
red
green
blue
}
}
}
}
@ -350,6 +456,12 @@ describe('Test Graphql Relations API End to End', () => {
labels {
id
name
color {
name
red
green
blue
}
}
}
}
@ -405,5 +517,184 @@ describe('Test Graphql Relations API End to End', () => {
});
}
});
test('Create person', async () => {
const person = {
name: 'Chuck Norris',
privateName: 'Jean-Eude',
};
const res = await graphqlQuery({
query: /* GraphQL */ `
mutation createPerson($input: createPersonInput) {
createPerson(input: $input) {
person {
id
name
}
}
}
`,
variables: {
input: {
data: person,
},
},
});
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({
data: {
createPerson: {
person: {
id: expect.anything(),
name: person.name,
},
},
},
});
data.people.push(res.body.data.createPerson.person);
});
test("Can't list a private field", async () => {
const res = await graphqlQuery({
query: /* GraphQL */ `
{
people {
name
privateName
}
}
`,
});
expect(res.statusCode).toBe(400);
expect(res.body).toMatchObject({
errors: [
{
message: 'Cannot query field "privateName" on type "Person".',
},
],
});
});
test('Create a car linked to a person (oneToMany)', async () => {
const car = {
name: 'Peugeot 508',
person: data.people[0].id,
};
const res = await graphqlQuery({
query: /* GraphQL */ `
mutation createCar($input: createCarInput) {
createCar(input: $input) {
car {
id
name
person {
id
name
}
}
}
}
`,
variables: {
input: {
data: {
...car,
},
},
},
});
expect(res.statusCode).toBe(200);
expect(res.body).toMatchObject({
data: {
createCar: {
car: {
id: expect.anything(),
name: car.name,
person: data.people[0],
},
},
},
});
data.cars.push({ id: res.body.data.createCar.car.id });
});
test("Can't list a private oneToMany relation", async () => {
const res = await graphqlQuery({
query: /* GraphQL */ `
{
people {
name
privateCars
}
}
`,
});
expect(res.statusCode).toBe(400);
expect(res.body).toMatchObject({
errors: [
{
message: 'Cannot query field "privateCars" on type "Person".',
},
],
});
});
test('Edit person/cars relations removes correctly a car', async () => {
const newPerson = {
name: 'Check Norris Junior',
privateCars: [],
};
const mutationRes = await graphqlQuery({
query: /* GraphQL */ `
mutation updatePerson($input: updatePersonInput) {
updatePerson(input: $input) {
person {
id
}
}
}
`,
variables: {
input: {
where: {
id: data.people[0].id,
},
data: {
...newPerson,
},
},
},
});
expect(mutationRes.statusCode).toBe(200);
const queryRes = await graphqlQuery({
query: /* GraphQL */ `
query($id: ID!) {
car(id: $id) {
person {
id
}
}
}
`,
variables: {
id: data.cars[0].id,
},
});
expect(queryRes.statusCode).toBe(200);
expect(queryRes.body).toEqual({
data: {
car: {
person: null,
},
},
});
});
});
});

View File

@ -273,8 +273,11 @@ class Strapi extends EventEmitter {
};
}
stopWithError(err) {
stopWithError(err, customMessage) {
this.log.debug(`⛔️ Server wasn't able to start properly.`);
if (customMessage) {
this.log.error(customMessage);
}
this.log.error(err);
return this.stop();
}