mirror of
https://github.com/strapi/strapi.git
synced 2025-10-17 02:53:22 +00:00
Merge branch 'main' into feature/review-workflow
This commit is contained in:
commit
e386aff3d5
@ -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`.
|
||||
|
@ -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>
|
||||
```
|
||||
|
@ -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,
|
||||
|
@ -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">
|
||||
|
@ -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, []);
|
||||
|
@ -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 });
|
||||
|
@ -6,4 +6,5 @@ module.exports = async () => {
|
||||
await getService('components').syncConfigurations();
|
||||
await getService('content-types').syncConfigurations();
|
||||
await getService('permission').registerPermissions();
|
||||
getService('field-sizes').setCustomFieldInputSizes();
|
||||
};
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -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]),
|
||||
},
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
};
|
@ -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);
|
||||
|
||||
|
@ -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: [
|
||||
{
|
||||
|
@ -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',
|
||||
|
@ -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}`;
|
||||
|
||||
|
@ -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();
|
||||
};
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user