diff --git a/packages/cli/cli.js b/packages/cli/cli.js index 7db492c9c..733d29272 100644 --- a/packages/cli/cli.js +++ b/packages/cli/cli.js @@ -4,8 +4,6 @@ const program = require('commander'); const inquirer = require('inquirer'); const chalk = require('chalk'); const latestSemver = require('latest-semver'); -const loadJsonFile = require('load-json-file'); -const algoliasearch = require('algoliasearch'); const createInstantSearchApp = require('../create-instantsearch-app'); const { @@ -16,7 +14,12 @@ const { getAllTemplates, getTemplatePath, } = require('../shared/utils'); -const { getOptionsFromArguments, isQuestionAsked } = require('./utils'); +const { + getOptionsFromArguments, + getAttributesFromAnswers, + isQuestionAsked, + getConfiguration, +} = require('./utils'); const { version } = require('../../package.json'); let appPath; @@ -74,7 +77,8 @@ if (!appPath) { process.exit(1); } -const appName = path.basename(appPath); +const optionsFromArguments = getOptionsFromArguments(options.rawArgs); +const appName = optionsFromArguments.name || path.basename(appPath); try { checkAppPath(appPath); @@ -86,8 +90,6 @@ try { process.exit(1); } -const optionsFromArguments = getOptionsFromArguments(options.rawArgs); - const questions = [ { type: 'list', @@ -164,66 +166,22 @@ const questions = [ type: 'list', name: 'mainAttribute', message: 'Attribute to display', - choices: async answers => { - const client = algoliasearch(answers.appId, answers.apiKey); - const index = client.initIndex(answers.indexName); - const defaultAttributes = ['title', 'name', 'description']; - let attributes = []; - - try { - const { hits } = await index.search({ hitsPerPage: 1 }); - const [firstHit] = hits; - attributes = Object.keys(firstHit._highlightResult).sort( - value => !defaultAttributes.includes(value) - ); - } catch (err) { - attributes = defaultAttributes; - } - - return attributes; - }, + choices: async answers => await getAttributesFromAnswers(answers), when: ({ appId, apiKey, indexName }) => appId && apiKey && indexName, }, ].filter(question => isQuestionAsked({ question, args: optionsFromArguments })); -async function getConfig() { - let config; - - if (optionsFromArguments.config) { - // Get config from configuration file given as an argument - config = await loadJsonFile(optionsFromArguments.config); - } else { - // Get config from the arguments and the prompt - config = { - ...optionsFromArguments, - ...(await inquirer.prompt(questions)), - }; - } - - const templatePath = getTemplatePath(config.template); - let libraryVersion = config.libraryVersion; - - if (!libraryVersion) { - const templateConfig = getAppTemplateConfig(templatePath); - - libraryVersion = await fetchLibraryVersions( - templateConfig.libraryName - ).then(latestSemver); - } - - return { - ...config, - libraryVersion, - name: config.name || appName, - template: templatePath, - }; -} - async function run() { console.log(`Creating a new InstantSearch app in ${chalk.green(appPath)}.`); const config = { - ...(await getConfig()), + ...(await getConfiguration({ + options: { + ...optionsFromArguments, + name: appName, + }, + answers: await inquirer.prompt(questions), + })), installation: program.installation, }; diff --git a/packages/cli/utils.js b/packages/cli/utils.js index bd64a6673..b407196b1 100644 --- a/packages/cli/utils.js +++ b/packages/cli/utils.js @@ -1,3 +1,13 @@ +const algoliasearch = require('algoliasearch'); +const latestSemver = require('latest-semver'); +const loadJsonFile = require('load-json-file'); + +const { + getAppTemplateConfig, + fetchLibraryVersions, + getTemplatePath, +} = require('../shared/utils'); + function camelCase(string) { return string.replace(/-([a-z])/g, str => str[1].toUpperCase()); } @@ -22,7 +32,40 @@ function getOptionsFromArguments(rawArgs) { }, {}); } +async function getAttributesFromAnswers({ + appId, + apiKey, + indexName, + algoliasearchFn = algoliasearch, +} = {}) { + const client = algoliasearchFn(appId, apiKey); + const defaultAttributes = ['title', 'name', 'description']; + let attributes = []; + + try { + const { hits } = await client.search({ indexName, hitsPerPage: 1 }); + const [firstHit] = hits; + const highlightedAttributes = Object.keys(firstHit._highlightResult); + attributes = [ + ...new Set([ + ...defaultAttributes.map( + attribute => highlightedAttributes.includes(attribute) && attribute + ), + ...highlightedAttributes, + ]), + ]; + } catch (err) { + attributes = defaultAttributes; + } + + return attributes; +} + function isQuestionAsked({ question, args }) { + if (args.config) { + return false; + } + for (const optionName in args) { if (question.name === optionName) { // Skip if the arg in the command is valid @@ -38,8 +81,41 @@ function isQuestionAsked({ question, args }) { return true; } +async function getConfiguration({ + options = {}, + answers = {}, + loadJsonFileFn = loadJsonFile, +} = {}) { + const config = options.config + ? await loadJsonFileFn(options.config) // From configuration file given as an argument + : { ...options, ...answers }; // From the arguments and the prompt + + if (!config.template) { + throw new Error('The template is required in the config.'); + } + + const templatePath = getTemplatePath(config.template); + let { libraryVersion } = config; + + if (!libraryVersion) { + const templateConfig = getAppTemplateConfig(templatePath); + + libraryVersion = await fetchLibraryVersions( + templateConfig.libraryName + ).then(latestSemver); + } + + return { + ...config, + libraryVersion, + template: templatePath, + }; +} + module.exports = { camelCase, getOptionsFromArguments, + getAttributesFromAnswers, isQuestionAsked, + getConfiguration, }; diff --git a/packages/cli/utils.test.js b/packages/cli/utils.test.js index 11f8e095a..28d4a0166 100644 --- a/packages/cli/utils.test.js +++ b/packages/cli/utils.test.js @@ -1,3 +1,4 @@ +const path = require('path'); const utils = require('./utils'); describe('getOptionsFromArguments', () => { @@ -79,7 +80,52 @@ describe('getOptionsFromArguments', () => { }); }); -describe('isQuestionAsked', () => { +describe('getAttributesFromAnswers', () => { + const algoliasearchSuccessFn = () => ({ + search: jest.fn(() => ({ + hits: [ + { + _highlightResult: { + brand: 'brand', + description: 'description', + name: 'name', + title: 'title', + }, + }, + ], + })), + }); + + const algoliasearchFailureFn = () => ({ + search: jest.fn(() => { + throw new Error(); + }), + }); + + test('with search success should fetch attributes', async () => { + const attributes = await utils.getAttributesFromAnswers({ + appId: 'appId', + apiKey: 'apiKey', + indexName: 'indexName', + algoliasearchFn: algoliasearchSuccessFn, + }); + + expect(attributes).toEqual(['title', 'name', 'description', 'brand']); + }); + + test('with search failure should return default attributes', async () => { + const attributes = await utils.getAttributesFromAnswers({ + appId: 'appId', + apiKey: 'apiKey', + indexName: 'indexName', + algoliasearchFn: algoliasearchFailureFn, + }); + + expect(attributes).toEqual(['title', 'name', 'description']); + }); +}); + +test('isQuestionAsked', () => { expect( utils.isQuestionAsked({ question: { name: 'appId', validate: input => Boolean(input) }, @@ -137,3 +183,72 @@ describe('camelCase', () => { expect(utils.camelCase('instant-search-js')).toBe('instantSearchJs'); }); }); + +describe('getConfiguration', () => { + test('without template throws', async () => { + expect.assertions(1); + + try { + await utils.getConfiguration({}); + } catch (err) { + expect(err.message).toBe('The template is required in the config.'); + } + }); + + test('with template transforms to its relative path', async () => { + const configuration = await utils.getConfiguration({ + answers: { template: 'InstantSearch.js' }, + }); + + expect(configuration).toEqual( + expect.objectContaining({ + template: path.resolve('templates/InstantSearch.js'), + }) + ); + }); + + test('with options from arguments and prompt merge', async () => { + const configuration = await utils.getConfiguration({ + options: { + name: 'my-app', + }, + answers: { + template: 'InstantSearch.js', + libraryVersion: '1.0.0', + }, + }); + + expect(configuration).toEqual( + expect.objectContaining({ + name: 'my-app', + libraryVersion: '1.0.0', + }) + ); + }); + + test('with config file overrides all options', async () => { + const loadJsonFileFn = jest.fn(x => Promise.resolve(x)); + const ignoredOptions = { + libraryVersion: '2.0.0', + }; + const options = { + config: { + template: 'InstantSearch.js', + libraryVersion: '1.0.0', + }, + ...ignoredOptions, + }; + const answers = { + ignoredKey: 'ignoredValue', + }; + + const configuration = await utils.getConfiguration({ + options, + answers, + loadJsonFileFn, + }); + + expect(configuration).toEqual(expect.not.objectContaining(ignoredOptions)); + expect(configuration).toEqual(expect.not.objectContaining(answers)); + }); +});