feat: populate new entry from URL params (#3343)
This commit is contained in:
parent
773d83900d
commit
e0b1246810
@ -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: '<script>alert('hello')</script>' },
|
||||
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([
|
||||
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
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');
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
|
@ -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' }] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}>;
|
||||
|
@ -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).
|
||||
|
Loading…
x
Reference in New Issue
Block a user