UNPKG

@wordpress/core-data

Version:
849 lines (814 loc) 28.9 kB
/** * External dependencies */ import { camelCase } from 'change-case'; /** * WordPress dependencies */ import { addQueryArgs } from '@wordpress/url'; import { decodeEntities } from '@wordpress/html-entities'; import apiFetch from '@wordpress/api-fetch'; /** * Internal dependencies */ import { STORE_NAME } from './name'; import { additionalEntityConfigLoaders, DEFAULT_ENTITY_KEY } from './entities'; import { forwardResolver, getNormalizedCommaSeparable, getUserPermissionCacheKey, getUserPermissionsFromAllowHeader, ALLOWED_RESOURCE_ACTIONS, RECEIVE_INTERMEDIATE_RESULTS } from './utils'; import { getSyncProvider } from './sync'; import { fetchBlockPatterns } from './fetch'; /** * Requests authors from the REST API. * * @param {Object|undefined} query Optional object of query parameters to * include with request. */ export const getAuthors = query => async ({ dispatch }) => { const path = addQueryArgs('/wp/v2/users/?who=authors&per_page=100', query); const users = await apiFetch({ path }); dispatch.receiveUserQuery(path, users); }; /** * Requests the current user from the REST API. */ export const getCurrentUser = () => async ({ dispatch }) => { const currentUser = await apiFetch({ path: '/wp/v2/users/me' }); dispatch.receiveCurrentUser(currentUser); }; /** * Requests an entity's record from the REST API. * * @param {string} kind Entity kind. * @param {string} name Entity name. * @param {number|string} key Record's key * @param {Object|undefined} query Optional object of query parameters to * include with request. If requesting specific * fields, fields must always include the ID. */ export const getEntityRecord = (kind, name, key = '', query) => async ({ select, dispatch, registry, resolveSelect }) => { // For back-compat, we allow querying for static templates through // wp_template. if (kind === 'postType' && name === 'wp_template' && typeof key === 'string' && // __experimentalGetDirtyEntityRecords always calls getEntityRecord // with a string key, so we need that it's not a numeric ID. !/^\d+$/.test(key)) { name = 'wp_registered_template'; } const configs = await resolveSelect.getEntitiesConfig(kind); const entityConfig = configs.find(config => config.name === name && config.kind === kind); if (!entityConfig) { return; } const lock = await dispatch.__unstableAcquireStoreLock(STORE_NAME, ['entities', 'records', kind, name, key], { exclusive: false }); try { // Entity supports configs, // use the sync algorithm instead of the old fetch behavior. if (window.__experimentalEnableSync && entityConfig.syncConfig && !query) { if (globalThis.IS_GUTENBERG_PLUGIN) { const objectId = entityConfig.getSyncObjectId(key); // Loads the persisted document. await getSyncProvider().bootstrap(entityConfig.syncObjectType, objectId, record => { dispatch.receiveEntityRecords(kind, name, record, query); }); // Bootstraps the edited document as well (and load from peers). await getSyncProvider().bootstrap(entityConfig.syncObjectType + '--edit', objectId, record => { dispatch({ type: 'EDIT_ENTITY_RECORD', kind, name, recordId: key, edits: record, meta: { undo: undefined } }); }); } } else { if (query !== undefined && query._fields) { // If requesting specific fields, items and query association to said // records are stored by ID reference. Thus, fields must always include // the ID. query = { ...query, _fields: [...new Set([...(getNormalizedCommaSeparable(query._fields) || []), entityConfig.key || DEFAULT_ENTITY_KEY])].join() }; } if (query !== undefined && query._fields) { // The resolution cache won't consider query as reusable based on the // fields, so it's tested here, prior to initiating the REST request, // and without causing `getEntityRecord` resolution to occur. const hasRecord = select.hasEntityRecord(kind, name, key, query); if (hasRecord) { return; } } const path = addQueryArgs(entityConfig.baseURL + (key ? '/' + key : ''), { ...entityConfig.baseURLParams, ...query }); const response = await apiFetch({ path, parse: false }); const record = await response.json(); const permissions = getUserPermissionsFromAllowHeader(response.headers?.get('allow')); const canUserResolutionsArgs = []; const receiveUserPermissionArgs = {}; for (const action of ALLOWED_RESOURCE_ACTIONS) { receiveUserPermissionArgs[getUserPermissionCacheKey(action, { kind, name, id: key })] = permissions[action]; canUserResolutionsArgs.push([action, { kind, name, id: key }]); } registry.batch(() => { dispatch.receiveEntityRecords(kind, name, record, query); dispatch.receiveUserPermissions(receiveUserPermissionArgs); dispatch.finishResolutions('canUser', canUserResolutionsArgs); }); } } finally { dispatch.__unstableReleaseStoreLock(lock); } }; export const getTemplateAutoDraftId = staticTemplateId => async ({ resolveSelect, dispatch }) => { const record = await resolveSelect.getEntityRecord('postType', 'wp_registered_template', staticTemplateId); const autoDraft = await dispatch.saveEntityRecord('postType', 'wp_template', { ...record, id: undefined, type: 'wp_template', status: 'auto-draft' }); await dispatch.receiveTemplateAutoDraftId(staticTemplateId, autoDraft.id); }; /** * Requests an entity's record from the REST API. */ export const getRawEntityRecord = forwardResolver('getEntityRecord'); /** * Requests an entity's record from the REST API. */ export const getEditedEntityRecord = forwardResolver('getEntityRecord'); /** * Requests the entity's records from the REST API. * * @param {string} kind Entity kind. * @param {string} name Entity name. * @param {?Object} query Query Object. If requesting specific fields, fields * must always include the ID. */ export const getEntityRecords = (kind, name, query = {}) => async ({ dispatch, registry, resolveSelect }) => { const configs = await resolveSelect.getEntitiesConfig(kind); const entityConfig = configs.find(config => config.name === name && config.kind === kind); if (!entityConfig) { return; } const lock = await dispatch.__unstableAcquireStoreLock(STORE_NAME, ['entities', 'records', kind, name], { exclusive: false }); // Keep a copy of the original query for later use in getResolutionsArgs. // The query object may be modified below (for example, when _fields is // specified), but we want to use the original query when marking // resolutions as finished. const rawQuery = { ...query }; const key = entityConfig.key || DEFAULT_ENTITY_KEY; function getResolutionsArgs(records, recordsQuery) { const queryArgs = Object.fromEntries(Object.entries(recordsQuery).filter(([k, v]) => { return ['context', '_fields'].includes(k) && !!v; })); return records.filter(record => record?.[key]).map(record => [kind, name, record[key], Object.keys(queryArgs).length > 0 ? queryArgs : undefined]); } try { if (query._fields) { // If requesting specific fields, items and query association to said // records are stored by ID reference. Thus, fields must always include // the ID. query = { ...query, _fields: [...new Set([...(getNormalizedCommaSeparable(query._fields) || []), key])].join() }; } const path = addQueryArgs(entityConfig.baseURL, { ...entityConfig.baseURLParams, ...query }); let records = [], meta; if (entityConfig.supportsPagination && query.per_page !== -1) { const response = await apiFetch({ path, parse: false }); records = Object.values(await response.json()); meta = { totalItems: parseInt(response.headers.get('X-WP-Total')), totalPages: parseInt(response.headers.get('X-WP-TotalPages')) }; } else if (query.per_page === -1 && query[RECEIVE_INTERMEDIATE_RESULTS] === true) { let page = 1; let totalPages; do { const response = await apiFetch({ path: addQueryArgs(path, { page, per_page: 100 }), parse: false }); const pageRecords = Object.values(await response.json()); totalPages = parseInt(response.headers.get('X-WP-TotalPages')); if (!meta) { meta = { totalItems: parseInt(response.headers.get('X-WP-Total')), totalPages: 1 }; } records.push(...pageRecords); registry.batch(() => { dispatch.receiveEntityRecords(kind, name, records, query, false, undefined, meta); dispatch.finishResolutions('getEntityRecord', getResolutionsArgs(pageRecords, rawQuery)); }); page++; } while (page <= totalPages); } else { records = Object.values(await apiFetch({ path })); meta = { totalItems: records.length, totalPages: 1 }; } // If we request fields but the result doesn't contain the fields, // explicitly set these fields as "undefined" // that way we consider the query "fulfilled". if (query._fields) { records = records.map(record => { query._fields.split(',').forEach(field => { if (!record.hasOwnProperty(field)) { record[field] = undefined; } }); return record; }); } registry.batch(() => { dispatch.receiveEntityRecords(kind, name, records, query, false, undefined, meta); const targetHints = records.filter(record => !!record?.[key] && !!record?._links?.self?.[0]?.targetHints?.allow).map(record => ({ id: record[key], permissions: getUserPermissionsFromAllowHeader(record._links.self[0].targetHints.allow) })); const canUserResolutionsArgs = []; const receiveUserPermissionArgs = {}; for (const targetHint of targetHints) { for (const action of ALLOWED_RESOURCE_ACTIONS) { canUserResolutionsArgs.push([action, { kind, name, id: targetHint.id }]); receiveUserPermissionArgs[getUserPermissionCacheKey(action, { kind, name, id: targetHint.id })] = targetHint.permissions[action]; } } if (targetHints.length > 0) { dispatch.receiveUserPermissions(receiveUserPermissionArgs); dispatch.finishResolutions('canUser', canUserResolutionsArgs); } dispatch.finishResolutions('getEntityRecord', getResolutionsArgs(records, rawQuery)); dispatch.__unstableReleaseStoreLock(lock); }); } catch (e) { dispatch.__unstableReleaseStoreLock(lock); } }; getEntityRecords.shouldInvalidate = (action, kind, name) => { return (action.type === 'RECEIVE_ITEMS' || action.type === 'REMOVE_ITEMS') && action.invalidateCache && kind === action.kind && name === action.name; }; /** * Requests the total number of entity records. */ export const getEntityRecordsTotalItems = forwardResolver('getEntityRecords'); /** * Requests the number of available pages for the given query. */ export const getEntityRecordsTotalPages = forwardResolver('getEntityRecords'); /** * Requests the current theme. */ export const getCurrentTheme = () => async ({ dispatch, resolveSelect }) => { const activeThemes = await resolveSelect.getEntityRecords('root', 'theme', { status: 'active' }); dispatch.receiveCurrentTheme(activeThemes[0]); }; /** * Requests theme supports data from the index. */ export const getThemeSupports = forwardResolver('getCurrentTheme'); /** * Requests a preview from the Embed API. * * @param {string} url URL to get the preview for. */ export const getEmbedPreview = url => async ({ dispatch }) => { try { const embedProxyResponse = await apiFetch({ path: addQueryArgs('/oembed/1.0/proxy', { url }) }); dispatch.receiveEmbedPreview(url, embedProxyResponse); } catch (error) { // Embed API 404s if the URL cannot be embedded, so we have to catch the error from the apiRequest here. dispatch.receiveEmbedPreview(url, false); } }; /** * Checks whether the current user can perform the given action on the given * REST resource. * * @param {string} requestedAction Action to check. One of: 'create', 'read', 'update', * 'delete'. * @param {string|Object} resource Entity resource to check. Accepts entity object `{ kind: 'postType', name: 'attachment', id: 1 }` * or REST base as a string - `media`. * @param {?string} id ID of the rest resource to check. */ export const canUser = (requestedAction, resource, id) => async ({ dispatch, registry, resolveSelect }) => { if (!ALLOWED_RESOURCE_ACTIONS.includes(requestedAction)) { throw new Error(`'${requestedAction}' is not a valid action.`); } const { hasStartedResolution } = registry.select(STORE_NAME); // Prevent resolving the same resource twice. for (const relatedAction of ALLOWED_RESOURCE_ACTIONS) { if (relatedAction === requestedAction) { continue; } const isAlreadyResolving = hasStartedResolution('canUser', [relatedAction, resource, id]); if (isAlreadyResolving) { return; } } let resourcePath = null; if (typeof resource === 'object') { if (!resource.kind || !resource.name) { throw new Error('The entity resource object is not valid.'); } const configs = await resolveSelect.getEntitiesConfig(resource.kind); const entityConfig = configs.find(config => config.name === resource.name && config.kind === resource.kind); if (!entityConfig) { return; } resourcePath = entityConfig.baseURL + (resource.id ? '/' + resource.id : ''); } else { resourcePath = `/wp/v2/${resource}` + (id ? '/' + id : ''); } let response; try { response = await apiFetch({ path: resourcePath, method: 'OPTIONS', parse: false }); } catch (error) { // Do nothing if our OPTIONS request comes back with an API error (4xx or // 5xx). The previously determined isAllowed value will remain in the store. return; } // Optional chaining operator is used here because the API requests don't // return the expected result in the React native version. Instead, API requests // only return the result, without including response properties like the headers. const permissions = getUserPermissionsFromAllowHeader(response.headers?.get('allow')); registry.batch(() => { for (const action of ALLOWED_RESOURCE_ACTIONS) { const key = getUserPermissionCacheKey(action, resource, id); dispatch.receiveUserPermission(key, permissions[action]); // Mark related action resolutions as finished. if (action !== requestedAction) { dispatch.finishResolution('canUser', [action, resource, id]); } } }); }; /** * Checks whether the current user can perform the given action on the given * REST resource. * * @param {string} kind Entity kind. * @param {string} name Entity name. * @param {number|string} recordId Record's id. */ export const canUserEditEntityRecord = (kind, name, recordId) => async ({ dispatch }) => { await dispatch(canUser('update', { kind, name, id: recordId })); }; /** * Request autosave data from the REST API. * * @param {string} postType The type of the parent post. * @param {number} postId The id of the parent post. */ export const getAutosaves = (postType, postId) => async ({ dispatch, resolveSelect }) => { const { rest_base: restBase, rest_namespace: restNamespace = 'wp/v2', supports } = await resolveSelect.getPostType(postType); if (!supports?.autosave) { return; } const autosaves = await apiFetch({ path: `/${restNamespace}/${restBase}/${postId}/autosaves?context=edit` }); if (autosaves && autosaves.length) { dispatch.receiveAutosaves(postId, autosaves); } }; /** * Request autosave data from the REST API. * * This resolver exists to ensure the underlying autosaves are fetched via * `getAutosaves` when a call to the `getAutosave` selector is made. * * @param {string} postType The type of the parent post. * @param {number} postId The id of the parent post. */ export const getAutosave = (postType, postId) => async ({ resolveSelect }) => { await resolveSelect.getAutosaves(postType, postId); }; export const __experimentalGetCurrentGlobalStylesId = () => async ({ dispatch, resolveSelect }) => { const activeThemes = await resolveSelect.getEntityRecords('root', 'theme', { status: 'active' }); const globalStylesURL = activeThemes?.[0]?._links?.['wp:user-global-styles']?.[0]?.href; if (!globalStylesURL) { return; } // Regex matches the ID at the end of a URL or immediately before // the query string. const matches = globalStylesURL.match(/\/(\d+)(?:\?|$)/); const id = matches ? Number(matches[1]) : null; if (id) { dispatch.__experimentalReceiveCurrentGlobalStylesId(id); } }; export const __experimentalGetCurrentThemeBaseGlobalStyles = () => async ({ resolveSelect, dispatch }) => { const currentTheme = await resolveSelect.getCurrentTheme(); // Please adjust the preloaded requests if this changes! const themeGlobalStyles = await apiFetch({ path: `/wp/v2/global-styles/themes/${currentTheme.stylesheet}?context=view` }); dispatch.__experimentalReceiveThemeBaseGlobalStyles(currentTheme.stylesheet, themeGlobalStyles); }; export const __experimentalGetCurrentThemeGlobalStylesVariations = () => async ({ resolveSelect, dispatch }) => { const currentTheme = await resolveSelect.getCurrentTheme(); // Please adjust the preloaded requests if this changes! const variations = await apiFetch({ path: `/wp/v2/global-styles/themes/${currentTheme.stylesheet}/variations?context=view` }); dispatch.__experimentalReceiveThemeGlobalStyleVariations(currentTheme.stylesheet, variations); }; /** * Fetches and returns the revisions of the current global styles theme. */ export const getCurrentThemeGlobalStylesRevisions = () => async ({ resolveSelect, dispatch }) => { const globalStylesId = await resolveSelect.__experimentalGetCurrentGlobalStylesId(); const record = globalStylesId ? await resolveSelect.getEntityRecord('root', 'globalStyles', globalStylesId) : undefined; const revisionsURL = record?._links?.['version-history']?.[0]?.href; if (revisionsURL) { const resetRevisions = await apiFetch({ url: revisionsURL }); const revisions = resetRevisions?.map(revision => Object.fromEntries(Object.entries(revision).map(([key, value]) => [camelCase(key), value]))); dispatch.receiveThemeGlobalStyleRevisions(globalStylesId, revisions); } }; getCurrentThemeGlobalStylesRevisions.shouldInvalidate = action => { return action.type === 'SAVE_ENTITY_RECORD_FINISH' && action.kind === 'root' && !action.error && action.name === 'globalStyles'; }; export const getBlockPatterns = () => async ({ dispatch }) => { const patterns = await fetchBlockPatterns(); dispatch({ type: 'RECEIVE_BLOCK_PATTERNS', patterns }); }; export const getBlockPatternCategories = () => async ({ dispatch }) => { const categories = await apiFetch({ path: '/wp/v2/block-patterns/categories' }); dispatch({ type: 'RECEIVE_BLOCK_PATTERN_CATEGORIES', categories }); }; export const getUserPatternCategories = () => async ({ dispatch, resolveSelect }) => { const patternCategories = await resolveSelect.getEntityRecords('taxonomy', 'wp_pattern_category', { per_page: -1, _fields: 'id,name,description,slug', context: 'view' }); const mappedPatternCategories = patternCategories?.map(userCategory => ({ ...userCategory, label: decodeEntities(userCategory.name), name: userCategory.slug })) || []; dispatch({ type: 'RECEIVE_USER_PATTERN_CATEGORIES', patternCategories: mappedPatternCategories }); }; export const getNavigationFallbackId = () => async ({ dispatch, select, registry }) => { const fallback = await apiFetch({ path: addQueryArgs('/wp-block-editor/v1/navigation-fallback', { _embed: true }) }); const record = fallback?._embedded?.self; registry.batch(() => { dispatch.receiveNavigationFallbackId(fallback?.id); if (!record) { return; } // If the fallback is already in the store, don't invalidate navigation queries. // Otherwise, invalidate the cache for the scenario where there were no Navigation // posts in the state and the fallback created one. const existingFallbackEntityRecord = select.getEntityRecord('postType', 'wp_navigation', fallback.id); const invalidateNavigationQueries = !existingFallbackEntityRecord; dispatch.receiveEntityRecords('postType', 'wp_navigation', record, undefined, invalidateNavigationQueries); // Resolve to avoid further network requests. dispatch.finishResolution('getEntityRecord', ['postType', 'wp_navigation', fallback.id]); }); }; export const getDefaultTemplateId = query => async ({ dispatch, registry, resolveSelect }) => { const template = await apiFetch({ path: addQueryArgs('/wp/v2/templates/lookup', query) }); // Wait for the the entities config to be loaded, otherwise receiving // the template as an entity will not work. await resolveSelect.getEntitiesConfig('postType'); const id = template?.wp_id || template?.id; // Endpoint may return an empty object if no template is found. if (id) { template.id = id; template.type = typeof id === 'string' ? 'wp_registered_template' : 'wp_template'; registry.batch(() => { dispatch.receiveDefaultTemplateId(query, id); dispatch.receiveEntityRecords('postType', template.type, [template]); // Avoid further network requests. dispatch.finishResolution('getEntityRecord', ['postType', template.type, id]); }); } }; getDefaultTemplateId.shouldInvalidate = action => { return action.type === 'EDIT_ENTITY_RECORD' && action.kind === 'root' && action.name === 'site'; }; /** * Requests an entity's revisions from the REST API. * * @param {string} kind Entity kind. * @param {string} name Entity name. * @param {number|string} recordKey The key of the entity record whose revisions you want to fetch. * @param {Object|undefined} query Optional object of query parameters to * include with request. If requesting specific * fields, fields must always include the ID. */ export const getRevisions = (kind, name, recordKey, query = {}) => async ({ dispatch, registry, resolveSelect }) => { const configs = await resolveSelect.getEntitiesConfig(kind); const entityConfig = configs.find(config => config.name === name && config.kind === kind); if (!entityConfig) { return; } if (query._fields) { // If requesting specific fields, items and query association to said // records are stored by ID reference. Thus, fields must always include // the ID. query = { ...query, _fields: [...new Set([...(getNormalizedCommaSeparable(query._fields) || []), entityConfig.revisionKey || DEFAULT_ENTITY_KEY])].join() }; } const path = addQueryArgs(entityConfig.getRevisionsUrl(recordKey), query); let records, response; const meta = {}; const isPaginated = entityConfig.supportsPagination && query.per_page !== -1; try { response = await apiFetch({ path, parse: !isPaginated }); } catch (error) { // Do nothing if our request comes back with an API error. return; } if (response) { if (isPaginated) { records = Object.values(await response.json()); meta.totalItems = parseInt(response.headers.get('X-WP-Total')); } else { records = Object.values(response); } // If we request fields but the result doesn't contain the fields, // explicitly set these fields as "undefined" // that way we consider the query "fulfilled". if (query._fields) { records = records.map(record => { query._fields.split(',').forEach(field => { if (!record.hasOwnProperty(field)) { record[field] = undefined; } }); return record; }); } registry.batch(() => { dispatch.receiveRevisions(kind, name, recordKey, records, query, false, meta); // When requesting all fields, the list of results can be used to // resolve the `getRevision` selector in addition to `getRevisions`. if (!query?._fields && !query.context) { const key = entityConfig.key || DEFAULT_ENTITY_KEY; const resolutionsArgs = records.filter(record => record[key]).map(record => [kind, name, recordKey, record[key]]); dispatch.finishResolutions('getRevision', resolutionsArgs); } }); } }; // Invalidate cache when a new revision is created. getRevisions.shouldInvalidate = (action, kind, name, recordKey) => action.type === 'SAVE_ENTITY_RECORD_FINISH' && name === action.name && kind === action.kind && !action.error && recordKey === action.recordId; /** * Requests a specific Entity revision from the REST API. * * @param {string} kind Entity kind. * @param {string} name Entity name. * @param {number|string} recordKey The key of the entity record whose revisions you want to fetch. * @param {number|string} revisionKey The revision's key. * @param {Object|undefined} query Optional object of query parameters to * include with request. If requesting specific * fields, fields must always include the ID. */ export const getRevision = (kind, name, recordKey, revisionKey, query) => async ({ dispatch, resolveSelect }) => { const configs = await resolveSelect.getEntitiesConfig(kind); const entityConfig = configs.find(config => config.name === name && config.kind === kind); if (!entityConfig) { return; } if (query !== undefined && query._fields) { // If requesting specific fields, items and query association to said // records are stored by ID reference. Thus, fields must always include // the ID. query = { ...query, _fields: [...new Set([...(getNormalizedCommaSeparable(query._fields) || []), entityConfig.revisionKey || DEFAULT_ENTITY_KEY])].join() }; } const path = addQueryArgs(entityConfig.getRevisionsUrl(recordKey, revisionKey), query); let record; try { record = await apiFetch({ path }); } catch (error) { // Do nothing if our request comes back with an API error. return; } if (record) { dispatch.receiveRevisions(kind, name, recordKey, record, query); } }; /** * Requests a specific post type options from the REST API. * * @param {string} postType Post type slug. */ export const getRegisteredPostMeta = postType => async ({ dispatch, resolveSelect }) => { let options; try { const { rest_namespace: restNamespace = 'wp/v2', rest_base: restBase } = (await resolveSelect.getPostType(postType)) || {}; options = await apiFetch({ path: `${restNamespace}/${restBase}/?context=edit`, method: 'OPTIONS' }); } catch (error) { // Do nothing if the request comes back with an API error. return; } if (options) { dispatch.receiveRegisteredPostMeta(postType, options?.schema?.properties?.meta?.properties); } }; /** * Requests entity configs for the given kind from the REST API. * * @param {string} kind Entity kind. */ export const getEntitiesConfig = kind => async ({ dispatch }) => { const loader = additionalEntityConfigLoaders.find(l => l.kind === kind); if (!loader) { return; } try { const configs = await loader.loadEntities(); if (!configs.length) { return; } dispatch.addEntities(configs); } catch { // Do nothing if the request comes back with an API error. } }; //# sourceMappingURL=resolvers.js.map