import { Map, List, fromJS, OrderedMap, Set } from 'immutable'; import { dirname, join } from 'path'; import { isAbsolutePath, basename } from 'netlify-cms-lib-util'; import { trim, once, sortBy, set, orderBy, groupBy } from 'lodash'; import { stringTemplate } from 'netlify-cms-lib-widgets'; import { SortDirection } from '../types/redux'; import { folderFormatter } from '../lib/formatters'; import { selectSortDataPath } from './collections'; import { SEARCH_ENTRIES_SUCCESS } from '../actions/search'; import { ENTRY_REQUEST, ENTRY_SUCCESS, ENTRY_FAILURE, ENTRIES_REQUEST, ENTRIES_SUCCESS, ENTRIES_FAILURE, ENTRY_DELETE_SUCCESS, SORT_ENTRIES_REQUEST, SORT_ENTRIES_SUCCESS, SORT_ENTRIES_FAILURE, FILTER_ENTRIES_REQUEST, FILTER_ENTRIES_SUCCESS, FILTER_ENTRIES_FAILURE, GROUP_ENTRIES_REQUEST, GROUP_ENTRIES_SUCCESS, GROUP_ENTRIES_FAILURE, CHANGE_VIEW_STYLE, } from '../actions/entries'; import { VIEW_STYLE_LIST } from '../constants/collectionViews'; import { joinUrlPath } from '../lib/urlHelper'; import type { EntriesAction, EntryRequestPayload, EntrySuccessPayload, EntriesSuccessPayload, EntryObject, Entries, CmsConfig, Collection, EntryFailurePayload, EntryDeletePayload, EntriesRequestPayload, EntryDraft, EntryMap, EntryField, CollectionFiles, EntriesSortRequestPayload, EntriesSortFailurePayload, SortMap, SortObject, Sort, Filter, Group, FilterMap, GroupMap, EntriesFilterRequestPayload, EntriesFilterFailurePayload, ChangeViewStylePayload, EntriesGroupRequestPayload, EntriesGroupFailurePayload, GroupOfEntries, } from '../types/redux'; const { keyToPathArray } = stringTemplate; let collection: string; let loadedEntries: EntryObject[]; let append: boolean; let page: number; let slug: string; const storageSortKey = 'netlify-cms.entries.sort'; const viewStyleKey = 'netlify-cms.entries.viewStyle'; type StorageSortObject = SortObject & { index: number }; type StorageSort = { [collection: string]: { [key: string]: StorageSortObject } }; const loadSort = once(() => { const sortString = localStorage.getItem(storageSortKey); if (sortString) { try { const sort: StorageSort = JSON.parse(sortString); let map = Map() as Sort; Object.entries(sort).forEach(([collection, sort]) => { let orderedMap = OrderedMap() as SortMap; sortBy(Object.values(sort), ['index']).forEach(value => { const { key, direction } = value; orderedMap = orderedMap.set(key, fromJS({ key, direction })); }); map = map.set(collection, orderedMap); }); return map; } catch (e) { return Map() as Sort; } } return Map() as Sort; }); function clearSort() { localStorage.removeItem(storageSortKey); } function persistSort(sort: Sort | undefined) { if (sort) { const storageSort: StorageSort = {}; sort.keySeq().forEach(key => { const collection = key as string; const sortObjects = (sort.get(collection).valueSeq().toJS() as SortObject[]).map( (value, index) => ({ ...value, index }), ); sortObjects.forEach(value => { set(storageSort, [collection, value.key], value); }); }); localStorage.setItem(storageSortKey, JSON.stringify(storageSort)); } else { clearSort(); } } const loadViewStyle = once(() => { const viewStyle = localStorage.getItem(viewStyleKey); if (viewStyle) { return viewStyle; } localStorage.setItem(viewStyleKey, VIEW_STYLE_LIST); return VIEW_STYLE_LIST; }); function clearViewStyle() { localStorage.removeItem(viewStyleKey); } function persistViewStyle(viewStyle: string | undefined) { if (viewStyle) { localStorage.setItem(viewStyleKey, viewStyle); } else { clearViewStyle(); } } function entries( state = Map({ entities: Map(), pages: Map(), sort: loadSort(), viewStyle: loadViewStyle() }), action: EntriesAction, ) { switch (action.type) { case ENTRY_REQUEST: { const payload = action.payload as EntryRequestPayload; return state.setIn(['entities', `${payload.collection}.${payload.slug}`, 'isFetching'], true); } case ENTRY_SUCCESS: { const payload = action.payload as EntrySuccessPayload; collection = payload.collection; slug = payload.entry.slug; return state.withMutations(map => { map.setIn(['entities', `${collection}.${slug}`], fromJS(payload.entry)); const ids = map.getIn(['pages', collection, 'ids'], List()); if (!ids.includes(slug)) { map.setIn(['pages', collection, 'ids'], ids.unshift(slug)); } }); } case ENTRIES_REQUEST: { const payload = action.payload as EntriesRequestPayload; const newState = state.withMutations(map => { map.setIn(['pages', payload.collection, 'isFetching'], true); }); return newState; } case ENTRIES_SUCCESS: { const payload = action.payload as EntriesSuccessPayload; collection = payload.collection; loadedEntries = payload.entries; append = payload.append; page = payload.page; return state.withMutations(map => { loadedEntries.forEach(entry => map.setIn( ['entities', `${collection}.${entry.slug}`], fromJS(entry).set('isFetching', false), ), ); const ids = List(loadedEntries.map(entry => entry.slug)); map.setIn( ['pages', collection], Map({ page, ids: append ? map.getIn(['pages', collection, 'ids'], List()).concat(ids) : ids, }), ); }); } case ENTRIES_FAILURE: return state.setIn(['pages', action.meta.collection, 'isFetching'], false); case ENTRY_FAILURE: { const payload = action.payload as EntryFailurePayload; return state.withMutations(map => { map.setIn(['entities', `${payload.collection}.${payload.slug}`, 'isFetching'], false); map.setIn( ['entities', `${payload.collection}.${payload.slug}`, 'error'], payload.error.message, ); }); } case SEARCH_ENTRIES_SUCCESS: { const payload = action.payload as EntriesSuccessPayload; loadedEntries = payload.entries; return state.withMutations(map => { loadedEntries.forEach(entry => map.setIn( ['entities', `${entry.collection}.${entry.slug}`], fromJS(entry).set('isFetching', false), ), ); }); } case ENTRY_DELETE_SUCCESS: { const payload = action.payload as EntryDeletePayload; return state.withMutations(map => { map.deleteIn(['entities', `${payload.collectionName}.${payload.entrySlug}`]); map.updateIn(['pages', payload.collectionName, 'ids'], (ids: string[]) => ids.filter(id => id !== payload.entrySlug), ); }); } case SORT_ENTRIES_REQUEST: { const payload = action.payload as EntriesSortRequestPayload; const { collection, key, direction } = payload; const newState = state.withMutations(map => { const sort = OrderedMap({ [key]: Map({ key, direction }) }); map.setIn(['sort', collection], sort); map.setIn(['pages', collection, 'isFetching'], true); map.deleteIn(['pages', collection, 'page']); }); persistSort(newState.get('sort') as Sort); return newState; } case GROUP_ENTRIES_SUCCESS: case FILTER_ENTRIES_SUCCESS: case SORT_ENTRIES_SUCCESS: { const payload = action.payload as { collection: string; entries: EntryObject[] }; const { collection, entries } = payload; loadedEntries = entries; const newState = state.withMutations(map => { loadedEntries.forEach(entry => map.setIn( ['entities', `${entry.collection}.${entry.slug}`], fromJS(entry).set('isFetching', false), ), ); map.setIn(['pages', collection, 'isFetching'], false); const ids = List(loadedEntries.map(entry => entry.slug)); map.setIn( ['pages', collection], Map({ page: 1, ids, }), ); }); return newState; } case SORT_ENTRIES_FAILURE: { const payload = action.payload as EntriesSortFailurePayload; const { collection, key } = payload; const newState = state.withMutations(map => { map.deleteIn(['sort', collection, key]); map.setIn(['pages', collection, 'isFetching'], false); }); persistSort(newState.get('sort') as Sort); return newState; } case FILTER_ENTRIES_REQUEST: { const payload = action.payload as EntriesFilterRequestPayload; const { collection, filter } = payload; const newState = state.withMutations(map => { const current: FilterMap = map.getIn(['filter', collection, filter.id], fromJS(filter)); map.setIn( ['filter', collection, current.get('id')], current.set('active', !current.get('active')), ); }); return newState; } case FILTER_ENTRIES_FAILURE: { const payload = action.payload as EntriesFilterFailurePayload; const { collection, filter } = payload; const newState = state.withMutations(map => { map.deleteIn(['filter', collection, filter.id]); map.setIn(['pages', collection, 'isFetching'], false); }); return newState; } case GROUP_ENTRIES_REQUEST: { const payload = action.payload as EntriesGroupRequestPayload; const { collection, group } = payload; const newState = state.withMutations(map => { const current: GroupMap = map.getIn(['group', collection, group.id], fromJS(group)); map.deleteIn(['group', collection]); map.setIn( ['group', collection, current.get('id')], current.set('active', !current.get('active')), ); }); return newState; } case GROUP_ENTRIES_FAILURE: { const payload = action.payload as EntriesGroupFailurePayload; const { collection, group } = payload; const newState = state.withMutations(map => { map.deleteIn(['group', collection, group.id]); map.setIn(['pages', collection, 'isFetching'], false); }); return newState; } case CHANGE_VIEW_STYLE: { const payload = action.payload as unknown as ChangeViewStylePayload; const { style } = payload; const newState = state.withMutations(map => { map.setIn(['viewStyle'], style); }); persistViewStyle(newState.get('viewStyle') as string); return newState; } default: return state; } } export function selectEntriesSort(entries: Entries, collection: string) { const sort = entries.get('sort') as Sort | undefined; return sort?.get(collection); } export function selectEntriesFilter(entries: Entries, collection: string) { const filter = entries.get('filter') as Filter | undefined; return filter?.get(collection) || Map(); } export function selectEntriesGroup(entries: Entries, collection: string) { const group = entries.get('group') as Group | undefined; return group?.get(collection) || Map(); } export function selectEntriesGroupField(entries: Entries, collection: string) { const groups = selectEntriesGroup(entries, collection); const value = groups?.valueSeq().find(v => v?.get('active') === true); return value; } export function selectEntriesSortFields(entries: Entries, collection: string) { const sort = selectEntriesSort(entries, collection); const values = sort ?.valueSeq() .filter(v => v?.get('direction') !== SortDirection.None) .toArray() || []; return values; } export function selectEntriesFilterFields(entries: Entries, collection: string) { const filter = selectEntriesFilter(entries, collection); const values = filter ?.valueSeq() .filter(v => v?.get('active') === true) .toArray() || []; return values; } export function selectViewStyle(entries: Entries) { return entries.get('viewStyle'); } export function selectEntry(state: Entries, collection: string, slug: string) { return state.getIn(['entities', `${collection}.${slug}`]); } export function selectPublishedSlugs(state: Entries, collection: string) { return state.getIn(['pages', collection, 'ids'], List()); } function getPublishedEntries(state: Entries, collectionName: string) { const slugs = selectPublishedSlugs(state, collectionName); const entries = slugs && (slugs.map(slug => selectEntry(state, collectionName, slug as string)) as List); return entries; } export function selectEntries(state: Entries, collection: Collection) { const collectionName = collection.get('name'); let entries = getPublishedEntries(state, collectionName); const sortFields = selectEntriesSortFields(state, collectionName); if (sortFields && sortFields.length > 0) { const keys = sortFields.map(v => selectSortDataPath(collection, v.get('key'))); const orders = sortFields.map(v => v.get('direction') === SortDirection.Ascending ? 'asc' : 'desc', ); entries = fromJS(orderBy(entries.toJS(), keys, orders)); } const filters = selectEntriesFilterFields(state, collectionName); if (filters && filters.length > 0) { entries = entries .filter(e => { const allMatched = filters.every(f => { const pattern = f.get('pattern'); const field = f.get('field'); const data = e!.get('data') || Map(); const toMatch = data.getIn(keyToPathArray(field)); const matched = toMatch !== undefined && new RegExp(String(pattern)).test(String(toMatch)); return matched; }); return allMatched; }) .toList(); } return entries; } function getGroup(entry: EntryMap, selectedGroup: GroupMap) { const label = selectedGroup.get('label'); const field = selectedGroup.get('field'); const fieldData = entry.getIn(['data', ...keyToPathArray(field)]); if (fieldData === undefined) { return { id: 'missing_value', label, value: fieldData, }; } const dataAsString = String(fieldData); if (selectedGroup.has('pattern')) { const pattern = selectedGroup.get('pattern'); let value = ''; try { const regex = new RegExp(pattern); const matched = dataAsString.match(regex); if (matched) { value = matched[0]; } } catch (e) { console.warn(`Invalid view group pattern '${pattern}' for field '${field}'`, e); } return { id: `${label}${value}`, label, value, }; } return { id: `${label}${fieldData}`, label, value: typeof fieldData === 'boolean' ? fieldData : dataAsString, }; } export function selectGroups(state: Entries, collection: Collection) { const collectionName = collection.get('name'); const entries = getPublishedEntries(state, collectionName); const selectedGroup = selectEntriesGroupField(state, collectionName); if (selectedGroup === undefined) { return []; } let groups: Record = {}; const groupedEntries = groupBy(entries.toArray(), entry => { const group = getGroup(entry, selectedGroup); groups = { ...groups, [group.id]: group }; return group.id; }); const groupsArray: GroupOfEntries[] = Object.entries(groupedEntries).map(([id, entries]) => { return { ...groups[id], paths: Set(entries.map(entry => entry.get('path'))), }; }); return groupsArray; } export function selectEntryByPath(state: Entries, collection: string, path: string) { const slugs = selectPublishedSlugs(state, collection); const entries = slugs && (slugs.map(slug => selectEntry(state, collection, slug as string)) as List); return entries && entries.find(e => e?.get('path') === path); } export function selectEntriesLoaded(state: Entries, collection: string) { return !!state.getIn(['pages', collection]); } export function selectIsFetching(state: Entries, collection: string) { return state.getIn(['pages', collection, 'isFetching'], false); } const DRAFT_MEDIA_FILES = 'DRAFT_MEDIA_FILES'; function getFileField(collectionFiles: CollectionFiles, slug: string | undefined) { const file = collectionFiles.find(f => f?.get('name') === slug); return file; } function hasCustomFolder( folderKey: 'media_folder' | 'public_folder', collection: Collection | null, slug: string | undefined, field: EntryField | undefined, ) { if (!collection) { return false; } if (field && field.has(folderKey)) { return true; } if (collection.has('files')) { const file = getFileField(collection.get('files')!, slug); if (file && file.has(folderKey)) { return true; } } if (collection.has(folderKey)) { return true; } return false; } function traverseFields( folderKey: 'media_folder' | 'public_folder', config: CmsConfig, collection: Collection, entryMap: EntryMap | undefined, field: EntryField, fields: EntryField[], currentFolder: string, ): string | null { const matchedField = fields.filter(f => f === field)[0]; if (matchedField) { return folderFormatter( matchedField.has(folderKey) ? matchedField.get(folderKey)! : `{{${folderKey}}}`, entryMap, collection, currentFolder, folderKey, config.slug, ); } for (let f of fields) { if (!f.has(folderKey)) { // add identity template if doesn't exist f = f.set(folderKey, `{{${folderKey}}}`); } const folder = folderFormatter( f.get(folderKey)!, entryMap, collection, currentFolder, folderKey, config.slug, ); let fieldFolder = null; if (f.has('fields')) { fieldFolder = traverseFields( folderKey, config, collection, entryMap, field, f.get('fields')!.toArray(), folder, ); } else if (f.has('field')) { fieldFolder = traverseFields( folderKey, config, collection, entryMap, field, [f.get('field')!], folder, ); } else if (f.has('types')) { fieldFolder = traverseFields( folderKey, config, collection, entryMap, field, f.get('types')!.toArray(), folder, ); } if (fieldFolder != null) { return fieldFolder; } } return null; } function evaluateFolder( folderKey: 'media_folder' | 'public_folder', config: CmsConfig, collection: Collection, entryMap: EntryMap | undefined, field: EntryField | undefined, ) { let currentFolder = config[folderKey]!; // add identity template if doesn't exist if (!collection.has(folderKey)) { collection = collection.set(folderKey, `{{${folderKey}}}`); } if (collection.has('files')) { // files collection evaluate the collection template // then move on to the specific file configuration denoted by the slug currentFolder = folderFormatter( collection.get(folderKey)!, entryMap, collection, currentFolder, folderKey, config.slug, ); let file = getFileField(collection.get('files')!, entryMap?.get('slug')); if (file) { if (!file.has(folderKey)) { // add identity template if doesn't exist file = file.set(folderKey, `{{${folderKey}}}`); } // evaluate the file template and keep evaluating until we match our field currentFolder = folderFormatter( file.get(folderKey)!, entryMap, collection, currentFolder, folderKey, config.slug, ); if (field) { const fieldFolder = traverseFields( folderKey, config, collection, entryMap, field, file.get('fields')!.toArray(), currentFolder, ); if (fieldFolder !== null) { currentFolder = fieldFolder; } } } } else { // folder collection, evaluate the collection template // and keep evaluating until we match our field currentFolder = folderFormatter( collection.get(folderKey)!, entryMap, collection, currentFolder, folderKey, config.slug, ); if (field) { const fieldFolder = traverseFields( folderKey, config, collection, entryMap, field, collection.get('fields')!.toArray(), currentFolder, ); if (fieldFolder !== null) { currentFolder = fieldFolder; } } } return currentFolder; } export function selectMediaFolder( config: CmsConfig, collection: Collection | null, entryMap: EntryMap | undefined, field: EntryField | undefined, ) { const name = 'media_folder'; let mediaFolder = config[name]; const customFolder = hasCustomFolder(name, collection, entryMap?.get('slug'), field); console.log('[MEDIA][selectMediaFolder] mediaFolder', mediaFolder, 'customFolder', customFolder); if (customFolder) { const folder = evaluateFolder(name, config, collection!, entryMap, field); console.log('[MEDIA][selectMediaFolder] has custom folder! folder', folder); if (folder.startsWith('/')) { // return absolute paths as is mediaFolder = join(folder); console.log('[MEDIA][selectMediaFolder] folder is absolute!'); } else { const entryPath = entryMap?.get('path'); mediaFolder = entryPath ? join(dirname(entryPath), folder) : join(collection!.get('folder') as string, DRAFT_MEDIA_FILES); console.log('[MEDIA][selectMediaFolder] folder is NOT absolute! entryPath', entryPath, 'new mediaFolder', mediaFolder); } } console.log('[MEDIA][selectMediaFolder] trimming mediaFolder', trim(mediaFolder, '/')); return trim(mediaFolder, '/'); } export function selectMediaFilePath( config: CmsConfig, collection: Collection | null, entryMap: EntryMap | undefined, mediaPath: string, field: EntryField | undefined, ) { console.log('[MEDIA][selectMediaFilePath] mediaPath', mediaPath); if (isAbsolutePath(mediaPath)) { console.log('[MEDIA][selectMediaFilePath] mediaPath is absolute!'); return mediaPath; } const mediaFolder = selectMediaFolder(config, collection, entryMap, field); console.log('[MEDIA][selectMediaFilePath] final mediaFolder', mediaFolder, 'full file path', join(mediaFolder, basename(mediaPath))); return join(mediaFolder, basename(mediaPath)); } export function selectMediaFilePublicPath( config: CmsConfig, collection: Collection | null, mediaPath: string, entryMap: EntryMap | undefined, field: EntryField | undefined, ) { console.log('[MEDIA][selectMediaFilePublicPath] mediaPath', mediaPath); if (isAbsolutePath(mediaPath)) { console.log('[MEDIA][selectMediaFilePublicPath] mediaPath is absolute!'); return mediaPath; } const name = 'public_folder'; let publicFolder = config[name]!; const customFolder = hasCustomFolder(name, collection, entryMap?.get('slug'), field); console.log('[MEDIA][selectMediaFilePublicPath] publicFolder', publicFolder, 'customFolder', customFolder); if (customFolder) { publicFolder = evaluateFolder(name, config, collection!, entryMap, field); console.log('[MEDIA][selectMediaFilePublicPath] has custom folder! new public folder', publicFolder); } if (isAbsolutePath(publicFolder)) { console.log('[MEDIA][selectMediaFilePublicPath] publicFolder is absolute! publicPath', joinUrlPath(publicFolder, basename(mediaPath))); return joinUrlPath(publicFolder, basename(mediaPath)); } console.log('[MEDIA][selectMediaFilePublicPath] publicFolder is NOT absolute! publicPath', join(publicFolder, basename(mediaPath))); return join(publicFolder, basename(mediaPath)); } export function selectEditingDraft(state: EntryDraft) { const entry = state.get('entry'); const workflowDraft = entry && !entry.isEmpty(); return workflowDraft; } export default entries;