tabs.some(function(tab) { // if activeTab: 'x' is in both objects, set the active tab // and write this better..maybe use ids if(objectContains(params, tab.tabDef.params)) { tab.setActiveTab(merge(cloneDeep(tab.tabDef), { params: params })); } });
before(function (done) { registerViewModels({ adapter: adapterViewModel, test_input(node) { let value = ko.observable(), error = ko.observable(true), context = this; // using subscribe pattern if (this.data && this.data.subscribe) { this.data.subscribe(data => { value(data[node.id]); }); } return merge(node, { context, getValue() { return value(); }, setValue(val) { value(val); }, error }); }, test_parent(node) { let mappedChildNodes = createViewModels.call(this, node.children || []); return merge(node, { mappedChildNodes }); }, errors(node) { let dict = this.dictionary(), aggregateErrors = function () { return Object.keys(dict).filter(function (id) { return (dict[id].error) ? dict[id].error() : false; }); }; return aggregateErrors; } }); adapter = createViewModel(node); done(); });
dataSourceEndpointArray.forEach((e) => { let endpoint = e; if (endpoint.uri) { console.warn('dataSourceEndpoint expects URI in "target". Please update your JSON to reflect the new syntax'); endpoint = merge(endpoint, { target: endpoint }); delete endpoint.uri; } createViewModel.call(context, { type: 'action', actionType: 'ajax', options: endpoint }).action({ callback: function (error, results) { let resultsByKey, keyMapArray = endpoint.keyMap || [{}], newDataObject = {}; count += 1; if (!Array.isArray(keyMapArray)) { keyMapArray = [keyMapArray]; } if (!error) { keyMapArray.forEach((keyMap) => { resultsByKey = keyMap.resultsKey ? get(results, keyMap.resultsKey) : results; // optional: keyMap.dataKey path to extend dataObject on if (keyMap.dataKey) { newDataObject[keyMap.dataKey] = resultsByKey; } else if (keyMap.storeKey) { noticeboard.setValue(keyMap.storeKey, resultsByKey); } else { newDataObject = resultsByKey; } extend(dataObject, newDataObject); }); } if (count === dataSourceEndpointArray.length) { updateData(dataObject); if (!mappedChildNodes().length) { mappedChildNodes(createViewModels.call(context, node.children || [])); } } } }); });
export default function globalNavigation(node) { const routes = ko.observableArray(node.routes), navLinks = navigation.navLinks, activeLink = navigation.activeLink; function walkGetTypes(nodes) { return (nodes || []) .reduce((types, childNode) => types.concat([childNode.type]) .concat(walkGetTypes(childNode.children)), []); } routes().forEach((route) => { navigation.addNav(route, (routeInfo) => { const name = route.get.replace('{path}', routeInfo.path ? `_${routeInfo.path.replace('/', '_')}` : ''); dataservice.ajax({ uri: name }, (err, metadata) => { if (err) { return; } const types = _.uniq(walkGetTypes(Array.isArray(metadata) ? metadata : [metadata])) .filter(type => type && getRegisteredTypes().indexOf(type) === -1 ); // console.log('Missing types:', types); layout.content(metadata); }); }); }); navigation.init(node.initial || 0); routes.subscribe((oldRoutes) => { oldRoutes.forEach((routeOptions) => { navigation.removeNav(routeOptions.text); }); }, null, 'beforeChange'); return merge(node, { navLinks, activeLink, dispose: function () { routes([]); } }); }
/** * Route action to re-route to another page * * @module route * * @param {object} node * The configuration object for the route action * @param {string} node.type='action' * The type of the node is action * @param {string} node.actionType='route' * The actionType of the node is route * @param {string} node.text * The text to display on the button * @param {object} node.options * The options pertaining to the route action * @param {string} node.options.target * The uri to route to * @param {object} node.options.data * The data to send along when routing * @param {object} node.options.params * Key-value pairs to merge pass along as data when routing * @param {string} node.options.paramsKey * The key of the data for the parameters * * @example * { * "type": "action", * "actionType": "route", * "text": "Add User", * "options": { * "target": "add-user" * } * } */ function route(options) { let data = unwrap(options.data || (this && this.data)), target = unwrap(options.target), params; if (options.params && options.paramsKey) { data = merge(data, options[options.paramsKey]); } params = options.params ? renderParams(options.params, data) : undefined; if (options.renderTarget) { target = mustache.render(target, data); } setRoute(target, params); }
/** * Redirect action to redirect the current page to another * * @module redirect * * @param {object} node * The configuration object for the redirect action * @param {string} node.type='action' * The type of the node is action * @param {string} node.actionType='redirect' * The actionType of the node is redirect * @param {string} node.text * The text to display on the button * @param {object} node.options * The options pertaining to the redirect action * @param {string} node.options.target * The url to redirect to * @param {object} node.options.data * The data to mustache render the target url with * @param {object} node.options.params * Key-value pairs to merge pass along as data when redirecting * @param {string} node.options.paramsKey * The key of the data for the parameters * * @example * { * "type": "action", * "actionType": "redirect", * "text": "Redirect", * "options": { * "target": "https://www.google.com" * } * } */ function redirect(options) { if (!options.target) { console.error('Must provide target!'); return; } let data = unwrap(options.data || (this && this.data)), url; if (options.params && options.paramsKey) { data = merge(data, options[options.paramsKey]); } url = mustache.render(options.target, data); window.location.replace(url); }
function getOptions() { return merge({ theme: 'pjson', onResult: onResult, onAdd: onAdd, onDelete: onDelete, preventDuplicates: true, minChars: 0, caching: false, searchDelay: 0, animateDropdown: false, allowTabOut: true, noResultsText: null, searchingText: null, disabled: ko.unwrap(bindings.tokeninputDisable), prePopulate: valueAccessor().filter(result => (value() || []).includes(result.id)) }, tokeninputOptions); }
tabObj.setActiveTab = function () { // merge the params from route with current query as not to overwrite it // also merge with the tabObj.tabDef.params so that the tab initializes correctly // for the objectContains check below, if the tabDef.params is part of the query it will use that tab as the initial tab // so we need to make sure, that if we're setting a tabRoute, that the params of the last tab are overwriten with the params in the current tab // so you dont get another route accidentally createViewModel.call(context, { type: 'action', actionType: 'route', options: { target: tab.route.target, params: merge( getCurrent().query, tabObj.tabDef.params, tab.route.params ) } }).action(); }
/** * Grid component to display a grid of data * * @module grid * * @param {object} node * The configuration object for the grid * @param {object} node.data * Initial data to populate the grid with * @param {string} node.id * The id of the grid * @param {string} node.classes * The classes to apply to the grid * @param {string} node.gridHeaderClasses * The classes to apply to the grid header * @param {array} node.gridHeader * An array of PJSON components to use as the grid header * @param {object} node.dataSourceEndpoint * Configuration object for the grid's data source * @param {object} node.dataSourceEndpoint.target * Configuration object for the target of the grid's data source * @param {string} node.dataSourceEndpoint.target.uri * The uri endpoint for the grid's data source * @param {object} node.dataSourceEndpoint.target.dataMapFunctions * An object of functions to run on the data * @param {string} node.dataSourceEndpoint.target.dataMapFunctions.before * Function to run before the data is added to the grid? * @param {string} node.dataSourceEndpoint.target.dataMapFunctions.after * Function to run after the data is added to the grid? * @param {object|array} node.dataSourceEndpoint.keyMap * A mapper object or array of mapper objects to map keys * @param {object} node.pagination * An object to specify pagination for the grid * @param {number} node.pagination.start=0 * The number of which page to start the grid at * @param {number} node.pagination.limit=15 * The max number of grid items to show on each page * @param {array} node.columns * An array of objects to build the columns * @param {object} node.selection * A PJSON action to use when a row is selected * @param {object} node.options * The options pertaining to the grid * @param {boolean} node.options.infinite * Boolean to specify whether to show infinite items on the grid * @param {boolean} node.options.fixedHeader * Boolean to specify if the grid header should be fixed or not * @param {object} node.options.footer * Configuration object for the grid footer * @param {boolean} node.options.footer.hideOnDone * Boolean to hide the footer once the grid is loaded or not * @param {string} node.options.footer.loadingText * A string to show while the grid is loading * @param {string} node.options.footer.doneText * A string to show when the grid has finished loading. * @param {object} node.options.hasChildren * Configuration object for a grid row's child * @param {string} node.options.hasChildren.showIcon * The class to apply to the show child button * @param {string} node.options.hasChildren.hideIcon * The class to apply to the hide child button * @param {string} node.options.hasChildren.template * The template to apply to the child row * @param {boolean} node.options.hasChildren.onRowSelect * Boolean to determine whether to show/hide the child on selecting the row or via a button * @param {boolean} node.options.hasChildren.accordion * Boolean to determine if only one child should be shown at a time * @param {boolean} node.options.clientSearch * Boolean on whether to search/sort client side * @param {boolean|expression} node.options.gridDisplay * Boolean or expression to render the grid programmatically * @param {string} node.options.scrollElement * Element to scroll grid on, defaults to scrolling on window * * @example * { * "type": "grid", * "id": "my_grid_id", * "classes": "grid-container", * "gridHeaderClasses": "grid-header", * "options": { * "infinite": true, * "fixedHeader": true, * "footer": { * "hideOnDone": true, * "loadingText": "Loading...", * "doneText": "Loaded all rows." * }, * "hasChildren": { * "showIcon": "icon-open", * "hideIcon": "icon-close", * "template": "grid_child_template", * "onRowSelect": true, * "accordion": true, * }, * } * "dataSourceEndpoint": { * "target": { * "uri": "endpoint" * }, * "keyMap": { * "resultsKey": "data" * } * }, * "pagination": { * "start": 0, * "limit": 30 * }, * "columns": [ * { * "data": "colData", * "title": "Column Data Title" * } * ] * } */ export default function (node) { const data = node.data, options = node.options, columns = node.columns, context = this, pagination = node.pagination, endpoint = node.dataSourceEndpoint, rows = ko.observableArray(), skip = ko.observable(pagination.start || 0), limit = ko.observable(pagination.limit || 15), search = ko.observable(''), filters = ko.observable({}), caseInsensitive = ko.observable(true), clientSearch = options.clientSearch, selectedItem = ko.observable({}), gridContext = { search, filters, rows, skip, clientSearch, caseInsensitive, getValue: this.getValue, parentContext: context }, loaderNoText = '', loaderLoading = 'Loading more...', loaderDone = 'Loaded all rows', loader = { text: ko.observable(loaderNoText), done: false, inProgress: ko.observable(false) }, subs = []; let query, queryCallback, gridHeaderItems = node.gridHeader || []; function setupQuery() { query = createViewModel({ type: 'action', actionType: 'ajax', options: endpoint }); } function setupGetResponse() { queryCallback = { callback: function (err, results) { if (!err) { const key = get(endpoint, 'keyMap.resultsKey'), resultData = key && results ? results[key] : results; rows.push(...resultData); skip(results.skip); loader.inProgress(false); loader.done = results.skip >= results.total; if (loader.done) { loader.text(loaderDone); } else { loader.text(loaderNoText); } } else { console.error(`Error in grid query callback: ${err.message || ''}`); } } }; } function sendQuery(isFilter) { if (isFilter || (!loader.done && !loader.inProgress())) { if (isFilter) { skip(0); rows.removeAll(); } query.options.target.data = { skip: skip(), limit: limit() }; // data is in target.data, so it will be sent as is if (!clientSearch) { query.options.target.data.search = search(); query.options.target.data.filters = filters(); } loader.inProgress(true); loader.text(loaderLoading); query.action(queryCallback); // TODO: call a resetfilter function to update skip/row observs in here } } function setupGridHeader() { gridHeaderItems = gridHeaderItems.map(item => createViewModel.call(gridContext, item)); // TODO: update to createViewModels after Erica updates mf search.extend({ rateLimit: 1000 }); if (!clientSearch) { search.subscribe(() => sendQuery(true)); filters.subscribe(() => sendQuery(true)); } } function setupRefresh() { subs.push(receive(`${node.id}.refresh`, () => { sendQuery(true); })); } function setupSelection() { if (node.selection) { selectedItem.subscribe((item) => { const action = _.cloneDeep(node.selection), selectionCtx = _.cloneDeep(context); selectionCtx.data = item; createViewModel.call(selectionCtx, action).action(); }); } } function setupData() { if (endpoint) { setupQuery(); setupGetResponse(); sendQuery(); setupRefresh(); } else if (data) { rows(data); } } function addRow(row) { rows.push(...row); } // Set up a receiver to push rows to the grid. subs.push(receive(`${node.id}.add`, (row) => { addRow(row); })); setupData(); setupSelection(); setupGridHeader(); return merge(node, { rows, columns, sendQuery, loader, options, gridHeaderItems, search, filters, caseInsensitive, selectedItem, dispose: function () { subs.forEach((sub) => { sub.dispose(); }); } }); }
// when tabs are selected update the tab route // do not update tabroute on initialization, just replace function setTabRoute(tabDef) { var currentRoute = getCurrent().route + (getCurrent().path ? '/' + getCurrent().path : '' ), query = getCurrent().query; setRoute(tabDef.target || currentRoute, merge(query,tabDef.params), false, initialized); initialized = true; }
export default function (node, metadata) { var context = this, createViewModel = createViewModelUnbound.bind(context), //ensures context is passed options = node.options || {}, mappedChildNodes, activeTabRegion = observable({}), initialized = false, subs = [], query = getCurrent().query, tabs = [], initialActiveTab; // when tabs are selected update the tab route // do not update tabroute on initialization, just replace function setTabRoute(tabDef) { var currentRoute = getCurrent().route + (getCurrent().path ? '/' + getCurrent().path : '' ), query = getCurrent().query; setRoute(tabDef.target || currentRoute, merge(query,tabDef.params), false, initialized); initialized = true; } // zip the children tabs with the tab defs node.children.forEach(function (tab, index) { var tabTemplate = observable(), // can by dynamic because of ajax tabs tabDef = typeof node.headers[index] === 'string' ? { text: node.headers[index] } : node.headers[index], tabName = tabDef.computed ? computed(function () { return formatText(tabDef.text, context.getValue); }) : tabDef.text, tabObj = { tabName: tabName, tabDef: tabDef, tabTemplate: tabTemplate, keepCache: tab.cache || node.cache }, tabViewModel; // sets the active tab region to the tab template // sets newRoute if defined, else sets tabRoute tabObj.setActiveTab = function (newRoute) { if (options.validations && options.validations[tabDef.text]) { notify(options.validations[tabDef.text], { successCallback: function() { activeTabRegion(tabTemplate()); if (options.setRoute !== false) { setTabRoute(newRoute || tabDef); } } }); } else { activeTabRegion(tabTemplate()); if (options.setRoute !== false) { setTabRoute(newRoute || tabDef); } } }; // tab is active when the active region is the template tabObj.isActive = computed(function () { return activeTabRegion() === tabTemplate(); }); // create tabViewModel from type if defined // else call createViewModel if (tabTypes[tab.type]) { tabViewModel = tabTypes[tab.type].call(context, tabObj, tab) } else { tabViewModel = createViewModel(tab) tabTemplate(template('metadata_item_template', tabViewModel)); } extend(tabObj, tabViewModel); // visible expression binding using context' getValue if(has(tabDef.visible)) { tabObj.visible = is(tabDef.visible, 'boolean') ? tabDef.visible : computed(function() { return evaluate(tabDef.visible, context.getValue || function (id) { console.error('Trying to evaluate a binding when getValue isnt specified on the context', tabObj); }); }); } else { tabObj.visible = true; } tabs.push(tabObj); // sets the active tab if defined in the query or tabDef if(tabDef.isActive || tabDef.params && query && objectContains(query, tabDef.params)) { initialActiveTab = tabObj; } }); if (initialActiveTab) { initialActiveTab.setActiveTab(); } else if (!activeTabRegion().template) { // initialize to first tab if we havent routed to a specific tab // will set first visible tab to active tab let initialTab = tabs.filter((tab) => { return unwrap(tab.visible); })[0]; initialTab && initialTab.setActiveTab(); } // receive events to set active tab if(node.id) { subs.push(receive(node.id +'.setActiveTab', function (params) { tabs.some(function(tab) { // if activeTab: 'x' is in both objects, set the active tab // and write this better..maybe use ids if(objectContains(params, tab.tabDef.params)) { tab.setActiveTab(merge(cloneDeep(tab.tabDef), { params: params })); } }); })); subs.push(receive(node.id+'.setNextTab', function () { var currentIndex = 0, newIndex; tabs.some(function(tab, index) { if (activeTabRegion() == tab.tabTemplate()) { currentIndex = index; } }) while(newIndex === undefined) { if (!tabs[(currentIndex+1) % tabs.length].isChild) { newIndex = currentIndex +1; } else { currentIndex++ } } tabs[newIndex].setActiveTab(); })); } return merge(node, { tabs: tabs, mappedChildNodes: tabs, activeTabRegion: activeTabRegion, context: this, dispose: function () { subs.forEach(function (sub) { sub.dispose(); }); tabs.forEach(function (tab) { var tabViewModels = tab.tabTemplate() && tab.tabTemplate().template.data; // why is tab viewmodels an array, anyway? if (tabViewModels && !Array.isArray(tabViewModels)) { tabViewModels = [tabViewModels]; } (tabViewModels || []).forEach(function(vm) { vm && vm.dispose && vm.dispose(); }); }); } }); };
/* TODO: In PJSON, we used readonly, errors, etc. We need a way to do that outside of adapter i.e. plugin to adapter context with other components */ /** Adapter: a viewless component which keeps track of child nodes and the data for the nodes * @module adapter * * @param {object} node * The configuration object for the module * @param {string} node.type='adapter' * The type of the node is adapter * @param {string} node.id * The id for the module * @param {boolean} [node.lazy=false] * If the child nodes need to be lazily loaded * (e.g. delay creation of children viewmodels until data returns) * @param {boolean} [node.persist=false] * If data object should be persisted from one fetch data call to the next (upon refresh) * @param {object|Object[]} [node.dataSourceEndpoint] * An object defining the endpoint(s) that makes the ajax calls * @param {string} node.dataSourceEndpoint.uri * The uri for the endpoint * @param {string} [node.dataSourceEndpoint.url] * The url for the endpoint * @param {array|object} [node.dataSourceEndpoint.keyMap] * A mapper object or array of mapper objects to map keys * @param {string} [node.dataSourceEndpoint.keyMap.resultsKey] * Map the results from the ajax call with this key * @param {string} [node.dataSourceEndpoint.keyMap.dataKey] * Extend the data object with this key * @param {string} [node.dataSourceEndpoint.keyMap.storeKey] * Place the resultsByKey inside of the store with this key * @param {object} [node.dataSourceEndpoint.options] * Options for the ajax call * @param {array} node.children * The json configuration for children nodes which will be mapped * to view models and kept track of from the adapter * @param {array} [node.plugins] * The json configuration for plugins which will be accessible * from getValue function, based upon type * * @property {array} mappedChildNodes the mapped children nodes * @property {observable} data the data retrieved from dataSourceEndpoint and tracked from children * @property {object} contextPlugins an object that contains the plugins which have * been added to the adapter context * @property {context} the context for the adapter (which can be utilized in a custom template) * @property {function} dispose the dispose function for all internal subs * * @example * { * "type": "adapter", * "id": "ADAPTER_ID", * "dataSourceEndpoint": [ * { * "uri": "endpoint/uri", * "options": { * "type": "PUT" * }, * "keyMap": { * "dataKey": "a", * "resultsKey": "b" * } * } * ], * "children": [ * // children json configuration goes here * ] * } */ export default function adapterViewModel(node) { const dictionary = observable({}), // dictionary of nodes with an id data = observable({}), // data of dictionary contents context = { metadata: node.children, parentContext: this, getValue: getValue, dictionary: dictionary, data: data, id: node.id }, mappedChildNodes = observableArray(), subs = [], plugins = node.plugins ? createViewModels.call(context, node.plugins) : [], contextPlugins = {}; let dataSyncSubscription, updated = false; plugins.forEach((plugin) => { contextPlugins[plugin.type] = plugin; }); // recursive function which parses through nodes and adds nodes with an id to dictionary function createDictionary(nodes) { const dict = dictionary.peek(); nodes.forEach((n) => { // add node to dictionary if it isnt there yet if (n.id && !dict[n.id]) { dict[n.id] = n; updated = true; } // add children to dictionary if getValue function is not exposed if (!n.getValue) { createDictionary(unwrap(n.mappedChildNodes) || []); } }); } // keep the data current if the node value changed with dataSyncDescription function syncDataDictionary() { dataSyncSubscription = computed(() => { const dict = dictionary(); Object.keys(dict).forEach((id) => { if (dict[id].rendered) { if (dict[id].rendered() && dict[id].getValue) { data()[id] = dict[id].getValue(); } else if (!dict[id].rendered()) { if (dict[id].trackIfHidden) { data()[id] = dict[id].getValue(); } else { delete data()[id]; } } } }); }); } // pause dataSyncDescription and update the data function updateData(newData) { dataSyncSubscription && dataSyncSubscription.dispose(); data(newData); syncDataDictionary(); } // fetches the data from dataSourceEndpoint(s) function fetchData() { const dataSourceEndpointArray = Array.isArray(node.dataSourceEndpoint) ? node.dataSourceEndpoint : [node.dataSourceEndpoint], dataObject = node.persist ? data() : {}; let count = 0; dataSourceEndpointArray.forEach((e) => { let endpoint = e; if (endpoint.uri) { console.warn('dataSourceEndpoint expects URI in "target". Please update your JSON to reflect the new syntax'); endpoint = merge(endpoint, { target: endpoint }); delete endpoint.uri; } createViewModel.call(context, { type: 'action', actionType: 'ajax', options: endpoint }).action({ callback: function (error, results) { let resultsByKey, keyMapArray = endpoint.keyMap || [{}], newDataObject = {}; count += 1; if (!Array.isArray(keyMapArray)) { keyMapArray = [keyMapArray]; } if (!error) { keyMapArray.forEach((keyMap) => { resultsByKey = keyMap.resultsKey ? get(results, keyMap.resultsKey) : results; // optional: keyMap.dataKey path to extend dataObject on if (keyMap.dataKey) { newDataObject[keyMap.dataKey] = resultsByKey; } else if (keyMap.storeKey) { noticeboard.setValue(keyMap.storeKey, resultsByKey); } else { newDataObject = resultsByKey; } extend(dataObject, newDataObject); }); } if (count === dataSourceEndpointArray.length) { updateData(dataObject); if (!mappedChildNodes().length) { mappedChildNodes(createViewModels.call(context, node.children || [])); } } } }); }); } function getValue(id) { const dictNode = dictionary()[id], dataValue = (data() || {})[id]; // the node has been defined so get the value from the node if (dictNode && dictNode.getValue) { return dictNode.getValue(); } // data has been defined for the node but the node doesnt exist yet if (dataValue) { return dataValue; } if (contextPlugins && contextPlugins[id]) { return contextPlugins[id](); } return context.parentContext.getValue(id); } if (node.keepContextData) { data(unwrap(this.data) || {}); } if (!node.lazy) { mappedChildNodes(createViewModels.call(context, node.children || [])); } // update dictionary if mappedChildNodes of a node updates computed(() => { updated = false; createDictionary(mappedChildNodes()); if (updated) { dictionary.valueHasMutated(); } }); // initialize the data subscription syncDataDictionary(); // get initial data if (node.dataSourceEndpoint) { fetchData(); } // listen for 'refresh' event subs.push(receive(`${node.id}.refresh`, (options) => { // console.log('-->', node); if (node.dataSourceEndpoint) { fetchData(options); } else { Object.keys(dictionary()).forEach((key) => { dictionary()[key].setValue && dictionary()[key].setValue(''); }); } })); return merge(node, { mappedChildNodes: mappedChildNodes, data: data, contextPlugins: contextPlugins, context: context, dispose: function () { subs.forEach((sub) => { sub.dispose(); }); } }); }