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
fields:
- name: required
label: 'Required Validation'
label: Required Validation
widget: boolean
- name: with_default
label: Required With Default
widget: boolean
default: true
- name: pattern
label: 'Pattern Validation'
label: Pattern Validation
widget: boolean
pattern: ['true', 'Must be true']
required: false
@ -111,29 +115,58 @@ collections:
description: Code widget
fields:
- name: required
label: 'Required Validation'
label: Required Validation
widget: code
- name: with_default
label: Required With Default
widget: code
default: '<div>Some html!</div>'
- name: pattern
label: 'Pattern Validation'
label: Pattern Validation
widget: code
pattern: ['.{12,}', 'Must have at least 12 characters']
allow_input: true
required: false
- name: language
label: 'Language Selection'
label: Language Selection
widget: code
allow_language_selection: true
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
label: Color
file: _widgets/color.json
description: Color widget
fields:
- name: required
label: 'Required Validation'
label: Required Validation
widget: color
- name: with_default
label: Required With Default
widget: color
default: '#2121c5'
- name: pattern
label: 'Pattern Validation'
label: Pattern Validation
widget: color
pattern: ['^#([0-9a-fA-F]{3})(?:[0-9a-fA-F]{3})?$', 'Must be a valid hex code']
allow_input: true
@ -143,6 +176,12 @@ collections:
widget: color
enable_alpha: true
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
label: DateTime
file: _widgets/datetime.json
@ -166,33 +205,59 @@ collections:
date_format: 'MMM d, yyyy'
time_format: 'h:mm aaa'
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
label: Date
widget: datetime
format: 'MMM d, yyyy'
date_format: 'MMM d, yyyy'
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
label: Time
widget: datetime
format: 'h:mm aaa'
time_format: 'h:mm aaa'
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
label: File
file: _widgets/file.json
description: File widget
fields:
- name: required
label: 'Required Validation'
label: Required Validation
widget: file
- name: with_default
label: Required With Default
widget: file
default: /assets/uploads/moby-dick.jpg
- name: pattern
label: 'Pattern Validation'
label: Pattern Validation
widget: file
pattern: ['\.pdf', 'Must be a pdf']
required: false
- name: choose_url
label: 'Choose URL'
label: Choose URL
widget: file
required: false
media_library:
@ -203,15 +268,19 @@ collections:
description: Image widget
fields:
- name: required
label: 'Required Validation'
label: Required Validation
widget: image
- name: with_default
label: Required With Default
widget: image
default: /assets/uploads/moby-dick.jpg
- name: pattern
label: 'Pattern Validation'
label: Pattern Validation
widget: image
pattern: ['\.png', 'Must be a png']
required: false
- name: choose_url
label: 'Choose URL'
label: Choose URL
widget: image
required: false
media_library:
@ -222,7 +291,7 @@ collections:
description: List widget
fields:
- name: list
label: List
label: Required List
widget: list
fields:
- label: Name
@ -232,6 +301,32 @@ collections:
- label: Description
name: description
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
label: Typed List
widget: list
@ -268,7 +363,60 @@ collections:
widget: datetime
- label: 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
- 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
name: type_3_object
widget: object
@ -285,10 +433,14 @@ collections:
description: Map widget
fields:
- name: required
label: 'Required Validation'
label: Required Validation
widget: map
- name: with_default
label: Required With Default
widget: map
default: '{ "type": "Point", "coordinates": [-73.9852661, 40.7478738] }'
- name: pattern
label: 'Pattern Validation'
label: Pattern Validation
widget: map
pattern: ['\[-([7-9][0-9]|1[0-2][0-9])\.', 'Must be between latitude -70 and -129']
required: false
@ -298,10 +450,14 @@ collections:
description: Markdown widget
fields:
- name: required
label: 'Required Validation'
label: Required Validation
widget: markdown
- name: with_default
label: Required With Default
widget: markdown
default: Default **markdown** value
- name: pattern
label: 'Pattern Validation'
label: Pattern Validation
widget: markdown
pattern: ['# [a-zA-Z0-9]+', 'Must have a header']
required: false
@ -311,26 +467,30 @@ collections:
description: Number widget
fields:
- name: required
label: 'Required Validation'
label: Required Validation
widget: number
- name: with_default
label: Required With Default
widget: number
default: 5
- name: min
label: 'Min Validation'
label: Min Validation
widget: number
min: 5
required: false
- name: max
label: 'Max Validation'
label: Max Validation
widget: number
max: 10
required: false
- name: min_and_max
label: 'Min and Max Validation'
label: Min and Max Validation
widget: number
min: 5
max: 10
required: false
- name: pattern
label: 'Pattern Validation'
label: Pattern Validation
widget: number
pattern: ['[0-9]{3,}', 'Must be at least 3 digits']
required: false
@ -346,12 +506,28 @@ collections:
- label: Number of posts on frontpage
name: front_limit
widget: number
- label: Default Author
- label: Author
name: author
widget: string
- label: Default Thumbnail
- label: Thumbnail
name: thumb
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
name: optional
widget: object
@ -361,11 +537,31 @@ collections:
name: front_limit
widget: number
required: false
- label: Default Author
- label: Author
name: author
widget: string
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
widget: image
required: false
@ -385,6 +581,18 @@ collections:
- title
- body
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
name: optional
widget: relation
@ -410,6 +618,22 @@ collections:
- title
- body
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
label: Select
file: _widgets/select.json
@ -422,6 +646,14 @@ collections:
- a
- b
- c
- label: Required With Default
name: with_default
widget: select
default: b
options:
- a
- b
- c
- label: Pattern Validation
name: pattern
widget: select
@ -431,13 +663,39 @@ collections:
- c
pattern: ['[a-b]', 'Must be a or b']
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
name: value_and_label
widget: select
options:
- value: a
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
- value: c
label: And one more fancy label
@ -451,6 +709,19 @@ collections:
pattern: ['[a-b]', 'Must be a or b']
multiple: true
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
name: value_and_label_multiple
widget: select
@ -468,10 +739,14 @@ collections:
description: String widget
fields:
- name: required
label: 'Required Validation'
label: Required Validation
widget: string
- name: with_default
label: Required With Default
widget: string
default: Default value
- name: pattern
label: 'Pattern Validation'
label: Pattern Validation
widget: string
pattern: ['.{12,}', 'Must have at least 12 characters']
required: false
@ -483,6 +758,10 @@ collections:
- name: required
label: 'Required Validation'
widget: text
- name: with_default
label: Required With Default
widget: text
default: Default value
- name: pattern
label: 'Pattern Validation'
widget: text
@ -778,7 +1057,7 @@ collections:
widget: number
- label: Markdown
name: markdown
widget: text
widget: markdown
- label: Datetime
name: datetime
widget: datetime
@ -817,7 +1096,7 @@ collections:
widget: number
- label: Markdown
name: markdown
widget: text
widget: markdown
- label: Datetime
name: datetime
widget: datetime
@ -870,7 +1149,7 @@ collections:
widget: datetime
- label: Markdown
name: markdown
widget: text
widget: markdown
- label: Type 3 Object
name: type_3_object
widget: object

