diff --git a/.storybook/main.js b/.storybook/main.js index 46c55160..fcf38137 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,4 +1,5 @@ const path = require('path'); +const {resolve} = require("path"); module.exports = { @@ -62,6 +63,7 @@ module.exports = { 'vue': path.resolve(__dirname, '../node_modules/vue/dist/vue.esm-bundler.js'), '~core': path.resolve(__dirname, '../src/core'), '~widgets': path.resolve(__dirname, '../src/widgets'), + '~constants': resolve(__dirname, '../src/constants'), }; return config; diff --git a/jest.config.js b/jest.config.js index 85ee3809..03f94edd 100644 --- a/jest.config.js +++ b/jest.config.js @@ -18,6 +18,7 @@ module.exports = { moduleNameMapper: { '^~widgets/(.*)$': './src/widgets/$1', '^~core/(.*)$': './src/core/$1', + '^~constants/(.*)$': './src/constants/$1', // This replaces import of files from @cloudblueconnect/material-svg in .spec.js files to optimize the run time of all unit tests '^.+\\.svg$': '/test/helpers/svgMock.js', }, diff --git a/src/constants/portal-routes.js b/src/constants/portal-routes.js new file mode 100644 index 00000000..0d3e921a --- /dev/null +++ b/src/constants/portal-routes.js @@ -0,0 +1,274 @@ +const routes = { + dashboard: 'dashboard', + userProfile: 'userProfile', + settings: 'settings', + + devops: 'devops', + extensions: 'extensions', + extensionDevops: { + name: 'devops.services.details', + requires: 'id', + }, + extensionSettings: { + name: 'settings.extensions', + requires: 'id', + }, + + subscriptions: 'subscriptions', + subscriptionDetails: { + name: 'subscriptions.directory.details', + requires: 'id', + }, + fulfillmentRequests: { + name: 'subscriptions', + tab: 'fulfillment', + }, + fulfillmentRequestDetails: { + name: 'subscriptions.fulfillment.details', + requires: 'id', + }, + subscriptionsBillingRequests: { + name: 'subscriptions', + tab: 'billing', + }, + subscriptionsBillingRequestDetails: { + name: 'subscriptions.billing.details', + requires: 'id', + }, + tierConfigs: 'tierConfigs', + tierConfigDetails: { + name: 'tierConfigs.directory.details', + requires: 'id', + }, + tierConfigRequests: { + name: 'tierConfigs', + tab: 'requests', + }, + tierConfigRequestDetails: { + name: 'tierConfigs.requests.details', + requires: 'id', + }, + products: 'products', + productDetails: { + name: 'product', + requires: 'id', + }, + productItems: { + name: 'product.items', + requires: 'id', + }, + productParameters: { + name: 'product.parameters', + requires: 'id', + }, + productSettings: { + name: 'product.settings', + requires: 'id', + }, + productEmbedding: { + name: 'product.embedding', + requires: 'id', + }, + productVersions: { + name: 'product.versions', + requires: 'id', + }, + productLocalization: { + name: 'product.localization', + requires: 'id', + }, + productSSO: { + name: 'product.ssoServices', + requires: 'id', + }, + + catalog: 'catalog', + + customers: 'customers', + customerDetails: { + name: 'customers.directory.details', + requires: 'id', + }, + customerRequests: { + name: 'customers', + tab: 'requests', + }, + customerRequestsDetails: { + name: 'customers.requests.details', + requires: 'id', + }, + + pricing: 'pricings', + pricingDetails: { + name: 'pricings.lists.details', + requires: 'id', + }, + + offers: 'offers', + offerDetails: { + name: 'offers.details', + requires: 'id', + }, + + helpdesk: 'helpdesk', + helpdeskCaseDetails: { + name: 'helpdesk.cases.details', + requires: 'id', + }, + + news: 'news', + + pim: 'pim', + pimAttributes: 'pim.attributes', + pimAttributeDetails: { + name: 'pim.attributes.details', + requires: 'id', + }, + pimGroups: 'pim.groups', + pimGroupDetails: { + name: 'pim.groups.details', + requires: 'id', + }, + pimClassDetails: { + name: 'pim.classes.details', + requires: 'id', + }, + pimCategoryDetails: { + name: 'pim.categories.details', + requires: 'id', + }, + pimVariants: 'pim.variants', + pimVariantDetails: { + name: 'pim.variants.details', + requires: 'id', + }, + + marketplaces: 'marketplaces', + marketplaceDetails: { + name: 'marketplaces.details', + requires: 'id', + }, + hubs: 'hubs', + hubDetails: { + name: 'hubs.details', + requires: 'id', + }, + + localizationContexts: { + name: 'localization', + tab: 'contexts', + }, + localizationTranslations: { + name: 'localization', + tab: 'translations', + }, + localizationTranslationDetails: { + name: 'localization.translations.details', + requires: 'id', + }, + localizationLocales: { + name: 'localization', + tab: 'locales', + }, + + usage: 'usages', + usageDetails: { + name: 'usages.details', + requires: 'id', + }, + + listings: 'listings', + listingsRequests: { + name: 'listings', + tab: 'requests', + }, + listingDetails: { + name: 'listings.directory.details', + requires: 'id', + }, + listingsRequestDetails: { + name: 'listings.requests.details', + requires: 'id', + }, + + integrations: 'integrations', + integrationsWebhooks: 'integrations.webhooks', + integrationsTokens: 'integrations.tokens', + integrationsExtensions: 'integrations.extensions', + + reports: 'reports', + reportsSchedules: { + name: 'reports', + tab: 'schedules', + }, + reportDetails: { + name: 'reports.details', + requires: 'id', + }, + reportsRequestDetails: { + name: 'reports.requests.details', + requires: 'id', + }, + + billingStreams: 'commerce.billing.streams', + billingStreamDetails: { + name: 'commerce.billing.streams.details', + requires: 'id', + }, + billingBatches: 'commerce.billing.batches', + billingBatchDetails: { + name: 'commerce.billing.batches.details', + requires: 'id', + }, + billingRequests: 'commerce.billing.requests', + billingRequestDetails: { + name: 'commerce.billing.requests.details', + requires: 'id', + }, + + pricingStreams: 'commerce.pricing.streams', + pricingStreamDetails: { + name: 'commerce.pricing.streams.details', + requires: 'id', + }, + pricingBatches: 'commerce.pricing.batches', + pricingBatchDetails: { + name: 'commerce.pricing.batches.details', + requires: 'id', + }, + pricingRequests: 'commerce.pricing.requests', + pricingRequestDetails: { + name: 'commerce.pricing.requests.details', + requires: 'id', + }, + + partners: 'partners', + partnerDetails: { + name: 'partners.details', + requires: 'id', + }, + partnersForms: 'partners.forms', + agreements: 'partners.agreements', + agreementDetails: { + name: 'partners.agreements.details', + requires: 'id', + }, + contracts: 'partners.contracts', + contractDetails: { + name: 'partners.contracts.details', + requires: 'id', + }, +}; + +export const connectPortalRoutesDict = Object.freeze(Object.keys(routes).reduce((acc, curr) => { + acc[curr] = Symbol(curr); + + return acc; +}, {})); + +// Transform all route keys to Symbol and freeze resulting object +export const connectPortalRoutes = Object.freeze(Object.entries(routes).reduce((acc, [key, value]) => { + acc[connectPortalRoutesDict[key]] = value; + + return acc; +}, {})); diff --git a/src/core/injector/core/injectorFactory.js b/src/core/injector/core/injectorFactory.js index a2fc2f88..e312fbe4 100644 --- a/src/core/injector/core/injectorFactory.js +++ b/src/core/injector/core/injectorFactory.js @@ -3,48 +3,61 @@ import { has, } from '~core/helpers'; -export default core => ({ - watch(a, b, {immediate} = {}) { - let fn, name; - - if (typeof a === 'function') { - name = '*'; - fn = a; - } else { - name = a; - fn = b; - } - - if (!has(name, core.watchers)) core.watchers[name] = []; - - core.watchers[name].push(() => { - fn(name === '*' ? core.state : core.state[name]); - }); - - if (immediate) { - fn(name === '*' ? core.state : core.state[name]); - } - }, - - commit(data) { - core.assign(data); - - window.top.postMessage({ - $id: core.id || null, - data: core.state ? clone(core.state) : null, - }, "*"); - }, - - emit(name, data = true) { - window.top.postMessage({ - $id: core.id || null, - events: { - [name]: data, - }, - }, "*"); - }, - - listen(name, cb) { - core.listeners[name] = cb; - }, -}); +import { + processRoute +} from '~core/router'; + + +export default core => { + const injector = { + watch(a, b, {immediate} = {}) { + let fn, name; + + if (typeof a === 'function') { + name = '*'; + fn = a; + } else { + name = a; + fn = b; + } + + if (!has(name, core.watchers)) core.watchers[name] = []; + + core.watchers[name].push(() => { + fn(name === '*' ? core.state : core.state[name]); + }); + + if (immediate) { + fn(name === '*' ? core.state : core.state[name]); + } + }, + + commit(data) { + core.assign(data); + + window.top.postMessage({ + $id: core.id || null, + data: core.state ? clone(core.state) : null, + }, "*"); + }, + + emit(name, data = true) { + window.top.postMessage({ + $id: core.id || null, + events: { + [name]: data, + }, + }, "*"); + }, + + listen(name, cb) { + core.listeners[name] = cb; + }, + + navigateTo(route, param) { + injector.emit('navigate-to', processRoute(route, param)); + }, + }; + + return injector; +}; diff --git a/src/core/injector/core/injectorFactory.spec.js b/src/core/injector/core/injectorFactory.spec.js index 0da7e5ad..2f83c7ba 100644 --- a/src/core/injector/core/injectorFactory.spec.js +++ b/src/core/injector/core/injectorFactory.spec.js @@ -1,4 +1,12 @@ import injectorFactory from './injectorFactory'; +import { + processRoute +} from '~core/router'; + + +jest.mock('~core/router', () => ({ + processRoute: jest.fn().mockReturnValue('processRouteMockedReturnValue'), +})); describe('injectorFactory', () => { describe('#watch()', () => { @@ -161,4 +169,26 @@ describe('injectorFactory', () => { expect(cb).toHaveBeenCalled(); }); }); + + describe('#navigateTo', () => { + let injector; + let injectorEmitSpy; + + beforeEach(() => { + injector = injectorFactory({}); + injectorEmitSpy = jest.spyOn(injector, 'emit'); + }); + + it('calls processRoute with the given arguments', () => { + injector.navigateTo('foo', 'bar'); + + expect(processRoute).toHaveBeenCalledWith('foo', 'bar'); + }); + + it('calls injector.emit with the result of calling processRoute', () => { + injector.navigateTo('foo', 'bar'); + + expect(injectorEmitSpy).toHaveBeenCalledWith('navigate-to', 'processRouteMockedReturnValue'); + }); + }); }); diff --git a/src/core/router.js b/src/core/router.js new file mode 100644 index 00000000..190aa74e --- /dev/null +++ b/src/core/router.js @@ -0,0 +1,53 @@ +import { + connectPortalRoutes, + connectPortalRoutesDict, +} from '~constants/portal-routes'; + + +const processRegisteredRoute = (route, param) => { + const spaRoute = connectPortalRoutes[route]; + let processedRoute = { name: '' }; + + if (!spaRoute) { + throw new Error(`[Connect UI Toolkit]: Route ${route.toString()} does not exist.\nThe following routes are available:\n${Object.keys(connectPortalRoutesDict).join(', ')}`); + } + + if (typeof spaRoute === 'string') { + processedRoute.name = spaRoute; + } else { + processedRoute.name = spaRoute.name; + processedRoute.params = {}; + + if (spaRoute.tab) { + processedRoute.params.tab = spaRoute.tab; + } + + if (spaRoute.requires) { + if (!param) { + throw new Error(`[Connect UI Toolkit]: Route ${route.toString()} requires the ${spaRoute.requires} parameter.`); + } + + processedRoute.params[spaRoute.requires] = param; + } + } + + return processedRoute; +}; + +export const processRoute = (route, param) => { + if (!route) { + throw new Error('[Connect UI Toolkit]: Empty route cannot be processed.'); + } + + // If route is an object or a string, avoid processing + if (['object', 'string'].includes(typeof route)) { + return route; + } + + // If route is symbol, process it according to the registered spa routes + if (typeof route === 'symbol') { + return processRegisteredRoute(route, param); + } + + throw new Error(`[Connect UI Toolkit]: Route could not be processed. Route is: ${JSON.stringify(route)}`); +}; diff --git a/src/core/router.spec.js b/src/core/router.spec.js new file mode 100644 index 00000000..1f4bbfd5 --- /dev/null +++ b/src/core/router.spec.js @@ -0,0 +1,110 @@ +import { + connectPortalRoutes, + connectPortalRoutesDict, +} from '~constants/portal-routes'; + +import { + processRoute, +} from '~core/router'; + + +describe('#processRoute', () => { + let result; + let err; + + beforeEach(() => { + result = undefined; + err = undefined; + }); + + describe('if route is not used', () => { + it('throws an error', () => { + try { + processRoute(); + } catch(e) { + err = e; + } + + expect(err).toBeInstanceOf(Error); + expect(err.message).toEqual('[Connect UI Toolkit]: Empty route cannot be processed.'); + }); + }); + + describe('if route is a String', () => { + it('returns the route without processing', () => { + result = processRoute('foo'); + + expect(result).toEqual('foo'); + }); + }); + + describe('if route is an Object', () => { + it('returns the route without processing', () => { + result = processRoute({foo: 'bar'}); + + expect(result).toEqual({foo: 'bar'}); + }); + }); + + describe('if route is a Symbol', () => { + it('throws an error if the route is not part of the connect portal routes', () => { + const fakeRoute = Symbol('foo'); + + try { + processRoute(fakeRoute); + } catch(e) { + err = e; + } + + expect(err).toBeInstanceOf(Error); + expect(err.message).toEqual(`[Connect UI Toolkit]: Route ${fakeRoute.toString()} does not exist.\nThe following routes are available:\n${Object.keys(connectPortalRoutesDict).join(', ')}`); + }); + + it('returns the correct route for a simple route', () => { + const simpleRoute = connectPortalRoutesDict.dashboard; + + result = processRoute(simpleRoute); + + expect(result).toEqual({ name: connectPortalRoutes[simpleRoute] }); + }); + + it('returns the correct route for a route that has a tab', () => { + const routeWithTab = connectPortalRoutesDict.fulfillmentRequests; + + result = processRoute(routeWithTab); + + expect(result).toEqual({ + name: connectPortalRoutes[routeWithTab].name, + params: { + tab: connectPortalRoutes[routeWithTab].tab, + }, + }); + }); + + it('throws an error if the route requires a parameter that is not sent', () => { + const routeWithRequiredParameter = connectPortalRoutesDict.marketplaceDetails; + + try { + processRoute(routeWithRequiredParameter); + } catch(e) { + err = e; + } + + expect(err).toBeInstanceOf(Error); + expect(err.message).toEqual(`[Connect UI Toolkit]: Route ${routeWithRequiredParameter.toString()} requires the ${connectPortalRoutes[routeWithRequiredParameter].requires} parameter.`); + }); + + it('returns the correct route for a route that requires a parameter and it is sent', () => { + const routeWithRequiredParameter = connectPortalRoutesDict.marketplaceDetails; + + result = processRoute(routeWithRequiredParameter, 'MKP-123'); + + expect(result).toEqual({ + name: connectPortalRoutes[routeWithRequiredParameter].name, + params: { + [connectPortalRoutes[routeWithRequiredParameter].requires]: 'MKP-123', + }, + }); + }); + }); +}); diff --git a/src/index.js b/src/index.js index 673541dd..8f802b9d 100644 --- a/src/index.js +++ b/src/index.js @@ -14,6 +14,9 @@ import table from './widgets/table/widget.vue'; import _store from './core/store'; import _bus from './core/eventBus'; +import { + connectPortalRoutesDict, +} from './constants/portal-routes'; export const Tabs = tabs; export const Tab = tab; @@ -29,6 +32,8 @@ export const Table = table; export const bus = _bus; export const store = _store; +export const connectPortalRoutes = connectPortalRoutesDict; + export default (widgets = {}, options = {}) => { for (const widget in widgets) registerWidget(widget, widgets[widget]); diff --git a/webpack.config.js b/webpack.config.js index 543bce49..6bd4d287 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -63,6 +63,7 @@ module.exports = { alias: { '~core': resolve(__dirname, './src/core'), '~widgets': resolve(__dirname, './src/widgets'), + '~constants': resolve(__dirname, './src/constants'), }, },