feat: populate new entry from URL params (#3343)

This commit is contained in:
Erez Rokah 2020-03-02 11:32:22 +01:00 committed by GitHub
parent 773d83900d
commit e0b1246810
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 364 additions and 43 deletions

View File

@ -1,5 +1,6 @@
import { fromJS, Map } from 'immutable';
import {
createEmptyDraft,
createEmptyDraftData,
retrieveLocalBackup,
persistLocalBackup,
@ -23,6 +24,99 @@ const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('entries', () => {
describe('createEmptyDraft', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should dispatch draft created action', () => {
const store = mockStore({ mediaLibrary: fromJS({ files: [] }) });
const collection = fromJS({
fields: [{ name: 'title' }],
});
return store.dispatch(createEmptyDraft(collection, '')).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(1);
expect(actions[0]).toEqual({
payload: {
collection: undefined,
data: {},
isModification: null,
label: null,
mediaFiles: fromJS([]),
metaData: null,
partial: false,
path: '',
raw: '',
slug: '',
},
type: 'DRAFT_CREATE_EMPTY',
});
});
});
it('should populate draft entry from URL param', () => {
const store = mockStore({ mediaLibrary: fromJS({ files: [] }) });
const collection = fromJS({
fields: [{ name: 'title' }, { name: 'boolean' }],
});
return store.dispatch(createEmptyDraft(collection, '?title=title&boolean=True')).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(1);
expect(actions[0]).toEqual({
payload: {
collection: undefined,
data: { title: 'title', boolean: true },
isModification: null,
label: null,
mediaFiles: fromJS([]),
metaData: null,
partial: false,
path: '',
raw: '',
slug: '',
},
type: 'DRAFT_CREATE_EMPTY',
});
});
});
it('should html escape URL params', () => {
const store = mockStore({ mediaLibrary: fromJS({ files: [] }) });
const collection = fromJS({
fields: [{ name: 'title' }],
});
return store
.dispatch(createEmptyDraft(collection, "?title=<script>alert('hello')</script>"))
.then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(1);
expect(actions[0]).toEqual({
payload: {
collection: undefined,
data: { title: '&lt;script&gt;alert(&#039;hello&#039;)&lt;/script&gt;' },
isModification: null,
label: null,
mediaFiles: fromJS([]),
metaData: null,
partial: false,
path: '',
raw: '',
slug: '',
},
type: 'DRAFT_CREATE_EMPTY',
});
});
});
});
describe('createEmptyDraftData', () => {
it('should set default value for list field widget', () => {
const fields = fromJS([

View File

@ -5,7 +5,7 @@ import { serializeValues } from '../lib/serializeEntryValues';
import { currentBackend, Backend } from '../backend';
import { getIntegrationProvider } from '../integrations';
import { selectIntegration, selectPublishedSlugs } from '../reducers';
import { selectFields } from '../reducers/collections';
import { selectFields, updateFieldByKey } from '../reducers/collections';
import { selectCollectionEntriesCursor } from '../reducers/cursors';
import { Cursor, ImplementationMediaFile } from 'netlify-cms-lib-util';
import { createEntry, EntryValue } from '../valueObjects/Entry';
@ -488,9 +488,37 @@ export function traverseCollectionCursor(collection: Collection, action: string)
};
}
export function createEmptyDraft(collection: Collection) {
const escapeHtml = (unsafe: string) => {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
const processValue = (unsafe: string) => {
if (['true', 'True', 'TRUE'].includes(unsafe)) {
return true;
}
if (['false', 'False', 'FALSE'].includes(unsafe)) {
return false;
}
return escapeHtml(unsafe);
};
export function createEmptyDraft(collection: Collection, search: string) {
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const dataFields = createEmptyDraftData(collection.get('fields', List()));
const params = new URLSearchParams(search);
params.forEach((value, key) => {
collection = updateFieldByKey(collection, key, field =>
field.set('default', processValue(value)),
);
});
const fields = collection.get('fields', List());
const dataFields = createEmptyDraftData(fields);
let mediaFiles = [] as MediaFile[];
if (!collection.has('media_folder')) {
@ -504,12 +532,21 @@ export function createEmptyDraft(collection: Collection) {
}
interface DraftEntryData {
[name: string]: string | null | DraftEntryData | DraftEntryData[] | (string | DraftEntryData)[];
[name: string]:
| string
| null
| boolean
| DraftEntryData
| DraftEntryData[]
| (string | DraftEntryData | boolean)[];
}
export function createEmptyDraftData(fields: EntryFields, withNameKey = true) {
return fields.reduce(
(reduction: DraftEntryData | string | undefined, value: EntryField | undefined) => {
(
reduction: DraftEntryData | string | undefined | boolean,
value: EntryField | undefined | boolean,
) => {
const acc = reduction as DraftEntryData;
const item = value as EntryField;
const subfields = item.get('field') || item.get('fields');

View File

@ -78,6 +78,7 @@ export class Editor extends React.Component {
user: ImmutablePropTypes.map.isRequired,
location: PropTypes.shape({
pathname: PropTypes.string,
search: PropTypes.string,
}),
hasChanged: PropTypes.bool,
t: PropTypes.func.isRequired,
@ -104,7 +105,7 @@ export class Editor extends React.Component {
retrieveLocalBackup(collection, slug);
if (newEntry) {
createEmptyDraft(collection);
createEmptyDraft(collection, this.props.location.search);
} else {
loadEntry(collection, slug);
}
@ -209,7 +210,7 @@ export class Editor extends React.Component {
const fieldsMetaData = this.props.entryDraft && this.props.entryDraft.get('fieldsMetaData');
this.createDraft(deserializedEntry, fieldsMetaData);
} else if (newEntry) {
prevProps.createEmptyDraft(collection);
prevProps.createEmptyDraft(collection, this.props.location.search);
}
}

View File

@ -50,6 +50,7 @@ describe('Editor', () => {
localBackup: fromJS({}),
retrieveLocalBackup: jest.fn(),
persistLocalBackup: jest.fn(),
location: { search: '?title=title' },
};
beforeEach(() => {
@ -114,7 +115,7 @@ describe('Editor', () => {
);
expect(props.createEmptyDraft).toHaveBeenCalledTimes(1);
expect(props.createEmptyDraft).toHaveBeenCalledWith(props.collection);
expect(props.createEmptyDraft).toHaveBeenCalledWith(props.collection, '?title=title');
expect(props.loadEntry).toHaveBeenCalledTimes(0);
});

View File

@ -8,6 +8,7 @@ import collections, {
selectMediaFolders,
getFieldsNames,
selectField,
updateFieldByKey,
} from '../collections';
import { FILES, FOLDER } from 'Constants/collectionTypes';
@ -381,4 +382,109 @@ describe('collections', () => {
);
});
});
describe('updateFieldByKey', () => {
it('should update field by key', () => {
const collection = fromJS({
fields: [
{ name: 'title' },
{ name: 'image' },
{
name: 'object',
fields: [{ name: 'title' }, { name: 'gallery', fields: [{ name: 'image' }] }],
},
{ name: 'list', field: { name: 'image' } },
{ name: 'body' },
{ name: 'widgetList', types: [{ name: 'widget' }] },
],
});
const updater = field => field.set('default', 'default');
expect(updateFieldByKey(collection, 'non-existent', updater)).toBe(collection);
expect(updateFieldByKey(collection, 'title', updater)).toEqual(
fromJS({
fields: [
{ name: 'title', default: 'default' },
{ name: 'image' },
{
name: 'object',
fields: [{ name: 'title' }, { name: 'gallery', fields: [{ name: 'image' }] }],
},
{ name: 'list', field: { name: 'image' } },
{ name: 'body' },
{ name: 'widgetList', types: [{ name: 'widget' }] },
],
}),
);
expect(updateFieldByKey(collection, 'object.title', updater)).toEqual(
fromJS({
fields: [
{ name: 'title' },
{ name: 'image' },
{
name: 'object',
fields: [
{ name: 'title', default: 'default' },
{ name: 'gallery', fields: [{ name: 'image' }] },
],
},
{ name: 'list', field: { name: 'image' } },
{ name: 'body' },
{ name: 'widgetList', types: [{ name: 'widget' }] },
],
}),
);
expect(updateFieldByKey(collection, 'object.gallery.image', updater)).toEqual(
fromJS({
fields: [
{ name: 'title' },
{ name: 'image' },
{
name: 'object',
fields: [
{ name: 'title' },
{ name: 'gallery', fields: [{ name: 'image', default: 'default' }] },
],
},
{ name: 'list', field: { name: 'image' } },
{ name: 'body' },
{ name: 'widgetList', types: [{ name: 'widget' }] },
],
}),
);
expect(updateFieldByKey(collection, 'list.image', updater)).toEqual(
fromJS({
fields: [
{ name: 'title' },
{ name: 'image' },
{
name: 'object',
fields: [{ name: 'title' }, { name: 'gallery', fields: [{ name: 'image' }] }],
},
{ name: 'list', field: { name: 'image', default: 'default' } },
{ name: 'body' },
{ name: 'widgetList', types: [{ name: 'widget' }] },
],
}),
);
expect(updateFieldByKey(collection, 'widgetList.widget', updater)).toEqual(
fromJS({
fields: [
{ name: 'title' },
{ name: 'image' },
{
name: 'object',
fields: [{ name: 'title' }, { name: 'gallery', fields: [{ name: 'image' }] }],
},
{ name: 'list', field: { name: 'image' } },
{ name: 'body' },
{ name: 'widgetList', types: [{ name: 'widget', default: 'default' }] },
],
}),
);
});
});
});

View File

@ -233,6 +233,53 @@ export const selectField = (collection: Collection, key: string) => {
return field;
};
export const updateFieldByKey = (
collection: Collection,
key: string,
updater: (field: EntryField) => EntryField,
) => {
const selected = selectField(collection, key);
if (!selected) {
return collection;
}
let updated = false;
const traverseFields = (fields: List<EntryField>) => {
if (updated) {
// we can stop once the field is found
return fields;
}
fields = fields
.map(f => {
const field = f as EntryField;
if (field === selected) {
updated = true;
return updater(field);
} else if (field.has('fields')) {
return field.set('fields', traverseFields(field.get('fields')!));
} else if (field.has('field')) {
return field.set('field', traverseFields(List([field.get('field')!])).get(0));
} else if (field.has('types')) {
return field.set('types', traverseFields(field.get('types')!));
} else {
return field;
}
})
.toList() as List<EntryField>;
return fields;
};
collection = collection.set(
'fields',
traverseFields(collection.get('fields', List<EntryField>())),
);
return collection;
};
export const selectIdentifier = (collection: Collection) => {
const identifier = collection.get('identifier_field');
const identifierFields = identifier ? [identifier, ...IDENTIFIER_FIELDS] : IDENTIFIER_FIELDS;

View File

@ -95,7 +95,7 @@ export type EntryField = StaticallyTypedRecord<{
types?: List<EntryField>;
widget: string;
name: string;
default: string | null;
default: string | null | boolean;
media_folder?: string;
public_folder?: string;
}>;

View File

@ -142,10 +142,10 @@ And for the image field being populated with a value of `image.png`.
Supports all of the [`slug` templates](/docs/configuration-options#slug) and:
* `{{filename}}` The file name without the extension part.
* `{{extension}}` The file extension.
* `{{media_folder}}` The global `media_folder`.
* `{{public_folder}}` The global `public_folder`.
- `{{filename}}` The file name without the extension part.
- `{{extension}}` The file extension.
- `{{media_folder}}` The global `media_folder`.
- `{{public_folder}}` The global `public_folder`.
## List Widget: Variable Types
@ -156,9 +156,9 @@ pane.**
To use variable types in the list widget, update your field configuration as follows:
1. Instead of defining your list fields under `fields` or `field`, define them under `types`. Similar to `fields`, `types` must be an array of field definition objects.
1. Instead of defining your list fields under `fields` or `field`, define them under `types`. Similar to `fields`, `types` must be an array of field definition objects.
2. Each field definition under `types` must use the `object` widget (this is the default value for
`widget`).
`widget`).
### Additional list widget options
@ -171,27 +171,27 @@ The example configuration below imagines a scenario where the editor can add two
either a "carousel" or a "spotlight". Each type has a unique name and set of fields.
```yaml
- label: "Home Section"
name: "sections"
widget: "list"
- label: 'Home Section'
name: 'sections'
widget: 'list'
types:
- label: "Carousel"
name: "carousel"
- label: 'Carousel'
name: 'carousel'
widget: object
fields:
- {label: Header, name: header, widget: string, default: "Image Gallery"}
- {label: Template, name: template, widget: string, default: "carousel.html"}
- { label: Header, name: header, widget: string, default: 'Image Gallery' }
- { label: Template, name: template, widget: string, default: 'carousel.html' }
- label: Images
name: images
widget: list
field: {label: Image, name: image, widget: image}
- label: "Spotlight"
name: "spotlight"
field: { label: Image, name: image, widget: image }
- label: 'Spotlight'
name: 'spotlight'
widget: object
fields:
- {label: Header, name: header, widget: string, default: "Spotlight"}
- {label: Template, name: template, widget: string, default: "spotlight.html"}
- {label: Text, name: text, widget: text, default: "Hello World"}
- { label: Header, name: header, widget: string, default: 'Spotlight' }
- { label: Template, name: template, widget: string, default: 'spotlight.html' }
- { label: Text, name: text, widget: text, default: 'Hello World' }
```
### Example Output
@ -310,8 +310,8 @@ CMS.registerPreviewTemplate(...);
* Takes advantage of the `toString` method in the return value of `css-loader`.
*/
import CMS from 'netlify-cms';
import styles from '!css-loader!sass-loader!../main.scss'
CMS.registerPreviewStyle(styles.toString(), { raw: true })
import styles from '!css-loader!sass-loader!../main.scss';
CMS.registerPreviewStyle(styles.toString(), { raw: true });
```
## Squash merge GitHub pull requests
@ -348,14 +348,14 @@ backend:
Netlify CMS generates the following commit types:
Commit type | When is it triggered? | Available template tags
--------------|------------------------------|-----------------------------
`create` | A new entry is created | `slug`, `path`, `collection`
`update` | An existing entry is changed | `slug`, `path`, `collection`
`delete` | An exising entry is deleted | `slug`, `path`, `collection`
`uploadMedia` | A media file is uploaded | `path`
`deleteMedia` | A media file is deleted | `path`
`openAuthoring` | A commit is made via a forked repository | `message`, `author-login`, `author-name`
| Commit type | When is it triggered? | Available template tags |
| --------------- | ---------------------------------------- | ---------------------------------------- |
| `create` | A new entry is created | `slug`, `path`, `collection` |
| `update` | An existing entry is changed | `slug`, `path`, `collection` |
| `delete` | An exising entry is deleted | `slug`, `path`, `collection` |
| `uploadMedia` | A media file is uploaded | `path` |
| `deleteMedia` | A media file is deleted | `path` |
| `openAuthoring` | A commit is made via a forked repository | `message`, `author-login`, `author-name` |
Template tags produce the following output:
@ -378,10 +378,10 @@ You can set a limit to as what the maximum file size of a file is that users can
Example config:
```yaml
- label: "Featured Image"
name: "thumbnail"
widget: "image"
default: "/uploads/chocolate-dogecoin.jpg"
- label: 'Featured Image'
name: 'thumbnail'
widget: 'image'
default: '/uploads/chocolate-dogecoin.jpg'
media_library:
config:
max_file_size: 512000 # in bytes, only for default media library
@ -415,4 +415,39 @@ CMS.registerEventListener({
});
```
> Supported events are `prePublish`, `postPublish`, `preUnpublish` and `postUnpublish`
**Note:** Supported events are `prePublish`, `postPublish`, `preUnpublish` and `postUnpublish`.
## Dynamic Default Values
When linking to `/admin/#/collections/posts/new` you can pass URL parameters to pre-populate an entry.
For example given the configuration:
```yaml
collections:
- name: posts
label: Posts
folder: content/posts
create: true
fields:
- label: Title
name: title
widget: string
- label: Object
name: object
widget: object
fields:
- label: Title
name: title
widget: string
- label: body
name: body
widget: markdown
```
clicking the following link: `/#/collections/posts/new?title=first&object.title=second&body=%23%20content`
will open the editor for a new post with the `title` field populated with `first`, the nested `object.title` field
with `second` and the markdown `body` field with `# content`.
**Note:** URL Encoding might be required for certain values (e.g. in the previous example the value for `body` is URL encoded).