react-carousel-query
Version:
A infinite carousel component made with react that handles the pagination for you.
290 lines (251 loc) • 8.84 kB
JavaScript
import "core-js/modules/es.array.reduce.js";
import memoize from 'memoizerific';
import mapValues from 'lodash/mapValues';
import pick from 'lodash/pick';
import global from 'global';
import { SynchronousPromise } from 'synchronous-promise';
import { StoryIndexStore } from './StoryIndexStore';
import { ArgsStore } from './ArgsStore';
import { GlobalsStore } from './GlobalsStore';
import { processCSFFile, prepareStory, normalizeProjectAnnotations } from './csf';
import { HooksContext } from './hooks'; // TODO -- what are reasonable values for these?
const CSF_CACHE_SIZE = 1000;
const STORY_CACHE_SIZE = 10000;
export class StoryStore {
constructor() {
this.storyIndex = void 0;
this.importFn = void 0;
this.projectAnnotations = void 0;
this.globals = void 0;
this.args = void 0;
this.hooks = void 0;
this.cachedCSFFiles = void 0;
this.processCSFFileWithCache = void 0;
this.prepareStoryWithCache = void 0;
this.initializationPromise = void 0;
this.resolveInitializationPromise = void 0;
this.getStoriesJsonData = () => {
const value = this.getSetStoriesPayload();
const allowedParameters = ['fileName', 'docsOnly', 'framework', '__id', '__isArgsStory'];
const stories = mapValues(value.stories, story => {
var _global$FEATURES;
return Object.assign({}, pick(story, ['id', 'name', 'title']), {
importPath: this.storyIndex.stories[story.id].importPath
}, !((_global$FEATURES = global.FEATURES) !== null && _global$FEATURES !== void 0 && _global$FEATURES.breakingChangesV7) && {
kind: story.title,
story: story.name,
parameters: Object.assign({}, pick(story.parameters, allowedParameters), {
fileName: this.storyIndex.stories[story.id].importPath
})
});
});
return {
v: 3,
stories
};
};
this.globals = new GlobalsStore();
this.args = new ArgsStore();
this.hooks = {}; // We use a cache for these two functions for two reasons:
// 1. For performance
// 2. To ensure that when the same story is prepared with the same inputs you get the same output
this.processCSFFileWithCache = memoize(CSF_CACHE_SIZE)(processCSFFile);
this.prepareStoryWithCache = memoize(STORY_CACHE_SIZE)(prepareStory); // We cannot call `loadStory()` until we've been initialized properly. But we can wait for it.
this.initializationPromise = new SynchronousPromise(resolve => {
this.resolveInitializationPromise = resolve;
});
}
setProjectAnnotations(projectAnnotations) {
// By changing `this.projectAnnotations, we implicitly invalidate the `prepareStoryWithCache`
this.projectAnnotations = normalizeProjectAnnotations(projectAnnotations);
const {
globals,
globalTypes
} = projectAnnotations;
this.globals.set({
globals,
globalTypes
});
}
initialize({
storyIndex,
importFn,
cache = false
}) {
this.storyIndex = new StoryIndexStore(storyIndex);
this.importFn = importFn; // We don't need the cache to be loaded to call `loadStory`, we just need the index ready
this.resolveInitializationPromise();
return cache ? this.cacheAllCSFFiles() : SynchronousPromise.resolve();
} // This means that one of the CSF files has changed.
// If the `importFn` has changed, we will invalidate both caches.
// If the `storyIndex` data has changed, we may or may not invalidate the caches, depending
// on whether we've loaded the relevant files yet.
async onStoriesChanged({
importFn,
storyIndex
}) {
if (importFn) this.importFn = importFn;
if (storyIndex) this.storyIndex.stories = storyIndex.stories;
if (this.cachedCSFFiles) await this.cacheAllCSFFiles();
} // To load a single CSF file to service a story we need to look up the importPath in the index
loadCSFFileByStoryId(storyId) {
const {
importPath,
title
} = this.storyIndex.storyIdToEntry(storyId);
return this.importFn(importPath).then(moduleExports => // We pass the title in here as it may have been generated by autoTitle on the server.
this.processCSFFileWithCache(moduleExports, importPath, title));
}
loadAllCSFFiles() {
const importPaths = {};
Object.entries(this.storyIndex.stories).forEach(([storyId, {
importPath
}]) => {
importPaths[importPath] = storyId;
});
const csfFilePromiseList = Object.entries(importPaths).map(([importPath, storyId]) => this.loadCSFFileByStoryId(storyId).then(csfFile => ({
importPath,
csfFile
})));
return SynchronousPromise.all(csfFilePromiseList).then(list => list.reduce((acc, {
importPath,
csfFile
}) => {
acc[importPath] = csfFile;
return acc;
}, {}));
}
cacheAllCSFFiles() {
return this.initializationPromise.then(() => this.loadAllCSFFiles().then(csfFiles => {
this.cachedCSFFiles = csfFiles;
}));
} // Load the CSF file for a story and prepare the story from it and the project annotations.
async loadStory({
storyId
}) {
await this.initializationPromise;
const csfFile = await this.loadCSFFileByStoryId(storyId);
return this.storyFromCSFFile({
storyId,
csfFile
});
} // This function is synchronous for convenience -- often times if you have a CSF file already
// it is easier not to have to await `loadStory`.
storyFromCSFFile({
storyId,
csfFile
}) {
const storyAnnotations = csfFile.stories[storyId];
if (!storyAnnotations) {
throw new Error(`Didn't find '${storyId}' in CSF file, this is unexpected`);
}
const componentAnnotations = csfFile.meta;
const story = this.prepareStoryWithCache(storyAnnotations, componentAnnotations, this.projectAnnotations);
this.args.setInitial(story);
this.hooks[story.id] = this.hooks[story.id] || new HooksContext();
return story;
} // If we have a CSF file we can get all the stories from it synchronously
componentStoriesFromCSFFile({
csfFile
}) {
return Object.keys(this.storyIndex.stories).filter(storyId => !!csfFile.stories[storyId]).map(storyId => this.storyFromCSFFile({
storyId,
csfFile
}));
} // A prepared story does not include args, globals or hooks. These are stored in the story store
// and updated separtely to the (immutable) story.
getStoryContext(story) {
return Object.assign({}, story, {
args: this.args.get(story.id),
globals: this.globals.get(),
hooks: this.hooks[story.id]
});
}
cleanupStory(story) {
this.hooks[story.id].clean();
}
extract(options = {
includeDocsOnly: false
}) {
if (!this.cachedCSFFiles) {
throw new Error('Cannot call extract() unless you call cacheAllCSFFiles() first.');
}
return Object.entries(this.storyIndex.stories).reduce((acc, [storyId, {
importPath
}]) => {
const csfFile = this.cachedCSFFiles[importPath];
const story = this.storyFromCSFFile({
storyId,
csfFile
});
if (!options.includeDocsOnly && story.parameters.docsOnly) {
return acc;
}
acc[storyId] = Object.entries(story).reduce((storyAcc, [key, value]) => {
if (typeof value === 'function') {
return storyAcc;
}
if (Array.isArray(value)) {
return Object.assign(storyAcc, {
[key]: value.slice().sort()
});
}
return Object.assign(storyAcc, {
[key]: value
});
}, {
args: story.initialArgs
});
return acc;
}, {});
}
getSetStoriesPayload() {
const stories = this.extract({
includeDocsOnly: true
});
const kindParameters = Object.values(stories).reduce((acc, {
title
}) => {
acc[title] = {};
return acc;
}, {});
return {
v: 2,
globals: this.globals.get(),
globalParameters: {},
kindParameters,
stories
};
}
raw() {
return Object.values(this.extract()).map(({
id
}) => this.fromId(id));
}
fromId(storyId) {
if (!this.cachedCSFFiles) {
throw new Error('Cannot call fromId/raw() unless you call cacheAllCSFFiles() first.');
}
let importPath;
try {
({
importPath
} = this.storyIndex.storyIdToEntry(storyId));
} catch (err) {
return null;
}
const csfFile = this.cachedCSFFiles[importPath];
const story = this.storyFromCSFFile({
storyId,
csfFile
});
return Object.assign({}, story, {
storyFn: update => {
const context = Object.assign({}, this.getStoryContext(story), {
viewMode: 'story'
});
return story.unboundStoryFn(Object.assign({}, context, update));
}
});
}
}