Improved types and updated documentation (#71)

* v1.0.0-alpha44
This commit is contained in:
Daniel Lautzenheiser
2022-11-07 10:27:58 -05:00
committed by GitHub
parent ba1cde4e01
commit c55d1f912f
91 changed files with 3695 additions and 2546 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{
"name": "@staticcms/core",
"version": "1.0.0-alpha38",
"version": "1.0.0-alpha44",
"license": "MIT",
"description": "Static CMS core application.",
"repository": "https://github.com/StaticJsCMS/static-cms",

View File

@ -5,7 +5,6 @@ import trimStart from 'lodash/trimStart';
import yaml from 'yaml';
import { resolveBackend } from '../backend';
import { FILES, FOLDER } from '../constants/collectionTypes';
import { validateConfig } from '../constants/configSchema';
import { I18N, I18N_FIELD, I18N_STRUCTURE } from '../lib/i18n';
import { selectDefaultSortableFields } from '../lib/util/collection.util';
@ -22,6 +21,7 @@ import type {
ListField,
LocalBackend,
ObjectField,
UnknownField,
} from '../interface';
import type { RootState } from '../store';
@ -29,11 +29,11 @@ export const CONFIG_REQUEST = 'CONFIG_REQUEST';
export const CONFIG_SUCCESS = 'CONFIG_SUCCESS';
export const CONFIG_FAILURE = 'CONFIG_FAILURE';
function isObjectField(field: Field): field is BaseField & ObjectField {
function isObjectField<F extends BaseField = UnknownField>(field: Field<F>): field is ObjectField {
return 'fields' in (field as ObjectField);
}
function isFieldList(field: Field): field is BaseField & ListField {
function isFieldList<F extends BaseField = UnknownField>(field: Field<F>): field is ListField {
return 'types' in (field as ListField) || 'field' in (field as ListField);
}
@ -186,15 +186,13 @@ export function applyDefaults(originalConfig: Config) {
delete collection[I18N];
}
if (collection.fields) {
if ('fields' in collection && collection.fields) {
collection.fields = setI18nDefaultsForFields(collection.fields, Boolean(collectionI18n));
}
const { folder, files, view_filters, view_groups } = collection;
if (folder) {
collection.type = FOLDER;
const { view_filters, view_groups } = collection;
if ('folder' in collection && collection.folder) {
if (collection.path && !collection.media_folder) {
// default value for media folder when using the path config
collection.media_folder = '';
@ -204,21 +202,17 @@ export function applyDefaults(originalConfig: Config) {
collection.public_folder = collection.media_folder;
}
if (collection.fields) {
if ('fields' in collection && collection.fields) {
collection.fields = traverseFieldsJS(collection.fields, setDefaultPublicFolderForField);
}
collection.folder = trim(folder, '/');
collection.folder = trim(collection.folder, '/');
}
if (files) {
collection.type = FILES;
if ('files' in collection && collection.files) {
throwOnInvalidFileCollectionStructure(collectionI18n);
delete collection.nested;
for (const file of files) {
for (const file of collection.files) {
file.file = trimStart(file.file, '/');
if ('media_folder' in file && !('public_folder' in file)) {

View File

@ -1,9 +1,9 @@
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 { SortDirection } from '../interface';
import { duplicateDefaultI18nFields, hasI18n, I18N_FIELD, serializeI18n } from '../lib/i18n';
import { serializeValues } from '../lib/serializeEntryValues';
import { Cursor } from '../lib/util';
@ -33,6 +33,7 @@ import type {
I18nSettings,
ImplementationMediaFile,
ObjectValue,
SortDirection,
ValueOrNestedValue,
ViewFilter,
ViewGroup,
@ -292,7 +293,7 @@ async function getAllEntries(state: RootState, collection: Collection) {
export function sortByField(
collection: Collection,
key: string,
direction: SortDirection = SortDirection.Ascending,
direction: SortDirection = SORT_DIRECTION_ASCENDING,
) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
@ -838,6 +839,10 @@ function processValue(unsafe: string) {
export function createEmptyDraft(collection: Collection, search: string) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
if ('files' in collection) {
return;
}
const params = new URLSearchParams(search);
params.forEach((value, key) => {
collection = updateFieldByKey(collection, key, field => {
@ -885,7 +890,7 @@ export function createEmptyDraftData(
}
const subfields = 'fields' in item && item.fields;
const list = item.widget == 'list';
const list = item.widget === 'list';
const name = item.name;
const defaultValue = (('default' in item ? item.default : null) ?? null) as EntryData;

View File

@ -6,7 +6,7 @@ import { getMediaDisplayURL, getMediaFile, waitForMediaLibraryToLoad } from './m
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { Collection, Entry, Field } from '../interface';
import type { BaseField, Collection, Entry, Field, UnknownField } from '../interface';
import type { RootState } from '../store';
import type AssetProxy from '../valueObjects/AssetProxy';
@ -83,11 +83,11 @@ async function loadAsset(
const promiseCache: Record<string, Promise<AssetProxy>> = {};
export function getAsset(
export function getAsset<F extends BaseField = UnknownField>(
collection: Collection | null | undefined,
entry: Entry | null | undefined,
path: string,
field?: Field,
field?: F,
) {
return (
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
@ -102,7 +102,13 @@ export function getAsset(
return Promise.resolve(emptyAsset);
}
const resolvedPath = selectMediaFilePath(state.config.config, collection, entry, path, field);
const resolvedPath = selectMediaFilePath(
state.config.config,
collection,
entry,
path,
field as Field,
);
let { asset, isLoading, error } = state.medias[resolvedPath] || {};
if (isLoading) {
return promiseCache[resolvedPath];

View File

@ -14,11 +14,13 @@ import { waitUntilWithTimeout } from './waitUntil';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type {
BaseField,
DisplayURLState,
Field,
ImplementationMediaFile,
MediaFile,
MediaLibraryInstance,
UnknownField,
} from '../interface';
import type { RootState } from '../store';
import type AssetProxy from '../valueObjects/AssetProxy';
@ -72,7 +74,7 @@ export function removeMediaControl(id: string) {
};
}
export function openMediaLibrary(
export function openMediaLibrary<F extends BaseField = UnknownField>(
payload: {
controlID?: string;
forImage?: boolean;
@ -80,17 +82,27 @@ export function openMediaLibrary(
allowMultiple?: boolean;
replaceIndex?: number;
config?: Record<string, unknown>;
field?: Field;
field?: F;
} = {},
) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.externalLibrary;
const { controlID, value, config = {}, allowMultiple, forImage, replaceIndex, field } = payload;
if (mediaLibrary) {
const { controlID: id, value, config = {}, allowMultiple, forImage } = payload;
mediaLibrary.show({ id, value, config, allowMultiple, imagesOnly: forImage });
mediaLibrary.show({ id: controlID, value, config, allowMultiple, imagesOnly: forImage });
}
dispatch(mediaLibraryOpened(payload));
dispatch(
mediaLibraryOpened({
controlID,
forImage,
value,
allowMultiple,
replaceIndex,
config,
field: field as Field,
}),
);
};
}

View File

@ -5,7 +5,6 @@ import get from 'lodash/get';
import isError from 'lodash/isError';
import uniq from 'lodash/uniq';
import { FILES, FOLDER } from './constants/collectionTypes';
import { resolveFormat } from './formats/formats';
import { commitMessageFormatter, slugFormatter } from './lib/formatters';
import {
@ -262,7 +261,7 @@ interface PersistArgs {
function collectionDepth(collection: Collection) {
let depth;
depth = collection.nested?.depth || getPathDepth(collection.path ?? '');
depth = 'nested' in collection && collection.nested?.depth || getPathDepth(collection.path ?? '');
if (hasI18n(collection)) {
depth = getI18nFilesDepth(collection, depth);
@ -441,20 +440,17 @@ export class Backend {
async listEntries(collection: Collection) {
const extension = selectFolderEntryExtension(collection);
let listMethod: () => Promise<ImplementationEntry[]>;
const collectionType = collection.type;
if (collectionType === FOLDER) {
if ('folder' in collection) {
listMethod = () => {
const depth = collectionDepth(collection);
return this.implementation.entriesByFolder(collection.folder as string, extension, depth);
};
} else if (collectionType === FILES) {
const files = collection.files!.map(collectionFile => ({
} else {
const files = collection.files.map(collectionFile => ({
path: collectionFile!.file,
label: collectionFile!.label,
}));
listMethod = () => this.implementation.entriesByFiles(files);
} else {
throw new Error(`Unknown collection type: ${collectionType}`);
}
const loadedEntries = await listMethod();
/*
@ -481,7 +477,7 @@ export class Backend {
// returns all the collected entries. Used to retrieve all entries
// for local searches and queries.
async listAllEntries(collection: Collection) {
if (collection.folder && this.implementation.allEntriesByFolder) {
if ('folder' in collection && collection.folder && this.implementation.allEntriesByFolder) {
const depth = collectionDepth(collection);
const extension = selectFolderEntryExtension(collection);
return this.implementation
@ -513,8 +509,8 @@ export class Backend {
// TODO: pass search fields in as an argument
let searchFields: (string | null | undefined)[] = [];
if (collection.type === FILES) {
collection.files?.forEach(f => {
if ('files' in collection) {
collection.files.forEach(f => {
const topLevelFields = f!.fields.map(f => f!.name);
searchFields = [...searchFields, ...topLevelFields];
});
@ -970,19 +966,19 @@ export class Backend {
}
fieldsOrder(collection: Collection, entry: Entry) {
const fields = collection.fields;
if (fields) {
return collection.fields.map(f => f!.name);
if ('fields' in collection) {
return collection.fields?.map(f => f!.name) ?? [];
} else {
const files = collection.files ?? [];
const file: CollectionFile | null = files.filter(f => f!.name === entry.slug)?.[0] ?? null;
if (file == null) {
throw new Error(`No file found for ${entry.slug} in ${collection.name}`);
}
return file.fields.map(f => f.name);
}
const files = collection.files;
const file: CollectionFile | null =
(files ?? []).filter(f => f!.name === entry.slug)?.[0] ?? null;
if (file == null) {
throw new Error(`No file found for ${entry.slug} in ${collection.name}`);
}
return file.fields.map(f => f.name);
return [];
}
filterEntries(collection: { entries: Entry[] }, filterRule: FilterRule) {

View File

@ -87,7 +87,10 @@ const Header = ({
}, []);
const createableCollections = useMemo(
() => Object.values(collections).filter(collection => collection.create),
() =>
Object.values(collections).filter(collection =>
'folder' in collection ? collection.create ?? false : false,
),
[collections],
);

View File

@ -10,7 +10,7 @@ import {
sortByField as sortByFieldAction,
} from '../../actions/entries';
import { components } from '../../components/UI/styles';
import { SortDirection } from '../../interface';
import { SORT_DIRECTION_ASCENDING } from '../../constants';
import { getNewEntryUrl } from '../../lib/urlHelper';
import {
selectSortableFields,
@ -31,7 +31,13 @@ import Sidebar from './Sidebar';
import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux';
import type { Collection, TranslatedProps, ViewFilter, ViewGroup } from '../../interface';
import type {
Collection,
SortDirection,
TranslatedProps,
ViewFilter,
ViewGroup,
} from '../../interface';
import type { RootState } from '../../store';
const CollectionMain = styled('main')`
@ -77,7 +83,7 @@ const CollectionView = ({
}, [collection]);
const newEntryUrl = useMemo(() => {
let url = collection.create ? getNewEntryUrl(collectionName) : '';
let url = 'fields' in collection && collection.create ? getNewEntryUrl(collectionName) : '';
if (url && filterTerm) {
url = getNewEntryUrl(collectionName);
if (filterTerm) {
@ -163,7 +169,7 @@ const CollectionView = ({
return;
}
const defaultSort = collection.sortable_fields.default;
const defaultSort = collection.sortable_fields?.default;
if (!defaultSort || !defaultSort.field) {
if (!readyToLoad) {
setReadyToLoad(true);
@ -177,7 +183,7 @@ const CollectionView = ({
const sortEntries = () => {
setTimeout(async () => {
await onSortClick(defaultSort.field, defaultSort.direction ?? SortDirection.Ascending);
await onSortClick(defaultSort.field, defaultSort.direction ?? SORT_DIRECTION_ASCENDING);
if (alive) {
setReadyToLoad(true);
@ -218,8 +224,8 @@ const CollectionView = ({
sortableFields={sortableFields}
onSortClick={onSortClick}
sort={sort}
viewFilters={viewFilters}
viewGroups={viewGroups}
viewFilters={viewFilters ?? []}
viewGroups={viewGroups ?? []}
t={t}
onFilterClick={onFilterClick}
onGroupClick={onGroupClick}

View File

@ -94,9 +94,10 @@ function mapStateToProps(state: RootState, ownProps: EntryCardOwnProps) {
...ownProps,
path: `/collections/${collection.name}/entries/${entry.slug}`,
image,
imageField: collection.fields?.find(
f => f.name === inferedFields.imageField && f.widget === 'image',
),
imageField:
'fields' in collection
? collection.fields?.find(f => f.name === inferedFields.imageField && f.widget === 'image')
: undefined,
isLoadingAsset,
};
}

View File

@ -165,7 +165,7 @@ export function walk(treeData: TreeNodeData[], callback: (node: TreeNodeData) =>
}
export function getTreeData(collection: Collection, entries: Entry[]): TreeNodeData[] {
const collectionFolder = collection.folder ?? '';
const collectionFolder = 'folder' in collection ? collection.folder : '';
const rootFolder = '/';
const entriesObj = entries.map(e => ({ ...e, path: e.path.slice(collectionFolder.length) }));

View File

@ -8,9 +8,13 @@ import MenuItem from '@mui/material/MenuItem';
import React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot';
import { SortDirection } from '../../interface';
import {
SORT_DIRECTION_ASCENDING,
SORT_DIRECTION_DESCENDING,
SORT_DIRECTION_NONE,
} from '../../constants';
import type { SortableField, SortMap, TranslatedProps } from '../../interface';
import type { SortableField, SortDirection, SortMap, TranslatedProps } from '../../interface';
const StyledMenuIconWrapper = styled('div')`
width: 32px;
@ -22,12 +26,12 @@ const StyledMenuIconWrapper = styled('div')`
function nextSortDirection(direction: SortDirection) {
switch (direction) {
case SortDirection.Ascending:
return SortDirection.Descending;
case SortDirection.Descending:
return SortDirection.None;
case SORT_DIRECTION_ASCENDING:
return SORT_DIRECTION_DESCENDING;
case SORT_DIRECTION_DESCENDING:
return SORT_DIRECTION_NONE;
default:
return SortDirection.Ascending;
return SORT_DIRECTION_NONE;
}
}
@ -53,7 +57,7 @@ const SortControl = ({ t, fields, onSortClick, sort }: TranslatedProps<SortContr
}
const sortValues = Object.values(sort);
if (Object.values(sortValues).length < 1 || sortValues[0].direction === SortDirection.None) {
if (Object.values(sortValues).length < 1 || sortValues[0].direction === SORT_DIRECTION_NONE) {
return { key: undefined, direction: undefined };
}
@ -83,7 +87,7 @@ const SortControl = ({ t, fields, onSortClick, sort }: TranslatedProps<SortContr
}}
>
{fields.map(field => {
const sortDir = sort?.[field.name]?.direction ?? SortDirection.None;
const sortDir = sort?.[field.name]?.direction ?? SORT_DIRECTION_NONE;
const nextSortDir = nextSortDirection(sortDir);
return (
<MenuItem
@ -94,7 +98,7 @@ const SortControl = ({ t, fields, onSortClick, sort }: TranslatedProps<SortContr
<ListItemText>{field.label ?? field.name}</ListItemText>
<StyledMenuIconWrapper>
{field.name === selectedSort.key ? (
selectedSort.direction === SortDirection.Ascending ? (
selectedSort.direction === SORT_DIRECTION_ASCENDING ? (
<KeyboardArrowUpIcon fontSize="small" />
) : (
<KeyboardArrowDownIcon fontSize="small" />

View File

@ -7,7 +7,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync';
import { colorsRaw, components, zIndex } from '../../components/UI/styles';
import { FILES } from '../../constants/collectionTypes';
import { transientOptions } from '../../lib';
import { getI18nInfo, getPreviewEntry, hasI18n } from '../../lib/i18n';
import { getFileFromSlug } from '../../lib/util/collection.util';
@ -221,7 +220,7 @@ const EditorInterface = ({
let preview = collection.editor?.preview ?? true;
let frame = collection.editor?.frame ?? true;
if (collection.type === FILES) {
if ('files' in collection) {
const file = getFileFromSlug(collection, entry.slug);
if (file?.editor?.preview !== undefined) {
preview = file.editor.preview;

View File

@ -106,7 +106,10 @@ const EditorToolbar = ({
t,
editorBackLink,
}: TranslatedProps<EditorToolbarProps>) => {
const canCreate = useMemo(() => collection.create ?? false, [collection.create]);
const canCreate = useMemo(
() => ('folder' in collection && collection.create) ?? false,
[collection],
);
const canDelete = useMemo(() => selectAllowDeletion(collection), [collection]);
const isPublished = useMemo(() => !isNewEntry && !hasChanged, [hasChanged, isNewEntry]);

3
core/src/constants.ts Normal file
View File

@ -0,0 +1,3 @@
export const SORT_DIRECTION_ASCENDING = 'Ascending';
export const SORT_DIRECTION_DESCENDING = 'Descending';
export const SORT_DIRECTION_NONE = 'None';

View File

@ -1,4 +0,0 @@
export const FILES = 'file_based_collection';
export const FOLDER = 'folder_based_collection';
export type CollectionType = typeof FILES | typeof FOLDER;

View File

@ -1,6 +1,6 @@
import type {
EditorPlugin as MarkdownPlugin,
EditorType as MarkdownEditorType
EditorPlugin as MarkdownPlugin,
EditorType as MarkdownEditorType,
} from '@toast-ui/editor/types/editor';
import type { ToolbarItemOptions as MarkdownToolbarItemOptions } from '@toast-ui/editor/types/ui';
import type { PropertiesSchema } from 'ajv/dist/types/json-schema';
@ -8,6 +8,11 @@ import type { ComponentType, FunctionComponent, ReactNode } from 'react';
import type { t, TranslateProps as ReactPolyglotTranslateProps } from 'react-polyglot';
import type { MediaFile as BackendMediaFile } from './backend';
import type { EditorControlProps } from './components/Editor/EditorControlPane/EditorControl';
import type {
SORT_DIRECTION_ASCENDING,
SORT_DIRECTION_DESCENDING,
SORT_DIRECTION_NONE,
} from './constants';
import type { formatExtensions } from './formats/formats';
import type { I18N_STRUCTURE } from './lib/i18n';
import type { AllowedEvent } from './lib/registry';
@ -162,38 +167,47 @@ export interface i18nCollection<EF extends BaseField = UnknownField>
i18n: Required<Collection<EF>>['i18n'];
}
export interface Collection<EF extends BaseField = UnknownField> {
export interface BaseCollection {
name: string;
description?: string;
icon?: string;
folder?: string;
files?: CollectionFile<EF>[];
fields: Field<EF>[];
isFetching?: boolean;
media_folder?: string;
public_folder?: string;
summary?: string;
filter?: FilterRule;
type: 'file_based_collection' | 'folder_based_collection';
extension?: string;
format?: Format;
frontmatter_delimiter?: string | [string, string];
create?: boolean;
delete?: boolean;
identifier_field?: string;
path?: string;
slug?: string;
label_singular?: string;
label: string;
sortable_fields: SortableFields;
view_filters: ViewFilter[];
view_groups: ViewGroup[];
nested?: Nested;
sortable_fields?: SortableFields;
view_filters?: ViewFilter[];
view_groups?: ViewGroup[];
i18n?: boolean | I18nInfo;
hide?: boolean;
editor?: EditorConfig;
identifier_field?: string;
path?: string;
extension?: string;
format?: Format;
frontmatter_delimiter?: string | [string, string];
slug?: string;
media_folder?: string;
public_folder?: string;
}
export interface FilesCollection<EF extends BaseField = UnknownField> extends BaseCollection {
files: CollectionFile<EF>[];
}
export interface FolderCollection<EF extends BaseField = UnknownField> extends BaseCollection {
folder: string;
fields: Field<EF>[];
create?: boolean;
delete?: boolean;
nested?: Nested;
}
export type Collection<EF extends BaseField = UnknownField> =
| FilesCollection<EF>
| FolderCollection<EF>;
export type Collections<EF extends BaseField = UnknownField> = Record<string, Collection<EF>>;
export interface MediaLibraryInstance {
@ -220,7 +234,10 @@ export interface DisplayURLState {
export type TranslatedProps<T> = T & ReactPolyglotTranslateProps;
export type GetAssetFunction = (path: string, field?: Field) => Promise<AssetProxy>;
export type GetAssetFunction<F extends BaseField = UnknownField> = (
path: string,
field?: F,
) => Promise<AssetProxy>;
export interface WidgetControlProps<T, F extends BaseField = UnknownField> {
collection: Collection<F>;
@ -230,7 +247,7 @@ export interface WidgetControlProps<T, F extends BaseField = UnknownField> {
fieldsErrors: FieldsErrors;
submitted: boolean;
forList: boolean;
getAsset: GetAssetFunction;
getAsset: GetAssetFunction<F>;
isDisabled: boolean;
isFieldDuplicate: EditorControlProps['isFieldDuplicate'];
isFieldHidden: EditorControlProps['isFieldHidden'];
@ -250,22 +267,16 @@ export interface WidgetControlProps<T, F extends BaseField = UnknownField> {
value: T | undefined | null;
}
export interface WidgetPreviewProps<
T = unknown,
F extends BaseField = UnknownField
> {
export interface WidgetPreviewProps<T = unknown, F extends BaseField = UnknownField> {
config: Config<F>;
collection: Collection<F>;
entry: Entry;
field: RenderedField<F>;
getAsset: GetAssetFunction;
getAsset: GetAssetFunction<F>;
value: T | undefined | null;
}
export type WidgetPreviewComponent<
T = unknown,
F extends BaseField = UnknownField
> =
export type WidgetPreviewComponent<T = unknown, F extends BaseField = UnknownField> =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| React.ReactElement<unknown, string | React.JSXElementConstructor<any>>
| ComponentType<WidgetPreviewProps<T, F>>;
@ -288,14 +299,15 @@ export interface TemplatePreviewProps<T = EntryData, EF extends BaseField = Unkn
entry: Entry<T>;
document: Document | undefined | null;
window: Window | undefined | null;
getAsset: GetAssetFunction;
getAsset: GetAssetFunction<Field<EF>>;
widgetFor: (name: T extends EntryData ? string : keyof T) => ReactNode;
widgetsFor: WidgetsFor<T>;
}
export type TemplatePreviewComponent<T = EntryData, EF extends BaseField = UnknownField> = ComponentType<
TemplatePreviewProps<T, EF>
>;
export type TemplatePreviewComponent<
T = EntryData,
EF extends BaseField = UnknownField,
> = ComponentType<TemplatePreviewProps<T, EF>>;
export interface WidgetOptions<T = unknown, F extends BaseField = UnknownField> {
validator?: Widget<T, F>['validator'];
@ -490,6 +502,7 @@ export interface BaseField {
pattern?: [string, string];
i18n?: boolean | 'translate' | 'duplicate' | 'none';
comment?: string;
widget: string;
}
export interface BooleanField extends BaseField {
@ -623,7 +636,7 @@ export interface HiddenField extends BaseField {
export interface StringOrTextField extends BaseField {
// This is the default widget, so declaring its type is optional.
widget?: 'string' | 'text';
widget: 'string' | 'text';
default?: string;
}
@ -662,11 +675,10 @@ export interface ViewGroup {
pattern?: string;
}
export enum SortDirection {
Ascending = 'Ascending',
Descending = 'Descending',
None = 'None',
}
export type SortDirection =
| typeof SORT_DIRECTION_ASCENDING
| typeof SORT_DIRECTION_DESCENDING
| typeof SORT_DIRECTION_NONE;
export interface SortableFieldsDefault {
field: string;
@ -870,3 +882,8 @@ export interface MarkdownEditorOptions {
toolbarItems?: MarkdownToolbarItemsFactory;
plugins?: MarkdownPluginFactory[];
}
export enum CollectionType {
FOLDER,
FILES,
}

View File

@ -11,7 +11,7 @@ import {
addFileTemplateFields,
compileStringTemplate,
keyToPathArray,
parseDateFromEntry,
parseDateFromEntry
} from './widgets/stringTemplate';
import type { Collection, Config, Entry, EntryData, Slug } from '../interface';
@ -111,7 +111,9 @@ export function summaryFormatter(summaryTemplate: string, entry: Entry, collecti
const date = parseDateFromEntry(entry, selectInferedField(collection, 'date')) || null;
const identifier = get(entryData, keyToPathArray(selectIdentifier(collection)));
entryData = addFileTemplateFields(entry.path, entryData, collection.folder) ?? {};
entryData =
addFileTemplateFields(entry.path, entryData, 'folder' in collection ? collection.folder : '') ??
{};
// allow commit information in summary template
if (entry.author && !selectField(collection, COMMIT_AUTHOR)) {
entryData = set(entryData, COMMIT_AUTHOR, entry.author);
@ -136,7 +138,11 @@ export function folderFormatter(
}
let fields = set(entry.data, folderKey, defaultFolder) as EntryData;
fields = addFileTemplateFields(entry.path, fields, collection.folder);
fields = addFileTemplateFields(
entry.path,
fields,
'folder' in collection ? collection.folder : '',
);
const date = parseDateFromEntry(entry, selectInferedField(collection, 'date')) || null;
const identifier = get(fields, keyToPathArray(selectIdentifier(collection)));

View File

@ -22,13 +22,13 @@ export enum I18N_FIELD {
NONE = 'none',
}
export function hasI18n(collection: Collection): collection is i18nCollection {
export function hasI18n(collection: Collection | i18nCollection): collection is i18nCollection {
return I18N in collection;
}
export function getI18nInfo(collection: i18nCollection): I18nInfo;
export function getI18nInfo(collection: Collection): I18nInfo | null;
export function getI18nInfo(collection: Collection): I18nInfo | null {
export function getI18nInfo(collection: Collection | i18nCollection): I18nInfo | null {
if (!hasI18n(collection) || typeof collection[I18N] !== 'object') {
return null;
}

View File

@ -5,6 +5,7 @@ import type {
BackendClass,
BackendInitializer,
BackendInitializerOptions,
BaseField,
Config,
CustomIcon,
Entry,
@ -19,6 +20,7 @@ import type {
PreviewStyle,
PreviewStyleOptions,
TemplatePreviewComponent,
UnknownField,
Widget,
WidgetOptions,
WidgetParam,
@ -124,21 +126,21 @@ export function getPreviewTemplate(name: string): TemplatePreviewComponent<Entry
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function registerWidget(widgets: WidgetParam<any, any>[]): void;
export function registerWidget(widget: WidgetParam): void;
export function registerWidget<T = unknown>(
export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
name: string,
control: string | Widget<T>['control'],
preview?: Widget<T>['preview'],
options?: WidgetOptions,
control: string | Widget<T, F>['control'],
preview?: Widget<T, F>['preview'],
options?: WidgetOptions<T, F>,
): void;
export function registerWidget<T = unknown>(
name: string | WidgetParam<T> | WidgetParam[],
control?: string | Widget<T>['control'],
preview?: Widget<T>['preview'],
export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
name: string | WidgetParam<T, F> | WidgetParam[],
control?: string | Widget<T, F>['control'],
preview?: Widget<T, F>['preview'],
{
schema,
validator = () => false,
getValidValue = (value: unknown) => value,
}: WidgetOptions = {},
getValidValue = (value: T | null | undefined) => value,
}: WidgetOptions<T, F> = {},
): void {
if (Array.isArray(name)) {
name.forEach(widget => {
@ -184,12 +186,12 @@ export function registerWidget<T = unknown>(
throw Error(`Widget "${widgetName}" registered without \`controlComponent\`.`);
}
registry.widgets[widgetName] = {
control: control as Widget['control'],
preview: preview as Widget['preview'],
validator: validator as Widget['validator'],
getValidValue: getValidValue as Widget['getValidValue'],
control,
preview,
validator,
getValidValue,
schema,
};
} as unknown as Widget;
} else {
console.error('`registerWidget` failed, called with incorrect arguments.');
}

View File

@ -1,7 +1,6 @@
import get from 'lodash/get';
import { useMemo } from 'react';
import { FILES, FOLDER } from '../../constants/collectionTypes';
import { COMMIT_AUTHOR, COMMIT_DATE } from '../../constants/commitProps';
import {
IDENTIFIER_FIELDS,
@ -19,114 +18,94 @@ import type { Backend } from '../../backend';
import type { InferredField } from '../../constants/fieldInference';
import type {
Collection,
CollectionFile,
Config,
Entry,
Field,
FilesCollection,
ObjectField,
SortableField,
} from '../../interface';
const selectors = {
[FOLDER]: {
entryExtension(collection: Collection) {
return (collection.extension || formatExtensions[collection.format ?? 'frontmatter']).replace(
/^\./,
'',
);
},
fields(collection: Collection) {
return collection.fields;
},
entryPath(collection: Collection, slug: string) {
const folder = (collection.folder as string).replace(/\/$/, '');
return `${folder}/${slug}.${this.entryExtension(collection)}`;
},
entrySlug(collection: Collection, path: string) {
const folder = (collection.folder as string).replace(/\/$/, '');
const slug = path
.split(folder + '/')
.pop()
?.replace(new RegExp(`\\.${this.entryExtension(collection)}$`), '');
return slug;
},
allowNewEntries(collection: Collection) {
return collection.create;
},
allowDeletion(collection: Collection) {
return collection.delete ?? true;
},
templateName(collection: Collection) {
return collection.name;
},
},
[FILES]: {
fileForEntry(collection: Collection, slug?: string) {
const files = collection.files;
if (!slug) {
return files?.[0];
}
return files && files.filter(f => f?.name === slug)?.[0];
},
fields(collection: Collection, slug?: string) {
const file = this.fileForEntry(collection, slug);
return file && file.fields;
},
entryPath(collection: Collection, slug: string) {
const file = this.fileForEntry(collection, slug);
return file && file.file;
},
entrySlug(collection: Collection, path: string) {
const file = (collection.files as CollectionFile[]).filter(f => f?.file === path)?.[0];
return file && file.name;
},
entryLabel(collection: Collection, slug: string) {
const file = this.fileForEntry(collection, slug);
return file && file.label;
},
allowNewEntries() {
return false;
},
allowDeletion(collection: Collection) {
return collection.delete ?? false;
},
templateName(_collection: Collection, slug: string) {
return slug;
},
},
};
function fileForEntry(collection: FilesCollection, slug?: string) {
const files = collection.files;
if (!slug) {
return files?.[0];
}
return files && files.filter(f => f?.name === slug)?.[0];
}
export function selectFields(collection: Collection, slug?: string) {
return selectors[collection.type].fields(collection, slug);
if ('fields' in collection) {
return collection.fields;
}
const file = fileForEntry(collection, slug);
return file && file.fields;
}
export function selectFolderEntryExtension(collection: Collection) {
return selectors[FOLDER].entryExtension(collection);
return (collection.extension || formatExtensions[collection.format ?? 'frontmatter']).replace(
/^\./,
'',
);
}
export function selectFileEntryLabel(collection: Collection, slug: string) {
return selectors[FILES].entryLabel(collection, slug);
if ('fields' in collection) {
return undefined;
}
const file = fileForEntry(collection, slug);
return file && file.label;
}
export function selectEntryPath(collection: Collection, slug: string) {
return selectors[collection.type].entryPath(collection, slug);
if ('fields' in collection) {
const folder = collection.folder.replace(/\/$/, '');
return `${folder}/${slug}.${selectFolderEntryExtension(collection)}`;
}
const file = fileForEntry(collection, slug);
return file && file.file;
}
export function selectEntrySlug(collection: Collection, path: string) {
return selectors[collection.type].entrySlug(collection, path);
if ('fields' in collection) {
const folder = (collection.folder as string).replace(/\/$/, '');
const slug = path
.split(folder + '/')
.pop()
?.replace(new RegExp(`\\.${selectFolderEntryExtension(collection)}$`), '');
return slug;
}
const file = collection.files.filter(f => f?.file === path)?.[0];
return file && file.name;
}
export function selectAllowNewEntries(collection: Collection) {
return selectors[collection.type].allowNewEntries(collection);
if ('fields' in collection) {
return collection.create ?? true;
}
return false;
}
export function selectAllowDeletion(collection: Collection) {
return selectors[collection.type].allowDeletion(collection);
if ('fields' in collection) {
return collection.delete ?? true;
}
return false;
}
export function selectTemplateName(collection: Collection, slug: string) {
return selectors[collection.type].templateName(collection, slug);
if ('fields' in collection) {
return collection.name;
}
return slug;
}
export function selectEntryCollectionTitle(collection: Collection, entry: Entry): string {
@ -137,7 +116,7 @@ export function selectEntryCollectionTitle(collection: Collection, entry: Entry)
}
// if the collection is a file collection return the label of the entry
if (collection.type == FILES) {
if ('files' in collection && collection.files) {
const label = selectFileEntryLabel(collection, entry.slug);
if (label) {
return label;
@ -250,7 +229,7 @@ function getFieldsWithMediaFolders(fields: Field[]) {
return fieldsWithMediaFolders;
}
export function getFileFromSlug(collection: Collection, slug: string) {
export function getFileFromSlug(collection: FilesCollection, slug: string) {
return collection.files?.find(f => f.name === slug);
}
@ -258,7 +237,7 @@ export function selectFieldsWithMediaFolders(collection: Collection, slug: strin
if ('folder' in collection) {
const fields = collection.fields;
return getFieldsWithMediaFolders(fields);
} else if ('files' in collection) {
} else {
const fields = getFileFromSlug(collection, slug)?.fields || [];
return getFieldsWithMediaFolders(fields);
}
@ -274,17 +253,15 @@ export function selectMediaFolders(config: Config, collection: Collection, entry
if (file) {
folders.unshift(selectMediaFolder(config, collection, entry, undefined));
}
}
if ('media_folder' in collection) {
} else if ('media_folder' in collection) {
// stop evaluating media folders at collection level
const newCollection = { ...collection };
delete newCollection.files;
folders.unshift(selectMediaFolder(config, newCollection, entry, undefined));
}
return [...new Set(...folders)];
}
export function getFieldsNames(fields: (Field | Field)[] | undefined, prefix = '') {
export function getFieldsNames(fields: Field[] | undefined, prefix = '') {
let names = fields?.map(f => `${prefix}${f.name}`) ?? [];
fields?.forEach((f, index) => {
@ -347,7 +324,9 @@ export function updateFieldByKey(
}
}
collection.fields = traverseFields(collection.fields ?? [], updateAndBreak, () => updated);
if ('fields' in collection) {
collection.fields = traverseFields(collection.fields ?? [], updateAndBreak, () => updated);
}
return collection;
}
@ -355,7 +334,7 @@ export function updateFieldByKey(
export function selectIdentifier(collection: Collection) {
const identifier = collection.identifier_field;
const identifierFields = identifier ? [identifier, ...IDENTIFIER_FIELDS] : [...IDENTIFIER_FIELDS];
const fieldNames = getFieldsNames(collection.fields ?? []);
const fieldNames = getFieldsNames('fields' in collection ? collection.fields ?? [] : []);
return identifierFields.find(id =>
fieldNames.find(name => name.toLowerCase().trim() === id.toLowerCase().trim()),
);
@ -377,7 +356,7 @@ export function selectInferedField(collection: Collection, fieldName: string) {
}
>
)[fieldName];
const fields = collection.fields as (Field | Field)[];
const fields = 'fields' in collection ? collection.fields ?? [] : [];
let field;
// If collection has no fields or fieldName is not defined within inferables list, return null

View File

@ -6,15 +6,18 @@ import type { Collection, Field } from '../../interface';
export function selectField(collection: Collection, key: string) {
const array = keyToPathArray(key);
let name: string | undefined;
let field;
let fields = collection.fields ?? [];
while ((name = array.shift()) && fields) {
field = fields.find(f => f.name === name);
if (field) {
if ('fields' in field) {
fields = field?.fields ?? [];
} else if ('types' in field) {
fields = field?.types ?? [];
let field: Field | undefined;
if ('fields' in collection) {
let fields = collection.fields ?? [];
while ((name = array.shift()) && fields) {
field = fields.find(f => f.name === name);
if (field) {
if ('fields' in field) {
fields = field?.fields ?? [];
} else if ('types' in field) {
fields = field?.types ?? [];
}
}
}
}

View File

@ -49,7 +49,7 @@ function hasCustomFolder(
return true;
}
if (collection.files) {
if ('files' in collection) {
const file = getFileField(collection.files, slug);
if (file && file[folderKey]) {
return true;
@ -78,7 +78,7 @@ function evaluateFolder(
collection[folderKey] = `{{${folderKey}}}`;
}
if (collection.files) {
if ('files' in collection) {
// files collection evaluate the collection template
// then move on to the specific file configuration denoted by the slug
currentFolder = folderFormatter(
@ -244,7 +244,7 @@ export function selectMediaFolder(
const entryPath = entryMap?.path;
mediaFolder = entryPath
? join(dirname(entryPath), folder)
: join(collection!.folder as string, DRAFT_MEDIA_FILES);
: join(collection && 'folder' in collection ? collection.folder : '', DRAFT_MEDIA_FILES);
}
}

View File

@ -21,11 +21,11 @@ import {
GROUP_ENTRIES_SUCCESS,
SORT_ENTRIES_FAILURE,
SORT_ENTRIES_REQUEST,
SORT_ENTRIES_SUCCESS,
SORT_ENTRIES_SUCCESS
} from '../actions/entries';
import { SEARCH_ENTRIES_SUCCESS } from '../actions/search';
import { SORT_DIRECTION_ASCENDING, SORT_DIRECTION_NONE } from '../constants';
import { VIEW_STYLE_LIST } from '../constants/collectionViews';
import { SortDirection } from '../interface';
import { set } from '../lib/util/object.util';
import { selectSortDataPath } from '../lib/util/sort.util';
@ -44,7 +44,7 @@ import type {
Pages,
Sort,
SortMap,
SortObject,
SortObject
} from '../interface';
import type { EntryDraftState } from './entryDraft';
@ -568,7 +568,7 @@ export function selectEntriesGroupField(entries: EntriesState, collection: strin
export function selectEntriesSortFields(entries: EntriesState, collection: string) {
const sort = selectEntriesSort(entries, collection);
const values = Object.values(sort ?? {}).filter(v => v?.direction !== SortDirection.None) || [];
const values = Object.values(sort ?? {}).filter(v => v?.direction !== SORT_DIRECTION_NONE) || [];
return values;
}
@ -605,7 +605,7 @@ export function selectEntries(state: EntriesState, collection: Collection) {
const sortFields = selectEntriesSortFields(state, collectionName);
if (sortFields && sortFields.length > 0) {
const keys = sortFields.map(v => selectSortDataPath(collection, v.key));
const orders = sortFields.map(v => (v.direction === SortDirection.Ascending ? 'asc' : 'desc'));
const orders = sortFields.map(v => (v.direction === SORT_DIRECTION_ASCENDING ? 'asc' : 'desc'));
entries = orderBy(entries, keys, orders);
}

View File

@ -1,13 +0,0 @@
declare module 'tomlify-j0.4' {
interface ToTomlOptions {
replace?(key: string, value: unknown): string | false;
sort?(a: string, b: string): number;
}
interface Tomlify {
toToml(data: object, options?: ToTomlOptions): string;
}
const tomlify: Tomlify;
export default tomlify;
}

View File

@ -7,7 +7,7 @@ import type { FileOrImageField, GetAssetFunction, WidgetPreviewProps } from '../
interface FileLinkProps {
value: string;
getAsset: GetAssetFunction;
getAsset: GetAssetFunction<FileOrImageField>;
field: FileOrImageField;
}
@ -40,7 +40,7 @@ const StyledFileLink = styled(FileLink)`
interface FileLinkListProps {
values: string[];
getAsset: GetAssetFunction;
getAsset: GetAssetFunction<FileOrImageField>;
field: FileOrImageField;
}

View File

@ -126,7 +126,7 @@ const SortableImageButtons = ({ onRemove, onReplace }: SortableImageButtonsProps
interface SortableImageProps {
itemValue: string;
getAsset: GetAssetFunction;
getAsset: GetAssetFunction<FileOrImageField>;
field: FileOrImageField;
onRemove: MouseEventHandler;
onReplace: MouseEventHandler;
@ -167,7 +167,7 @@ const StyledSortableMultiImageWrapper = styled('div')`
interface SortableMultiImageWrapperProps {
items: string[];
getAsset: GetAssetFunction;
getAsset: GetAssetFunction<FileOrImageField>;
field: FileOrImageField;
onRemoveOne: (index: number) => MouseEventHandler;
onReplaceOne: (index: number) => MouseEventHandler;

View File

@ -18,7 +18,7 @@ const StyledImage = styled(({ src }: StyledImageProps) => (
`;
interface ImageAssetProps {
getAsset: GetAssetFunction;
getAsset: GetAssetFunction<FileOrImageField>;
value: string;
field: FileOrImageField;
}

View File

@ -0,0 +1,11 @@
import React from 'react';
import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer';
import type { ListField, ObjectValue, WidgetPreviewProps } from '../../interface';
function ObjectPreview({ field }: WidgetPreviewProps<ObjectValue[], ListField>) {
return <WidgetPreviewContainer>{field.fields ?? null}</WidgetPreviewContainer>;
}
export default ObjectPreview;

View File

@ -1,5 +1,5 @@
import ObjectPreview from '../object/ObjectPreview';
import controlComponent from './ListControl';
import previewComponent from './ListPreview';
import schema from './schema';
import type { ListField, WidgetParam, ObjectValue } from '../../interface';
@ -8,7 +8,7 @@ const ListWidget = (): WidgetParam<ObjectValue[], ListField> => {
return {
name: 'list',
controlComponent,
previewComponent: ObjectPreview,
previewComponent,
options: {
schema,
},

View File

@ -5,7 +5,7 @@ import type AssetProxy from '../../../valueObjects/AssetProxy';
interface UseMediaProps {
value: string | undefined | null;
getAsset: GetAssetFunction;
getAsset: GetAssetFunction<MarkdownField>;
field: MarkdownField;
}

View File

@ -7,7 +7,7 @@ import Outline from '../../components/UI/Outline';
import { transientOptions } from '../../lib';
import { compileStringTemplate } from '../../lib/widgets/stringTemplate';
import type { ListField, ObjectField, ObjectValue, WidgetControlProps } from '../../interface';
import type { ObjectField, ObjectValue, WidgetControlProps } from '../../interface';
const StyledObjectControlWrapper = styled('div')`
position: relative;
@ -59,7 +59,7 @@ const ObjectControl = ({
i18n,
hasErrors,
value = {},
}: WidgetControlProps<ObjectValue, ObjectField | ListField>) => {
}: WidgetControlProps<ObjectValue, ObjectField>) => {
const [collapsed, setCollapsed] = useState(false);
const handleCollapseToggle = useCallback(() => {

View File

@ -2,11 +2,9 @@ import React from 'react';
import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer';
import type { WidgetPreviewProps, ObjectField, ListField, ObjectValue } from '../../interface';
import type { ObjectField, ObjectValue, WidgetPreviewProps } from '../../interface';
function ObjectPreview({
field,
}: WidgetPreviewProps<ObjectValue | ObjectValue[], ObjectField | ListField>) {
function ObjectPreview({ field }: WidgetPreviewProps<ObjectValue, ObjectField>) {
return <WidgetPreviewContainer>{field.fields ?? null}</WidgetPreviewContainer>;
}

View File

@ -2,9 +2,9 @@ import controlComponent from './ObjectControl';
import previewComponent from './ObjectPreview';
import schema from './schema';
import type { ListField, ObjectField, WidgetParam, ObjectValue } from '../../interface';
import type { ObjectField, ObjectValue, WidgetParam } from '../../interface';
const ObjectWidget = (): WidgetParam<ObjectValue, ObjectField | ListField> => {
const ObjectWidget = (): WidgetParam<ObjectValue, ObjectField> => {
return {
name: 'object',
controlComponent,

File diff suppressed because it is too large Load Diff