Feature/fix hidden widget (#196)

This commit is contained in:
Daniel Lautzenheiser 2022-12-06 08:31:07 -05:00 committed by GitHub
parent 2d7e661fdb
commit c4a812a575
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 784 additions and 646 deletions

View File

@ -98,10 +98,14 @@ collections:
description: Boolean widget description: Boolean widget
fields: fields:
- name: required - name: required
label: 'Required Validation' label: Required Validation
widget: boolean widget: boolean
- name: with_default
label: Required With Default
widget: boolean
default: true
- name: pattern - name: pattern
label: 'Pattern Validation' label: Pattern Validation
widget: boolean widget: boolean
pattern: ['true', 'Must be true'] pattern: ['true', 'Must be true']
required: false required: false
@ -111,29 +115,58 @@ collections:
description: Code widget description: Code widget
fields: fields:
- name: required - name: required
label: 'Required Validation' label: Required Validation
widget: code widget: code
- name: with_default
label: Required With Default
widget: code
default: '<div>Some html!</div>'
- name: pattern - name: pattern
label: 'Pattern Validation' label: Pattern Validation
widget: code widget: code
pattern: ['.{12,}', 'Must have at least 12 characters'] pattern: ['.{12,}', 'Must have at least 12 characters']
allow_input: true allow_input: true
required: false required: false
- name: language - name: language
label: 'Language Selection' label: Language Selection
widget: code widget: code
allow_language_selection: true allow_language_selection: true
required: false required: false
- name: language_with_default
label: Language Selection With Default Language
widget: code
allow_language_selection: true
required: false
default_language: html
- name: language_with_default_language_and_value
label: Language Selection With Default Language and Value
widget: code
allow_language_selection: true
required: false
default:
lang: html
code: '<div>Some html!</div>'
- name: language_with_default_language_and_value_string_default
label: Language Selection With Default Language and Value (String Default)
widget: code
allow_language_selection: true
required: false
default_language: html
default: '<div>Some html!</div>'
- name: color - name: color
label: Color label: Color
file: _widgets/color.json file: _widgets/color.json
description: Color widget description: Color widget
fields: fields:
- name: required - name: required
label: 'Required Validation' label: Required Validation
widget: color widget: color
- name: with_default
label: Required With Default
widget: color
default: '#2121c5'
- name: pattern - name: pattern
label: 'Pattern Validation' label: Pattern Validation
widget: color widget: color
pattern: ['^#([0-9a-fA-F]{3})(?:[0-9a-fA-F]{3})?$', 'Must be a valid hex code'] pattern: ['^#([0-9a-fA-F]{3})(?:[0-9a-fA-F]{3})?$', 'Must be a valid hex code']
allow_input: true allow_input: true
@ -143,6 +176,12 @@ collections:
widget: color widget: color
enable_alpha: true enable_alpha: true
required: false required: false
- name: alpha_with_default
label: Alpha With Default
widget: color
enable_alpha: true
required: false
default: 'rgba(175, 28, 28, 0.65)'
- name: datetime - name: datetime
label: DateTime label: DateTime
file: _widgets/datetime.json file: _widgets/datetime.json
@ -166,33 +205,59 @@ collections:
date_format: 'MMM d, yyyy' date_format: 'MMM d, yyyy'
time_format: 'h:mm aaa' time_format: 'h:mm aaa'
required: false required: false
- name: date_and_time_with_default
label: Date and Time With Deafult
widget: datetime
format: 'MMM d, yyyy h:mm aaa'
date_format: 'MMM d, yyyy'
time_format: 'h:mm aaa'
required: false
default: 'Jan 12, 2023 12:00 am'
- name: date - name: date
label: Date label: Date
widget: datetime widget: datetime
format: 'MMM d, yyyy' format: 'MMM d, yyyy'
date_format: 'MMM d, yyyy' date_format: 'MMM d, yyyy'
required: false required: false
- name: date_with_default
label: Date With Deafult
widget: datetime
format: 'MMM d, yyyy'
date_format: 'MMM d, yyyy'
required: false
default: 'Jan 12, 2023'
- name: time - name: time
label: Time label: Time
widget: datetime widget: datetime
format: 'h:mm aaa' format: 'h:mm aaa'
time_format: 'h:mm aaa' time_format: 'h:mm aaa'
required: false required: false
- name: time_with_default
label: Time With Deafult
widget: datetime
format: 'h:mm aaa'
time_format: 'h:mm aaa'
required: false
default: '12:00 am'
- name: file - name: file
label: File label: File
file: _widgets/file.json file: _widgets/file.json
description: File widget description: File widget
fields: fields:
- name: required - name: required
label: 'Required Validation' label: Required Validation
widget: file widget: file
- name: with_default
label: Required With Default
widget: file
default: /assets/uploads/moby-dick.jpg
- name: pattern - name: pattern
label: 'Pattern Validation' label: Pattern Validation
widget: file widget: file
pattern: ['\.pdf', 'Must be a pdf'] pattern: ['\.pdf', 'Must be a pdf']
required: false required: false
- name: choose_url - name: choose_url
label: 'Choose URL' label: Choose URL
widget: file widget: file
required: false required: false
media_library: media_library:
@ -203,15 +268,19 @@ collections:
description: Image widget description: Image widget
fields: fields:
- name: required - name: required
label: 'Required Validation' label: Required Validation
widget: image widget: image
- name: with_default
label: Required With Default
widget: image
default: /assets/uploads/moby-dick.jpg
- name: pattern - name: pattern
label: 'Pattern Validation' label: Pattern Validation
widget: image widget: image
pattern: ['\.png', 'Must be a png'] pattern: ['\.png', 'Must be a png']
required: false required: false
- name: choose_url - name: choose_url
label: 'Choose URL' label: Choose URL
widget: image widget: image
required: false required: false
media_library: media_library:
@ -222,7 +291,7 @@ collections:
description: List widget description: List widget
fields: fields:
- name: list - name: list
label: List label: Required List
widget: list widget: list
fields: fields:
- label: Name - label: Name
@ -232,6 +301,32 @@ collections:
- label: Description - label: Description
name: description name: description
widget: text widget: text
- name: with_default
label: Required With Default
widget: list
default:
- name: Bob Billy
description: Some text about bob
fields:
- label: Name
name: name
widget: string
hint: First and Last
- label: Description
name: description
widget: text
- name: optional
label: Optional List
widget: list
required: false
fields:
- label: Name
name: name
widget: string
hint: First and Last
- label: Description
name: description
widget: text
- name: typed_list - name: typed_list
label: Typed List label: Typed List
widget: list widget: list
@ -268,7 +363,60 @@ collections:
widget: datetime widget: datetime
- label: Markdown - label: Markdown
name: markdown name: markdown
widget: markdown
- label: Type 3 Object
name: type_3_object
widget: object
fields:
- label: Image
name: image
widget: image
- label: File
name: file
widget: file
- name: typed_list_with_default
label: Typed List With Default
widget: list
default:
- type: type_2_object
number: 5
select: c
datetime: '2022-12-05T20:22:52+0000'
markdown: Some ***Markdown*** ~content~ text
types:
- label: Type 1 Object
name: type_1_object
widget: object
fields:
- label: String
name: string
widget: string
- label: Boolean
name: boolean
widget: boolean
- label: Text
name: text
widget: text widget: text
- label: Type 2 Object
name: type_2_object
widget: object
fields:
- label: Number
name: number
widget: number
- label: Select
name: select
widget: select
options:
- a
- b
- c
- label: Datetime
name: datetime
widget: datetime
- label: Markdown
name: markdown
widget: markdown
- label: Type 3 Object - label: Type 3 Object
name: type_3_object name: type_3_object
widget: object widget: object
@ -285,10 +433,14 @@ collections:
description: Map widget description: Map widget
fields: fields:
- name: required - name: required
label: 'Required Validation' label: Required Validation
widget: map widget: map
- name: with_default
label: Required With Default
widget: map
default: '{ "type": "Point", "coordinates": [-73.9852661, 40.7478738] }'
- name: pattern - name: pattern
label: 'Pattern Validation' label: Pattern Validation
widget: map widget: map
pattern: ['\[-([7-9][0-9]|1[0-2][0-9])\.', 'Must be between latitude -70 and -129'] pattern: ['\[-([7-9][0-9]|1[0-2][0-9])\.', 'Must be between latitude -70 and -129']
required: false required: false
@ -298,10 +450,14 @@ collections:
description: Markdown widget description: Markdown widget
fields: fields:
- name: required - name: required
label: 'Required Validation' label: Required Validation
widget: markdown widget: markdown
- name: with_default
label: Required With Default
widget: markdown
default: Default **markdown** value
- name: pattern - name: pattern
label: 'Pattern Validation' label: Pattern Validation
widget: markdown widget: markdown
pattern: ['# [a-zA-Z0-9]+', 'Must have a header'] pattern: ['# [a-zA-Z0-9]+', 'Must have a header']
required: false required: false
@ -311,26 +467,30 @@ collections:
description: Number widget description: Number widget
fields: fields:
- name: required - name: required
label: 'Required Validation' label: Required Validation
widget: number widget: number
- name: with_default
label: Required With Default
widget: number
default: 5
- name: min - name: min
label: 'Min Validation' label: Min Validation
widget: number widget: number
min: 5 min: 5
required: false required: false
- name: max - name: max
label: 'Max Validation' label: Max Validation
widget: number widget: number
max: 10 max: 10
required: false required: false
- name: min_and_max - name: min_and_max
label: 'Min and Max Validation' label: Min and Max Validation
widget: number widget: number
min: 5 min: 5
max: 10 max: 10
required: false required: false
- name: pattern - name: pattern
label: 'Pattern Validation' label: Pattern Validation
widget: number widget: number
pattern: ['[0-9]{3,}', 'Must be at least 3 digits'] pattern: ['[0-9]{3,}', 'Must be at least 3 digits']
required: false required: false
@ -346,12 +506,28 @@ collections:
- label: Number of posts on frontpage - label: Number of posts on frontpage
name: front_limit name: front_limit
widget: number widget: number
- label: Default Author - label: Author
name: author name: author
widget: string widget: string
- label: Default Thumbnail - label: Thumbnail
name: thumb name: thumb
widget: image widget: image
- label: Required With Defaults
name: with_defaults
widget: object
fields:
- label: Number of posts on frontpage
name: front_limit
widget: number
default: 5
- label: Author
name: author
widget: string
default: Bob
- label: Thumbnail
name: thumb
widget: image
default: /assets/uploads/moby-dick.jpg
- label: Optional Validation - label: Optional Validation
name: optional name: optional
widget: object widget: object
@ -361,11 +537,31 @@ collections:
name: front_limit name: front_limit
widget: number widget: number
required: false required: false
- label: Default Author - label: Author
name: author name: author
widget: string widget: string
required: false required: false
- label: Default Thumbnail - label: Thumbnail
name: thumb
widget: image
required: false
- label: With Hidden Field
name: hidden_field
widget: object
required: false
fields:
- name: layout
widget: hidden
default: post
- label: Number of posts on frontpage
name: front_limit
widget: number
required: false
- label: Author
name: author
widget: string
required: false
- label: Thumbnail
name: thumb name: thumb
widget: image widget: image
required: false required: false
@ -385,6 +581,18 @@ collections:
- title - title
- body - body
value_field: title value_field: title
- label: Required With Default
name: with_default
widget: relation
collection: posts
display_fields:
- title
- date
search_fields:
- title
- body
value_field: title
default: This is a YAML front matter post
- label: Optional Validation - label: Optional Validation
name: optional name: optional
widget: relation widget: relation
@ -410,6 +618,22 @@ collections:
- title - title
- body - body
value_field: title value_field: title
- label: Multiple With Default
name: multiple_with_default
widget: relation
multiple: true
required: false
collection: posts
default:
- This is a JSON front matter post
- This is a YAML front matter post
display_fields:
- title
- date
search_fields:
- title
- body
value_field: title
- name: select - name: select
label: Select label: Select
file: _widgets/select.json file: _widgets/select.json
@ -422,6 +646,14 @@ collections:
- a - a
- b - b
- c - c
- label: Required With Default
name: with_default
widget: select
default: b
options:
- a
- b
- c
- label: Pattern Validation - label: Pattern Validation
name: pattern name: pattern
widget: select widget: select
@ -431,13 +663,39 @@ collections:
- c - c
pattern: ['[a-b]', 'Must be a or b'] pattern: ['[a-b]', 'Must be a or b']
required: false required: false
- label: Number Value
name: number
widget: select
options:
- 1
- 2
- 3
- label: Number With Default
name: number_with_default
widget: select
default: 3
options:
- 1
- 2
- 3
- label: Value and Label - label: Value and Label
name: value_and_label name: value_and_label
widget: select widget: select
options: options:
- value: a - value: a
label: A fancy label label: A fancy label
- value: b - value: 2
label: Another fancy label
- value: c
label: And one more fancy label
- label: Value and Label With Default
name: value_and_label_with_default
widget: select
default: 2
options:
- value: a
label: A fancy label
- value: 2
label: Another fancy label label: Another fancy label
- value: c - value: c
label: And one more fancy label label: And one more fancy label
@ -451,6 +709,19 @@ collections:
pattern: ['[a-b]', 'Must be a or b'] pattern: ['[a-b]', 'Must be a or b']
multiple: true multiple: true
required: false required: false
- label: Multiple With Default
name: multiple_with_default
widget: select
default:
- b
- c
options:
- a
- b
- c
pattern: ['[a-b]', 'Must be a or b']
multiple: true
required: false
- label: Value and Label Multiple - label: Value and Label Multiple
name: value_and_label_multiple name: value_and_label_multiple
widget: select widget: select
@ -468,10 +739,14 @@ collections:
description: String widget description: String widget
fields: fields:
- name: required - name: required
label: 'Required Validation' label: Required Validation
widget: string widget: string
- name: with_default
label: Required With Default
widget: string
default: Default value
- name: pattern - name: pattern
label: 'Pattern Validation' label: Pattern Validation
widget: string widget: string
pattern: ['.{12,}', 'Must have at least 12 characters'] pattern: ['.{12,}', 'Must have at least 12 characters']
required: false required: false
@ -483,6 +758,10 @@ collections:
- name: required - name: required
label: 'Required Validation' label: 'Required Validation'
widget: text widget: text
- name: with_default
label: Required With Default
widget: text
default: Default value
- name: pattern - name: pattern
label: 'Pattern Validation' label: 'Pattern Validation'
widget: text widget: text
@ -778,7 +1057,7 @@ collections:
widget: number widget: number
- label: Markdown - label: Markdown
name: markdown name: markdown
widget: text widget: markdown
- label: Datetime - label: Datetime
name: datetime name: datetime
widget: datetime widget: datetime
@ -817,7 +1096,7 @@ collections:
widget: number widget: number
- label: Markdown - label: Markdown
name: markdown name: markdown
widget: text widget: markdown
- label: Datetime - label: Datetime
name: datetime name: datetime
widget: datetime widget: datetime
@ -870,7 +1149,7 @@ collections:
widget: datetime widget: datetime
- label: Markdown - label: Markdown
name: markdown name: markdown
widget: text widget: markdown
- label: Type 3 Object - label: Type 3 Object
name: type_3_object name: type_3_object
widget: object widget: object

View File

@ -8,13 +8,11 @@ import { resolveBackend } from '../backend';
import validateConfig from '../constants/configSchema'; import validateConfig from '../constants/configSchema';
import { I18N, I18N_FIELD, I18N_STRUCTURE } from '../lib/i18n'; import { I18N, I18N_FIELD, I18N_STRUCTURE } from '../lib/i18n';
import { selectDefaultSortableFields } from '../lib/util/collection.util'; import { selectDefaultSortableFields } from '../lib/util/collection.util';
import { getIntegrations, selectIntegration } from '../reducers/integrations';
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk'; import type { ThunkDispatch } from 'redux-thunk';
import type { import type {
BaseField, BaseField,
Collection,
Config, Config,
Field, Field,
I18nInfo, I18nInfo,
@ -126,12 +124,6 @@ function throwOnMissingDefaultLocale(i18n?: I18nInfo) {
} }
} }
function hasIntegration(config: Config, collection: Collection) {
const integrations = getIntegrations(config);
const integration = selectIntegration(integrations, collection.name, 'listEntries');
return !!integration;
}
export function applyDefaults(originalConfig: Config) { export function applyDefaults(originalConfig: Config) {
return produce(originalConfig, config => { return produce(originalConfig, config => {
config.slug = config.slug || {}; config.slug = config.slug || {};
@ -247,11 +239,7 @@ export function applyDefaults(originalConfig: Config) {
if (!collection.sortable_fields) { if (!collection.sortable_fields) {
collection.sortable_fields = { collection.sortable_fields = {
fields: selectDefaultSortableFields( fields: selectDefaultSortableFields(collection, backend),
collection,
backend,
hasIntegration(config, collection),
),
}; };
} }

View File

@ -3,12 +3,11 @@ import isEqual from 'lodash/isEqual';
import { currentBackend } from '../backend'; import { currentBackend } from '../backend';
import { SORT_DIRECTION_ASCENDING } from '../constants'; import { SORT_DIRECTION_ASCENDING } from '../constants';
import ValidationErrorTypes from '../constants/validationErrorTypes'; import ValidationErrorTypes from '../constants/validationErrorTypes';
import { getSearchIntegrationProvider } from '../integrations';
import { duplicateDefaultI18nFields, hasI18n, I18N_FIELD, serializeI18n } from '../lib/i18n'; import { duplicateDefaultI18nFields, hasI18n, I18N_FIELD, serializeI18n } from '../lib/i18n';
import { serializeValues } from '../lib/serializeEntryValues'; import { serializeValues } from '../lib/serializeEntryValues';
import { Cursor } from '../lib/util'; import { Cursor } from '../lib/util';
import { selectFields, updateFieldByKey } from '../lib/util/collection.util'; import { selectFields, updateFieldByKey } from '../lib/util/collection.util';
import { selectIntegration, selectPublishedSlugs } from '../reducers'; import { selectPublishedSlugs } from '../reducers';
import { selectCollectionEntriesCursor } from '../reducers/cursors'; import { selectCollectionEntriesCursor } from '../reducers/cursors';
import { selectEntriesSortFields, selectIsFetching } from '../reducers/entries'; import { selectEntriesSortFields, selectIsFetching } from '../reducers/entries';
import { navigateToEntry } from '../routing/history'; import { navigateToEntry } from '../routing/history';
@ -277,17 +276,7 @@ async function getAllEntries(state: RootState, collection: Collection) {
} }
const backend = currentBackend(configState.config); const backend = currentBackend(configState.config);
const integration = selectIntegration(state, collection.name, 'listEntries'); return backend.listAllEntries(collection);
const provider = integration
? getSearchIntegrationProvider(state.integrations, integration)
: backend;
if (!provider) {
return [];
}
const entries = await provider.listAllEntries(collection);
return entries;
} }
export function sortByField( export function sortByField(
@ -680,14 +669,6 @@ export function loadEntries(collection: Collection, page = 0) {
} }
const backend = currentBackend(configState.config); const backend = currentBackend(configState.config);
const integration = selectIntegration(state, collection.name, 'listEntries');
const provider = integration
? getSearchIntegrationProvider(state.integrations, integration)
: backend;
if (!provider) {
throw new Error('Provider not found');
}
const append = !!(page && !isNaN(page) && page > 0); const append = !!(page && !isNaN(page) && page > 0);
dispatch(entriesLoading(collection)); dispatch(entriesLoading(collection));
@ -701,8 +682,8 @@ export function loadEntries(collection: Collection, page = 0) {
entries: Entry[]; entries: Entry[];
} = await (loadAllEntries } = await (loadAllEntries
? // nested collections require all entries to construct the tree ? // nested collections require all entries to construct the tree
provider.listAllEntries(collection).then((entries: Entry[]) => ({ entries })) backend.listAllEntries(collection).then((entries: Entry[]) => ({ entries }))
: provider.listEntries(collection, page)); : backend.listEntries(collection));
const cleanResponse = { const cleanResponse = {
...response, ...response,

View File

@ -1,8 +1,6 @@
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import { currentBackend } from '../backend'; import { currentBackend } from '../backend';
import { getSearchIntegrationProvider } from '../integrations';
import { selectIntegration } from '../reducers';
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk'; import type { ThunkDispatch } from 'redux-thunk';
@ -103,43 +101,26 @@ export function searchEntries(searchTerm: string, searchCollections: string[], p
const backend = currentBackend(configState.config); const backend = currentBackend(configState.config);
const allCollections = searchCollections || Object.keys(state.collections); const allCollections = searchCollections || Object.keys(state.collections);
const collections = allCollections.filter(collection =>
selectIntegration(state, collection, 'search'),
);
const integration = selectIntegration(state, collections[0], 'search');
// avoid duplicate searches // avoid duplicate searches
if ( if (
search.isFetching && search.isFetching &&
search.term === searchTerm && search.term === searchTerm &&
isEqual(allCollections, search.collections) && isEqual(allCollections, search.collections)
// if an integration doesn't exist, 'page' is not used
(search.page === page || !integration)
) { ) {
return; return;
} }
dispatch(searchingEntries(searchTerm, allCollections, page)); dispatch(searchingEntries(searchTerm, allCollections, page));
const searchPromise = integration try {
? getSearchIntegrationProvider(state.integrations, integration)?.search( const response = await backend.search(
collections,
searchTerm,
page,
)
: backend.search(
Object.entries(state.collections) Object.entries(state.collections)
.filter(([key, _value]) => allCollections.indexOf(key) !== -1) .filter(([key, _value]) => allCollections.indexOf(key) !== -1)
.map(([_key, value]) => value), .map(([_key, value]) => value),
searchTerm, searchTerm,
); );
try {
const response = await searchPromise;
if (!response) {
return dispatch(searchFailure(new Error(`No integration found for name "${integration}"`)));
}
return dispatch(searchSuccess(response.entries, page)); return dispatch(searchSuccess(response.entries, page));
} catch (error: unknown) { } catch (error: unknown) {
console.error(error); console.error(error);
@ -170,7 +151,6 @@ export function query(
} }
const backend = currentBackend(configState.config); const backend = currentBackend(configState.config);
const integration = selectIntegration(state, collectionName, 'search');
const collection = Object.values(state.collections).find( const collection = Object.values(state.collections).find(
collection => collection.name === collectionName, collection => collection.name === collectionName,
); );
@ -178,16 +158,14 @@ export function query(
return dispatch(queryFailure(new Error('Collection not found'))); return dispatch(queryFailure(new Error('Collection not found')));
} }
const queryPromise = integration
? getSearchIntegrationProvider(state.integrations, integration)?.searchBy(
JSON.stringify(searchFields.map(f => `data.${f}`)),
collectionName,
searchTerm,
)
: backend.query(collection, searchFields, searchTerm, file, limit);
try { try {
const response: SearchQueryResponse = await queryPromise; const response: SearchQueryResponse = await backend.query(
collection,
searchFields,
searchTerm,
file,
limit,
);
return dispatch(querySuccess(namespace, response.hits)); return dispatch(querySuccess(namespace, response.hits));
} catch (error: unknown) { } catch (error: unknown) {
console.error(error); console.error(error);

View File

@ -46,6 +46,7 @@ import createEntry from './valueObjects/createEntry';
import type { import type {
BackendClass, BackendClass,
BackendInitializer, BackendInitializer,
BaseField,
Collection, Collection,
CollectionFile, CollectionFile,
Config, Config,
@ -60,6 +61,7 @@ import type {
ImplementationEntry, ImplementationEntry,
SearchQueryResponse, SearchQueryResponse,
SearchResponse, SearchResponse,
UnknownField,
User, User,
} from './interface'; } from './interface';
import type { AllowedEvent } from './lib/registry'; import type { AllowedEvent } from './lib/registry';
@ -109,7 +111,7 @@ function getEntryBackupKey(collectionName?: string, slug?: string) {
return `${baseKey}.${collectionName}${suffix}`; return `${baseKey}.${collectionName}${suffix}`;
} }
function getEntryField(field: string, entry: Entry): string { export function getEntryField(field: string, entry: Entry): string {
const value = get(entry.data, field); const value = get(entry.data, field);
if (value) { if (value) {
return String(value); return String(value);
@ -210,9 +212,15 @@ export function mergeExpandedEntries(entries: (Entry & { field: string })[]) {
return Object.values(merged); return Object.values(merged);
} }
function sortByScore(a: fuzzy.FilterResult<Entry>, b: fuzzy.FilterResult<Entry>) { export function sortByScore(a: fuzzy.FilterResult<Entry>, b: fuzzy.FilterResult<Entry>) {
if (a.score > b.score) return -1; if (a.score > b.score) {
if (a.score < b.score) return 1; return -1;
}
if (a.score < b.score) {
return 1;
}
return 0; return 0;
} }
@ -478,16 +486,16 @@ export class Backend<BC extends BackendClass = BackendClass> {
// repeats the process. Once there is no available "next" action, it // repeats the process. Once there is no available "next" action, it
// returns all the collected entries. Used to retrieve all entries // returns all the collected entries. Used to retrieve all entries
// for local searches and queries. // for local searches and queries.
async listAllEntries(collection: Collection) { async listAllEntries<T extends BaseField = UnknownField>(collection: Collection<T>) {
if ('folder' in collection && collection.folder && this.implementation.allEntriesByFolder) { if ('folder' in collection && collection.folder && this.implementation.allEntriesByFolder) {
const depth = collectionDepth(collection); const depth = collectionDepth(collection as Collection);
const extension = selectFolderEntryExtension(collection); const extension = selectFolderEntryExtension(collection as Collection);
return this.implementation return this.implementation
.allEntriesByFolder(collection.folder as string, extension, depth) .allEntriesByFolder(collection.folder as string, extension, depth)
.then(entries => this.processEntries(entries, collection)); .then(entries => this.processEntries(entries, collection as Collection));
} }
const response = await this.listEntries(collection); const response = await this.listEntries(collection as Collection);
const { entries } = response; const { entries } = response;
let { cursor } = response; let { cursor } = response;
while (cursor && cursor.actions?.has('next')) { while (cursor && cursor.actions?.has('next')) {
@ -557,14 +565,14 @@ export class Backend<BC extends BackendClass = BackendClass> {
return { entries: hits, pagination: 1 }; return { entries: hits, pagination: 1 };
} }
async query( async query<T extends BaseField = UnknownField>(
collection: Collection, collection: Collection<T>,
searchFields: string[], searchFields: string[],
searchTerm: string, searchTerm: string,
file?: string, file?: string,
limit?: number, limit?: number,
): Promise<SearchQueryResponse> { ): Promise<SearchQueryResponse> {
let entries = await this.listAllEntries(collection); let entries = await this.listAllEntries(collection as Collection);
if (file) { if (file) {
entries = entries.filter(e => e.slug === file); entries = entries.filter(e => e.slug === file);
} }
@ -1014,11 +1022,11 @@ export function resolveBackend(config?: Config) {
export const currentBackend = (function () { export const currentBackend = (function () {
let backend: Backend; let backend: Backend;
return (config: Config) => { return <T extends BaseField = UnknownField>(config: Config<T>) => {
if (backend) { if (backend) {
return backend; return backend;
} }
return (backend = resolveBackend(config)); return (backend = resolveBackend(config as Config));
}; };
})(); })();

View File

@ -23,6 +23,7 @@ import useMemoCompare from '@staticcms/core/lib/hooks/useMemoCompare';
import useUUID from '@staticcms/core/lib/hooks/useUUID'; import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { resolveWidget } from '@staticcms/core/lib/registry'; import { resolveWidget } from '@staticcms/core/lib/registry';
import { getFieldLabel } from '@staticcms/core/lib/util/field.util'; import { getFieldLabel } from '@staticcms/core/lib/util/field.util';
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import { validate } from '@staticcms/core/lib/util/validation.util'; import { validate } from '@staticcms/core/lib/util/validation.util';
import { selectFieldErrors } from '@staticcms/core/reducers/entryDraft'; import { selectFieldErrors } from '@staticcms/core/reducers/entryDraft';
import { selectIsLoadingAsset } from '@staticcms/core/reducers/medias'; import { selectIsLoadingAsset } from '@staticcms/core/reducers/medias';
@ -213,8 +214,32 @@ const EditorControl = ({
const finalValue = useMemoCompare(value, isEqual); const finalValue = useMemoCompare(value, isEqual);
const [version, setVersion] = useState(0);
useEffect(() => {
if (isNotNullish(finalValue)) {
return;
}
if ('default' in field && isNotNullish(!field.default)) {
if (widget.getDefaultValue) {
handleChangeDraftField(
widget.getDefaultValue(field.default, field as unknown as UnknownField),
);
} else {
handleChangeDraftField(field.default);
}
setVersion(version => version + 1);
return;
}
if (widget.getDefaultValue) {
handleChangeDraftField(widget.getDefaultValue(null, field as unknown as UnknownField));
setVersion(version => version + 1);
}
}, [field, finalValue, handleChangeDraftField, widget]);
return useMemo(() => { return useMemo(() => {
if (!collection || !entry || !config) { if (!collection || !entry || !config || field.widget === 'hidden') {
return null; return null;
} }
@ -222,7 +247,7 @@ const EditorControl = ({
<ControlContainer $isHidden={isHidden}> <ControlContainer $isHidden={isHidden}>
<> <>
{createElement(widget.control, { {createElement(widget.control, {
key: id, key: `${id}-${version}`,
collection, collection,
config, config,
entry, entry,

View File

@ -188,9 +188,7 @@ const EditorControlPane = ({
/> />
</LocaleRowWrapper> </LocaleRowWrapper>
) : null} ) : null}
{fields {fields.map(field => {
.filter(f => f.widget !== 'hidden')
.map(field => {
const isTranslatable = isFieldTranslatable(field, locale, i18n?.defaultLocale); const isTranslatable = isFieldTranslatable(field, locale, i18n?.defaultLocale);
const isDuplicate = isFieldDuplicate(field, locale, i18n?.defaultLocale); const isDuplicate = isFieldDuplicate(field, locale, i18n?.defaultLocale);
const isHidden = isFieldHidden(field, locale, i18n?.defaultLocale); const isHidden = isFieldHidden(field, locale, i18n?.defaultLocale);

View File

@ -1,11 +1,7 @@
import React from 'react';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import React from 'react';
import type { Field, TemplatePreviewProps } from '@staticcms/core/interface'; import type { TemplatePreviewProps } from '@staticcms/core/interface';
function isVisible(field: Field) {
return field.widget !== 'hidden';
}
const PreviewContainer = styled('div')` const PreviewContainer = styled('div')`
overflow-y: auto; overflow-y: auto;
@ -21,7 +17,7 @@ const Preview = ({ collection, fields, widgetFor }: TemplatePreviewProps) => {
return ( return (
<PreviewContainer> <PreviewContainer>
{fields.filter(isVisible).map(field => ( {fields.map(field => (
<div key={field.name}>{widgetFor(field.name)}</div> <div key={field.name}>{widgetFor(field.name)}</div>
))} ))}
</PreviewContainer> </PreviewContainer>

View File

@ -188,7 +188,7 @@ function isReactFragment(value: any): value is ReactFragment {
function getWidget( function getWidget(
config: Config, config: Config,
field: RenderedField, field: RenderedField<Field>,
collection: Collection, collection: Collection,
value: ValueOrNestedValue | ReactNode, value: ValueOrNestedValue | ReactNode,
entry: Entry, entry: Entry,
@ -202,6 +202,10 @@ function getWidget(
const widget = resolveWidget(field.widget); const widget = resolveWidget(field.widget);
const key = idx ? field.name + '_' + idx : field.name; const key = idx ? field.name + '_' + idx : field.name;
if (field.widget === 'hidden' || !widget.preview) {
return null;
}
/** /**
* Use an HOC to provide conditional updates for all previews. * Use an HOC to provide conditional updates for all previews.
*/ */
@ -214,7 +218,12 @@ function getWidget(
config={config} config={config}
collection={collection} collection={collection}
value={ value={
value && typeof value === 'object' && !isJsxElement(value) && !isReactFragment(value) value &&
typeof value === 'object' &&
!Array.isArray(value) &&
field.name in value &&
!isJsxElement(value) &&
!isReactFragment(value)
? (value as Record<string, unknown>)[field.name] ? (value as Record<string, unknown>)[field.name]
: value : value
} }

View File

@ -35,21 +35,21 @@ export default function addExtensions() {
registerBackend('test-repo', TestBackend); registerBackend('test-repo', TestBackend);
registerBackend('proxy', ProxyBackend); registerBackend('proxy', ProxyBackend);
registerWidget([ registerWidget([
StringWidget(),
NumberWidget(),
TextWidget(),
ImageWidget(),
FileWidget(),
SelectWidget(),
MarkdownWidget(),
ListWidget(),
ObjectWidget(),
RelationWidget(),
BooleanWidget(), BooleanWidget(),
MapWidget(),
DateTimeWidget(),
CodeWidget(), CodeWidget(),
ColorStringWidget(), ColorStringWidget(),
DateTimeWidget(),
FileWidget(),
ImageWidget(),
ListWidget(),
MapWidget(),
MarkdownWidget(),
NumberWidget(),
ObjectWidget(),
RelationWidget(),
SelectWidget(),
StringWidget(),
TextWidget(),
]); ]);
Object.keys(locales).forEach(locale => { Object.keys(locales).forEach(locale => {

View File

@ -1,37 +0,0 @@
import Algolia from './providers/algolia/implementation';
import type { AlgoliaConfig, SearchIntegrationProvider } from '../interface';
interface IntegrationsConfig {
providers?: {
algolia?: AlgoliaConfig;
};
}
interface Integrations {
algolia?: Algolia;
}
export function resolveIntegrations(config: IntegrationsConfig | undefined) {
const integrationInstances: Integrations = {};
if (config?.providers?.algolia) {
integrationInstances.algolia = new Algolia(config.providers.algolia);
}
return integrationInstances;
}
export const getSearchIntegrationProvider = (function () {
let integrations: Integrations = {};
return (config: IntegrationsConfig | undefined, provider: SearchIntegrationProvider) => {
if (provider in (config?.providers ?? {}))
if (integrations) {
return integrations[provider];
} else {
integrations = resolveIntegrations(config);
return integrations[provider];
}
};
})();

View File

@ -1,205 +0,0 @@
import flatten from 'lodash/flatten';
import { unsentRequest } from '@staticcms/core/lib/util';
import { selectEntrySlug } from '@staticcms/core/lib/util/collection.util';
import createEntry from '@staticcms/core/valueObjects/createEntry';
import type { AlgoliaConfig, Collection, Entry, SearchResponse } from '@staticcms/core/interface';
const { fetchWithTimeout: fetch } = unsentRequest;
function getSlug(path: string): string {
return (
path
.split('/')
.pop()
?.replace(/\.[^.]+$/, '') ?? path
);
}
interface EntriesCache {
collection: Collection | null;
page: number | null;
entries: Entry[];
}
interface AlgoliaHits {
nbPages?: number;
page: number;
hits: {
path: string;
slug: string;
data: unknown;
}[];
}
interface AlgoliaSearchResponse {
results: AlgoliaHits[];
}
export default class Algolia {
private applicationID: string;
private apiKey: string;
private indexPrefix: string;
private searchURL: string;
private entriesCache: EntriesCache;
constructor(config: AlgoliaConfig) {
if (config.applicationID == null || config.apiKey == null) {
throw 'The Algolia search integration needs the credentials (applicationID and apiKey) in the integration configuration.';
}
this.applicationID = config.applicationID;
this.apiKey = config.apiKey;
const prefix = config.indexPrefix;
this.indexPrefix = prefix ? `${prefix}-` : '';
this.searchURL = `https://${this.applicationID}-dsn.algolia.net/1`;
this.entriesCache = {
collection: null,
page: null,
entries: [],
};
}
requestHeaders(headers = {}) {
return {
'X-Algolia-API-Key': this.apiKey,
'X-Algolia-Application-Id': this.applicationID,
'Content-Type': 'application/json',
...headers,
};
}
parseJsonResponse(response: Response) {
return response.json().then(json => {
if (!response.ok) {
return Promise.reject(json);
}
return json;
});
}
urlFor(path: string, optionParams: Record<string, string> = {}) {
const params = [];
for (const key in optionParams) {
params.push(`${key}=${encodeURIComponent(optionParams[key])}`);
}
if (params.length) {
path += `?${params.join('&')}`;
}
return path;
}
request(
path: string,
options: RequestInit & {
params?: Record<string, string>;
},
) {
const headers = this.requestHeaders(options.headers || {});
const url = this.urlFor(path, options.params);
return fetch(url, { ...options, headers }).then(response => {
const contentType = response.headers.get('Content-Type');
if (contentType && contentType.match(/json/)) {
return this.parseJsonResponse(response);
}
return response.text();
});
}
search(collections: string[], searchTerm: string, page: number): Promise<SearchResponse> {
const searchCollections = collections.map(collection => ({
indexName: `${this.indexPrefix}${collection}`,
params: `query=${searchTerm}&page=${page}`,
}));
return this.request(`${this.searchURL}/indexes/*/queries`, {
method: 'POST',
body: JSON.stringify({ requests: searchCollections }),
}).then((response: AlgoliaSearchResponse) => {
const entries = response.results.map((result, index) =>
result.hits.map(hit => {
const slug = getSlug(hit.path);
return createEntry(collections[index], slug, hit.path, { data: hit.data, partial: true });
}),
);
return { entries: flatten(entries), pagination: page };
});
}
searchBy(field: string, collection: string, query: string) {
return this.request(`${this.searchURL}/indexes/${this.indexPrefix}${collection}`, {
params: {
restrictSearchableAttributes: field,
query,
},
});
}
listEntries(collection: Collection, page: number) {
if (this.entriesCache.collection === collection && this.entriesCache.page === page) {
return Promise.resolve({ page: this.entriesCache.page, entries: this.entriesCache.entries });
} else {
return this.request(`${this.searchURL}/indexes/${this.indexPrefix}${collection.name}`, {
params: { page: `${page}` },
}).then((response: AlgoliaHits) => {
const entries = response.hits.map(hit => {
const slug = selectEntrySlug(collection, hit.path);
return createEntry(collection.name, slug, hit.path, {
data: hit.data,
partial: true,
});
});
this.entriesCache = { collection, page: response.page, entries };
return { entries, page: response.page };
});
}
}
async listAllEntries(collection: Collection) {
const params = {
hitsPerPage: '1000',
};
let response: AlgoliaHits = await this.request(
`${this.searchURL}/indexes/${this.indexPrefix}${collection.name}`,
{ params },
);
let { nbPages = 0, hits, page } = response;
page = page + 1;
while (page < nbPages) {
response = await this.request(
`${this.searchURL}/indexes/${this.indexPrefix}${collection.name}`,
{
params: { ...params, page: `${page}` },
},
);
hits = [...hits, ...response.hits];
page = page + 1;
}
const entries = hits.map(hit => {
const slug = selectEntrySlug(collection, hit.path);
return createEntry(collection.name, slug, hit.path, {
data: hit.data,
partial: true,
});
});
return entries;
}
getEntry(collection: Collection, slug: string) {
return this.searchBy('slug', collection.name, slug).then((response: AlgoliaHits) => {
const entry = response.hits.filter(hit => hit.slug === slug)[0];
return createEntry(collection.name, slug, entry.path, {
data: entry.data,
partial: true,
});
});
}
}

View File

@ -313,6 +313,7 @@ export type TemplatePreviewComponent<
export interface WidgetOptions<T = unknown, F extends BaseField = UnknownField> { export interface WidgetOptions<T = unknown, F extends BaseField = UnknownField> {
validator?: Widget<T, F>['validator']; validator?: Widget<T, F>['validator'];
getValidValue?: Widget<T, F>['getValidValue']; getValidValue?: Widget<T, F>['getValidValue'];
getDefaultValue?: Widget<T, F>['getDefaultValue'];
schema?: Widget<T, F>['schema']; schema?: Widget<T, F>['schema'];
} }
@ -321,6 +322,7 @@ export interface Widget<T = unknown, F extends BaseField = UnknownField> {
preview?: WidgetPreviewComponent<T, F>; preview?: WidgetPreviewComponent<T, F>;
validator: FieldValidationMethod<T, F>; validator: FieldValidationMethod<T, F>;
getValidValue: (value: T | undefined | null) => T | undefined | null; getValidValue: (value: T | undefined | null) => T | undefined | null;
getDefaultValue?: (defaultValue: T | undefined | null, field: F) => T;
schema?: PropertiesSchema<unknown>; schema?: PropertiesSchema<unknown>;
} }

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { getAsset } from '@staticcms/core/actions/media'; import { getAsset } from '@staticcms/core/actions/media';
import { useAppDispatch } from '@staticcms/core/store/hooks'; import { useAppDispatch } from '@staticcms/core/store/hooks';
import { isNotEmpty } from '../util/string.util';
import type { Collection, Entry, FileOrImageField, MarkdownField } from '@staticcms/core/interface'; import type { Collection, Entry, FileOrImageField, MarkdownField } from '@staticcms/core/interface';
@ -23,5 +24,5 @@ export default function useMediaAsset<T extends FileOrImageField | MarkdownField
fetchMedia(); fetchMedia();
}, [collection, dispatch, entry, field, url]); }, [collection, dispatch, entry, field, url]);
return assetSource; return isNotEmpty(assetSource) ? assetSource : url;
} }

View File

@ -140,6 +140,7 @@ export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
schema, schema,
validator = () => false, validator = () => false,
getValidValue = (value: T | null | undefined) => value, getValidValue = (value: T | null | undefined) => value,
getDefaultValue,
}: WidgetOptions<T, F> = {}, }: WidgetOptions<T, F> = {},
): void { ): void {
if (Array.isArray(name)) { if (Array.isArray(name)) {
@ -162,6 +163,7 @@ export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
preview: preview as Widget['preview'], preview: preview as Widget['preview'],
validator: validator as Widget['validator'], validator: validator as Widget['validator'],
getValidValue: getValidValue as Widget['getValidValue'], getValidValue: getValidValue as Widget['getValidValue'],
getDefaultValue: getDefaultValue as Widget['getDefaultValue'],
schema, schema,
}; };
} }
@ -173,6 +175,7 @@ export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
options: { options: {
validator = () => false, validator = () => false,
getValidValue = (value: T | undefined | null) => value, getValidValue = (value: T | undefined | null) => value,
getDefaultValue,
schema, schema,
} = {}, } = {},
} = name; } = name;
@ -190,6 +193,7 @@ export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
preview, preview,
validator, validator,
getValidValue, getValidValue,
getDefaultValue,
schema, schema,
} as unknown as Widget; } as unknown as Widget;
} else { } else {

View File

@ -136,21 +136,17 @@ export function selectEntryCollectionTitle(collection: Collection, entry: Entry)
return result; return result;
} }
export function selectDefaultSortableFields( export function selectDefaultSortableFields(collection: Collection, backend: Backend) {
collection: Collection,
backend: Backend,
hasIntegration: boolean,
) {
let defaultSortable = SORTABLE_FIELDS.map((type: string) => { let defaultSortable = SORTABLE_FIELDS.map((type: string) => {
const field = selectInferedField(collection, type); const field = selectInferedField(collection, type);
if (backend.isGitBackend() && type === 'author' && !field && !hasIntegration) { if (backend.isGitBackend() && type === 'author' && !field) {
// default to commit author if not author field is found // default to commit author if not author field is found
return COMMIT_AUTHOR; return COMMIT_AUTHOR;
} }
return field; return field;
}).filter(Boolean); }).filter(Boolean);
if (backend.isGitBackend() && !hasIntegration) { if (backend.isGitBackend()) {
// always have commit date by default // always have commit date by default
defaultSortable = [COMMIT_DATE, ...defaultSortable]; defaultSortable = [COMMIT_DATE, ...defaultSortable];
} }

View File

@ -2,6 +2,7 @@ import { CONFIG_SUCCESS } from '../actions/config';
import type { ConfigAction } from '../actions/config'; import type { ConfigAction } from '../actions/config';
import type { Collection, Collections } from '../interface'; import type { Collection, Collections } from '../interface';
import type { RootState } from '../store';
export type CollectionsState = Collections; export type CollectionsState = Collections;
@ -25,3 +26,7 @@ function collections(
} }
export default collections; export default collections;
export const selectCollection = (collectionName: string) => (state: RootState) => {
return Object.values(state.collections).find(collection => collection.name === collectionName);
};

View File

@ -5,7 +5,6 @@ import cursors from './cursors';
import entries, * as fromEntries from './entries'; import entries, * as fromEntries from './entries';
import entryDraft from './entryDraft'; import entryDraft from './entryDraft';
import globalUI from './globalUI'; import globalUI from './globalUI';
import integrations, * as fromIntegrations from './integrations';
import mediaLibrary from './mediaLibrary'; import mediaLibrary from './mediaLibrary';
import medias from './medias'; import medias from './medias';
import scroll from './scroll'; import scroll from './scroll';
@ -14,7 +13,6 @@ import status from './status';
import type { Collection } from '../interface'; import type { Collection } from '../interface';
import type { RootState } from '../store'; import type { RootState } from '../store';
import type { IntegrationHooks } from './integrations';
const reducers = { const reducers = {
auth, auth,
@ -24,7 +22,6 @@ const reducers = {
entries, entries,
entryDraft, entryDraft,
globalUI, globalUI,
integrations,
mediaLibrary, mediaLibrary,
medias, medias,
scroll, scroll,
@ -55,11 +52,3 @@ export function selectSearchedEntries(state: RootState, availableCollections: st
.filter(entryId => availableCollections.indexOf(entryId!.collection) !== -1) .filter(entryId => availableCollections.indexOf(entryId!.collection) !== -1)
.map(entryId => fromEntries.selectEntry(state.entries, entryId!.collection, entryId!.slug)); .map(entryId => fromEntries.selectEntry(state.entries, entryId!.collection, entryId!.slug));
} }
export function selectIntegration<K extends keyof IntegrationHooks>(
state: RootState,
collection: string | null,
hook: K,
): IntegrationHooks[K] | false {
return fromIntegrations.selectIntegration(state.integrations, collection, hook);
}

View File

@ -1,76 +0,0 @@
import get from 'lodash/get';
import { CONFIG_SUCCESS } from '../actions/config';
import type { ConfigAction } from '../actions/config';
import type { AlgoliaConfig, Config, SearchIntegrationProvider } from '../interface';
export interface IntegrationHooks {
search?: SearchIntegrationProvider;
listEntries?: SearchIntegrationProvider;
}
export interface IntegrationsState {
providers: {
algolia?: AlgoliaConfig;
};
hooks: IntegrationHooks;
collectionHooks: Record<string, IntegrationHooks>;
}
export function getIntegrations(config: Config): IntegrationsState {
const integrations = config.integrations || [];
const newState = integrations.reduce(
(acc, integration) => {
const { collections, ...providerData } = integration;
const integrationCollections =
collections === '*' ? config.collections.map(collection => collection.name) : collections;
if (providerData.provider === 'algolia') {
acc.providers[providerData.provider] = providerData;
if (!collections) {
providerData.hooks.forEach(hook => (acc.hooks[hook] = providerData.provider));
return acc;
}
integrationCollections?.forEach(collection => {
providerData.hooks.forEach(
hook => (acc.collectionHooks[collection][hook] = providerData.provider),
);
});
}
return acc;
},
{ providers: {}, hooks: {} } as IntegrationsState,
);
return newState;
}
const defaultState: IntegrationsState = { providers: {}, hooks: {}, collectionHooks: {} };
function integrations(
state: IntegrationsState = defaultState,
action: ConfigAction,
): IntegrationsState {
switch (action.type) {
case CONFIG_SUCCESS: {
return getIntegrations(action.payload);
}
default:
return state;
}
}
export function selectIntegration<K extends keyof IntegrationHooks>(
state: IntegrationsState,
collection: string | null,
hook: string,
): IntegrationHooks[K] | false {
return collection
? get(state, ['collectionHooks', collection, hook], false)
: get(state, ['hooks', hook], false);
}
export default integrations;

View File

@ -1,7 +1,7 @@
import { red } from '@mui/material/colors'; import { red } from '@mui/material/colors';
import FormControlLabel from '@mui/material/FormControlLabel'; import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch'; import Switch from '@mui/material/Switch';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { BooleanField, WidgetControlProps } from '@staticcms/core/interface'; import type { BooleanField, WidgetControlProps } from '@staticcms/core/interface';
import type { ChangeEvent, FC } from 'react'; import type { ChangeEvent, FC } from 'react';
@ -22,15 +22,6 @@ const BooleanControl: FC<WidgetControlProps<boolean, BooleanField>> = ({
[onChange], [onChange],
); );
useEffect(() => {
if (typeof internalValue !== 'boolean') {
setInternalValue(false);
setTimeout(() => {
onChange(false);
});
}
}, [internalValue, onChange]);
return ( return (
<FormControlLabel <FormControlLabel
key="boolean-field-label" key="boolean-field-label"

View File

@ -1,4 +1,5 @@
import controlComponent from './BooleanControl'; import controlComponent from './BooleanControl';
import schema from './schema';
import type { BooleanField, WidgetParam } from '@staticcms/core/interface'; import type { BooleanField, WidgetParam } from '@staticcms/core/interface';
@ -6,9 +7,15 @@ const BooleanWidget = (): WidgetParam<boolean, BooleanField> => {
return { return {
name: 'boolean', name: 'boolean',
controlComponent, controlComponent,
options: {
schema,
getDefaultValue: (defaultValue: boolean | undefined | null) => {
return typeof defaultValue === 'boolean' ? defaultValue : false;
},
},
}; };
}; };
export { controlComponent as BooleanControl }; export { controlComponent as BooleanControl, schema as BooleanSchema };
export default BooleanWidget; export default BooleanWidget;

View File

@ -0,0 +1,5 @@
export default {
properties: {
default: { type: 'boolean' },
},
};

View File

@ -1,17 +1,22 @@
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import { loadLanguage } from '@uiw/codemirror-extensions-langs'; import { loadLanguage } from '@uiw/codemirror-extensions-langs';
import CodeMirror from '@uiw/react-codemirror'; import CodeMirror from '@uiw/react-codemirror';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import ObjectWidgetTopBar from '@staticcms/core/components/UI/ObjectWidgetTopBar'; import ObjectWidgetTopBar from '@staticcms/core/components/UI/ObjectWidgetTopBar';
import Outline from '@staticcms/core/components/UI/Outline'; import Outline from '@staticcms/core/components/UI/Outline';
import useUUID from '@staticcms/core/lib/hooks/useUUID'; import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import transientOptions from '@staticcms/core/lib/util/transientOptions'; import transientOptions from '@staticcms/core/lib/util/transientOptions';
import languages from './data/languages'; import languages from './data/languages';
import SettingsButton from './SettingsButton'; import SettingsButton from './SettingsButton';
import SettingsPane from './SettingsPane'; import SettingsPane from './SettingsPane';
import type { CodeField, WidgetControlProps } from '@staticcms/core/interface'; import type {
CodeField,
ProcessedCodeLanguage,
WidgetControlProps,
} from '@staticcms/core/interface';
import type { LanguageName } from '@uiw/codemirror-extensions-langs'; import type { LanguageName } from '@uiw/codemirror-extensions-langs';
import type { FC } from 'react'; import type { FC } from 'react';
@ -73,11 +78,7 @@ const CodeControl: FC<WidgetControlProps<string | { [key: string]: string }, Cod
const valueIsMap = useMemo(() => Boolean(!field.output_code_only), [field.output_code_only]); const valueIsMap = useMemo(() => Boolean(!field.output_code_only), [field.output_code_only]);
const [internalValue, setInternalValue] = useState(value ?? ''); const [internalValue, setInternalValue] = useState(value ?? '');
const [lang, setLang] = useState(() => { const [lang, setLang] = useState<ProcessedCodeLanguage | null>(null);
return valueIsMap && typeof internalValue !== 'string'
? internalValue && internalValue[keys.lang]
: field.default_language ?? '';
});
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [hasFocus, setHasFocus] = useState(false); const [hasFocus, setHasFocus] = useState(false);
@ -105,20 +106,20 @@ const CodeControl: FC<WidgetControlProps<string | { [key: string]: string }, Cod
(newValue: string) => { (newValue: string) => {
if (valueIsMap) { if (valueIsMap) {
handleOnChange({ handleOnChange({
...(typeof internalValue !== 'string' ? internalValue : {}), lang: lang?.label ?? '',
code: newValue, code: newValue,
}); });
} }
handleOnChange(newValue); handleOnChange(newValue);
}, },
[handleOnChange, internalValue, valueIsMap], [handleOnChange, lang?.label, valueIsMap],
); );
const loadedLangExtension = useMemo(() => { const loadedLangExtension = useMemo(() => {
if (!lang) { if (!lang) {
return null; return null;
} }
return loadLanguage(lang as LanguageName); return loadLanguage(lang.codemirror_mode as LanguageName);
}, [lang]); }, [lang]);
const extensions = useMemo(() => { const extensions = useMemo(() => {
@ -154,9 +155,29 @@ const CodeControl: FC<WidgetControlProps<string | { [key: string]: string }, Cod
[field.allow_language_selection], [field.allow_language_selection],
); );
const availableLanguages = languages.map(language => const availableLanguages = languages.map(language => valueToOption(language.label));
valueToOption({ name: language.codemirror_mode, label: language.label }),
); const handleSetLanguage = useCallback((langIdentifier: string) => {
const language = languages.find(language => language.identifiers.includes(langIdentifier));
if (language) {
setLang(language);
}
}, []);
useEffect(() => {
let langIdentifier: string;
if (typeof internalValue !== 'string') {
langIdentifier = internalValue[keys.lang];
} else {
langIdentifier = internalValue;
}
if (isEmpty(langIdentifier)) {
return;
}
handleSetLanguage(langIdentifier);
}, [field.default_language, handleSetLanguage, internalValue, keys.lang, valueIsMap]);
return ( return (
<StyledCodeControlWrapper> <StyledCodeControlWrapper>
@ -168,9 +189,9 @@ const CodeControl: FC<WidgetControlProps<string | { [key: string]: string }, Cod
hideSettings={hideSettings} hideSettings={hideSettings}
uniqueId={uniqueId} uniqueId={uniqueId}
languages={availableLanguages} languages={availableLanguages}
language={valueToOption(lang)} language={valueToOption(lang?.label ?? '')}
allowLanguageSelection={allowLanguageSelection} allowLanguageSelection={allowLanguageSelection}
onChangeLanguage={newLang => setLang(newLang)} onChangeLanguage={handleSetLanguage}
/> />
) )
) : null} ) : null}

View File

@ -11,6 +11,29 @@ const CodeWidget = (): WidgetParam<string | { [key: string]: string }, CodeField
previewComponent, previewComponent,
options: { options: {
schema, schema,
getDefaultValue: (
defaultValue: string | { [key: string]: string } | null | undefined,
field: CodeField,
) => {
if (field.output_code_only) {
return String(defaultValue);
}
const langKey = field.keys?.['lang'] ?? 'lang';
const codeKey = field.keys?.['code'] ?? 'code';
if (typeof defaultValue === 'string') {
return {
[langKey]: field.default_language ?? '',
[codeKey]: defaultValue,
};
}
return {
[langKey]: field.default_language ?? defaultValue?.[langKey] ?? '',
[codeKey]: defaultValue?.[codeKey] ?? '',
};
},
}, },
}; };
}; };

View File

@ -7,5 +7,8 @@ export default {
type: 'object', type: 'object',
properties: { code: { type: 'string' }, lang: { type: 'string' } }, properties: { code: { type: 'string' }, lang: { type: 'string' } },
}, },
default: {
oneOf: [{ type: 'string' }, { type: 'object' }],
},
}, },
}; };

View File

@ -1,5 +1,6 @@
import controlComponent from './ColorControl'; import controlComponent from './ColorControl';
import previewComponent from './ColorPreview'; import previewComponent from './ColorPreview';
import schema from './schema';
import type { ColorField, WidgetParam } from '@staticcms/core/interface'; import type { ColorField, WidgetParam } from '@staticcms/core/interface';
@ -8,9 +9,16 @@ const ColorWidget = (): WidgetParam<string, ColorField> => {
name: 'color', name: 'color',
controlComponent, controlComponent,
previewComponent, previewComponent,
options: {
schema,
},
}; };
}; };
export { controlComponent as ColorControl, previewComponent as ColorPreview }; export {
controlComponent as ColorControl,
previewComponent as ColorPreview,
schema as ColorSchema,
};
export default ColorWidget; export default ColorWidget;

View File

@ -0,0 +1,5 @@
export default {
properties: {
default: { type: 'string' },
},
};

View File

@ -11,13 +11,18 @@ import formatDate from 'date-fns/format';
import formatISO from 'date-fns/formatISO'; import formatISO from 'date-fns/formatISO';
import parse from 'date-fns/parse'; import parse from 'date-fns/parse';
import parseISO from 'date-fns/parseISO'; import parseISO from 'date-fns/parseISO';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util'; import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import type { DateTimeField, TranslatedProps, WidgetControlProps } from '@staticcms/core/interface'; import type { DateTimeField, TranslatedProps, WidgetControlProps } from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react'; import type { FC, MouseEvent } from 'react';
export function localToUTC(dateTime: Date, timezoneOffset: number) {
const utcFromLocal = new Date(dateTime.getTime() - timezoneOffset);
return utcFromLocal;
}
const StyledNowButton = styled('div')` const StyledNowButton = styled('div')`
width: fit-content; width: fit-content;
`; `;
@ -77,14 +82,6 @@ const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
const timezoneOffset = useMemo(() => new Date().getTimezoneOffset() * 60000, []); const timezoneOffset = useMemo(() => new Date().getTimezoneOffset() * 60000, []);
const localToUTC = useCallback(
(dateTime: Date) => {
const utcFromLocal = new Date(dateTime.getTime() - timezoneOffset);
return utcFromLocal;
},
[timezoneOffset],
);
const inputFormat = useMemo(() => { const inputFormat = useMemo(() => {
if (typeof dateFormat === 'string' || typeof timeFormat === 'string') { if (typeof dateFormat === 'string' || typeof timeFormat === 'string') {
const formatParts: string[] = []; const formatParts: string[] = [];
@ -105,13 +102,13 @@ const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
}, [dateFormat, timeFormat]); }, [dateFormat, timeFormat]);
const defaultValue = useMemo(() => { const defaultValue = useMemo(() => {
const today = field.picker_utc ? localToUTC(new Date()) : new Date(); const today = field.picker_utc ? localToUTC(new Date(), timezoneOffset) : new Date();
return field.default === undefined return field.default === undefined
? format ? format
? formatDate(today, format) ? formatDate(today, format)
: formatDate(today, inputFormat) : formatDate(today, inputFormat)
: field.default; : field.default;
}, [field.default, field.picker_utc, format, inputFormat, localToUTC]); }, [field.default, field.picker_utc, format, inputFormat, timezoneOffset]);
const [internalValue, setInternalValue] = useState(value); const [internalValue, setInternalValue] = useState(value);
@ -138,7 +135,7 @@ const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
return; return;
} }
const adjustedValue = field.picker_utc ? localToUTC(datetime) : datetime; const adjustedValue = field.picker_utc ? localToUTC(datetime, timezoneOffset) : datetime;
let formattedValue: string; let formattedValue: string;
if (format) { if (format) {
@ -149,20 +146,9 @@ const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
setInternalValue(formattedValue); setInternalValue(formattedValue);
onChange(formattedValue); onChange(formattedValue);
}, },
[defaultValue, field.picker_utc, format, localToUTC, onChange], [defaultValue, field.picker_utc, format, onChange, timezoneOffset],
); );
useEffect(() => {
if (isNotEmpty(internalValue)) {
return;
}
setInternalValue(defaultValue);
setTimeout(() => {
onChange(defaultValue);
});
}, [defaultValue, internalValue, onChange]);
const dateTimePicker = useMemo(() => { const dateTimePicker = useMemo(() => {
if (!internalValue) { if (!internalValue) {
return null; return null;

View File

@ -1,4 +1,8 @@
import controlComponent from './DateTimeControl'; import formatDate from 'date-fns/format';
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import controlComponent, { localToUTC } from './DateTimeControl';
import previewComponent from './DateTimePreview'; import previewComponent from './DateTimePreview';
import schema from './schema'; import schema from './schema';
@ -11,6 +15,38 @@ const DateTimeWidget = (): WidgetParam<string, DateTimeField> => {
previewComponent, previewComponent,
options: { options: {
schema, schema,
getDefaultValue: (defaultValue: string | null | undefined, field: DateTimeField) => {
if (isNotNullish(defaultValue)) {
return defaultValue;
}
const timezoneOffset = new Date().getTimezoneOffset() * 60000;
const format = field.format;
// dateFormat and timeFormat are strictly for modifying input field with the date/time pickers
const dateFormat: string | boolean = field.date_format ?? true;
// show time-picker? false hides it, true shows it using default format
const timeFormat: string | boolean = field.time_format ?? true;
let inputFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX";
if (typeof dateFormat === 'string' || typeof timeFormat === 'string') {
const formatParts: string[] = [];
if (typeof dateFormat === 'string' && isNotEmpty(dateFormat)) {
formatParts.push(dateFormat);
}
if (typeof timeFormat === 'string' && isNotEmpty(timeFormat)) {
formatParts.push(timeFormat);
}
if (formatParts.length > 0) {
inputFormat = formatParts.join(' ');
}
}
const today = field.picker_utc ? localToUTC(new Date(), timezoneOffset) : new Date();
return format ? formatDate(today, format) : formatDate(today, inputFormat);
},
}, },
}; };
}; };

View File

@ -4,5 +4,6 @@ export default {
date_format: { oneOf: [{ type: 'string' }, { type: 'boolean' }] }, date_format: { oneOf: [{ type: 'string' }, { type: 'boolean' }] },
time_format: { oneOf: [{ type: 'string' }, { type: 'boolean' }] }, time_format: { oneOf: [{ type: 'string' }, { type: 'boolean' }] },
picker_utc: { type: 'boolean' }, picker_utc: { type: 'boolean' },
default: { type: 'string' },
}, },
}; };

View File

@ -1,5 +1,16 @@
export default { export default {
properties: { properties: {
allow_multiple: { type: 'boolean' }, allow_multiple: { type: 'boolean' },
default: {
oneOf: [
{ type: 'string' },
{
type: 'array',
items: {
type: 'string',
},
},
],
},
}, },
}; };

View File

@ -8,14 +8,16 @@ import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import ObjectWidgetTopBar from '@staticcms/core/components/UI/ObjectWidgetTopBar'; import ObjectWidgetTopBar from '@staticcms/core/components/UI/ObjectWidgetTopBar';
import Outline from '@staticcms/core/components/UI/Outline'; import Outline from '@staticcms/core/components/UI/Outline';
import { borders, effects, lengths, shadows } from '@staticcms/core/components/UI/styles'; import { borders, effects, lengths, shadows } from '@staticcms/core/components/UI/styles';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert'; import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert';
import useUUID from '@staticcms/core/lib/hooks/useUUID'; import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { basename, transientOptions } from '@staticcms/core/lib/util'; import { basename, transientOptions } from '@staticcms/core/lib/util';
import { isEmpty } from '@staticcms/core/lib/util/string.util'; import { isEmpty } from '@staticcms/core/lib/util/string.util';
import type { import type {
Collection,
Entry,
FileOrImageField, FileOrImageField,
GetAssetFunction,
WidgetControlProps, WidgetControlProps,
} from '@staticcms/core/interface'; } from '@staticcms/core/interface';
import type { FC, MouseEvent, MouseEventHandler } from 'react'; import type { FC, MouseEvent, MouseEventHandler } from 'react';
@ -102,11 +104,16 @@ const StyledImage = styled('img')`
`; `;
interface ImageProps { interface ImageProps {
src: string; value: string;
collection: Collection<FileOrImageField>;
field: FileOrImageField;
entry: Entry;
} }
const Image: FC<ImageProps> = ({ src }) => { const Image: FC<ImageProps> = ({ value, collection, field, entry }) => {
return <StyledImage key="image" role="presentation" src={src} />; const assetSource = useMediaAsset(value, collection, field, entry);
return <StyledImage key="image" role="presentation" src={assetSource} />;
}; };
interface SortableImageButtonsProps { interface SortableImageButtonsProps {
@ -129,34 +136,25 @@ const SortableImageButtons: FC<SortableImageButtonsProps> = ({ onRemove, onRepla
interface SortableImageProps { interface SortableImageProps {
itemValue: string; itemValue: string;
getAsset: GetAssetFunction<FileOrImageField>; collection: Collection<FileOrImageField>;
field: FileOrImageField; field: FileOrImageField;
entry: Entry;
onRemove: MouseEventHandler; onRemove: MouseEventHandler;
onReplace: MouseEventHandler; onReplace: MouseEventHandler;
} }
const SortableImage: FC<SortableImageProps> = ({ const SortableImage: FC<SortableImageProps> = ({
itemValue, itemValue,
getAsset, collection,
field, field,
entry,
onRemove, onRemove,
onReplace, onReplace,
}: SortableImageProps) => { }: SortableImageProps) => {
const [assetSource, setAssetSource] = useState('');
useEffect(() => {
const getImage = async () => {
const asset = (await getAsset(itemValue, field))?.toString() ?? '';
setAssetSource(asset);
};
getImage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemValue]);
return ( return (
<div> <div>
<ImageWrapper key="image-wrapper" $sortable> <ImageWrapper key="image-wrapper" $sortable>
<Image key="image" src={assetSource} /> <Image key="image" value={itemValue} collection={collection} field={field} entry={entry} />
</ImageWrapper> </ImageWrapper>
<SortableImageButtons <SortableImageButtons
key="image-buttons" key="image-buttons"
@ -212,12 +210,13 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
const FileControl: FC<WidgetControlProps<string | string[], FileOrImageField>> = memo( const FileControl: FC<WidgetControlProps<string | string[], FileOrImageField>> = memo(
({ ({
value, value,
collection,
field, field,
entry,
onChange, onChange,
openMediaLibrary, openMediaLibrary,
clearMediaControl, clearMediaControl,
removeMediaControl, removeMediaControl,
getAsset,
hasErrors, hasErrors,
t, t,
}) => { }) => {
@ -343,23 +342,6 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
); );
}, []); }, []);
const [assetSource, setAssetSource] = useState('');
useEffect(() => {
if (Array.isArray(internalValue)) {
return;
}
const getImage = async () => {
const newValue = (await getAsset(internalValue, field))?.toString() ?? '';
if (newValue !== internalValue) {
setAssetSource(newValue);
}
};
getImage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [internalValue]);
const renderedImagesLinks = useMemo(() => { const renderedImagesLinks = useMemo(() => {
if (forImage) { if (forImage) {
if (!internalValue) { if (!internalValue) {
@ -373,8 +355,9 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
<SortableImage <SortableImage
key={`item-${itemValue}`} key={`item-${itemValue}`}
itemValue={itemValue} itemValue={itemValue}
getAsset={getAsset} collection={collection}
field={field} field={field}
entry={entry}
onRemove={onRemoveOne(index)} onRemove={onRemoveOne(index)}
onReplace={onReplaceOne(index)} onReplace={onReplaceOne(index)}
/> />
@ -385,7 +368,13 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
return ( return (
<ImageWrapper key="single-image-wrapper"> <ImageWrapper key="single-image-wrapper">
<Image key="single-image" src={assetSource} /> <Image
key="single-image"
value={internalValue}
collection={collection}
field={field}
entry={entry}
/>
</ImageWrapper> </ImageWrapper>
); );
} }
@ -403,7 +392,7 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
} }
return <FileLinks key="single-file-links">{renderFileLink(internalValue)}</FileLinks>; return <FileLinks key="single-file-links">{renderFileLink(internalValue)}</FileLinks>;
}, [assetSource, field, getAsset, internalValue, onRemoveOne, onReplaceOne, renderFileLink]); }, [collection, entry, field, internalValue, onRemoveOne, onReplaceOne, renderFileLink]);
const content = useMemo(() => { const content = useMemo(() => {
const subject = forImage ? 'image' : 'file'; const subject = forImage ? 'image' : 'file';

View File

@ -1,5 +1,16 @@
export default { export default {
properties: { properties: {
allow_multiple: { type: 'boolean' }, allow_multiple: { type: 'boolean' },
default: {
oneOf: [
{ type: 'string' },
{
type: 'array',
items: {
type: 'string',
},
},
],
},
}, },
}; };

View File

@ -311,11 +311,17 @@ const ListControl: FC<WidgetControlProps<ObjectValue[], ListField>> = ({
<DndContext key="dnd-context" onDragEnd={handleDragEnd}> <DndContext key="dnd-context" onDragEnd={handleDragEnd}>
<SortableContext items={keys}> <SortableContext items={keys}>
<StyledSortableList $collapsed={collapsed}> <StyledSortableList $collapsed={collapsed}>
{internalValue.map((item, index) => ( {internalValue.map((item, index) => {
const key = keys[index];
if (!key) {
return null;
}
return (
<SortableItem <SortableItem
index={index} index={index}
key={keys[index]} key={key}
id={keys[index]} id={key}
item={item} item={item}
valueType={valueType} valueType={valueType}
handleRemove={handleRemove} handleRemove={handleRemove}
@ -331,7 +337,8 @@ const ListControl: FC<WidgetControlProps<ObjectValue[], ListField>> = ({
value={item as Record<string, ObjectValue>} value={item as Record<string, ObjectValue>}
i18n={i18n} i18n={i18n}
/> />
))} );
})}
</StyledSortableList> </StyledSortableList>
</SortableContext> </SortableContext>
</DndContext> </DndContext>

View File

@ -6,7 +6,7 @@ import type { MapField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react'; import type { FC } from 'react';
const MapPreview: FC<WidgetPreviewProps<string, MapField>> = ({ value }) => { const MapPreview: FC<WidgetPreviewProps<string, MapField>> = ({ value }) => {
return <WidgetPreviewContainer>{value ? value.toString() : null}</WidgetPreviewContainer>; return <WidgetPreviewContainer>{value}</WidgetPreviewContainer>;
}; };
export default MapPreview; export default MapPreview;

View File

@ -58,7 +58,7 @@ const MarkdownControl: FC<WidgetControlProps<string, MarkdownField>> = ({
const handleOnChange = useCallback( const handleOnChange = useCallback(
(slateValue: MdValue) => { (slateValue: MdValue) => {
const newValue = slateValue.map(v => serialize(v as BlockType | LeafType)).join('\n'); const newValue = slateValue.map(v => serialize(v as BlockType | LeafType)).join('\n');
console.log('[Plate] slateValue', slateValue, 'newMarkdownValue', newValue); // console.log('[Plate] slateValue', slateValue, 'newMarkdownValue', newValue);
if (newValue !== internalValue) { if (newValue !== internalValue) {
setInternalValue(newValue); setInternalValue(newValue);
onChange(newValue); onChange(newValue);
@ -73,7 +73,7 @@ const MarkdownControl: FC<WidgetControlProps<string, MarkdownField>> = ({
const [slateValue, loaded] = useMarkdownToSlate(internalValue); const [slateValue, loaded] = useMarkdownToSlate(internalValue);
console.log('[Plate] slateValue', slateValue); // console.log('[Plate] slateValue', slateValue);
return useMemo( return useMemo(
() => ( () => (

View File

@ -1,5 +1,6 @@
import controlComponent from './MarkdownControl'; import controlComponent from './MarkdownControl';
import previewComponent from './MarkdownPreview'; import previewComponent from './MarkdownPreview';
import schema from './schema';
import type { MarkdownField, WidgetParam } from '@staticcms/core/interface'; import type { MarkdownField, WidgetParam } from '@staticcms/core/interface';
@ -8,10 +9,17 @@ const MarkdownWidget = (): WidgetParam<string, MarkdownField> => {
name: 'markdown', name: 'markdown',
controlComponent, controlComponent,
previewComponent, previewComponent,
options: {
schema,
},
}; };
}; };
export * from './plate'; export * from './plate';
export { controlComponent as MarkdownControl, previewComponent as MarkdownPreview }; export {
controlComponent as MarkdownControl,
previewComponent as MarkdownPreview,
schema as MarkdownSchema,
};
export default MarkdownWidget; export default MarkdownWidget;

View File

@ -55,6 +55,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react';
import { DndProvider } from 'react-dnd'; import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { BalloonToolbar } from './components/balloon-toolbar'; import { BalloonToolbar } from './components/balloon-toolbar';
import { BlockquoteElement } from './components/nodes/blockquote'; import { BlockquoteElement } from './components/nodes/blockquote';
import { CodeBlockElement } from './components/nodes/code-block'; import { CodeBlockElement } from './components/nodes/code-block';
@ -236,11 +237,14 @@ const PlateEditor: FC<PlateEditorProps> = ({
[components], [components],
); );
const id = useUUID();
return useMemo( return useMemo(
() => ( () => (
<StyledPlateEditor> <StyledPlateEditor>
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<PlateProvider<MdValue> <PlateProvider<MdValue>
id={id}
key="plate-provider" key="plate-provider"
initialValue={initialValue} initialValue={initialValue}
plugins={plugins} plugins={plugins}
@ -258,6 +262,7 @@ const PlateEditor: FC<PlateEditorProps> = ({
<div key="editor-wrapper" ref={editorContainerRef} style={styles.container}> <div key="editor-wrapper" ref={editorContainerRef} style={styles.container}>
<Plate <Plate
key="editor" key="editor"
id={id}
editableProps={{ editableProps={{
...editableProps, ...editableProps,
onFocus: handleOnFocus, onFocus: handleOnFocus,

View File

@ -0,0 +1,5 @@
export default {
properties: {
default: { type: 'string' },
},
};

View File

@ -4,5 +4,6 @@ export default {
value_type: { type: 'string' }, value_type: { type: 'string' },
min: { type: 'number' }, min: { type: 'number' },
max: { type: 'number' }, max: { type: 'number' },
default: { type: 'number' },
}, },
}; };

View File

@ -1,3 +1,4 @@
import * as fuzzy from 'fuzzy';
import Autocomplete from '@mui/material/Autocomplete'; import Autocomplete from '@mui/material/Autocomplete';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
@ -6,7 +7,13 @@ import get from 'lodash/get';
import uniqBy from 'lodash/uniqBy'; import uniqBy from 'lodash/uniqBy';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { QUERY_SUCCESS } from '@staticcms/core/actions/search'; import {
currentBackend,
expandSearchEntries,
getEntryField,
mergeExpandedEntries,
sortByScore,
} from '@staticcms/core/backend';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util'; import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import { import {
addFileTemplateFields, addFileTemplateFields,
@ -14,7 +21,10 @@ import {
expandPath, expandPath,
extractTemplateVars, extractTemplateVars,
} from '@staticcms/core/lib/widgets/stringTemplate'; } from '@staticcms/core/lib/widgets/stringTemplate';
import { useAppSelector } from '@staticcms/core/store/hooks';
import { selectCollection } from '@staticcms/core/reducers/collections';
import type { FilterOptionsState } from '@mui/material/useAutocomplete';
import type { import type {
Entry, Entry,
EntryData, EntryData,
@ -106,11 +116,10 @@ function getSelectedValue(
} }
const RelationControl: FC<WidgetControlProps<string | string[], RelationField>> = ({ const RelationControl: FC<WidgetControlProps<string | string[], RelationField>> = ({
path,
value, value,
field, field,
onChange, onChange,
query, config,
locale, locale,
label, label,
hasErrors, hasErrors,
@ -118,6 +127,12 @@ const RelationControl: FC<WidgetControlProps<string | string[], RelationField>>
const [internalValue, setInternalValue] = useState(value); const [internalValue, setInternalValue] = useState(value);
const [initialOptions, setInitialOptions] = useState<HitOption[]>([]); const [initialOptions, setInitialOptions] = useState<HitOption[]>([]);
const searchCollectionSelector = useMemo(
() => selectCollection(field.collection),
[field.collection],
);
const searchCollection = useAppSelector(searchCollectionSelector);
const isMultiple = useMemo(() => { const isMultiple = useMemo(() => {
return field.multiple ?? false; return field.multiple ?? false;
}, [field.multiple]); }, [field.multiple]);
@ -183,6 +198,7 @@ const RelationControl: FC<WidgetControlProps<string | string[], RelationField>>
); );
const [options, setOptions] = useState<HitOption[]>([]); const [options, setOptions] = useState<HitOption[]>([]);
const [entries, setEntries] = useState<Entry[]>([]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const valueNotEmpty = useMemo( const valueNotEmpty = useMemo(
() => (Array.isArray(internalValue) ? internalValue.length > 0 : isNotEmpty(internalValue)), () => (Array.isArray(internalValue) ? internalValue.length > 0 : isNotEmpty(internalValue)),
@ -193,33 +209,45 @@ const RelationControl: FC<WidgetControlProps<string | string[], RelationField>>
[open, valueNotEmpty, options.length], [open, valueNotEmpty, options.length],
); );
const filterOptions = useCallback(
(_options: HitOption[], { inputValue }: FilterOptionsState<HitOption>) => {
const searchFields = field.search_fields;
const limit = field.options_length || 20;
const expandedEntries = expandSearchEntries(entries, searchFields);
let hits = fuzzy
.filter(inputValue, expandedEntries, {
extract: entry => {
return getEntryField(entry.field, entry);
},
})
.sort(sortByScore)
.map(f => f.original);
if (limit !== undefined && limit > 0) {
hits = hits.slice(0, limit);
}
return parseHitOptions(mergeExpandedEntries(hits));
},
[entries, field.options_length, field.search_fields, parseHitOptions],
);
useEffect(() => { useEffect(() => {
let alive = true; if (!loading || !searchCollection) {
if (!loading) { return;
return undefined;
} }
(async () => { const getOptions = async () => {
const collection = field.collection; const backend = currentBackend(config);
const optionsLength = field.options_length || 20;
const searchFieldsArray = field.search_fields;
const file = field.file;
const response = await query(path, collection, searchFieldsArray, '', file, optionsLength); const options = await backend.listAllEntries(searchCollection);
if (alive) { setEntries(options);
if (response?.type === QUERY_SUCCESS) { setOptions(parseHitOptions(options));
const hits = response.payload.hits ?? [];
const options = parseHitOptions(hits);
setOptions(uniqOptions(initialOptions, options));
}
}
})();
return () => {
alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [field.collection, field.file, field.options_length, field.search_fields, loading]); getOptions();
}, [searchCollection, config, loading, parseHitOptions]);
const uniqueOptions = uniqOptions(initialOptions, options); const uniqueOptions = uniqOptions(initialOptions, options);
const selectedValue = getSelectedValue(internalValue, uniqueOptions, isMultiple); const selectedValue = getSelectedValue(internalValue, uniqueOptions, isMultiple);
@ -230,6 +258,7 @@ const RelationControl: FC<WidgetControlProps<string | string[], RelationField>>
disablePortal disablePortal
options={uniqueOptions} options={uniqueOptions}
fullWidth fullWidth
filterOptions={filterOptions}
renderInput={params => ( renderInput={params => (
<TextField <TextField
key="relation-control-input" key="relation-control-input"

View File

@ -9,6 +9,17 @@ export default {
max: { type: 'integer' }, max: { type: 'integer' },
display_fields: { type: 'array', minItems: 1, items: { type: 'string' } }, display_fields: { type: 'array', minItems: 1, items: { type: 'string' } },
options_length: { type: 'integer' }, options_length: { type: 'integer' },
default: {
oneOf: [
{ type: 'string' },
{
type: 'array',
items: {
type: 'string',
},
},
],
},
}, },
oneOf: [ oneOf: [
{ {

View File

@ -3,6 +3,18 @@ export default {
multiple: { type: 'boolean' }, multiple: { type: 'boolean' },
min: { type: 'integer' }, min: { type: 'integer' },
max: { type: 'integer' }, max: { type: 'integer' },
default: {
oneOf: [
{ type: 'string' },
{ type: 'number' },
{
type: 'array',
items: {
oneOf: [{ type: 'string' }, { type: 'number' }],
},
},
],
},
options: { options: {
type: 'array', type: 'array',
items: { items: {

View File

@ -1,3 +1,4 @@
import schema from './schema';
import controlComponent from './StringControl'; import controlComponent from './StringControl';
import previewComponent from './StringPreview'; import previewComponent from './StringPreview';
@ -8,9 +9,16 @@ const StringWidget = (): WidgetParam<string, StringOrTextField> => {
name: 'string', name: 'string',
controlComponent, controlComponent,
previewComponent, previewComponent,
options: {
schema,
},
}; };
}; };
export { controlComponent as StringControl, previewComponent as StringPreview }; export {
controlComponent as StringControl,
previewComponent as StringPreview,
schema as StringSchema,
};
export default StringWidget; export default StringWidget;

View File

@ -0,0 +1,5 @@
export default {
properties: {
default: { type: 'string' },
},
};

View File

@ -1,3 +1,4 @@
import schema from './schema';
import controlComponent from './TextControl'; import controlComponent from './TextControl';
import previewComponent from './TextPreview'; import previewComponent from './TextPreview';
@ -8,9 +9,12 @@ const TextWidget = (): WidgetParam<string, StringOrTextField> => {
name: 'text', name: 'text',
controlComponent, controlComponent,
previewComponent, previewComponent,
options: {
schema,
},
}; };
}; };
export { controlComponent as TextControl, previewComponent as TextPreview }; export { controlComponent as TextControl, previewComponent as TextPreview, schema as TextSchema };
export default TextWidget; export default TextWidget;

View File

@ -0,0 +1,5 @@
export default {
properties: {
default: { type: 'string' },
},
};