mirror of
https://github.com/wassname/talk.git
synced 2026-06-30 01:41:13 +08:00
276 lines
8.0 KiB
JavaScript
276 lines
8.0 KiB
JavaScript
import React from 'react';
|
|
import uniq from 'lodash/uniq';
|
|
import pick from 'lodash/pick';
|
|
import merge from 'lodash/merge';
|
|
import flattenDeep from 'lodash/flattenDeep';
|
|
import isEmpty from 'lodash/isEmpty';
|
|
import flatten from 'lodash/flatten';
|
|
import mapValues from 'lodash/mapValues';
|
|
import get from 'lodash/get';
|
|
import values from 'lodash/values';
|
|
import { getDisplayName } from 'coral-framework/helpers/hoc';
|
|
import camelize from '../helpers/camelize';
|
|
|
|
// This is returned for pluginsConfig when it is empty.
|
|
const emptyConfig = {};
|
|
|
|
// Memoize the warnings so we only show them once.
|
|
const memoizedWarnings = [];
|
|
|
|
// withWarnings decorates the props of queryData with a proxy that
|
|
// prints a warning when accessing deeper props.
|
|
function withWarnings(component, queryData) {
|
|
if (process.env.NODE_ENV !== 'production' && window.Proxy) {
|
|
// Show warnings when accessing queryData only when not in production.
|
|
return mapValues(queryData, (value, key) => {
|
|
// Keep null values..
|
|
if (!queryData[key]) {
|
|
return queryData[key];
|
|
}
|
|
return new Proxy(queryData[key], {
|
|
get(target, name) {
|
|
// Detect access from React DevTools and ignore those.
|
|
const error = new Error();
|
|
const accessFromDevTools = ['backend.js', 'dehydrate'].every(
|
|
keyword => error.stack && error.stack.includes(keyword)
|
|
);
|
|
|
|
// Only care about the components defined in the plugins.
|
|
if (component.talkPluginName && !accessFromDevTools) {
|
|
const warning = `'${getDisplayName(component)}' of '${
|
|
component.talkPluginName
|
|
}' accessed '${key}.${name}' but did not define fragments using the withFragment HOC`;
|
|
if (memoizedWarnings.indexOf(warning) === -1) {
|
|
console.warn(warning);
|
|
memoizedWarnings.push(warning);
|
|
}
|
|
}
|
|
return queryData[key][name];
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
return queryData;
|
|
}
|
|
|
|
function addMetaDataToSlotComponents(plugins) {
|
|
// Add talkPluginName to Slot Components.
|
|
plugins.forEach(plugin => {
|
|
const slots = plugin.module.slots;
|
|
slots &&
|
|
Object.keys(slots).forEach(slot => {
|
|
slots[slot].forEach(component => {
|
|
// Attach plugin name to the component
|
|
component.talkPluginName = plugin.name;
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* getSlotComponentProps calculate the props we would pass to the slot component.
|
|
* query datas are only passed to the component if it is defined in `component.fragments`.
|
|
*/
|
|
function getSlotComponentProps(component, reduxState, props, queryData) {
|
|
const pluginsConfig = get(reduxState, 'config.plugins_config') || emptyConfig;
|
|
return {
|
|
...props,
|
|
config: pluginsConfig,
|
|
...(component.fragments
|
|
? pick(queryData, Object.keys(component.fragments))
|
|
: withWarnings(component, queryData)),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* splitProps detects objects coming from the query and
|
|
* returns `queryData` and `rest`. We use `__typename`
|
|
* in order to detect objects from the query.
|
|
*/
|
|
function splitProps(props) {
|
|
const rest = { ...props };
|
|
const queryData = {};
|
|
Object.keys(props).forEach(k => {
|
|
if (
|
|
get(props[k], `__typename`) ||
|
|
get(props[k], `0.__typename`) // Arrays
|
|
) {
|
|
queryData[k] = props[k];
|
|
delete rest[k];
|
|
}
|
|
});
|
|
return { queryData, rest };
|
|
}
|
|
|
|
class PluginsService {
|
|
constructor(plugins) {
|
|
this.plugins = plugins;
|
|
addMetaDataToSlotComponents(plugins);
|
|
}
|
|
|
|
isSlotEmpty(slot, reduxState, props = {}) {
|
|
return this.getSlotElements(slot, reduxState, props).length === 0;
|
|
}
|
|
|
|
/**
|
|
* Returns props that would pass to the given slot component.
|
|
*/
|
|
getSlotComponentProps(component, reduxState, props) {
|
|
const { queryData, rest } = splitProps(props);
|
|
return getSlotComponentProps(component, reduxState, rest, queryData);
|
|
}
|
|
|
|
/**
|
|
* Returns React Elements for given slot.
|
|
*/
|
|
getSlotElements(slot, reduxState, props = {}, options = {}) {
|
|
const pluginsConfig =
|
|
get(reduxState, 'config.plugins_config') || emptyConfig;
|
|
const { size = 0 } = options;
|
|
const { queryData, rest } = splitProps(props);
|
|
|
|
const isDisabled = component => {
|
|
if (
|
|
pluginsConfig &&
|
|
pluginsConfig[component.talkPluginName] &&
|
|
pluginsConfig[component.talkPluginName].disable_components
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
// Check if component is excluded.
|
|
if (component.isExcluded) {
|
|
let resolvedProps = getSlotComponentProps(
|
|
component,
|
|
reduxState,
|
|
rest,
|
|
queryData
|
|
);
|
|
if (component.mapStateToProps) {
|
|
resolvedProps = {
|
|
...resolvedProps,
|
|
...component.mapStateToProps(reduxState),
|
|
};
|
|
}
|
|
return component.isExcluded(resolvedProps);
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const slots = flatten(
|
|
this.plugins
|
|
.filter(o => o.module.slots && o.module.slots[slot])
|
|
.map(o => o.module.slots[slot])
|
|
);
|
|
|
|
if (size > 0 && slots.length > size) {
|
|
console.warn(
|
|
`Slot[${slot}] supports a maximum of ${size} plugins providing slots, got ${
|
|
slots.length
|
|
}, will only use the first ${size}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* This adds a consistent keying for the slot elements.
|
|
* It uses the plugin name as the key. If the same plugin inserts
|
|
* multiple elements it will append `.${noOfOccurence}` to the
|
|
* key starting with the second element.
|
|
*/
|
|
const getKey = (() => {
|
|
const map = {};
|
|
return component => {
|
|
if (map[component.talkPluginName] === undefined) {
|
|
map[component.talkPluginName] = 0;
|
|
} else {
|
|
map[component.talkPluginName]++;
|
|
}
|
|
const i = map[component.talkPluginName];
|
|
return `${component.talkPluginName}${i > 0 ? `.${i}` : ''}`;
|
|
};
|
|
})();
|
|
|
|
return (size > 0 ? slots.slice(0, size) : slots)
|
|
.map(component => ({
|
|
component,
|
|
disabled: isDisabled(component),
|
|
key: getKey(component),
|
|
}))
|
|
.filter(o => !o.disabled)
|
|
.map(({ component, key }) =>
|
|
React.createElement(component, {
|
|
key,
|
|
...getSlotComponentProps(component, reduxState, rest, queryData),
|
|
})
|
|
);
|
|
}
|
|
|
|
getSlotFragments(slot, part) {
|
|
const components = uniq(
|
|
flattenDeep(
|
|
this.plugins
|
|
.filter(o => (o.module.slots ? o.module.slots[slot] : false))
|
|
.map(o => o.module.slots[slot])
|
|
)
|
|
);
|
|
|
|
const documents = components
|
|
.map(c => c.fragments)
|
|
.filter(fragments => fragments && fragments[part])
|
|
.reduce((res, fragments) => {
|
|
res.push(fragments[part]);
|
|
return res;
|
|
}, []);
|
|
|
|
return documents;
|
|
}
|
|
|
|
getGraphQLExtensions() {
|
|
return flatten(
|
|
this.plugins.map(o => {
|
|
const extension = pick(o.module, ['mutations', 'queries', 'fragments']);
|
|
// Include extension defined in Slot Components.
|
|
const slotComponentExtensions = !o.module.slots
|
|
? []
|
|
: flatten(values(o.module.slots)).map(cmp => cmp.graphqlExtension);
|
|
return [extension, ...slotComponentExtensions];
|
|
})
|
|
).filter(o => !isEmpty(o));
|
|
}
|
|
|
|
getModQueueConfigs() {
|
|
return merge(...this.plugins.map(o => o.module.modQueues).filter(o => o));
|
|
}
|
|
|
|
getTranslations() {
|
|
return this.plugins.map(o => o.module.translations).filter(o => o);
|
|
}
|
|
|
|
getReducers() {
|
|
return merge(
|
|
...this.plugins
|
|
.filter(o => o.module.reducer)
|
|
.map(o => ({ [camelize(o.name)]: o.module.reducer }))
|
|
);
|
|
}
|
|
|
|
async executeInit(context) {
|
|
const results = this.plugins
|
|
.map(o => o.module.init)
|
|
.filter(fn => fn)
|
|
.map(fn => fn(context));
|
|
await Promise.all(results);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* createPluginsService returns a plugins service.
|
|
* @param {Array} plugins config as returned from importing `pluginsConfig`
|
|
* @return {Object} plugins service
|
|
*/
|
|
export function createPluginsService(pluginsConfig) {
|
|
return new PluginsService(pluginsConfig);
|
|
}
|