View File

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

View File

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

View File

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

View File

@ -46,6 +46,7 @@ import createEntry from './valueObjects/createEntry';
import type {
BackendClass,
BackendInitializer,
BaseField,
Collection,
CollectionFile,
Config,
@ -60,6 +61,7 @@ import type {
ImplementationEntry,
SearchQueryResponse,
SearchResponse,
UnknownField,
User,
} from './interface';
import type { AllowedEvent } from './lib/registry';
@ -109,7 +111,7 @@ function getEntryBackupKey(collectionName?: string, slug?: string) {
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);
if (value) {
return String(value);
@ -210,9 +212,15 @@ export function mergeExpandedEntries(entries: (Entry & { field: string })[]) {
return Object.values(merged);
}
function sortByScore(a: fuzzy.FilterResult<Entry>, b: fuzzy.FilterResult<Entry>) {
if (a.score > b.score) return -1;
if (a.score < b.score) return 1;
export function sortByScore(a: fuzzy.FilterResult<Entry>, b: fuzzy.FilterResult<Entry>) {
if (a.score > b.score) {
return -1;
}
if (a.score < b.score) {
return 1;
}
return 0;
}
@ -478,16 +486,16 @@ export class Backend<BC extends BackendClass = BackendClass> {
// repeats the process. Once there is no available "next" action, it
// returns all the collected entries. Used to retrieve all entries
// 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) {
const depth = collectionDepth(collection);
const extension = selectFolderEntryExtension(collection);
const depth = collectionDepth(collection as Collection);
const extension = selectFolderEntryExtension(collection as Collection);
return this.implementation
.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;
let { cursor } = response;
while (cursor && cursor.actions?.has('next')) {
@ -557,14 +565,14 @@ export class Backend<BC extends BackendClass = BackendClass> {
return { entries: hits, pagination: 1 };
}
async query(
collection: Collection,
async query<T extends BaseField = UnknownField>(
collection: Collection<T>,
searchFields: string[],
searchTerm: string,
file?: string,
limit?: number,
): Promise<SearchQueryResponse> {
let entries = await this.listAllEntries(collection);
let entries = await this.listAllEntries(collection as Collection);
if (file) {
entries = entries.filter(e => e.slug === file);
}
@ -1014,11 +1022,11 @@ export function resolveBackend(config?: Config) {
export const currentBackend = (function () {
let backend: Backend;
return (config: Config) => {
return <T extends BaseField = UnknownField>(config: Config<T>) => {
if (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 { resolveWidget } from '@staticcms/core/lib/registry';
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 { selectFieldErrors } from '@staticcms/core/reducers/entryDraft';
import { selectIsLoadingAsset } from '@staticcms/core/reducers/medias';
@ -213,8 +214,32 @@ const EditorControl = ({
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(() => {
if (!collection || !entry || !config) {
if (!collection || !entry || !config || field.widget === 'hidden') {
return null;
}
@ -222,7 +247,7 @@ const EditorControl = ({
<ControlContainer $isHidden={isHidden}>
<>
{createElement(widget.control, {
key: id,
key: `${id}-${version}`,
collection,
config,
entry,

View File

@ -188,31 +188,29 @@ const EditorControlPane = ({
/>
</LocaleRowWrapper>
) : null}
{fields
.filter(f => f.widget !== 'hidden')
.map(field => {
const isTranslatable = isFieldTranslatable(field, locale, i18n?.defaultLocale);
const isDuplicate = isFieldDuplicate(field, locale, i18n?.defaultLocale);
const isHidden = isFieldHidden(field, locale, i18n?.defaultLocale);
const key = i18n ? `field-${locale}_${field.name}` : `field-${field.name}`;
{fields.map(field => {
const isTranslatable = isFieldTranslatable(field, locale, i18n?.defaultLocale);
const isDuplicate = isFieldDuplicate(field, locale, i18n?.defaultLocale);
const isHidden = isFieldHidden(field, locale, i18n?.defaultLocale);
const key = i18n ? `field-${locale}_${field.name}` : `field-${field.name}`;
return (
<EditorControl
key={key}
field={field}
value={getFieldValue(field, entry, isTranslatable, locale)}
fieldsErrors={fieldsErrors}
submitted={submitted}
isDisabled={isDuplicate}
isHidden={isHidden}
isFieldDuplicate={field => isFieldDuplicate(field, locale, i18n?.defaultLocale)}
isFieldHidden={field => isFieldHidden(field, locale, i18n?.defaultLocale)}
locale={locale}
parentPath=""
i18n={i18n}
/>
);
})}
return (
<EditorControl
key={key}
field={field}
value={getFieldValue(field, entry, isTranslatable, locale)}
fieldsErrors={fieldsErrors}
submitted={submitted}
isDisabled={isDuplicate}
isHidden={isHidden}
isFieldDuplicate={field => isFieldDuplicate(field, locale, i18n?.defaultLocale)}
isFieldHidden={field => isFieldHidden(field, locale, i18n?.defaultLocale)}
locale={locale}
parentPath=""
i18n={i18n}
/>
);
})}
</ControlPaneContainer>
);
};

View File

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

View File

@ -188,7 +188,7 @@ function isReactFragment(value: any): value is ReactFragment {
function getWidget(
config: Config,
field: RenderedField,
field: RenderedField<Field>,
collection: Collection,
value: ValueOrNestedValue | ReactNode,
entry: Entry,
@ -202,6 +202,10 @@ function getWidget(
const widget = resolveWidget(field.widget);
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.
*/
@ -214,7 +218,12 @@ function getWidget(
config={config}
collection={collection}
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
}

View File

@ -35,21 +35,21 @@ export default function addExtensions() {
registerBackend('test-repo', TestBackend);
registerBackend('proxy', ProxyBackend);
registerWidget([
StringWidget(),
NumberWidget(),
TextWidget(),
ImageWidget(),
FileWidget(),
SelectWidget(),
MarkdownWidget(),
ListWidget(),
ObjectWidget(),
RelationWidget(),
BooleanWidget(),
MapWidget(),
DateTimeWidget(),
CodeWidget(),
ColorStringWidget(),
DateTimeWidget(),
FileWidget(),
ImageWidget(),
ListWidget(),
MapWidget(),
MarkdownWidget(),
NumberWidget(),
ObjectWidget(),
RelationWidget(),
SelectWidget(),
StringWidget(),
TextWidget(),
]);
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> {
validator?: Widget<T, F>['validator'];
getValidValue?: Widget<T, F>['getValidValue'];
getDefaultValue?: Widget<T, F>['getDefaultValue'];
schema?: Widget<T, F>['schema'];
}
@ -321,6 +322,7 @@ export interface Widget<T = unknown, F extends BaseField = UnknownField> {
preview?: WidgetPreviewComponent<T, F>;
validator: FieldValidationMethod<T, F>;
getValidValue: (value: T | undefined | null) => T | undefined | null;
getDefaultValue?: (defaultValue: T | undefined | null, field: F) => T;
schema?: PropertiesSchema<unknown>;
}

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { getAsset } from '@staticcms/core/actions/media';
import { useAppDispatch } from '@staticcms/core/store/hooks';
import { isNotEmpty } from '../util/string.util';
import type { Collection, Entry, FileOrImageField, MarkdownField } from '@staticcms/core/interface';
@ -23,5 +24,5 @@ export default function useMediaAsset<T extends FileOrImageField | MarkdownField
fetchMedia();
}, [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,
validator = () => false,
getValidValue = (value: T | null | undefined) => value,
getDefaultValue,
}: WidgetOptions<T, F> = {},
): void {
if (Array.isArray(name)) {
@ -162,6 +163,7 @@ export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
preview: preview as Widget['preview'],
validator: validator as Widget['validator'],
getValidValue: getValidValue as Widget['getValidValue'],
getDefaultValue: getDefaultValue as Widget['getDefaultValue'],
schema,
};
}
@ -173,6 +175,7 @@ export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
options: {
validator = () => false,
getValidValue = (value: T | undefined | null) => value,
getDefaultValue,
schema,
} = {},
} = name;
@ -190,6 +193,7 @@ export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
preview,
validator,
getValidValue,
getDefaultValue,
schema,
} as unknown as Widget;
} else {

View File

@ -136,21 +136,17 @@ export function selectEntryCollectionTitle(collection: Collection, entry: Entry)
return result;
}
export function selectDefaultSortableFields(
collection: Collection,
backend: Backend,
hasIntegration: boolean,
) {
export function selectDefaultSortableFields(collection: Collection, backend: Backend) {
let defaultSortable = SORTABLE_FIELDS.map((type: string) => {
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
return COMMIT_AUTHOR;
}
return field;
}).filter(Boolean);
if (backend.isGitBackend() && !hasIntegration) {
if (backend.isGitBackend()) {
// always have commit date by default
defaultSortable = [COMMIT_DATE, ...defaultSortable];
}

View File

@ -2,6 +2,7 @@ import { CONFIG_SUCCESS } from '../actions/config';
import type { ConfigAction } from '../actions/config';
import type { Collection, Collections } from '../interface';
import type { RootState } from '../store';
export type CollectionsState = Collections;
@ -25,3 +26,7 @@ function 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 entryDraft from './entryDraft';
import globalUI from './globalUI';
import integrations, * as fromIntegrations from './integrations';
import mediaLibrary from './mediaLibrary';
import medias from './medias';
import scroll from './scroll';
@ -14,7 +13,6 @@ import status from './status';
import type { Collection } from '../interface';
import type { RootState } from '../store';
import type { IntegrationHooks } from './integrations';
const reducers = {
auth,
@ -24,7 +22,6 @@ const reducers = {
entries,
entryDraft,
globalUI,
integrations,
mediaLibrary,
medias,
scroll,
@ -55,11 +52,3 @@ export function selectSearchedEntries(state: RootState, availableCollections: st
.filter(entryId => availableCollections.indexOf(entryId!.collection) !== -1)
.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 FormControlLabel from '@mui/material/FormControlLabel';
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 { ChangeEvent, FC } from 'react';
@ -22,15 +22,6 @@ const BooleanControl: FC<WidgetControlProps<boolean, BooleanField>> = ({
[onChange],
);
useEffect(() => {
if (typeof internalValue !== 'boolean') {
setInternalValue(false);
setTimeout(() => {
onChange(false);
});
}
}, [internalValue, onChange]);
return (
<FormControlLabel
key="boolean-field-label"

View File

@ -1,4 +1,5 @@
import controlComponent from './BooleanControl';
import schema from './schema';
import type { BooleanField, WidgetParam } from '@staticcms/core/interface';
@ -6,9 +7,15 @@ const BooleanWidget = (): WidgetParam<boolean, BooleanField> => {
return {
name: 'boolean',
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;

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 { loadLanguage } from '@uiw/codemirror-extensions-langs';
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 Outline from '@staticcms/core/components/UI/Outline';
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 languages from './data/languages';
import SettingsButton from './SettingsButton';
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 { 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 [internalValue, setInternalValue] = useState(value ?? '');
const [lang, setLang] = useState(() => {
return valueIsMap && typeof internalValue !== 'string'
? internalValue && internalValue[keys.lang]
: field.default_language ?? '';
});
const [lang, setLang] = useState<ProcessedCodeLanguage | null>(null);
const [collapsed, setCollapsed] = useState(false);
const [hasFocus, setHasFocus] = useState(false);
@ -105,20 +106,20 @@ const CodeControl: FC<WidgetControlProps<string | { [key: string]: string }, Cod
(newValue: string) => {
if (valueIsMap) {
handleOnChange({
...(typeof internalValue !== 'string' ? internalValue : {}),
lang: lang?.label ?? '',
code: newValue,
});
}
handleOnChange(newValue);
},
[handleOnChange, internalValue, valueIsMap],
[handleOnChange, lang?.label, valueIsMap],
);
const loadedLangExtension = useMemo(() => {
if (!lang) {
return null;
}
return loadLanguage(lang as LanguageName);
return loadLanguage(lang.codemirror_mode as LanguageName);
}, [lang]);
const extensions = useMemo(() => {
@ -154,9 +155,29 @@ const CodeControl: FC<WidgetControlProps<string | { [key: string]: string }, Cod
[field.allow_language_selection],
);
const availableLanguages = languages.map(language =>
valueToOption({ name: language.codemirror_mode, label: language.label }),
);
const availableLanguages = languages.map(language => valueToOption(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 (
<StyledCodeControlWrapper>
@ -168,9 +189,9 @@ const CodeControl: FC<WidgetControlProps<string | { [key: string]: string }, Cod
hideSettings={hideSettings}
uniqueId={uniqueId}
languages={availableLanguages}
language={valueToOption(lang)}
language={valueToOption(lang?.label ?? '')}
allowLanguageSelection={allowLanguageSelection}
onChangeLanguage={newLang => setLang(newLang)}
onChangeLanguage={handleSetLanguage}
/>
)
) : null}

View File

@ -11,6 +11,29 @@ const CodeWidget = (): WidgetParam<string | { [key: string]: string }, CodeField
previewComponent,
options: {
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',
properties: { code: { type: 'string' }, lang: { type: 'string' } },
},
default: {
oneOf: [{ type: 'string' }, { type: 'object' }],
},
},
};

View File

@ -1,5 +1,6 @@
import controlComponent from './ColorControl';
import previewComponent from './ColorPreview';
import schema from './schema';
import type { ColorField, WidgetParam } from '@staticcms/core/interface';
@ -8,9 +9,16 @@ const ColorWidget = (): WidgetParam<string, ColorField> => {
name: 'color',
controlComponent,
previewComponent,
options: {
schema,
},
};
};
export { controlComponent as ColorControl, previewComponent as ColorPreview };
export {
controlComponent as ColorControl,
previewComponent as ColorPreview,
schema as ColorSchema,
};
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 parse from 'date-fns/parse';
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 type { DateTimeField, TranslatedProps, WidgetControlProps } from '@staticcms/core/interface';
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')`
width: fit-content;
`;
@ -77,14 +82,6 @@ const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
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(() => {
if (typeof dateFormat === 'string' || typeof timeFormat === 'string') {
const formatParts: string[] = [];
@ -105,13 +102,13 @@ const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
}, [dateFormat, timeFormat]);
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
? format
? formatDate(today, format)
: formatDate(today, inputFormat)
: field.default;
}, [field.default, field.picker_utc, format, inputFormat, localToUTC]);
}, [field.default, field.picker_utc, format, inputFormat, timezoneOffset]);
const [internalValue, setInternalValue] = useState(value);
@ -138,7 +135,7 @@ const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
return;
}
const adjustedValue = field.picker_utc ? localToUTC(datetime) : datetime;
const adjustedValue = field.picker_utc ? localToUTC(datetime, timezoneOffset) : datetime;
let formattedValue: string;
if (format) {
@ -149,20 +146,9 @@ const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
setInternalValue(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(() => {
if (!internalValue) {
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 schema from './schema';
@ -11,6 +15,38 @@ const DateTimeWidget = (): WidgetParam<string, DateTimeField> => {
previewComponent,
options: {
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' }] },
time_format: { oneOf: [{ type: 'string' }, { type: 'boolean' }] },
picker_utc: { type: 'boolean' },
default: { type: 'string' },
},
};

View File

@ -1,5 +1,16 @@
export default {
properties: {
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 Outline from '@staticcms/core/components/UI/Outline';
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 useUUID from '@staticcms/core/lib/hooks/useUUID';
import { basename, transientOptions } from '@staticcms/core/lib/util';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import type {
Collection,
Entry,
FileOrImageField,
GetAssetFunction,
WidgetControlProps,
} from '@staticcms/core/interface';
import type { FC, MouseEvent, MouseEventHandler } from 'react';
@ -102,11 +104,16 @@ const StyledImage = styled('img')`
`;
interface ImageProps {
src: string;
value: string;
collection: Collection<FileOrImageField>;
field: FileOrImageField;
entry: Entry;
}
const Image: FC<ImageProps> = ({ src }) => {
return <StyledImage key="image" role="presentation" src={src} />;
const Image: FC<ImageProps> = ({ value, collection, field, entry }) => {
const assetSource = useMediaAsset(value, collection, field, entry);
return <StyledImage key="image" role="presentation" src={assetSource} />;
};
interface SortableImageButtonsProps {
@ -129,34 +136,25 @@ const SortableImageButtons: FC<SortableImageButtonsProps> = ({ onRemove, onRepla
interface SortableImageProps {
itemValue: string;
getAsset: GetAssetFunction<FileOrImageField>;
collection: Collection<FileOrImageField>;
field: FileOrImageField;
entry: Entry;
onRemove: MouseEventHandler;
onReplace: MouseEventHandler;
}
const SortableImage: FC<SortableImageProps> = ({
itemValue,
getAsset,
collection,
field,
entry,
onRemove,
onReplace,
}: 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 (
<div>
<ImageWrapper key="image-wrapper" $sortable>
<Image key="image" src={assetSource} />
<Image key="image" value={itemValue} collection={collection} field={field} entry={entry} />
</ImageWrapper>
<SortableImageButtons
key="image-buttons"
@ -212,12 +210,13 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
const FileControl: FC<WidgetControlProps<string | string[], FileOrImageField>> = memo(
({
value,
collection,
field,
entry,
onChange,
openMediaLibrary,
clearMediaControl,
removeMediaControl,
getAsset,
hasErrors,
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(() => {
if (forImage) {
if (!internalValue) {
@ -373,8 +355,9 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
<SortableImage
key={`item-${itemValue}`}
itemValue={itemValue}
getAsset={getAsset}
collection={collection}
field={field}
entry={entry}
onRemove={onRemoveOne(index)}
onReplace={onReplaceOne(index)}
/>
@ -385,7 +368,13 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
return (
<ImageWrapper key="single-image-wrapper">
<Image key="single-image" src={assetSource} />
<Image
key="single-image"
value={internalValue}
collection={collection}
field={field}
entry={entry}
/>
</ImageWrapper>
);
}
@ -403,7 +392,7 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
}
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 subject = forImage ? 'image' : 'file';

View File

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

View File

@ -311,27 +311,34 @@ const ListControl: FC<WidgetControlProps<ObjectValue[], ListField>> = ({
<DndContext key="dnd-context" onDragEnd={handleDragEnd}>
<SortableContext items={keys}>
<StyledSortableList $collapsed={collapsed}>
{internalValue.map((item, index) => (
<SortableItem
index={index}
key={keys[index]}
id={keys[index]}
item={item}
valueType={valueType}
handleRemove={handleRemove}
data-testid={`object-control-${index}`}
entry={entry}
field={field}
fieldsErrors={fieldsErrors}
submitted={submitted}
isFieldDuplicate={isFieldDuplicate}
isFieldHidden={isFieldHidden}
locale={locale}
path={path}
value={item as Record<string, ObjectValue>}
i18n={i18n}
/>
))}
{internalValue.map((item, index) => {
const key = keys[index];
if (!key) {
return null;
}
return (
<SortableItem
index={index}
key={key}
id={key}
item={item}
valueType={valueType}
handleRemove={handleRemove}
data-testid={`object-control-${index}`}
entry={entry}
field={field}
fieldsErrors={fieldsErrors}
submitted={submitted}
isFieldDuplicate={isFieldDuplicate}
isFieldHidden={isFieldHidden}
locale={locale}
path={path}
value={item as Record<string, ObjectValue>}
i18n={i18n}
/>
);
})}
</StyledSortableList>
</SortableContext>
</DndContext>

View File

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

View File

@ -58,7 +58,7 @@ const MarkdownControl: FC<WidgetControlProps<string, MarkdownField>> = ({
const handleOnChange = useCallback(
(slateValue: MdValue) => {
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) {
setInternalValue(newValue);
onChange(newValue);
@ -73,7 +73,7 @@ const MarkdownControl: FC<WidgetControlProps<string, MarkdownField>> = ({
const [slateValue, loaded] = useMarkdownToSlate(internalValue);
console.log('[Plate] slateValue', slateValue);
// console.log('[Plate] slateValue', slateValue);
return useMemo(
() => (

View File

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

View File

@ -55,6 +55,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { BalloonToolbar } from './components/balloon-toolbar';
import { BlockquoteElement } from './components/nodes/blockquote';
import { CodeBlockElement } from './components/nodes/code-block';
@ -236,11 +237,14 @@ const PlateEditor: FC<PlateEditorProps> = ({
[components],
);
const id = useUUID();
return useMemo(
() => (
<StyledPlateEditor>
<DndProvider backend={HTML5Backend}>
<PlateProvider<MdValue>
id={id}
key="plate-provider"
initialValue={initialValue}
plugins={plugins}
@ -258,6 +262,7 @@ const PlateEditor: FC<PlateEditorProps> = ({
<div key="editor-wrapper" ref={editorContainerRef} style={styles.container}>
<Plate
key="editor"
id={id}
editableProps={{
...editableProps,
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' },
min: { 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 CircularProgress from '@mui/material/CircularProgress';
import TextField from '@mui/material/TextField';
@ -6,7 +7,13 @@ import get from 'lodash/get';
import uniqBy from 'lodash/uniqBy';
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 {
addFileTemplateFields,
@ -14,7 +21,10 @@ import {
expandPath,
extractTemplateVars,
} 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 {
Entry,
EntryData,
@ -106,11 +116,10 @@ function getSelectedValue(
}
const RelationControl: FC<WidgetControlProps<string | string[], RelationField>> = ({
path,
value,
field,
onChange,
query,
config,
locale,
label,
hasErrors,
@ -118,6 +127,12 @@ const RelationControl: FC<WidgetControlProps<string | string[], RelationField>>
const [internalValue, setInternalValue] = useState(value);
const [initialOptions, setInitialOptions] = useState<HitOption[]>([]);
const searchCollectionSelector = useMemo(
() => selectCollection(field.collection),
[field.collection],
);
const searchCollection = useAppSelector(searchCollectionSelector);
const isMultiple = useMemo(() => {
return field.multiple ?? false;
}, [field.multiple]);
@ -183,6 +198,7 @@ const RelationControl: FC<WidgetControlProps<string | string[], RelationField>>
);
const [options, setOptions] = useState<HitOption[]>([]);
const [entries, setEntries] = useState<Entry[]>([]);
const [open, setOpen] = useState(false);
const valueNotEmpty = useMemo(
() => (Array.isArray(internalValue) ? internalValue.length > 0 : isNotEmpty(internalValue)),
@ -193,33 +209,45 @@ const RelationControl: FC<WidgetControlProps<string | string[], RelationField>>
[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(() => {
let alive = true;
if (!loading) {
return undefined;
if (!loading || !searchCollection) {
return;
}
(async () => {
const collection = field.collection;
const optionsLength = field.options_length || 20;
const searchFieldsArray = field.search_fields;
const file = field.file;
const getOptions = async () => {
const backend = currentBackend(config);
const response = await query(path, collection, searchFieldsArray, '', file, optionsLength);
if (alive) {
if (response?.type === QUERY_SUCCESS) {
const hits = response.payload.hits ?? [];
const options = parseHitOptions(hits);
setOptions(uniqOptions(initialOptions, options));
}
}
})();
return () => {
alive = false;
const options = await backend.listAllEntries(searchCollection);
setEntries(options);
setOptions(parseHitOptions(options));
};
// 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 selectedValue = getSelectedValue(internalValue, uniqueOptions, isMultiple);
@ -230,6 +258,7 @@ const RelationControl: FC<WidgetControlProps<string | string[], RelationField>>
disablePortal
options={uniqueOptions}
fullWidth
filterOptions={filterOptions}
renderInput={params => (
<TextField
key="relation-control-input"

View File

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

View File

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

View File

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

View File

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