@qikdev/sdk
Version:
Promise based Javascript SDK
899 lines (764 loc) • 26.3 kB
JavaScript
///////////////////////////////////////////////////
/**
* Creates a new QikContent instance.
* This module provides a number of helper functions for creating and modifying content via the REST API
* @alias content
* @constructor
* @hideconstructor
* @param {QikCore} qik A reference to the parent instance of the QikCore module. This module is usually created by a QikCore instance that passes itself in as the first argument.
*/
export default function (qik) {
if (!qik.api) {
throw new Error(`Can't Instantiate QikContent before QikAPI exists`);
}
///////////////////////////////////////////////////
var service = {
debug: false,
loadingGlossary: false,
};
///////////////////////////////////////////////////////////////////////////////
const variables = {};
let inflightVariablesRequest;
/**
*
* Retrieves all global variables for the current user. This is often used when running custom code in an action.
* @alias content.variables
* @param {Array} keys Provide specific keys of variables you want to retrieve
* @param {Object} options Additional options when making the request
* @param {Boolean} options.reload Force variables to reload and not be cached. If false will retrieve any variables that are already known from the in memory cache.
* @example
*
* const { OAUTH_CLIENT_ID, OAUTH_KEY } = await sdk.content.variables();
*/
service.variables = async function (keys, options) {
options = options || {};
keys = keys || [];
var reload = options.refresh || options.reload || !variables.data;
if (reload) {
if (!inflightVariablesRequest) {
inflightVariablesRequest = qik.api.post(`/variables`, { keys });
inflightVariablesRequest.then(resolveRequest, resolveRequest);
function resolveRequest() {
inflightVariablesRequest = null;
}
}
const { data } = await inflightVariablesRequest;
variables.data = data;
qik.dispatch("variables", data);
}
return variables.data;
};
///////////////////////////////////////////////////
const glossaryCache = qik.cache.get("user:glossary");
let inflightGlossaryRequest;
/**
* Yield to the event loop to keep the UI responsive
* @private
*/
function yieldToMain() {
return new Promise((resolve) => {
if (typeof requestIdleCallback === "function") {
requestIdleCallback(resolve, { timeout: 50 });
} else {
setTimeout(resolve, 0);
}
});
}
/**
* Inflate compressed glossary data from the /glossary/compressed endpoint
* Processes in chunks to avoid blocking the main thread
* @private
*/
async function inflateGlossary({ _s: strings, _k: keyMap, types }) {
function inflate(val) {
if (typeof val === "string" && val[0] === "~") {
return strings[parseInt(val.slice(1))];
}
if (Array.isArray(val)) return val.map(inflate);
if (val && typeof val === "object") {
const result = {};
for (const [k, v] of Object.entries(val)) {
result[keyMap[k] || k] = inflate(v);
}
return result;
}
return val;
}
const CHUNK_SIZE = 50;
const results = [];
for (let i = 0; i < types.length; i += CHUNK_SIZE) {
const chunk = types.slice(i, i + CHUNK_SIZE);
results.push(...chunk.map(inflate));
// Yield to main thread between chunks
if (i + CHUNK_SIZE < types.length) {
await yieldToMain();
}
}
return results;
}
/**
*
* Retrieves the glossary of all content types visible to the requesting user.
By default this will include all fields, validation, expressions and other configuration
* @alias content.glossary
* @param {Object} options Additional options
* @param {Boolean} options.hash Whether to return the data as a keyed object
allowing for fast selection of specific content types, by default will return as an array
* @param {Boolean} options.reload Force glossary to reload and not be cached.
If false will retrieve content type data from the in memory cache
* @param {Boolean} options.uncompressed Use the uncompressed endpoint
* @example
*
* const { article, profile } = await sdk.content.glossary({hash:true});
* // Use compressed endpoint for faster loading
* const glossary = await sdk.content.glossary({compressed:true});
*/
service.glossary = async function (options) {
options = options || {};
// Check if we already have the glossary cached
let cached = glossaryCache.get("glossary");
const uncompressed = !!options.uncompressed;
let compressed = !uncompressed;
var reload = options.refresh || options.reload || !cached;
// If we are forcing a reload
if (reload) {
if (!inflightGlossaryRequest) {
service.loadingGlossary = true;
// Use compressed endpoint if requested
const endpoint = compressed ? `/glossary/compressed` : `/glossary`;
inflightGlossaryRequest = qik.api.get(endpoint, { cache: false });
inflightGlossaryRequest.then(resolveRequest, resolveRequest);
function resolveRequest() {
inflightGlossaryRequest = null;
service.loadingGlossary = false;
}
}
// Now we have the glossary
const { data } = await inflightGlossaryRequest;
// Inflate if compressed format (async to avoid blocking UI)
const glossaryData =
data._s && data._k && data.types ? await inflateGlossary(data) : data;
// Cache it for later
glossaryCache.set("glossary", glossaryData);
cached = glossaryData;
qik.dispatch("glossary", glossaryData);
}
if (options.hash) {
var result = qik.utils.hash(cached, "key");
return result;
}
if (options.hex) {
var result = qik.utils.hash(cached, "hex");
return result;
}
return cached;
};
///////////////////////////////////////////////////
const scopeGlossary = {};
let inflightScopeGlossaryRequest;
/**
*
* Retrieves the scope glossary of all scopes the user can know about. This helps to convert a scope id into a human readable title.
* @alias content.scopeGlossary
* @param {Object} options Additional options
* @param {Boolean} options.hash Whether to return the data as a keyed object with each scopes _id as the key
allowing for fast selection of specific scopes, by default will return a structured tree
* @param {Boolean} options.reload Force the glossary to reload and not be cached.
If false will retrieve content type data from the in memory cache
* @example
*
* const scopes = await sdk.content.scopeGlossary();
*/
service.scopeGlossary = async function (options) {
options = options || {};
var reload = options.refresh || options.reload || !scopeGlossary.data;
if (reload) {
if (!inflightScopeGlossaryRequest) {
inflightScopeGlossaryRequest = qik.api.get(`/scope/glossary`, {
cache: false,
});
inflightScopeGlossaryRequest.then(resolveRequest, resolveRequest);
function resolveRequest() {
inflightScopeGlossaryRequest = null;
}
}
const { data } = await inflightScopeGlossaryRequest;
scopeGlossary.data = data;
}
if (options.hash) {
var result = qik.utils.hash(scopeGlossary.data, "_id");
return result;
}
return scopeGlossary.data;
};
///////////////////////////////////////////////////
/**
*
* Retrieves all available filter comparators for each data type
* @alias content.comparators
* @param {Object} options Additional options
* @param {Boolean} options.reload Ignore any locally cached data
* @example
* const {hash, available, types} = await sdk.content.comparators();
* console.log(available) // {boolean:[{title:'Is equal to', operator:'equal'...}]}
* console.log(hash) // {equal:[{title:'Is equal to', operator:'equal'...}]}
*/
const comparators = {};
service.comparators = async function (options) {
options = options || {};
var reload = options.reload || options.refresh || !comparators.data;
if (reload) {
const { data } = await qik.api.get(`/system/comparators`);
/////////////////////////////
data.available = {};
Object.entries(data.types).forEach(([key, value]) => {
data.available[key] = value.map(function (comparatorKey) {
return data.hash[comparatorKey];
});
});
/////////////////////////////
comparators.data = data;
}
// if (options.hash) {
// var result = qik.utils.hash(comparators.data, 'key');
// return result;
// }
return comparators.data;
};
///////////////////////////////////////////////////
function getLimits(fieldDefinition) {
var { minimum, maximum } = fieldDefinition;
minimum = qik.utils.parseInt(minimum);
minimum = Math.max(minimum, 0);
maximum = qik.utils.parseInt(maximum);
maximum = Math.max(maximum, 0); // cant be less than 0
if (maximum == 0) {
//Unlimited maximum
} else {
maximum = Math.max(maximum, minimum); // cant be less than the minimum
}
return { minimum, maximum };
}
service.meetsValidationRequirements = function (
input,
fieldType,
validationCriteria,
) {
if (validationCriteria.minLength) {
var stringLength = String(input).length;
if (stringLength < validationCriteria.minLength) {
return `Must be at least ${validationCriteria.minLength} characters`;
}
}
if (validationCriteria.maxLength) {
var stringLength = String(input).length;
if (stringLength > validationCriteria.maxLength) {
return `Can be no more than ${validationCriteria.maxLength} characters`;
}
}
if (validationCriteria.minValue) {
if (input !== undefined) {
var number = Number(input);
if (number < validationCriteria.minValue) {
return `Must be at least ${validationCriteria.minValue}`;
}
}
}
if (validationCriteria.maxValue) {
if (input !== undefined) {
var number = Number(input);
if (number > validationCriteria.maxValue) {
return `Can be no greater than ${validationCriteria.maxValue}`;
}
}
}
};
/**
* Checks if a certain input validates against a field definition
* @alias content.validateField
* @param {Any} input The input to validate
* @param {Object} fieldDefinition The field to validate against
* @param {Object} options Additional options when calling the function
* @example
* const validationResult = await sdk.content.validateField('Johnny Bobbins', {title:'Name', key:'firstName', type:'string', minimum:1, maximum:1, ...});
* console.log(validationResult)
* // Results in { valid:true }
*
* const validationResult = await sdk.content.validateField('Johnny Bobbins', {title:'Number', key:'number', type:'integer', minimum:1, maximum:1, ...});
* console.log(validationResult)
* // Results in { valid:false, status:400, message:'Invalid number input for field' }
*/
service.validateField = function (input, fieldDefinition, options) {
options = options || {};
var fieldType = fieldDefinition.type || "string";
var { minimum, maximum } = getLimits(fieldDefinition);
var isObject = fieldDefinition.type == "group" && fieldDefinition.asObject;
var singleValue = isObject
? minimum === maximum && maximum === 1
: maximum === 1;
var multiValue = !singleValue;
//////////////////
var isNumeric;
switch (fieldType) {
case "integer":
case "decimal":
case "number":
case "float":
isNumeric = true;
break;
}
//////////////////
var inputWasProvided = qik.utils.exists(input);
// A required boolean field means "must be true" — false counts as missing.
if (fieldDefinition.type === "boolean" && input !== true) {
inputWasProvided = false;
}
//If an answer is required
if (minimum) {
//but none was provided
if (!inputWasProvided) {
//Throw an error
return {
valid: false,
message: `${fieldDefinition.title} is a required field`,
status: 400,
};
}
} else {
//No answer is needed and none was provided
if (!inputWasProvided) {
return {
valid: true,
};
}
}
//////////////////
//If we are requiring multiple values
if (multiValue) {
//But the input is not an array
if (!Array.isArray(input)) {
if (minimum) {
return {
valid: false,
message: `${fieldDefinition.title} requires at least ${minimum} values`,
status: 400,
};
} else {
console.log("NOT PROVIDED AS ARRAY", fieldDefinition.title, input);
return {
valid: false,
message: `${fieldDefinition.title} must be provided as an array`,
status: 400,
};
}
}
////////////////////////////
var compacted = input;
compacted = compacted.filter(function (v) {
//Check if we care about this value
var empty = v === undefined || v === null || v === "";
return !empty;
});
// console.log('COMPACTED', input.length, compacted.length);
////////////////////////////
//We need an exact number of answers
if (minimum == maximum) {
//But we don't have the number of answers needed
if (minimum && compacted.length != minimum) {
return {
valid: false,
message: `${fieldDefinition.title} requires exactly ${maximum} values`,
status: 400,
};
}
}
//We have too many answers
if (maximum && compacted.length > maximum) {
return {
valid: false,
message: `${fieldDefinition.title} requires less than ${maximum + 1} values`,
status: 400,
};
}
//We don't have enough answers
if (compacted.length < minimum) {
// console.log(fieldDefinition.title, 'INPUT', input.length, minimum, input, compacted)
return {
valid: false,
message: `${fieldDefinition.title} requires at least ${minimum} values`,
status: 400,
};
}
////////////////////////////
var foundBadEntry;
//Find any bad values
var badEntry = compacted.find(function (val) {
var valueFieldType = fieldType;
if (fieldDefinition.type === "group") {
valueFieldType = "object";
}
if (
fieldDefinition.type === "reference" &&
fieldDefinition.widget === "form"
) {
valueFieldType = "object";
}
var isValid = qik.utils.isValidValue(
val,
valueFieldType,
options.strict,
);
////////////////////////////
if (!isValid) {
foundBadEntry = true;
return true;
}
////////////////////////////
//Is there additional validation requirements
if (fieldDefinition.validation) {
var additionalValidationErrors = service.meetsValidationRequirements(
val,
fieldType,
fieldDefinition.validation,
);
if (additionalValidationErrors) {
foundBadEntry = true;
return true;
}
}
});
if (foundBadEntry) {
let badValueMessage = `Invalid input for ${fieldDefinition.title}`;
return {
valid: false,
message: badValueMessage,
status: 400,
};
}
} else {
var dataType = fieldType;
var widgetType = fieldDefinition.widget;
if (dataType === "group") {
dataType = "object";
}
if (dataType === "reference" && widgetType === "form") {
dataType = "object";
}
//////////////////
var cleanedValue = service.getCleanedValue(input, dataType, options);
//////////////////
var isValidValue = qik.utils.isValidValue(
cleanedValue,
dataType,
options.strict,
);
//////////////////
//Invalid input
if (!isValidValue) {
return {
valid: false,
message: `Single value '${input}' is not a valid ${dataType} for ${fieldDefinition.title}`,
criteria: {
isValidValue,
cleanedValue,
fieldType,
options,
},
status: 400,
};
}
//Is there additional validation requirements
let hasAdditionalValidation = false;
const validation = {};
if (String(fieldDefinition.minValue)) {
validation.minValue = parseInt(fieldDefinition.minValue);
hasAdditionalValidation = true;
}
if (String(fieldDefinition.maxValue)) {
validation.maxValue = parseInt(fieldDefinition.maxValue);
hasAdditionalValidation = true;
}
if (String(fieldDefinition.minLength)) {
validation.minLength = parseInt(fieldDefinition.minLength);
hasAdditionalValidation = true;
}
if (String(fieldDefinition.maxLength)) {
validation.maxLength = parseInt(fieldDefinition.maxLength);
hasAdditionalValidation = true;
}
if (hasAdditionalValidation) {
const validationCriteria = Object.assign(
{},
validation,
fieldDefinition.validation || {},
);
var additionalValidationError = service.meetsValidationRequirements(
input,
fieldType,
validationCriteria,
);
if (additionalValidationError) {
return {
valid: false,
message: additionalValidationError,
status: 400,
};
}
}
}
//////////////////
return {
valid: true,
};
};
service.getCleanedValue = function (input, dataType, options) {
switch (dataType) {
case "number":
case "float":
case "decimal":
if (!qik.utils.exists(input)) {
return undefined;
} else {
return Number(input);
}
break;
case "integer":
if (!qik.utils.exists(input)) {
return undefined;
} else {
return parseInt(input);
}
break;
case "boolean":
if (!qik.utils.exists(input)) {
return undefined;
}
return qik.utils.parseBoolean(input);
break;
case "email":
return options.strict ? input : String(input).toLowerCase();
break;
case "reference":
return options.strict ? input : qik.utils.id(input);
break;
default:
return options.strict
? input
: qik.utils.cleanValue(input, dataType, options);
break;
}
return input;
};
///////////////////////////////////////////////////
/**
*
* Retrieves a list of records matching the provided criteria
* @alias content.list
* @param {String} type The type or definition of records we want to retrieve
* @param {Object} options The options for our query
* @param {String} options.search Freeform text keywords
* @param {Object} options.sort How to sort the results
* @param {String} options.sort.key Which key to sort on
* @param {String} options.sort.direction Which direction to sort on
* @param {Object} options.sort.type What type of data is being sorted
* @param {Object} options.page Page configuration
* @param {Number} options.page.size Page size
* @param {Number} options.page.index Page index
* @param {Object} options.filter How to filter the results
* @example
*
*
* sdk.content.list('profile', {
* search:'Jim',
* page:{
* size:50,
* index:2,
* },
* sort:{
* key:'age',
* direction:'asc',
* type:'integer',
* },
* filter:{
* operator:'and',
* filters:[{
* key:'age',
* comparator:'>',
* value:5,
* }],
* },
* })
*/
service.list = async function (type, options, advanced) {
if (!advanced) {
advanced = {};
}
const remoteURL = advanced.remoteURL || `/content/${type}/list`;
if (advanced.cancellable) {
if (!advanced.config) {
advanced.config = {};
}
//Create a cancel token
const CancelToken = qik.api.CancelToken;
const source = CancelToken.source();
advanced.config.cancelToken = source.token;
const promise = qik.api.post(remoteURL, options, advanced.config);
return {
promise,
cancel(message) {
source.cancel(message || "Operation canceled by the user.");
},
};
} else {
const { data } = await qik.api.post(remoteURL, options, advanced.config);
return data;
}
};
///////////////////////////////////////////////////
/**
*
* Create an item
* @alias content.create
* @param {String} type The type or definition of the record we want to create
* @param {Object} input The data for our new record
* @example
*
*
* const result = await sdk.content.create('profile', {
* firstName:'Mickey',
* lastName:'Mouse',
* gender:'male',
* meta:{
* scopes:['61eca4746971e75c1fc670cf'],
* }
* })
*/
service.create = async function (type, input) {
const dataModel = { ...input };
// Sanitize down to just ids before we send
if (dataModel.meta?.scopes) {
dataModel.meta.scopes = qik.utils.ids(dataModel.meta.scopes);
}
const { data } = await qik.api.post(`/content/${type}/create`, dataModel);
return data;
};
/**
*
* Update an item, Only fields the user has permission to view will be returned
* @alias content.update
* @param {String} id The id of the record we want to update
* @param {Object} input The data to update
* @example
*
* const result = await sdk.content.update('61eca4746971e75c1fc670cd', {
* firstName:'Minnie',
* lastName:'Mouse',
* gender:'female',
* meta:{
* scopes:['61eca4746971e75c1fc670cd'],
* }
* })
*/
service.update = async function (id, input) {
id = qik.utils.id(id);
const dataModel = { ...input };
// Sanitize down to just ids before we send
if (dataModel.meta?.scopes) {
dataModel.meta.scopes = qik.utils.ids(dataModel.meta.scopes);
}
const { data } = await qik.api.put(`/content/${id}`, dataModel);
return data;
};
/**
*
* Partially update and patch an item, Only fields the user has permission to edit will be updated
* @alias content.patch
* @param {String} id The id of the record we want to update
* @param {Object} input The data to update, this will be merged with existing data
* @example
*
* const result = await sdk.content.patch('61eca4746971e75c1fc670cd', {
* firstName:'Mickey',
* gender:'male',
* })
*/
service.patch = async function (id, input) {
id = qik.utils.id(id);
const dataModel = { ...input };
// Sanitize down to just ids before we send
if (dataModel.meta?.scopes) {
dataModel.meta.scopes = qik.utils.ids(dataModel.meta.scopes);
}
const { data } = await qik.api.patch(`/content/${id}`, dataModel);
return data;
};
/**
*
* Get an item from the database, Only fields the user has permission to view will be returned
* @alias content.get
* @param {String} id The id of the record we want to update
* @example
*
* const result = await sdk.content.get('61eca4746971e75c1fc670cd')
*/
service.get = async function (id, params) {
id = qik.utils.id(id);
params = params || {};
const { data } = await qik.api.get(`/content/${id}`, params);
return data;
};
service.getFromID = async function (id) {
return service.get(id);
};
/**
*
* Delete an item from the database
* @alias content.delete
* @param {String} id The id of the record we want to delete
* @example
* const result = await sdk.content.delete('61eca4746971e75c1fc670cd')
*/
service.delete = async function (id, input) {
id = qik.utils.id(id);
const { data } = await qik.api.delete(`/content/${id}`, input);
return data;
};
/**
*
* Restore a deleted item from the database
* @alias content.restore
* @param {String} id The id of the record we want to restore
* @example
* const result = await sdk.content.restore('61eca4746971e75c1fc670cd')
*/
service.restore = async function (id, input) {
id = qik.utils.id(id);
const { data } = await qik.api.get(`/content/${id}/restore`, input);
return data;
};
/**
*
* Retrieve an item from the database by providing it's 'slug'
* @alias content.getFromSlug
* @param {String} slug The slug id of the record we want to retrieve
* it must be provided as either `(type):(slug)`` or `(definition):(slug)`
* (if unsure use the `meta.abs` absolute slug property of the item you are wanting to retrieve).
* @example
* const result = await sdk.content.getFromSlug('article:how-to-get-started')
* const result = await sdk.content.getFromSlug('car:toyota-landcruiser')
* const result = await sdk.content.getFromSlug('article:toyota-landcruiser')
*/
service.getFromSlug = async function (slug) {
const { data } = await qik.api.get(`/content/slug/${slug}`);
return data;
};
// service.getFromSlug = async function(type, slug) {
// const { data } = await qik.api.get(`/content/${type}/slug/${slug}`);
// return data;
// }
// service.getFromExternalID = async function(type, externalID) {
// const { data } = await qik.api.get(`/content/${type}/external/${externalID}`);
// return data;
// }
return service;
}