diff --git a/core/dev-test/config.yml b/core/dev-test/config.yml index e2e23c48..bbcb5b6a 100644 --- a/core/dev-test/config.yml +++ b/core/dev-test/config.yml @@ -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: '
Some html!
' - 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: '
Some html!
' + - 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: '
Some html!
' - 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 diff --git a/core/src/actions/config.ts b/core/src/actions/config.ts index f768ea89..0907c049 100644 --- a/core/src/actions/config.ts +++ b/core/src/actions/config.ts @@ -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), }; } diff --git a/core/src/actions/entries.ts b/core/src/actions/entries.ts index 32aa3539..df424670 100644 --- a/core/src/actions/entries.ts +++ b/core/src/actions/entries.ts @@ -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, diff --git a/core/src/actions/search.ts b/core/src/actions/search.ts index eba5e056..676bc1e9 100644 --- a/core/src/actions/search.ts +++ b/core/src/actions/search.ts @@ -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); diff --git a/core/src/backend.ts b/core/src/backend.ts index 01a9463e..56a42be8 100644 --- a/core/src/backend.ts +++ b/core/src/backend.ts @@ -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, b: fuzzy.FilterResult) { - if (a.score > b.score) return -1; - if (a.score < b.score) return 1; +export function sortByScore(a: fuzzy.FilterResult, b: fuzzy.FilterResult) { + if (a.score > b.score) { + return -1; + } + + if (a.score < b.score) { + return 1; + } + return 0; } @@ -478,16 +486,16 @@ export class Backend { // 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(collection: Collection) { 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 { return { entries: hits, pagination: 1 }; } - async query( - collection: Collection, + async query( + collection: Collection, searchFields: string[], searchTerm: string, file?: string, limit?: number, ): Promise { - 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 (config: Config) => { if (backend) { return backend; } - return (backend = resolveBackend(config)); + return (backend = resolveBackend(config as Config)); }; })(); diff --git a/core/src/components/Editor/EditorControlPane/EditorControl.tsx b/core/src/components/Editor/EditorControlPane/EditorControl.tsx index 3345fd85..1d4d6f6f 100644 --- a/core/src/components/Editor/EditorControlPane/EditorControl.tsx +++ b/core/src/components/Editor/EditorControlPane/EditorControl.tsx @@ -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 = ({ <> {createElement(widget.control, { - key: id, + key: `${id}-${version}`, collection, config, entry, diff --git a/core/src/components/Editor/EditorControlPane/EditorControlPane.tsx b/core/src/components/Editor/EditorControlPane/EditorControlPane.tsx index a42cbb7d..045f7de5 100644 --- a/core/src/components/Editor/EditorControlPane/EditorControlPane.tsx +++ b/core/src/components/Editor/EditorControlPane/EditorControlPane.tsx @@ -188,31 +188,29 @@ const EditorControlPane = ({ /> ) : 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 ( - isFieldDuplicate(field, locale, i18n?.defaultLocale)} - isFieldHidden={field => isFieldHidden(field, locale, i18n?.defaultLocale)} - locale={locale} - parentPath="" - i18n={i18n} - /> - ); - })} + return ( + isFieldDuplicate(field, locale, i18n?.defaultLocale)} + isFieldHidden={field => isFieldHidden(field, locale, i18n?.defaultLocale)} + locale={locale} + parentPath="" + i18n={i18n} + /> + ); + })} ); }; diff --git a/core/src/components/Editor/EditorPreviewPane/EditorPreview.tsx b/core/src/components/Editor/EditorPreviewPane/EditorPreview.tsx index 19d712f9..86306f03 100644 --- a/core/src/components/Editor/EditorPreviewPane/EditorPreview.tsx +++ b/core/src/components/Editor/EditorPreviewPane/EditorPreview.tsx @@ -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 ( - {fields.filter(isVisible).map(field => ( + {fields.map(field => (
{widgetFor(field.name)}
))}
diff --git a/core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.tsx b/core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.tsx index 6439c6bc..aca7b0ce 100644 --- a/core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.tsx +++ b/core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.tsx @@ -188,7 +188,7 @@ function isReactFragment(value: any): value is ReactFragment { function getWidget( config: Config, - field: RenderedField, + field: RenderedField, 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)[field.name] : value } diff --git a/core/src/extensions.ts b/core/src/extensions.ts index 1a1928dd..10887345 100644 --- a/core/src/extensions.ts +++ b/core/src/extensions.ts @@ -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 => { diff --git a/core/src/integrations/index.ts b/core/src/integrations/index.ts deleted file mode 100644 index 077420e6..00000000 --- a/core/src/integrations/index.ts +++ /dev/null @@ -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]; - } - }; -})(); diff --git a/core/src/integrations/providers/algolia/implementation.ts b/core/src/integrations/providers/algolia/implementation.ts deleted file mode 100644 index ef8b0a8c..00000000 --- a/core/src/integrations/providers/algolia/implementation.ts +++ /dev/null @@ -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 = {}) { - 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; - }, - ) { - 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 { - 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, - }); - }); - } -} diff --git a/core/src/interface.ts b/core/src/interface.ts index 483fa2bd..f0d17b07 100644 --- a/core/src/interface.ts +++ b/core/src/interface.ts @@ -313,6 +313,7 @@ export type TemplatePreviewComponent< export interface WidgetOptions { validator?: Widget['validator']; getValidValue?: Widget['getValidValue']; + getDefaultValue?: Widget['getDefaultValue']; schema?: Widget['schema']; } @@ -321,6 +322,7 @@ export interface Widget { preview?: WidgetPreviewComponent; validator: FieldValidationMethod; getValidValue: (value: T | undefined | null) => T | undefined | null; + getDefaultValue?: (defaultValue: T | undefined | null, field: F) => T; schema?: PropertiesSchema; } diff --git a/core/src/lib/hooks/useMediaAsset.ts b/core/src/lib/hooks/useMediaAsset.ts index 3b5e18f0..df17b025 100644 --- a/core/src/lib/hooks/useMediaAsset.ts +++ b/core/src/lib/hooks/useMediaAsset.ts @@ -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( schema, validator = () => false, getValidValue = (value: T | null | undefined) => value, + getDefaultValue, }: WidgetOptions = {}, ): void { if (Array.isArray(name)) { @@ -162,6 +163,7 @@ export function registerWidget( 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( options: { validator = () => false, getValidValue = (value: T | undefined | null) => value, + getDefaultValue, schema, } = {}, } = name; @@ -190,6 +193,7 @@ export function registerWidget( preview, validator, getValidValue, + getDefaultValue, schema, } as unknown as Widget; } else { diff --git a/core/src/lib/util/collection.util.ts b/core/src/lib/util/collection.util.ts index e9577430..ed22cb5c 100644 --- a/core/src/lib/util/collection.util.ts +++ b/core/src/lib/util/collection.util.ts @@ -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]; } diff --git a/core/src/reducers/collections.ts b/core/src/reducers/collections.ts index 62285c46..67996f7f 100644 --- a/core/src/reducers/collections.ts +++ b/core/src/reducers/collections.ts @@ -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); +}; diff --git a/core/src/reducers/index.ts b/core/src/reducers/index.ts index 10f3b13c..0ee6a90d 100644 --- a/core/src/reducers/index.ts +++ b/core/src/reducers/index.ts @@ -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( - state: RootState, - collection: string | null, - hook: K, -): IntegrationHooks[K] | false { - return fromIntegrations.selectIntegration(state.integrations, collection, hook); -} diff --git a/core/src/reducers/integrations.ts b/core/src/reducers/integrations.ts deleted file mode 100644 index 0f79a239..00000000 --- a/core/src/reducers/integrations.ts +++ /dev/null @@ -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; -} - -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( - 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; diff --git a/core/src/widgets/boolean/BooleanControl.tsx b/core/src/widgets/boolean/BooleanControl.tsx index cb0690a6..c3b1b743 100644 --- a/core/src/widgets/boolean/BooleanControl.tsx +++ b/core/src/widgets/boolean/BooleanControl.tsx @@ -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> = ({ [onChange], ); - useEffect(() => { - if (typeof internalValue !== 'boolean') { - setInternalValue(false); - setTimeout(() => { - onChange(false); - }); - } - }, [internalValue, onChange]); - return ( => { 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; diff --git a/core/src/widgets/boolean/schema.ts b/core/src/widgets/boolean/schema.ts new file mode 100644 index 00000000..858da99c --- /dev/null +++ b/core/src/widgets/boolean/schema.ts @@ -0,0 +1,5 @@ +export default { + properties: { + default: { type: 'boolean' }, + }, +}; diff --git a/core/src/widgets/code/CodeControl.tsx b/core/src/widgets/code/CodeControl.tsx index cb71536c..db4ee761 100644 --- a/core/src/widgets/code/CodeControl.tsx +++ b/core/src/widgets/code/CodeControl.tsx @@ -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 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(null); const [collapsed, setCollapsed] = useState(false); const [hasFocus, setHasFocus] = useState(false); @@ -105,20 +106,20 @@ const CodeControl: FC { 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 - 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 ( @@ -168,9 +189,9 @@ const CodeControl: FC setLang(newLang)} + onChangeLanguage={handleSetLanguage} /> ) ) : null} diff --git a/core/src/widgets/code/index.ts b/core/src/widgets/code/index.ts index 83884866..d3514392 100644 --- a/core/src/widgets/code/index.ts +++ b/core/src/widgets/code/index.ts @@ -11,6 +11,29 @@ const CodeWidget = (): WidgetParam { + 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] ?? '', + }; + }, }, }; }; diff --git a/core/src/widgets/code/schema.ts b/core/src/widgets/code/schema.ts index 204c641e..e5575750 100644 --- a/core/src/widgets/code/schema.ts +++ b/core/src/widgets/code/schema.ts @@ -7,5 +7,8 @@ export default { type: 'object', properties: { code: { type: 'string' }, lang: { type: 'string' } }, }, + default: { + oneOf: [{ type: 'string' }, { type: 'object' }], + }, }, }; diff --git a/core/src/widgets/colorstring/index.ts b/core/src/widgets/colorstring/index.ts index 97e1b3d2..2608dd24 100644 --- a/core/src/widgets/colorstring/index.ts +++ b/core/src/widgets/colorstring/index.ts @@ -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 => { 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; diff --git a/core/src/widgets/colorstring/schema.ts b/core/src/widgets/colorstring/schema.ts new file mode 100644 index 00000000..c1d7002a --- /dev/null +++ b/core/src/widgets/colorstring/schema.ts @@ -0,0 +1,5 @@ +export default { + properties: { + default: { type: 'string' }, + }, +}; diff --git a/core/src/widgets/datetime/DateTimeControl.tsx b/core/src/widgets/datetime/DateTimeControl.tsx index e5ae011d..e3c0fef4 100644 --- a/core/src/widgets/datetime/DateTimeControl.tsx +++ b/core/src/widgets/datetime/DateTimeControl.tsx @@ -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> = ({ 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> = ({ }, [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> = ({ 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> = ({ 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; diff --git a/core/src/widgets/datetime/index.tsx b/core/src/widgets/datetime/index.tsx index a84a160f..61bc9b6f 100644 --- a/core/src/widgets/datetime/index.tsx +++ b/core/src/widgets/datetime/index.tsx @@ -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 => { 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); + }, }, }; }; diff --git a/core/src/widgets/datetime/schema.ts b/core/src/widgets/datetime/schema.ts index 8e32e7ab..8b92e769 100644 --- a/core/src/widgets/datetime/schema.ts +++ b/core/src/widgets/datetime/schema.ts @@ -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' }, }, }; diff --git a/core/src/widgets/file/schema.ts b/core/src/widgets/file/schema.ts index 15dbf814..433db5ba 100644 --- a/core/src/widgets/file/schema.ts +++ b/core/src/widgets/file/schema.ts @@ -1,5 +1,16 @@ export default { properties: { allow_multiple: { type: 'boolean' }, + default: { + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { + type: 'string', + }, + }, + ], + }, }, }; diff --git a/core/src/widgets/file/withFileControl.tsx b/core/src/widgets/file/withFileControl.tsx index e21613e7..a5180f38 100644 --- a/core/src/widgets/file/withFileControl.tsx +++ b/core/src/widgets/file/withFileControl.tsx @@ -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; + field: FileOrImageField; + entry: Entry; } -const Image: FC = ({ src }) => { - return ; +const Image: FC = ({ value, collection, field, entry }) => { + const assetSource = useMediaAsset(value, collection, field, entry); + + return ; }; interface SortableImageButtonsProps { @@ -129,34 +136,25 @@ const SortableImageButtons: FC = ({ onRemove, onRepla interface SortableImageProps { itemValue: string; - getAsset: GetAssetFunction; + collection: Collection; field: FileOrImageField; + entry: Entry; onRemove: MouseEventHandler; onReplace: MouseEventHandler; } const SortableImage: FC = ({ 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 (
- + { const FileControl: FC> = 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 = {}) => { @@ -385,7 +368,13 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => { return ( - + ); } @@ -403,7 +392,7 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => { } return {renderFileLink(internalValue)}; - }, [assetSource, field, getAsset, internalValue, onRemoveOne, onReplaceOne, renderFileLink]); + }, [collection, entry, field, internalValue, onRemoveOne, onReplaceOne, renderFileLink]); const content = useMemo(() => { const subject = forImage ? 'image' : 'file'; diff --git a/core/src/widgets/image/schema.ts b/core/src/widgets/image/schema.ts index 15dbf814..433db5ba 100644 --- a/core/src/widgets/image/schema.ts +++ b/core/src/widgets/image/schema.ts @@ -1,5 +1,16 @@ export default { properties: { allow_multiple: { type: 'boolean' }, + default: { + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { + type: 'string', + }, + }, + ], + }, }, }; diff --git a/core/src/widgets/list/ListControl.tsx b/core/src/widgets/list/ListControl.tsx index 101dfd44..270a1f7f 100644 --- a/core/src/widgets/list/ListControl.tsx +++ b/core/src/widgets/list/ListControl.tsx @@ -311,27 +311,34 @@ const ListControl: FC> = ({ - {internalValue.map((item, index) => ( - } - i18n={i18n} - /> - ))} + {internalValue.map((item, index) => { + const key = keys[index]; + if (!key) { + return null; + } + + return ( + } + i18n={i18n} + /> + ); + })} diff --git a/core/src/widgets/map/MapPreview.tsx b/core/src/widgets/map/MapPreview.tsx index ad6d3404..6937ba28 100644 --- a/core/src/widgets/map/MapPreview.tsx +++ b/core/src/widgets/map/MapPreview.tsx @@ -6,7 +6,7 @@ import type { MapField, WidgetPreviewProps } from '@staticcms/core/interface'; import type { FC } from 'react'; const MapPreview: FC> = ({ value }) => { - return {value ? value.toString() : null}; + return {value}; }; export default MapPreview; diff --git a/core/src/widgets/markdown/MarkdownControl.tsx b/core/src/widgets/markdown/MarkdownControl.tsx index 2c1a79d2..894c36d2 100644 --- a/core/src/widgets/markdown/MarkdownControl.tsx +++ b/core/src/widgets/markdown/MarkdownControl.tsx @@ -58,7 +58,7 @@ const MarkdownControl: FC> = ({ 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> = ({ const [slateValue, loaded] = useMarkdownToSlate(internalValue); - console.log('[Plate] slateValue', slateValue); + // console.log('[Plate] slateValue', slateValue); return useMemo( () => ( diff --git a/core/src/widgets/markdown/index.ts b/core/src/widgets/markdown/index.ts index eae41944..e05fd927 100644 --- a/core/src/widgets/markdown/index.ts +++ b/core/src/widgets/markdown/index.ts @@ -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 => { 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; diff --git a/core/src/widgets/markdown/plate/PlateEditor.tsx b/core/src/widgets/markdown/plate/PlateEditor.tsx index fa285cfa..9981a43c 100644 --- a/core/src/widgets/markdown/plate/PlateEditor.tsx +++ b/core/src/widgets/markdown/plate/PlateEditor.tsx @@ -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 = ({ [components], ); + const id = useUUID(); + return useMemo( () => ( + id={id} key="plate-provider" initialValue={initialValue} plugins={plugins} @@ -258,6 +262,7 @@ const PlateEditor: FC = ({
> = ({ - path, value, field, onChange, - query, + config, locale, label, hasErrors, @@ -118,6 +127,12 @@ const RelationControl: FC> const [internalValue, setInternalValue] = useState(value); const [initialOptions, setInitialOptions] = useState([]); + 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> ); const [options, setOptions] = useState([]); + const [entries, setEntries] = useState([]); const [open, setOpen] = useState(false); const valueNotEmpty = useMemo( () => (Array.isArray(internalValue) ? internalValue.length > 0 : isNotEmpty(internalValue)), @@ -193,33 +209,45 @@ const RelationControl: FC> [open, valueNotEmpty, options.length], ); + const filterOptions = useCallback( + (_options: HitOption[], { inputValue }: FilterOptionsState) => { + 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> disablePortal options={uniqueOptions} fullWidth + filterOptions={filterOptions} renderInput={params => ( => { 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; diff --git a/core/src/widgets/string/schema.ts b/core/src/widgets/string/schema.ts new file mode 100644 index 00000000..c1d7002a --- /dev/null +++ b/core/src/widgets/string/schema.ts @@ -0,0 +1,5 @@ +export default { + properties: { + default: { type: 'string' }, + }, +}; diff --git a/core/src/widgets/text/index.ts b/core/src/widgets/text/index.ts index 3505b6a6..1e3b3993 100644 --- a/core/src/widgets/text/index.ts +++ b/core/src/widgets/text/index.ts @@ -1,3 +1,4 @@ +import schema from './schema'; import controlComponent from './TextControl'; import previewComponent from './TextPreview'; @@ -8,9 +9,12 @@ const TextWidget = (): WidgetParam => { 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; diff --git a/core/src/widgets/text/schema.ts b/core/src/widgets/text/schema.ts new file mode 100644 index 00000000..c1d7002a --- /dev/null +++ b/core/src/widgets/text/schema.ts @@ -0,0 +1,5 @@ +export default { + properties: { + default: { type: 'string' }, + }, +};