Merge branch 'main' into feature/review-workflow

This commit is contained in:
Gustav Hansen 2023-04-24 13:59:41 +02:00 committed by GitHub
commit e386aff3d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 419 additions and 87 deletions

View File

@ -52,6 +52,7 @@ The Strapi core team will review your pull request and either merge it, request
**Before submitting your pull request** make sure the following requirements are fulfilled:
- Fork the repository and create your new branch from `main`.
- Run `yarn install` in the root of the repository.
- Run `yarn setup` in the root of the repository.
- If you've fixed a bug or added code that should be tested, please make sure to add tests
- Ensure the following test suites are passing:
@ -78,6 +79,7 @@ Go to the root of the repository and run the setup:
```bash
cd strapi
yarn install
yarn setup
```
@ -183,4 +185,4 @@ Before submitting an issue you need to make sure:
- Make sure your application has a clean `node_modules` directory, meaning:
- you didn't link any dependencies (e.g., by running `yarn link`)
- you haven't made any inline changes to files in the `node_modules` directory
- you don't have any global dependency loops. If you aren't sure, the easiest way to double-check any of the above is to run: `$ rm -rf node_modules && yarn cache clean && yarn setup`.
- you don't have any global dependency loops. If you aren't sure, the easiest way to double-check any of the above is to run: `$ rm -rf node_modules && yarn cache clean && yarn install && yarn setup`.

View File

@ -2,24 +2,18 @@
Follow these steps to use a local version of the Strapi design system with the Strapi monorepo
First, run `yarn build` in `strapi-design-system/packages/strapi-design-system` to generate the bundle.
In your copy of the design system run `yarn build` to generate the bundle.
In your copy of Strapi, you can link the design system using either a [relative path](#relative-path) or [yarn link](#yarn-link).
### Relative path
Replace the version number in both `strapi/packages/core/admin/package.json` and `strapi/packages/core/helper-plugin/package.json` with the relative path to your copy of the design system:
In the Strapi monorepo link your local copy of the design system with [`yarn link`](https://yarnpkg.com/cli/link#gatsby-focus-wrapper):
```
"@strapi/design-system": "link:../../../../strapi-design-system/packages/strapi-design-system"
yarn link -r ../<relative-path-to-strapi-design-system>
```
### Yarn link
Running yarn build in `examples/getstarted` should now use your local version of the design system.
Alternatively, you can use [`yarn link`](https://classic.yarnpkg.com/lang/en/docs/cli/link/) by first running `yarn link` in `strapi-design-system/packages/design-system` and then `yarn link @strapi/design-system` in both `strapi/packages/core/admin` and `strapi/packages/core/helper-plugin`. With this approach, no changes need to be made to the `package.json`
Once the link is setup, run the following command from the root of the monorepo
To revert back to the released version of the design system use [`yarn unlink`](https://yarnpkg.com/cli/unlink#usage):
```
yarn clean && yarn setup
yarn unlink ../<relative-path-to-strapi-design-system>
```

View File

@ -97,11 +97,17 @@ const DraggedItem = ({
const accordionRef = useRef(null);
const { formatMessage } = useIntl();
const [parentFieldName] = componentFieldName.split('.');
/**
* The last item in the fieldName array will be the index of this component.
* Drag and drop should be isolated to the parent component so nested repeatable
* components are not affected by the drag and drop of the parent component in
* their own re-ordering context.
*/
const componentKey = componentFieldName.split('.').slice(0, -1).join('.');
const [{ handlerId, isDragging, handleKeyDown }, boxRef, dropRef, dragRef, dragPreviewRef] =
useDragAndDrop(!isReadOnly, {
type: `${ItemTypes.COMPONENT}_${parentFieldName}`,
type: `${ItemTypes.COMPONENT}_${componentKey}`,
index,
item: {
displayedValue,

View File

@ -102,7 +102,9 @@ const ModalForm = ({ onMetaChange, onSizeChange }) => {
);
});
const { isResizable } = fieldSizes[attributes[selectedField].type];
// Check for a custom input provided by a custom field, or use the default one for that type
const { type, customField } = attributes[selectedField];
const { isResizable } = fieldSizes[customField] ?? fieldSizes[type];
const sizeField = (
<GridItem col={6} key="size">

View File

@ -30,8 +30,14 @@ const reducer = (state = initialState, action) =>
}
case 'ON_ADD_FIELD': {
const newState = cloneDeep(state);
const type = get(newState, ['modifiedData', 'attributes', action.name, 'type'], '');
const size = action.fieldSizes[type]?.default ?? DEFAULT_FIELD_SIZE;
const attribute = get(newState, ['modifiedData', 'attributes', action.name], {});
// Get the default size, checking custom fields first, then the type and generic defaults
const size =
action.fieldSizes[attribute?.customField]?.default ??
action.fieldSizes[attribute?.type]?.default ??
DEFAULT_FIELD_SIZE;
const listSize = get(newState, layoutPathEdit, []).length;
const actualRowContentPath = [...layoutPathEdit, listSize - 1, 'rowContent'];
const rowContentToSet = get(newState, actualRowContentPath, []);

View File

@ -95,13 +95,14 @@ const redirectWithAuth = (ctx) => {
params: { provider },
} = ctx;
const redirectUrls = utils.getPrefixedRedirectUrls();
const domain = strapi.config.get('server.admin.auth.domain');
const { user } = ctx.state;
const jwt = getService('token').createJwtToken(user);
const isProduction = strapi.config.get('environment') === 'production';
const cookiesOptions = { httpOnly: false, secure: isProduction, overwrite: true };
const cookiesOptions = { httpOnly: false, secure: isProduction, overwrite: true, domain };
const sanitizedUser = getService('user').sanitizeUser(user);
strapi.eventHub.emit('admin.auth.success', { user: sanitizedUser, provider });

View File

@ -6,4 +6,5 @@ module.exports = async () => {
await getService('components').syncConfigurations();
await getService('content-types').syncConfigurations();
await getService('permission').registerPermissions();
getService('field-sizes').setCustomFieldInputSizes();
};

View File

@ -1,10 +1,36 @@
'use strict';
const fieldSizesService = require('../field-sizes');
const { ApplicationError } = require('@strapi/utils').errors;
const createFieldSizesService = require('../field-sizes');
const strapi = {
container: {
// Mock container.get('custom-fields')
get: jest.fn(() => ({
// Mock container.get('custom-fields').getAll()
getAll: jest.fn(() => ({
'plugin::mycustomfields.color': {
name: 'color',
plugin: 'mycustomfields',
type: 'string',
},
'plugin::mycustomfields.smallColor': {
name: 'smallColor',
plugin: 'mycustomfields',
type: 'string',
inputSize: {
default: 4,
isResizable: false,
},
},
})),
})),
},
};
describe('field sizes service', () => {
it('should return the correct field sizes', () => {
const { getAllFieldSizes } = fieldSizesService();
const { getAllFieldSizes } = createFieldSizesService({ strapi });
const fieldSizes = getAllFieldSizes();
Object.values(fieldSizes).forEach((fieldSize) => {
expect(typeof fieldSize.isResizable).toBe('boolean');
@ -13,21 +39,42 @@ describe('field sizes service', () => {
});
it('should return the correct field size for a given type', () => {
const { getFieldSize } = fieldSizesService();
const { getFieldSize } = createFieldSizesService({ strapi });
const fieldSize = getFieldSize('string');
expect(fieldSize.isResizable).toBe(true);
expect(fieldSize.default).toBe(6);
});
it('should throw an error if the type is not found', () => {
const { getFieldSize } = fieldSizesService();
expect(() => getFieldSize('not-found')).toThrowError(
'Could not find field size for type not-found'
);
const { getFieldSize } = createFieldSizesService({ strapi });
try {
getFieldSize('not-found');
} catch (error) {
expect(error instanceof ApplicationError).toBe(true);
expect(error.message).toBe('Could not find field size for type not-found');
}
});
it('should throw an error if the type is not provided', () => {
const { getFieldSize } = fieldSizesService();
expect(() => getFieldSize()).toThrowError('The type is required');
const { getFieldSize } = createFieldSizesService({ strapi });
try {
getFieldSize();
} catch (error) {
expect(error instanceof ApplicationError).toBe(true);
expect(error.message).toBe('The type is required');
}
});
it('should set the custom fields input sizes', () => {
const { setCustomFieldInputSizes, getAllFieldSizes } = createFieldSizesService({ strapi });
setCustomFieldInputSizes();
const fieldSizes = getAllFieldSizes();
console.log(fieldSizes);
expect(fieldSizes).not.toHaveProperty('plugin::mycustomfields.color');
expect(fieldSizes['plugin::mycustomfields.smallColor'].default).toBe(4);
expect(fieldSizes['plugin::mycustomfields.smallColor'].isResizable).toBe(false);
});
});

View File

@ -1,5 +1,7 @@
'use strict';
const { ApplicationError } = require('@strapi/utils').errors;
const needsFullSize = {
default: 12,
isResizable: false,
@ -44,20 +46,51 @@ const fieldSizes = {
uid: defaultSize,
};
module.exports = () => ({
getAllFieldSizes() {
return fieldSizes;
},
getFieldSize(type) {
if (!type) {
throw new Error('The type is required');
}
const createFieldSizesService = ({ strapi }) => {
const fieldSizesService = {
getAllFieldSizes() {
return fieldSizes;
},
const fieldSize = fieldSizes[type];
if (!fieldSize) {
throw new Error(`Could not find field size for type ${type}`);
}
getFieldSize(type) {
if (!type) {
throw new ApplicationError('The type is required');
}
return fieldSize;
},
});
const fieldSize = fieldSizes[type];
if (!fieldSize) {
throw new ApplicationError(`Could not find field size for type ${type}`);
}
return fieldSize;
},
setFieldSize(type, size) {
if (!type) {
throw new ApplicationError('The type is required');
}
if (!size) {
throw new ApplicationError('The size is required');
}
fieldSizes[type] = size;
},
setCustomFieldInputSizes() {
// Find all custom fields already registered
const customFields = strapi.container.get('custom-fields').getAll();
// If they have a custom field size, register it
Object.entries(customFields).forEach(([uid, customField]) => {
if (customField.inputSize) {
fieldSizesService.setFieldSize(uid, customField.inputSize);
}
});
},
};
return fieldSizesService;
};
module.exports = createFieldSizesService;

View File

@ -20,9 +20,18 @@ const isAllowedFieldSize = (type, size) => {
return size <= MAX_ROW_SIZE;
};
const getDefaultFieldSize = (type) => {
const getDefaultFieldSize = (attribute) => {
// Check if it's a custom field with a custom size
if (attribute.customField) {
const customField = strapi.container.get('custom-fields').get(attribute.customField);
if (customField.inputSize) {
return customField.inputSize.default;
}
}
// Get the default size for the field type
const { getFieldSize } = getService('field-sizes');
return getFieldSize(type).default;
return getFieldSize(attribute.type).default;
};
async function createDefaultLayouts(schema) {
@ -127,7 +136,7 @@ const appendToEditLayout = (layout = [], keysToAppend, schema) => {
for (const key of keysToAppend) {
const attribute = schema.attributes[key];
const attributeSize = getDefaultFieldSize(attribute.type);
const attributeSize = getDefaultFieldSize(attribute);
const currenRowSize = rowSize(layout[currentRowIndex]);
if (currenRowSize + attributeSize > MAX_ROW_SIZE) {

View File

@ -1,4 +1,4 @@
import { join } from 'path';
import { posix, win32 } from 'path';
import { cloneDeep } from 'lodash/fp';
import { Readable, Writable } from 'stream-chain';
import type { Schema } from '@strapi/strapi';
@ -48,13 +48,13 @@ const getAssetsMockSourceStream = (
data: Iterable<IAsset> = [
{
filename: 'foo.jpg',
filepath: join(__dirname, 'foo.jpg'),
filepath: posix.join(__dirname, 'foo.jpg'), // test a file with a posix path
stats: { size: 24 },
stream: Readable.from([1, 2, 3]),
},
{
filename: 'bar.jpg',
filepath: join(__dirname, 'bar.jpg'),
filepath: win32.join(__dirname, 'bar.jpg'), // test a file with a win32 path
stats: { size: 48 },
stream: Readable.from([4, 5, 6, 7, 8, 9]),
},

View File

@ -249,7 +249,8 @@ class LocalFileDestinationProvider implements IDestinationProvider {
return new Writable({
objectMode: true,
write(data: IAsset, _encoding, callback) {
const entryPath = path.join('assets', 'uploads', data.filename);
// always write tar files with posix paths so we have a standard format for paths regardless of system
const entryPath = path.posix.join('assets', 'uploads', data.filename);
const entry = archiveStream.entry({
name: entryPath,

View File

@ -1,5 +1,5 @@
import { Writable } from 'stream';
import { join } from 'path';
import { posix } from 'path';
import tar from 'tar-stream';
/**
@ -9,7 +9,8 @@ import tar from 'tar-stream';
export const createFilePathFactory =
(type: string) =>
(fileIndex = 0): string => {
return join(
// always write tar files with posix paths so we have a standard format for paths regardless of system
return posix.join(
// "{type}" directory
type,
// "${type}_XXXXX.jsonl" file

View File

@ -2,9 +2,10 @@ import { Readable } from 'stream';
import type { ILocalFileSourceProviderOptions } from '..';
import { createLocalFileSourceProvider } from '..';
import { isFilePathInDirname, isPathEquivalent, unknownPathToPosix } from '../utils';
describe('Stream assets', () => {
test('returns a stream', () => {
describe('File source provider', () => {
test('returns assets stream', () => {
const options: ILocalFileSourceProviderOptions = {
file: {
path: './test-file',
@ -21,4 +22,112 @@ describe('Stream assets', () => {
expect(stream instanceof Readable).toBeTruthy();
});
describe('utils', () => {
const unknownConversionCases = [
['some/path/on/posix', 'some/path/on/posix'],
['some/path/on/posix/', 'some/path/on/posix/'],
['some/path/on/posix.jpg', 'some/path/on/posix.jpg'],
['file.jpg', 'file.jpg'],
['noextension', 'noextension'],
['some\\windows\\filename.jpg', 'some/windows/filename.jpg'],
['some\\windows\\noendingslash', 'some/windows/noendingslash'],
['some\\windows\\endingslash\\', 'some/windows/endingslash/'],
['some\\windows/mixed', 'some\\windows/mixed'], // improper usage resulting in invalid path if provided mixed windows path, but test expected behaviour
];
test.each(unknownConversionCases)('unknownPathToPosix: %p -> %p', (input, expected) => {
expect(unknownPathToPosix(input)).toEqual(expected);
});
const isFilePathInDirnameCases: [string, string, boolean][] = [
// posix paths
['some/path/on/posix', 'some/path/on/posix/file.jpg', true],
['some/path/on/posix/', 'some/path/on/posix/file.jpg', true],
['./some/path/on/posix', 'some/path/on/posix/file.jpg', true],
['some/path/on/posix/', './some/path/on/posix/file.jpg', true],
['some/path/on/posix/', 'some/path/on/posix/', false], // invalid; second should include a filename
['some/path/on/posix', 'some/path/on/posix', false], // 'posix' in second should be interpreted as a filename
['', './file.jpg', true],
['./', './file.jpg', true],
['noextension', './noextension/file.jpg', true],
['./noextension', './noextension/file.jpg', true],
['./noextension', 'noextension/file.jpg', true],
['noextension', 'noextension/noextension', true],
// win32 paths
['some/path/on/win32', 'some\\path\\on\\win32\\file.jpg', true],
['some/path/on/win32/', 'some\\path\\on\\win32\\file.jpg', true],
['some/path/on/win32/', 'some\\path\\on\\win32\\', false], // invalid; second should include a filename
['some/path/on/win32', 'some\\path\\on\\win32', false], // 'win32' in second should be interpreted as a filename
['', '.\\file.jpg', true],
['./', '.\\file.jpg', true],
['noextension', '.\\noextension\\file.jpg', true],
['./noextension', '.\\noextension\\file.jpg', true],
['./noextension', 'noextension\\file.jpg', true],
['noextension', 'noextension\\noextension', true],
// no path structure
['', 'file.jpg', true],
['noextension', 'noextension', false], // second case is a file
];
test.each(isFilePathInDirnameCases)(
'isFilePathInDirname: %p : %p -> %p',
(inputA, inputB, expected) => {
expect(isFilePathInDirname(inputA, inputB)).toEqual(expected);
}
);
const isPathEquivalentCases: [string, string, boolean][] = [
// POSITIVES
// root level
['file.jpg', 'file.jpg', true],
['file.jpg', '.\\file.jpg', true],
['file.jpg', './file.jpg', true],
// cwd root level (posix)
['./file.jpg', 'file.jpg', true],
['./file.jpg', './file.jpg', true],
['./file.jpg', '.\\file.jpg', true],
// cwd root level (win32)
['.\\file.jpg', 'file.jpg', true],
['.\\file.jpg', './file.jpg', true],
['.\\file.jpg', '.\\file.jpg', true],
// directory with file (posix)
['one/two/file.jpg', 'one/two/file.jpg', true],
['one/two/file.jpg', './one/two/file.jpg', true],
['one/two/file.jpg', 'one\\two\\file.jpg', true],
['one/two/file.jpg', '.\\one\\two\\file.jpg', true],
// cwd with file (posix)
['./one/two/file.jpg', 'one/two/file.jpg', true],
['./one/two/file.jpg', './one/two/file.jpg', true],
['./one/two/file.jpg', 'one\\two\\file.jpg', true],
['./one/two/file.jpg', '.\\one\\two\\file.jpg', true],
// directory with file (win32)
['one\\two\\file.jpg', 'one/two/file.jpg', true],
['one\\two\\file.jpg', './one/two/file.jpg', true],
['one\\two\\file.jpg', '.\\one\\two\\file.jpg', true],
['one\\two\\file.jpg', 'one\\two\\file.jpg', true],
// cwd with file (win32)
['.\\one\\two\\file.jpg', 'one/two/file.jpg', true],
['.\\one\\two\\file.jpg', './one/two/file.jpg', true],
['.\\one\\two\\file.jpg', '.\\one\\two\\file.jpg', true],
['.\\one\\two\\file.jpg', 'one\\two\\file.jpg', true],
// special characters
[".\\one\\two\\fi ' ^&*() le.jpg", "one/two/fi ' ^&*() le.jpg", true], // valid characters on win32
['test/backslash\\file.jpg', 'test/backslash\\file.jpg', true], // backlash is valid on posix but not win32
// NEGATIVES
['file.jpg', 'one/file.jpg', false],
['file.jpg', 'one\\file.jpg', false],
['file.jpg', '/file.jpg', false],
['file.jpg', '\\file.jpg', false],
['one/file.jpg', '\\one\\file.jpg', false],
['one/file.jpg', '/one/file.jpg', false],
['one/file.jpg', 'file.jpg', false],
['test/mixedslash\\file.jpg', 'test/mixedslash/file.jpg', false], // windows path with mixed path separators should fail
];
test.each(isPathEquivalentCases)(
'isPathEquivalent: %p : %p -> %p',
(inputA, inputB, expected) => {
expect(isPathEquivalent(inputA, inputB)).toEqual(expected);
}
);
});
});

View File

@ -4,7 +4,7 @@ import fs from 'fs-extra';
import zip from 'zlib';
import tar from 'tar';
import path from 'path';
import { keyBy } from 'lodash/fp';
import { isEmpty, keyBy } from 'lodash/fp';
import { chain } from 'stream-chain';
import { pipeline, PassThrough } from 'stream';
import { parser } from 'stream-json/jsonl/Parser';
@ -15,6 +15,7 @@ import type { IAsset, IMetadata, ISourceProvider, ProviderType } from '../../../
import { createDecryptionCipher } from '../../../utils/encryption';
import { collect } from '../../../utils/stream';
import { ProviderInitializationError, ProviderTransferError } from '../../../errors/providers';
import { isFilePathInDirname, isPathEquivalent, unknownPathToPosix } from './utils';
type StreamItemArray = Parameters<typeof chain>[0];
@ -72,7 +73,8 @@ class LocalFileSourceProvider implements ISourceProvider {
try {
// Read the metadata to ensure the file can be parsed
this.#metadata = await this.getMetadata();
await this.#loadMetadata();
// TODO: we might also need to read the schema.jsonl files & implements a custom stream-check
} catch (e) {
if (this.options?.encryption?.enabled) {
throw new ProviderInitializationError(
@ -81,18 +83,32 @@ class LocalFileSourceProvider implements ISourceProvider {
}
throw new ProviderInitializationError(`File '${filePath}' is not a valid Strapi data file.`);
}
if (!this.#metadata) {
throw new ProviderInitializationError('Could not load metadata from Strapi data file.');
}
}
getMetadata() {
// TODO: need to read the file & extract the metadata json file
// => we might also need to read the schema.jsonl files & implements a custom stream-check
async #loadMetadata() {
const backupStream = this.#getBackupStream();
return this.#parseJSONFile<IMetadata>(backupStream, METADATA_FILE_PATH);
this.#metadata = await this.#parseJSONFile<IMetadata>(backupStream, METADATA_FILE_PATH);
}
async getMetadata() {
if (!this.#metadata) {
await this.#loadMetadata();
}
return this.#metadata ?? null;
}
async getSchemas() {
const schemas = await collect<Schema>(this.createSchemasReadStream());
if (isEmpty(schemas)) {
throw new ProviderInitializationError('Could not load schemas from Strapi data file.');
}
return keyBy('uid', schemas);
}
@ -121,20 +137,21 @@ class LocalFileSourceProvider implements ISourceProvider {
[
inStream,
new tar.Parse({
// find only files in the assets/uploads folder
filter(filePath, entry) {
if (entry.type !== 'File') {
return false;
}
const parts = filePath.split('/');
return parts[0] === 'assets' && parts[1] === 'uploads';
return isFilePathInDirname('assets/uploads', filePath);
},
onentry(entry) {
const { path: filePath, size = 0 } = entry;
const file = path.basename(filePath);
const normalizedPath = unknownPathToPosix(filePath);
const file = path.basename(normalizedPath);
const asset: IAsset = {
filename: file,
filepath: filePath,
filepath: normalizedPath,
stats: { size },
stream: entry as unknown as Readable,
};
@ -170,6 +187,7 @@ class LocalFileSourceProvider implements ISourceProvider {
return chain(streams);
}
// `directory` must be posix formatted path
#streamJsonlDirectory(directory: string) {
const inStream = this.#getBackupStream();
@ -184,14 +202,7 @@ class LocalFileSourceProvider implements ISourceProvider {
return false;
}
const parts = path.relative('.', filePath).split('/');
// TODO: this method is limiting us from having additional subdirectories and is requiring us to remove any "./" prefixes (the path.relative line above)
if (parts.length !== 2) {
return false;
}
return parts[0] === directory;
return isFilePathInDirname(directory, filePath);
},
async onentry(entry) {
@ -249,7 +260,11 @@ class LocalFileSourceProvider implements ISourceProvider {
* Filter the parsed entries to only keep the one that matches the given filepath
*/
filter(entryPath, entry) {
return !path.relative(filePath, entryPath).length && entry.type === 'File';
if (entry.type !== 'File') {
return false;
}
return isPathEquivalent(entryPath, filePath);
},
async onentry(entry) {

View File

@ -0,0 +1,58 @@
import path from 'path';
/**
* Note: in versions of the transfer engine <=4.9.0, exports were generated with windows paths
* on Windows systems, and posix paths on posix systems.
*
* We now store all paths as posix, but need to leave a separator conversion for legacy purposes, and to
* support manually-created tar files coming from Windows systems (ie, if a user creates a
* backup file with a windows tar tool rather than using the `export` command)
*
* Because of this, export/import files may never contain files with a forward slash in the name, even escaped
*
* */
/**
* Check if the directory of a given filePath (which can be either posix or win32) resolves to the same as the given posix-format path posixDirName
* We must be able to assume the first argument is a path to a directory and the second is a path to a file, otherwise path.dirname will interpret a path without any slashes as the filename
*
* @param {string} posixDirName A posix path pointing to a directory
* @param {string} filePath an unknown filesystem path pointing to a file
* @returns {boolean} is the file located in the given directory
*/
export const isFilePathInDirname = (posixDirName: string, filePath: string) => {
const normalizedDir = path.posix.dirname(unknownPathToPosix(filePath));
return isPathEquivalent(posixDirName, normalizedDir);
};
/**
* Check if two paths that can be either in posix or win32 format resolves to the same file
*
* @param {string} pathA a path that may be either win32 or posix
* @param {string} pathB a path that may be either win32 or posix
*
* @returns {boolean} do paths point to the same place
*/
export const isPathEquivalent = (pathA: string, pathB: string) => {
// Check if paths appear to be win32 or posix, and if win32 convert to posix
const normalizedPathA = path.posix.normalize(unknownPathToPosix(pathA));
const normalizedPathB = path.posix.normalize(unknownPathToPosix(pathB));
return !path.posix.relative(normalizedPathB, normalizedPathA).length;
};
/**
* Convert an unknown format path (win32 or posix) to a posix path
*
* @param {string} filePath a path that may be either win32 or posix
*
* @returns {string} a posix path
*/
export const unknownPathToPosix = (filePath: string) => {
// if it includes a forward slash, it must be posix already -- we will not support win32 with mixed path separators
if (filePath.includes(path.posix.sep)) {
return filePath;
}
return path.normalize(filePath).split(path.win32.sep).join(path.posix.sep);
};

View File

@ -152,7 +152,7 @@ const deleteRelations = async ({
.transacting(trx)
.execute();
done = batchToDelete.length < batchSize;
lastId = batchToDelete[batchToDelete.length - 1]?.id;
lastId = batchToDelete[batchToDelete.length - 1]?.id || 0;
const batchIds = map(inverseJoinColumn.name, batchToDelete);

View File

@ -244,6 +244,16 @@ const createMorphToMany = (attributeName, attribute, meta, metadata) => {
name: `${joinTableName}_fk`,
columns: [joinColumnName],
},
{
name: `${joinTableName}_order_index`,
columns: ['order'],
type: null,
},
{
name: `${joinTableName}_id_column_index`,
columns: [idColumnName],
type: null,
},
],
foreignKeys: [
{

View File

@ -90,6 +90,28 @@ describe('Custom fields registry', () => {
);
});
it('validates inputSize', () => {
const mockCF = {
name: 'test',
type: 'text',
};
const customFields = customFieldsRegistry(strapi);
expect(() => customFields.add({ ...mockCF, inputSize: 'small' })).toThrowError(
`inputSize should be an object with 'default' and 'isResizable' keys`
);
expect(() => customFields.add({ ...mockCF, inputSize: ['array'] })).toThrowError(
`inputSize should be an object with 'default' and 'isResizable' keys`
);
expect(() =>
customFields.add({ ...mockCF, inputSize: { default: 99, isResizable: true } })
).toThrowError('Custom fields require a valid default input size');
expect(() =>
customFields.add({ ...mockCF, inputSize: { default: 12, isResizable: 'true' } })
).toThrowError('Custom fields should specify if their input is resizable');
});
it('confirms the custom field does not already exist', () => {
const mockCF = {
name: 'test',

View File

@ -1,6 +1,6 @@
'use strict';
const { has } = require('lodash/fp');
const { has, isPlainObject } = require('lodash/fp');
const ALLOWED_TYPES = [
'biginteger',
@ -44,7 +44,7 @@ const customFieldsRegistry = (strapi) => {
throw new Error(`Custom fields require a 'name' and 'type' key`);
}
const { name, plugin, type } = cf;
const { name, plugin, type, inputSize } = cf;
if (!ALLOWED_TYPES.includes(type)) {
throw new Error(
`Custom field type: '${type}' is not a valid Strapi type or it can't be used with a Custom Field`
@ -56,6 +56,23 @@ const customFieldsRegistry = (strapi) => {
throw new Error(`Custom field name: '${name}' is not a valid object key`);
}
// Validate inputSize when provided
if (inputSize) {
if (
!isPlainObject(inputSize) ||
!has('default', inputSize) ||
!has('isResizable', inputSize)
) {
throw new Error(`inputSize should be an object with 'default' and 'isResizable' keys`);
}
if (![4, 6, 8, 12].includes(inputSize.default)) {
throw new Error('Custom fields require a valid default input size');
}
if (typeof inputSize.isResizable !== 'boolean') {
throw new Error('Custom fields should specify if their input is resizable');
}
}
// When no plugin is specified, or it isn't found in Strapi, default to global
const uid = strapi.plugin(plugin) ? `plugin::${plugin}.${name}` : `global::${name}`;

View File

@ -27,7 +27,7 @@ interface File {
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('aws-sdk/lib/maintenance_mode_message').suppress = true;
function assertUrlProtocol(url: string) {
function hasUrlProtocol(url: string) {
// Regex to test protocol like "http://", "https://"
return /^\w*:\/\//.test(url);
}
@ -93,11 +93,13 @@ export = {
}
// set the bucket file url
if (assertUrlProtocol(data.Location)) {
file.url = baseUrl ? `${baseUrl}/${fileKey}` : data.Location;
if (baseUrl) {
// Construct the url with the baseUrl
file.url = `${baseUrl}/${fileKey}`;
} else {
// Default protocol to https protocol
file.url = `https://${data.Location}`;
// Add the protocol if it is missing
// Some providers like DigitalOcean Spaces return the url without the protocol
file.url = hasUrlProtocol(data.Location) ? data.Location : `https://${data.Location}`;
}
resolve();
};

View File

@ -1,10 +1,6 @@
{
"buildCommand": "cd packages/core/helper-plugin && yarn build-storybook",
"github": {
"silent": true,
"autoJobCancelation": true
},
"installCommand": "yarn install --immutable",
"ignoreCommand": "git diff HEAD^ HEAD --quiet './packages/core/helper-plugin'",
"outputDirectory": "packages/core/helper-plugin/storybook-static"
}
}