mirror of
https://github.com/fluxscape/fluxscape.git
synced 2026-01-11 14:52:54 +01:00
Initial commit
Co-Authored-By: Eric Tuvesson <eric.tuvesson@gmail.com> Co-Authored-By: mikaeltellhed <2311083+mikaeltellhed@users.noreply.github.com> Co-Authored-By: kotte <14197736+mrtamagotchi@users.noreply.github.com> Co-Authored-By: Anders Larsson <64838990+anders-topp@users.noreply.github.com> Co-Authored-By: Johan <4934465+joolsus@users.noreply.github.com> Co-Authored-By: Tore Knudsen <18231882+torekndsn@users.noreply.github.com> Co-Authored-By: victoratndl <99176179+victoratndl@users.noreply.github.com>
This commit is contained in:
4
packages/noodl-viewer-cloud/.npmignore
Normal file
4
packages/noodl-viewer-cloud/.npmignore
Normal file
@@ -0,0 +1,4 @@
|
||||
src
|
||||
static
|
||||
webpack-configs
|
||||
tsconfig.json
|
||||
21
packages/noodl-viewer-cloud/LICENSE
Normal file
21
packages/noodl-viewer-cloud/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Future Platforms AB
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
||||
OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
13
packages/noodl-viewer-cloud/README.md
Normal file
13
packages/noodl-viewer-cloud/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Noodl Cloud runtime
|
||||
|
||||
The Noodl Cloud Runtime is a crucial component for executing Noodl Cloud functions.
|
||||
This repository contains the code necessary to run and manage these functions seamlessly within the Noodl ecosystem.
|
||||
|
||||
## Usage
|
||||
|
||||
The Noodl Cloud Runtime is designed to be used in conjunction with the [Noodl Cloud Service](https://github.com/noodlapp/noodl-cloudservice).
|
||||
Follow the instructions provided in the Noodl Cloud Service repository to set up the complete Noodl Cloud environment.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
22
packages/noodl-viewer-cloud/package.json
Normal file
22
packages/noodl-viewer-cloud/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@noodl/cloud-runtime",
|
||||
"author": "Noodl <info@noodl.net>",
|
||||
"homepage": "https://noodl.net",
|
||||
"version": "0.6.3",
|
||||
"license": "MIT",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"start": "webpack --config webpack-configs/webpack.dev.js",
|
||||
"build": "webpack --config webpack-configs/webpack.prod.js",
|
||||
"build:pack": "node ./scripts/pack.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noodl/runtime": "file:../noodl-runtime"
|
||||
},
|
||||
"devDependencies": {
|
||||
"generate-json-webpack-plugin": "^2.0.0",
|
||||
"ts-loader": "^9.3.1",
|
||||
"typescript": "^4.7.4",
|
||||
"copy-webpack-plugin": "^4.6.0"
|
||||
}
|
||||
}
|
||||
32
packages/noodl-viewer-cloud/scripts/pack.js
Normal file
32
packages/noodl-viewer-cloud/scripts/pack.js
Normal file
@@ -0,0 +1,32 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const publishDir = path.join(__dirname, '../publish');
|
||||
const publishDistDir = path.join(__dirname, '../publish/dist/');
|
||||
|
||||
// Delete the publish folder if it exists
|
||||
if (fs.existsSync(publishDir)) {
|
||||
fs.rmdirSync(publishDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Create the publish folders
|
||||
fs.mkdirSync(publishDistDir, { recursive: true });
|
||||
|
||||
// Copy over the wanted files
|
||||
const files = ['dist/main.js', 'package.json', 'README.md', 'LICENSE'];
|
||||
|
||||
files.forEach((file) => {
|
||||
const fromPath = path.join(__dirname, '..', file);
|
||||
const toPath = path.join(publishDir, file);
|
||||
fs.copyFileSync(fromPath, toPath);
|
||||
});
|
||||
|
||||
// Clean up package.json
|
||||
const packageFilePath = path.join(publishDir, 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageFilePath));
|
||||
|
||||
delete packageJson.scripts;
|
||||
delete packageJson.dependencies;
|
||||
delete packageJson.devDependencies;
|
||||
|
||||
fs.writeFileSync(packageFilePath, JSON.stringify(packageJson, null, 2));
|
||||
19
packages/noodl-viewer-cloud/src/api/files.js
Normal file
19
packages/noodl-viewer-cloud/src/api/files.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const CloudStore = require('@noodl/runtime/src/api/cloudstore');
|
||||
|
||||
const files = {
|
||||
async delete(fileName) {
|
||||
return new Promise((resolve, reject) => {
|
||||
CloudStore.instance.deleteFile({
|
||||
file: { name: fileName },
|
||||
success: (response) => {
|
||||
resolve();
|
||||
},
|
||||
error: (e) => {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = files;
|
||||
141
packages/noodl-viewer-cloud/src/api/users.js
Normal file
141
packages/noodl-viewer-cloud/src/api/users.js
Normal file
@@ -0,0 +1,141 @@
|
||||
const Model = require('@noodl/runtime/src/model');
|
||||
const CloudStore = require('@noodl/runtime/src/api/cloudstore');
|
||||
const NoodlRuntime = require('@noodl/runtime');
|
||||
const RecordsAPI = require('@noodl/runtime/src/api/records');
|
||||
|
||||
function createUsersAPI(modelScope) {
|
||||
let _cloudstore;
|
||||
const cloudstore = () => {
|
||||
// We must create the cloud store just in time so all meta data is loaded
|
||||
if (!_cloudstore) _cloudstore = new CloudStore(modelScope);
|
||||
return _cloudstore;
|
||||
};
|
||||
|
||||
const Records = RecordsAPI(modelScope);
|
||||
|
||||
const api = {
|
||||
// This API should support an options object, this is how it works on
|
||||
// the frontend and in the docs. To support any old use added a ugly fix
|
||||
async logIn(username, password) {
|
||||
if(typeof username === 'object' && password === undefined) {
|
||||
const options = username;
|
||||
username = options.username;
|
||||
password = options.password;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const userService = NoodlRuntime.Services.UserService.forScope(modelScope);
|
||||
userService.logIn({
|
||||
username,
|
||||
password,
|
||||
success: (user) => {
|
||||
resolve(user);
|
||||
},
|
||||
error: (e) => {
|
||||
reject(Error(e));
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async impersonate(username, options) {
|
||||
// Look for the user based on username
|
||||
const users = await Records.query('_User', {
|
||||
username: { equalTo: username }
|
||||
});
|
||||
|
||||
if (!users || users.length !== 1) {
|
||||
throw Error('Could not find user.');
|
||||
}
|
||||
|
||||
// See if there is a session already
|
||||
const user = users[0];
|
||||
|
||||
const query = {
|
||||
and: [{ user: { pointsTo: user.id } }, { expiresAt: { greaterThan: new Date() } }]
|
||||
};
|
||||
|
||||
if (options && options.installationId) {
|
||||
query.and.push({ installationId: { equalTo: options.installationId } });
|
||||
}
|
||||
|
||||
const sessions = await Records.query('_Session', query);
|
||||
|
||||
async function _fetchUser(sessionToken) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const userService = NoodlRuntime.Services.UserService.forScope(modelScope);
|
||||
userService.fetchUser({
|
||||
sessionToken,
|
||||
success: (user) => resolve(user),
|
||||
error: (e) => reject(Error(e))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (!sessions || sessions.length === 0) {
|
||||
// No session, we need to create one
|
||||
const session = await Records.create('_Session', {
|
||||
user: user.id,
|
||||
installationId: options ? options.installationId : undefined,
|
||||
sessionToken: 'r:' + Model.guid() + Model.guid(),
|
||||
expiresAt: new Date(
|
||||
Date.now() + (options && options.duration !== undefined ? options.duration : 24 * 60 * 60 * 1000)
|
||||
),
|
||||
restricted: false
|
||||
});
|
||||
|
||||
return _fetchUser(session.sessionToken);
|
||||
} else {
|
||||
return _fetchUser(sessions[0].sessionToken);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Object.defineProperty(api, 'Current', {
|
||||
get: function () {
|
||||
const request = (modelScope || Model).get('Request');
|
||||
const userId = request.get('UserId');
|
||||
if (!userId) return;
|
||||
|
||||
return {
|
||||
UserId: userId,
|
||||
Properties: (modelScope || Model).get(userId),
|
||||
async save(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
cloudstore().save({
|
||||
collection: '_User',
|
||||
objectId: userId,
|
||||
data: (modelScope || Model).get(userId).data,
|
||||
acl: options ? options.acl : undefined,
|
||||
success: (response) => {
|
||||
resolve();
|
||||
},
|
||||
error: (err) => {
|
||||
reject(Error(err || 'Failed to save.'));
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
async fetch() {
|
||||
return new Promise((resolve, reject) => {
|
||||
cloudstore().fetch({
|
||||
collection: '_User',
|
||||
objectId: userId,
|
||||
success: function (response) {
|
||||
var record = cloudstore()._fromJSON(response, '_User');
|
||||
resolve(record);
|
||||
},
|
||||
error: function (err) {
|
||||
reject(Error(err || 'Failed to fetch.'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
module.exports = createUsersAPI;
|
||||
16
packages/noodl-viewer-cloud/src/bridge.ts
Normal file
16
packages/noodl-viewer-cloud/src/bridge.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* TODO: Define the different kind of inputs to the request node.
|
||||
*/
|
||||
export interface NoodlRequest {
|
||||
headers: {};
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Define the different kind of outputs on the response node.
|
||||
*/
|
||||
export interface NoodlResponse {
|
||||
statusCode: string;
|
||||
headers: {};
|
||||
body: string;
|
||||
}
|
||||
99
packages/noodl-viewer-cloud/src/index.ts
Normal file
99
packages/noodl-viewer-cloud/src/index.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// We need to import this so it's available in NoodlRuntime.Services
|
||||
|
||||
//import WebSocket from 'ws';
|
||||
import { NoodlRequest, NoodlResponse } from './bridge';
|
||||
import { registerNodes } from './nodes';
|
||||
import NoodlRuntime from '@noodl/runtime';
|
||||
import Model from '@noodl/runtime/src/model';
|
||||
import NodeScope from '@noodl/runtime/src/nodescope';
|
||||
import './noodl-js-api';
|
||||
|
||||
require('./services/userservice');
|
||||
|
||||
export class CloudRunner {
|
||||
private runtime: NoodlRuntime;
|
||||
|
||||
constructor(options: {
|
||||
webSocketClass?: any;
|
||||
enableDebugInspectors?: boolean;
|
||||
connectToEditor?: boolean;
|
||||
editorAddress?: string;
|
||||
}) {
|
||||
this.runtime = new NoodlRuntime({
|
||||
type: 'cloud',
|
||||
platform: {
|
||||
requestUpdate: (f: any) => setImmediate(f),
|
||||
getCurrentTime: () => new Date().getTime(),
|
||||
objectToString: (o: any) => JSON.stringify(o, null, 2),
|
||||
webSocketClass: options.webSocketClass,
|
||||
isRunningLocally: () => options.connectToEditor
|
||||
},
|
||||
componentFilter: (c) => c.name.startsWith('/#__cloud__/'),
|
||||
dontCreateRootComponent: true
|
||||
});
|
||||
|
||||
registerNodes(this.runtime);
|
||||
|
||||
this.runtime.setDebugInspectorsEnabled(options.enableDebugInspectors);
|
||||
|
||||
if (options.connectToEditor && options.editorAddress) {
|
||||
this.runtime.connectToEditor(options.editorAddress);
|
||||
}
|
||||
}
|
||||
|
||||
public async load(exportData: any, projectSettings?: any) {
|
||||
await this.runtime.setData(exportData);
|
||||
|
||||
if (projectSettings) this.runtime.setProjectSettings(projectSettings);
|
||||
}
|
||||
|
||||
public async run(functionName: string, request: NoodlRequest): Promise<NoodlResponse> {
|
||||
return new Promise<NoodlResponse>((resolve, reject) => {
|
||||
const requestId = Math.random().toString(26).slice(2);
|
||||
|
||||
const requestScope = new NodeScope(this.runtime.context);
|
||||
requestScope.modelScope = new Model.Scope();
|
||||
|
||||
this.runtime.context
|
||||
.createComponentInstanceNode('/#__cloud__/' + functionName, requestId + '-' + functionName, requestScope)
|
||||
.then((functionComponent) => {
|
||||
// Look for the first request node (should only be one)
|
||||
const requestNode = functionComponent.nodeScope.getNodesWithType('noodl.cloud.request')[0];
|
||||
if (requestNode) {
|
||||
// Look for all response nodes
|
||||
let hasResponded = false;
|
||||
const responseNodes = functionComponent.nodeScope.getNodesWithTypeRecursive('noodl.cloud.response');
|
||||
responseNodes.forEach((resp) => {
|
||||
resp._internal._sendResponseCallback = (resp) => {
|
||||
if (hasResponded) return;
|
||||
hasResponded = true;
|
||||
|
||||
//the functionComponent is "manually" created outside of a scope, so call the delete function directly
|
||||
functionComponent._onNodeDeleted();
|
||||
requestScope.reset(); //this deletes any remaining nodes, although there shouldn't be any at this point
|
||||
|
||||
//clean upp all models
|
||||
requestScope.modelScope.reset();
|
||||
|
||||
resolve(resp);
|
||||
};
|
||||
});
|
||||
|
||||
setImmediate(() => {
|
||||
try {
|
||||
requestNode.sendRequest(request).catch(reject);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
reject(Error('Could not find request node for function'));
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
// Failed creating component
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
185
packages/noodl-viewer-cloud/src/nodes/cloud/request.ts
Normal file
185
packages/noodl-viewer-cloud/src/nodes/cloud/request.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
//import CloudStore from '@noodl/runtime/src/api/cloudstore'
|
||||
import Model from '@noodl/runtime/src/model';
|
||||
import NoodlRuntime from '@noodl/runtime';
|
||||
import ConfigService from '@noodl/runtime/src/api/configservice'
|
||||
|
||||
export const node = {
|
||||
name: 'noodl.cloud.request',
|
||||
displayNodeName: 'Request',
|
||||
category: 'Cloud',
|
||||
docs: 'https://docs.noodl.net/nodes/cloud-functions/request',
|
||||
useVariants: false,
|
||||
mountedInput: false,
|
||||
allowAsExportRoot: false,
|
||||
singleton: true,
|
||||
color: 'data',
|
||||
connectionPanel: {
|
||||
groupPriority: ['General', 'Mounted']
|
||||
},
|
||||
outputs: {
|
||||
receive: {
|
||||
displayName: 'Received',
|
||||
type: 'signal',
|
||||
group: 'General'
|
||||
},
|
||||
auth: {
|
||||
displayName: 'Authenticated',
|
||||
type: 'boolean',
|
||||
group: 'Request',
|
||||
getter: function () {
|
||||
return !!this._internal.authenticated;
|
||||
}
|
||||
},
|
||||
userId: {
|
||||
displayName: 'User Id',
|
||||
type: 'boolean',
|
||||
group: 'Request',
|
||||
getter: function () {
|
||||
return this._internal.authUserId;
|
||||
}
|
||||
}
|
||||
},
|
||||
inputs: {
|
||||
allowNoAuth: {
|
||||
group: 'General',
|
||||
type: 'boolean',
|
||||
displayName: 'Allow Unauthenticated',
|
||||
default: false,
|
||||
set: function (value) {
|
||||
this._internal.allowNoAuth = value;
|
||||
}
|
||||
},
|
||||
params: {
|
||||
group: 'Parameters',
|
||||
type: { name: 'stringlist', allowEditOnly: true },
|
||||
set: function (value) {
|
||||
this._internal.params = value;
|
||||
}
|
||||
}
|
||||
},
|
||||
initialize: function () {
|
||||
this._internal.allowNoAuth = false;
|
||||
this._internal.requestParameters = {};
|
||||
this._internal.userProperties = {
|
||||
Authenticated: false
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
getRequestParameter: function (name) {
|
||||
return this._internal.requestParameters[name];
|
||||
},
|
||||
setRequestParameter: function (name, value) {
|
||||
this._internal.requestParameters[name] = value;
|
||||
if (this.hasOutput('pm-' + name)) this.flagOutputDirty('pm-' + name);
|
||||
},
|
||||
fetchCurrentUser: async function (sessionToken) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const userService = NoodlRuntime.Services.UserService.forScope(this.nodeScope.modelScope);
|
||||
userService.fetchCurrentUser({
|
||||
sessionToken,
|
||||
success: resolve,
|
||||
error: reject
|
||||
});
|
||||
});
|
||||
},
|
||||
sendRequest: async function (req) {
|
||||
const sessionToken = req.headers['x-parse-session-token'];
|
||||
let params = {};
|
||||
try {
|
||||
params = JSON.parse(req.body);
|
||||
} catch (e) {}
|
||||
|
||||
if (sessionToken) {
|
||||
// There is a user token, fetch user
|
||||
try {
|
||||
await this.fetchCurrentUser(sessionToken);
|
||||
|
||||
const userService = NoodlRuntime.Services.UserService.forScope(this.nodeScope.modelScope);
|
||||
const userModel = userService.current;
|
||||
|
||||
this._internal.authenticated = true;
|
||||
this._internal.authUserId = userModel.getId();
|
||||
this.flagOutputDirty('userId');
|
||||
} catch (e) {
|
||||
// User could not be fetched
|
||||
if (!this._internal.allowNoAuth) throw Error('Unauthenticated requests not accepted.');
|
||||
}
|
||||
} else if (!this._internal.allowNoAuth) throw Error('Unauthenticated requests not accepted.');
|
||||
|
||||
// Make sure config is cached before processing request
|
||||
await ConfigService.instance.getConfig()
|
||||
|
||||
// Create request object
|
||||
const requestModel = (this.nodeScope.modelScope || Model).get('Request');
|
||||
requestModel.set('Authenticated', !!this._internal.authenticated);
|
||||
requestModel.set('UserId', this._internal.authUserId);
|
||||
requestModel.set('Parameters', params);
|
||||
requestModel.set('Headers', req.headers);
|
||||
|
||||
this.flagOutputDirty('auth');
|
||||
|
||||
for (let key in params) {
|
||||
this.setRequestParameter(key, params[key]);
|
||||
}
|
||||
this.sendSignalOnOutput('receive');
|
||||
},
|
||||
registerOutputIfNeeded: function (name) {
|
||||
if (this.hasOutput(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.startsWith('pm-'))
|
||||
this.registerOutput(name, {
|
||||
getter: this.getRequestParameter.bind(this, name.substring('pm-'.length))
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function setup(context, graphModel) {
|
||||
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
|
||||
return;
|
||||
}
|
||||
|
||||
function _managePortsForNode(node) {
|
||||
function _updatePorts() {
|
||||
var ports = [];
|
||||
|
||||
// Add params outputs
|
||||
var params = node.parameters.params;
|
||||
if (params !== undefined) {
|
||||
params = params.split(',');
|
||||
for (var i in params) {
|
||||
var p = params[i];
|
||||
|
||||
ports.push({
|
||||
type: '*',
|
||||
plug: 'output',
|
||||
group: 'Parameters',
|
||||
name: 'pm-' + p,
|
||||
displayName: p
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
context.editorConnection.sendDynamicPorts(node.id, ports);
|
||||
}
|
||||
|
||||
_updatePorts();
|
||||
node.on('parameterUpdated', function (event) {
|
||||
if (event.name === 'params') {
|
||||
_updatePorts();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
graphModel.on('editorImportComplete', () => {
|
||||
graphModel.on('nodeAdded.noodl.cloud.request', function (node) {
|
||||
_managePortsForNode(node);
|
||||
});
|
||||
|
||||
for (const node of graphModel.getNodesWithType('noodl.cloud.request')) {
|
||||
_managePortsForNode(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
149
packages/noodl-viewer-cloud/src/nodes/cloud/response.ts
Normal file
149
packages/noodl-viewer-cloud/src/nodes/cloud/response.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
export const node = {
|
||||
name: 'noodl.cloud.response',
|
||||
displayNodeName: 'Response',
|
||||
category: 'Cloud',
|
||||
docs: 'https://docs.noodl.net/nodes/cloud-functions/response',
|
||||
useVariants: false,
|
||||
mountedInput: false,
|
||||
allowAsExportRoot: false,
|
||||
color: "data",
|
||||
connectionPanel: {
|
||||
groupPriority: ['General', 'Mounted']
|
||||
},
|
||||
dynamicports:[
|
||||
{
|
||||
name:'conditionalports/extended',
|
||||
condition:"status = success OR status NOT SET",
|
||||
inputs:['params']
|
||||
},
|
||||
{
|
||||
name:'conditionalports/extended',
|
||||
condition:"status = failure",
|
||||
inputs:['errorMessage']
|
||||
}
|
||||
],
|
||||
initialize:function() {
|
||||
this._internal.responseParameters = {}
|
||||
},
|
||||
inputs: {
|
||||
params: {
|
||||
group: 'Parameters',
|
||||
type:{name:'stringlist',allowEditOnly:true},
|
||||
set: function (value) {
|
||||
this._internal.params = value;
|
||||
}
|
||||
},
|
||||
errorMessage: {
|
||||
group: 'General',
|
||||
type: 'string',
|
||||
displayName:'Error Message',
|
||||
set: function (value) {
|
||||
this._internal.errorMessage = value;
|
||||
}
|
||||
},
|
||||
send: {
|
||||
displayName: 'Send',
|
||||
type: 'signal',
|
||||
group: 'General',
|
||||
valueChangedToTrue: function () {
|
||||
if(this._internal.status === undefined || this._internal.status === 'success') {
|
||||
this._internal._sendResponseCallback({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({result:this._internal.responseParameters})
|
||||
})
|
||||
}
|
||||
else {
|
||||
this._internal._sendResponseCallback({
|
||||
statusCode: 400,
|
||||
body: JSON.stringify({error:this._internal.errorMessage})
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
status: {
|
||||
group: 'General',
|
||||
displayName:'Status',
|
||||
type: {
|
||||
name: 'enum',
|
||||
enums: [
|
||||
{
|
||||
label: 'Success',
|
||||
value: 'success'
|
||||
},
|
||||
{
|
||||
label: 'Failure',
|
||||
value: 'failure'
|
||||
}
|
||||
]
|
||||
},
|
||||
default:'success',
|
||||
set: function(value) {
|
||||
this._internal.status = value;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods:{
|
||||
setResponseParameter:function(name,value) {
|
||||
this._internal.responseParameters[name] = value
|
||||
},
|
||||
registerInputIfNeeded: function(name) {
|
||||
if(this.hasInput(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(name.startsWith('pm-')) this.registerInput(name, {
|
||||
set: this.setResponseParameter.bind(this, name.substring('pm-'.length))
|
||||
})
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export function setup(context, graphModel) {
|
||||
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
|
||||
return;
|
||||
}
|
||||
|
||||
function _managePortsForNode(node) {
|
||||
function _updatePorts() {
|
||||
var ports = []
|
||||
|
||||
// Add params outputs
|
||||
if(node.parameters.status === 'success' || node.parameters.status === undefined) {
|
||||
var params = node.parameters.params;
|
||||
if (params !== undefined) {
|
||||
params = params.split(',');
|
||||
for (var i in params) {
|
||||
var p = params[i];
|
||||
|
||||
ports.push({
|
||||
type: '*',
|
||||
plug: 'input',
|
||||
group: 'Parameters',
|
||||
name: 'pm-' + p,
|
||||
displayName: p
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.editorConnection.sendDynamicPorts(node.id, ports);
|
||||
}
|
||||
|
||||
_updatePorts();
|
||||
node.on("parameterUpdated", function (event) {
|
||||
if (event.name === 'params') {
|
||||
_updatePorts();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
graphModel.on("editorImportComplete", () => {
|
||||
graphModel.on("nodeAdded.noodl.cloud.response", function (node) {
|
||||
_managePortsForNode(node)
|
||||
})
|
||||
|
||||
for (const node of graphModel.getNodesWithType('noodl.cloud.response')) {
|
||||
_managePortsForNode(node)
|
||||
}
|
||||
})
|
||||
}
|
||||
602
packages/noodl-viewer-cloud/src/nodes/data/aggregatenode.js
Normal file
602
packages/noodl-viewer-cloud/src/nodes/data/aggregatenode.js
Normal file
@@ -0,0 +1,602 @@
|
||||
const { Node, EdgeTriggeredInput } = require('@noodl/runtime');
|
||||
|
||||
const CloudStore = require('@noodl/runtime/src/api/cloudstore'),
|
||||
JavascriptNodeParser = require('@noodl/runtime/src/javascriptnodeparser'),
|
||||
QueryUtils = require('@noodl/runtime/src/api/queryutils');
|
||||
|
||||
var AggregateNode = {
|
||||
name: 'noodl.cloud.aggregate',
|
||||
docs: 'https://docs.noodl.net/nodes/cloud-functions/cloud-data/aggregate-records',
|
||||
displayName: 'Aggregate Records',
|
||||
category: 'Cloud Services',
|
||||
usePortAsLabel: 'collectionName',
|
||||
color: 'data',
|
||||
initialize: function () {
|
||||
this._internal.queryParameters = {};
|
||||
|
||||
this._internal.storageSettings = {};
|
||||
|
||||
this._internal.aggregates = {};
|
||||
},
|
||||
getInspectInfo() {
|
||||
const aggregates = this._internal.aggregateValues;
|
||||
if (!aggregates) {
|
||||
return { type: 'text', value: '[Not executed yet]' };
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'value',
|
||||
value: aggregates
|
||||
}
|
||||
];
|
||||
},
|
||||
inputs: {
|
||||
aggregates:{
|
||||
index:100,
|
||||
group:"Aggregates",
|
||||
type:{name:"stringlist",allowEditOnly:true},
|
||||
displayName:"Aggregates",
|
||||
set:function(value) {
|
||||
this._internal.aggregatesList = value;
|
||||
}
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
fetched: {
|
||||
group: 'Events',
|
||||
type: 'signal',
|
||||
displayName: 'Success'
|
||||
},
|
||||
failure: {
|
||||
group: 'Events',
|
||||
type: 'signal',
|
||||
displayName: 'Failure'
|
||||
},
|
||||
error: {
|
||||
type: 'string',
|
||||
displayName: 'Error',
|
||||
group: 'Error',
|
||||
getter: function () {
|
||||
return this._internal.error;
|
||||
}
|
||||
}
|
||||
},
|
||||
prototypeExtensions: {
|
||||
setCollectionName: function (name) {
|
||||
this._internal.name = name;
|
||||
|
||||
if (this.isInputConnected('storageFetch') === false) this.scheduleFetch();
|
||||
},
|
||||
_onNodeDeleted: function () {
|
||||
Node.prototype._onNodeDeleted.call(this);
|
||||
},
|
||||
setError: function (err) {
|
||||
this._internal.err = err;
|
||||
this.flagOutputDirty('error');
|
||||
this.sendSignalOnOutput('failure');
|
||||
},
|
||||
scheduleFetch: function () {
|
||||
var internal = this._internal;
|
||||
|
||||
if (internal.fetchScheduled) return;
|
||||
internal.fetchScheduled = true;
|
||||
this.scheduleAfterInputsHaveUpdated(() => {
|
||||
internal.fetchScheduled = false;
|
||||
|
||||
this.fetch();
|
||||
});
|
||||
},
|
||||
fetch: function () {
|
||||
if (this.context.editorConnection) {
|
||||
let validateAggNames = true;
|
||||
if(this._internal.aggregatesList) {
|
||||
this._internal.aggregatesList.split(',').forEach(k => {
|
||||
if(k.indexOf(' ')!==-1)
|
||||
validateAggNames = false;
|
||||
})
|
||||
}
|
||||
|
||||
if (this._internal.name === undefined) {
|
||||
this.context.editorConnection.sendWarning(this.nodeScope.componentOwner.name, this.id, 'aggregate-node', {
|
||||
message: 'No class specified for aggregate.'
|
||||
});
|
||||
}
|
||||
else if(this._internal.aggregatesList === undefined) {
|
||||
this.context.editorConnection.sendWarning(this.nodeScope.componentOwner.name, this.id, 'aggregate-node', {
|
||||
message: 'No aggregates specified.'
|
||||
});
|
||||
}
|
||||
else if(!validateAggNames) {
|
||||
this.context.editorConnection.sendWarning(this.nodeScope.componentOwner.name, this.id, 'aggregate-node', {
|
||||
message: 'Invalid aggregate names, dont use space and special characters.'
|
||||
});
|
||||
}
|
||||
else {
|
||||
this.context.editorConnection.clearWarning(this.nodeScope.componentOwner.name, this.id, 'aggregate-node');
|
||||
}
|
||||
}
|
||||
|
||||
const f = this.getStorageFilter();
|
||||
this._internal.currentQuery = {
|
||||
where: f.where,
|
||||
};
|
||||
CloudStore.forScope(this.nodeScope.modelScope).aggregate({
|
||||
collection: this._internal.name,
|
||||
where: f.where,
|
||||
group: this.getAggregates(),
|
||||
success: (results) => {
|
||||
this._internal.aggregateValues = results;
|
||||
for(const key in results) {
|
||||
if(this.hasOutput('agg-'+key))
|
||||
this.flagOutputDirty('agg-'+key);
|
||||
}
|
||||
this.sendSignalOnOutput('fetched');
|
||||
},
|
||||
error: (err) => {
|
||||
this.setError(err || 'Failed to aggregate.');
|
||||
}
|
||||
});
|
||||
},
|
||||
getStorageFilter: function () {
|
||||
const storageSettings = this._internal.storageSettings;
|
||||
if (storageSettings['storageFilterType'] === undefined || storageSettings['storageFilterType'] === 'simple') {
|
||||
// Create simple filter
|
||||
const _where =
|
||||
this._internal.visualFilter !== undefined
|
||||
? QueryUtils.convertVisualFilter(this._internal.visualFilter, {
|
||||
queryParameters: this._internal.queryParameters,
|
||||
collectionName: this._internal.name
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
where: _where,
|
||||
};
|
||||
} else if (storageSettings['storageFilterType'] === 'json') {
|
||||
// JSON filter
|
||||
if (!this._internal.filterFunc) {
|
||||
try {
|
||||
var filterCode = storageSettings['storageJSONFilter'];
|
||||
|
||||
// Parse out variables
|
||||
filterCode = filterCode.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, ''); // Remove comments
|
||||
this._internal.filterVariables = filterCode.match(/\$[A-Za-z0-9]+/g) || [];
|
||||
|
||||
var args = ['where', 'Inputs']
|
||||
.concat(this._internal.filterVariables)
|
||||
.concat([filterCode]);
|
||||
this._internal.filterFunc = Function.apply(null, args);
|
||||
} catch (e) {
|
||||
this._internal.filterFunc = undefined;
|
||||
console.log('Error while parsing filter script: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this._internal.filterFunc) return;
|
||||
|
||||
var _filter = {},
|
||||
_this = this;
|
||||
|
||||
// Collect filter variables
|
||||
var _filterCb = function (f) {
|
||||
_filter = QueryUtils.convertFilterOp(f, {
|
||||
collectionName: _this._internal.name,
|
||||
error: function (err) {
|
||||
_this.context.editorConnection.sendWarning(
|
||||
_this.nodeScope.componentOwner.name,
|
||||
_this.id,
|
||||
'aggregate-node-filter',
|
||||
{
|
||||
message: err
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Extract inputs
|
||||
const inputs = {};
|
||||
for (let key in storageSettings) {
|
||||
if (key.startsWith('storageFilterValue-'))
|
||||
inputs[key.substring('storageFilterValue-'.length)] = storageSettings[key];
|
||||
}
|
||||
|
||||
var filterFuncArgs = [ _filterCb, inputs]; // One for filter, one for where
|
||||
|
||||
this._internal.filterVariables.forEach((v) => {
|
||||
filterFuncArgs.push(storageSettings['storageFilterValue-' + v.substring(1)]);
|
||||
});
|
||||
|
||||
// Run the code to get the filter
|
||||
try {
|
||||
this._internal.filterFunc.apply(this, filterFuncArgs);
|
||||
} catch (e) {
|
||||
console.log('Error while running filter script: ' + e);
|
||||
}
|
||||
|
||||
return { where: _filter };
|
||||
}
|
||||
},
|
||||
getAggregates:function() {
|
||||
if(!this._internal.aggregatesList) return {};
|
||||
if(!this._internal.aggregates) return {};
|
||||
|
||||
const schema = CloudStore._collections;
|
||||
const classSchema = schema[this._internal.name];
|
||||
if(!classSchema || !classSchema.schema || !classSchema.schema.properties) return {};
|
||||
|
||||
const defs = this._internal.aggregates;
|
||||
const aggs = {};
|
||||
this._internal.aggregatesList.split(',').forEach(a => {
|
||||
if(defs[a].prop === undefined) return;
|
||||
const propSchema = classSchema.schema.properties[defs[a].prop];
|
||||
if(propSchema === undefined) return;
|
||||
|
||||
const op = {}
|
||||
|
||||
const _def = (propSchema.type === 'String')?'distinct':'avg';
|
||||
|
||||
op[defs[a].op || _def] = defs[a].prop;
|
||||
aggs[a] = op;
|
||||
})
|
||||
|
||||
return aggs;
|
||||
},
|
||||
getAggregateValue:function(name) {
|
||||
if(!this._internal.aggregateValues) return;
|
||||
return this._internal.aggregateValues[name];
|
||||
},
|
||||
registerOutputIfNeeded: function (name) {
|
||||
if (this.hasOutput(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(name.startsWith('agg-')) this.registerOutput(name, {
|
||||
getter: this.getAggregateValue.bind(this, name.substring('agg-'.length))
|
||||
});
|
||||
},
|
||||
setVisualFilter: function (value) {
|
||||
this._internal.visualFilter = value;
|
||||
|
||||
if (this.isInputConnected('storageFetch') === false) this.scheduleFetch();
|
||||
},
|
||||
setQueryParameter: function (name, value) {
|
||||
this._internal.queryParameters[name] = value;
|
||||
|
||||
if (this.isInputConnected('storageFetch') === false) this.scheduleFetch();
|
||||
},
|
||||
setAggregateParameter:function(name,value) {
|
||||
const aggregates = this._internal.aggregates;
|
||||
if(name.startsWith('aggprop-')) {
|
||||
const _name = name.substring('aggprop-'.length);
|
||||
if(!aggregates[_name]) aggregates[_name] = {}
|
||||
aggregates[_name].prop = value;
|
||||
}
|
||||
else if(name.startsWith('aggop-')) {
|
||||
const _name = name.substring('aggop-'.length);
|
||||
if(!aggregates[_name]) aggregates[_name] = {}
|
||||
aggregates[_name].op = value;
|
||||
}
|
||||
},
|
||||
registerInputIfNeeded: function (name) {
|
||||
if (this.hasInput(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.startsWith('agg'))
|
||||
return this.registerInput(name, {
|
||||
set: this.setAggregateParameter.bind(this, name)
|
||||
});
|
||||
|
||||
if (name.startsWith('qp-'))
|
||||
return this.registerInput(name, {
|
||||
set: this.setQueryParameter.bind(this, name.substring('qp-'.length))
|
||||
});
|
||||
|
||||
const dynamicSignals = {
|
||||
storageFetch: this.scheduleFetch.bind(this)
|
||||
};
|
||||
|
||||
if (dynamicSignals[name])
|
||||
return this.registerInput(name, {
|
||||
set: EdgeTriggeredInput.createSetter({
|
||||
valueChangedToTrue: dynamicSignals[name]
|
||||
})
|
||||
});
|
||||
|
||||
const dynamicSetters = {
|
||||
collectionName: this.setCollectionName.bind(this),
|
||||
visualFilter: this.setVisualFilter.bind(this)
|
||||
};
|
||||
|
||||
if (dynamicSetters[name])
|
||||
return this.registerInput(name, {
|
||||
set: dynamicSetters[name]
|
||||
});
|
||||
|
||||
this.registerInput(name, {
|
||||
set: userInputSetter.bind(this, name)
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function userInputSetter(name, value) {
|
||||
/* jshint validthis:true */
|
||||
this._internal.storageSettings[name] = value;
|
||||
|
||||
if (this.isInputConnected('storageFetch') === false) this.scheduleFetch();
|
||||
}
|
||||
|
||||
const _defaultJSONQuery =
|
||||
'// Write your query script here, check out the reference documentation for examples\n' + 'where({ })\n';
|
||||
|
||||
function updatePorts(nodeId, parameters, editorConnection, graphModel) {
|
||||
var ports = [];
|
||||
|
||||
const dbCollections = graphModel.getMetaData('dbCollections');
|
||||
const systemCollections = graphModel.getMetaData('systemCollections');
|
||||
|
||||
const _systemClasses = [
|
||||
{ label: 'User', value: '_User' },
|
||||
{ label: 'Role', value: '_Role' }
|
||||
];
|
||||
ports.push({
|
||||
name: 'collectionName',
|
||||
type: {
|
||||
name: 'enum',
|
||||
enums: _systemClasses.concat(
|
||||
dbCollections !== undefined
|
||||
? dbCollections.map((c) => {
|
||||
return { value: c.name, label: c.name };
|
||||
})
|
||||
: []
|
||||
),
|
||||
allowEditOnly: true
|
||||
},
|
||||
displayName: 'Class',
|
||||
plug: 'input',
|
||||
group: 'General'
|
||||
});
|
||||
|
||||
ports.push({
|
||||
name: 'storageFilterType',
|
||||
type: {
|
||||
name: 'enum',
|
||||
allowEditOnly: true,
|
||||
enums: [
|
||||
{ value: 'simple', label: 'Visual' },
|
||||
{ value: 'json', label: 'Javascript' }
|
||||
]
|
||||
},
|
||||
displayName: 'Filter',
|
||||
default: 'simple',
|
||||
plug: 'input',
|
||||
group: 'General'
|
||||
});
|
||||
|
||||
ports.push({
|
||||
type: 'signal',
|
||||
plug: 'input',
|
||||
group: 'Actions',
|
||||
name: 'storageFetch',
|
||||
displayName: 'Do'
|
||||
});
|
||||
|
||||
if(parameters['aggregates'] !== undefined && parameters.collectionName !== undefined) {
|
||||
var c = dbCollections && dbCollections.find((c) => c.name === parameters.collectionName);
|
||||
if (c === undefined && systemCollections) c = systemCollections.find((c) => c.name === parameters.collectionName);
|
||||
if (c && c.schema && c.schema.properties) {
|
||||
|
||||
const aggs = parameters['aggregates'].split(',');
|
||||
|
||||
const props = Object.keys(c.schema.properties).filter(k => c.schema.properties[k].type === 'Number' || c.schema.properties[k].type === 'String');
|
||||
|
||||
aggs.forEach(a => {
|
||||
ports.push({
|
||||
index:101,
|
||||
name:'aggprop-'+a,
|
||||
plug:'input',
|
||||
type:{name:'enum',enums:props.map(k => ({value:k,label:k})),allowEditOnly:true},
|
||||
displayName:'Property',
|
||||
group:a
|
||||
});
|
||||
|
||||
if(parameters['aggprop-'+a] !== undefined) {
|
||||
const prop = parameters['aggprop-'+a];
|
||||
const schema = c.schema.properties[prop];
|
||||
|
||||
if(schema && schema.type === 'Number') {
|
||||
// Number aggregate
|
||||
ports.push({
|
||||
index:102,
|
||||
name: 'aggop-' + a,
|
||||
plug: 'input',
|
||||
type: {name:'enum', enums:[{value:'min',label:'Min'},
|
||||
{value:'max',label:'Max'},
|
||||
{value:'sum',label:'Sum'},
|
||||
{value:'avg',label:'Avg'}],allowEditOnly:true},
|
||||
default:'avg',
|
||||
displayName: 'Operation',
|
||||
group: a
|
||||
})
|
||||
|
||||
ports.push({
|
||||
name: 'agg-' + a,
|
||||
plug: 'output',
|
||||
type: 'number',
|
||||
displayName: a,
|
||||
group: 'Aggregates'
|
||||
})
|
||||
}
|
||||
else if(schema && schema.type === 'String') {
|
||||
// String aggregate
|
||||
ports.push({
|
||||
index:102,
|
||||
name: 'aggop-' + a,
|
||||
plug: 'input',
|
||||
type: {name:'enum', enums:[{value:'distinct',label:'Distinct'}],allowEditOnly:true},
|
||||
default:'distinct',
|
||||
displayName: 'Operation',
|
||||
group: a
|
||||
})
|
||||
|
||||
ports.push({
|
||||
name: 'agg-' + a,
|
||||
plug: 'output',
|
||||
type: 'string',
|
||||
displayName: a,
|
||||
group: 'Aggregates'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Simple query
|
||||
if (parameters['storageFilterType'] === undefined || parameters['storageFilterType'] === 'simple') {
|
||||
if (parameters.collectionName !== undefined) {
|
||||
var c = dbCollections && dbCollections.find((c) => c.name === parameters.collectionName);
|
||||
if (c === undefined && systemCollections) c = systemCollections.find((c) => c.name === parameters.collectionName);
|
||||
if (c && c.schema && c.schema.properties) {
|
||||
const schema = JSON.parse(JSON.stringify(c.schema));
|
||||
|
||||
// Find all records that have a relation with this type
|
||||
function _findRelations(c) {
|
||||
if (c.schema !== undefined && c.schema.properties !== undefined)
|
||||
for (var key in c.schema.properties) {
|
||||
var p = c.schema.properties[key];
|
||||
if (p.type === 'Relation' && p.targetClass === parameters.collectionName) {
|
||||
if (schema.relations === undefined) schema.relations = {};
|
||||
if (schema.relations[c.name] === undefined) schema.relations[c.name] = [];
|
||||
|
||||
schema.relations[c.name].push({ property: key });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dbCollections && dbCollections.forEach(_findRelations);
|
||||
systemCollections && systemCollections.forEach(_findRelations);
|
||||
|
||||
ports.push({
|
||||
name: 'visualFilter',
|
||||
plug: 'input',
|
||||
type: { name: 'query-filter', schema: schema, allowEditOnly: true },
|
||||
displayName: 'Filter',
|
||||
group: 'Filter'
|
||||
});
|
||||
}
|
||||
|
||||
if (parameters.visualFilter !== undefined) {
|
||||
// Find all input ports
|
||||
const uniqueInputs = {};
|
||||
function _collectInputs(query) {
|
||||
if (query === undefined) return;
|
||||
if (query.rules !== undefined) query.rules.forEach((r) => _collectInputs(r));
|
||||
else if (query.input !== undefined) uniqueInputs[query.input] = true;
|
||||
}
|
||||
|
||||
_collectInputs(parameters.visualFilter);
|
||||
Object.keys(uniqueInputs).forEach((input) => {
|
||||
ports.push({
|
||||
name: 'qp-' + input,
|
||||
plug: 'input',
|
||||
type: '*',
|
||||
displayName: input,
|
||||
group: 'Query Parameters'
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// JSON query
|
||||
else if (parameters['storageFilterType'] === 'json') {
|
||||
ports.push({
|
||||
type: { name: 'string', allowEditOnly: true, codeeditor: 'javascript' },
|
||||
plug: 'input',
|
||||
group: 'Filter',
|
||||
name: 'storageJSONFilter',
|
||||
default: _defaultJSONQuery,
|
||||
displayName: 'Filter'
|
||||
});
|
||||
|
||||
var filter = parameters['storageJSONFilter'];
|
||||
if (filter) {
|
||||
filter = filter.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, ''); // Remove comments
|
||||
var variables = filter.match(/\$[A-Za-z0-9]+/g);
|
||||
|
||||
if (variables) {
|
||||
const unique = {};
|
||||
variables.forEach((v) => {
|
||||
unique[v] = true;
|
||||
});
|
||||
|
||||
Object.keys(unique).forEach((p) => {
|
||||
ports.push({
|
||||
name: 'storageFilterValue-' + p.substring(1),
|
||||
displayName: p.substring(1),
|
||||
group: 'Filter Values',
|
||||
plug: 'input',
|
||||
type: { name: '*', allowConnectionsOnly: true }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Support variables with the "Inputs."" syntax
|
||||
JavascriptNodeParser.parseAndAddPortsFromScript(filter, ports, {
|
||||
inputPrefix: 'storageFilterValue-',
|
||||
inputGroup: 'Filter Values',
|
||||
inputType: { name: '*', allowConnectionsOnly: true },
|
||||
skipOutputs: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
editorConnection.sendDynamicPorts(nodeId, ports);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
node: AggregateNode,
|
||||
setup: function (context, graphModel) {
|
||||
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
|
||||
return;
|
||||
}
|
||||
|
||||
function _managePortsForNode(node) {
|
||||
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
|
||||
|
||||
node.on('parameterUpdated', function (event) {
|
||||
if (event.name.startsWith('storage') || event.name === 'visualFilter' || event.name === 'collectionName' || event.name.startsWith('agg')) {
|
||||
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
|
||||
}
|
||||
});
|
||||
|
||||
graphModel.on('metadataChanged.dbCollections', function (data) {
|
||||
CloudStore.invalidateCollections();
|
||||
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
|
||||
});
|
||||
|
||||
graphModel.on('metadataChanged.systemCollections', function (data) {
|
||||
CloudStore.invalidateCollections();
|
||||
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
|
||||
});
|
||||
|
||||
graphModel.on('metadataChanged.cloudservices', function (data) {
|
||||
CloudStore.instance._initCloudServices();
|
||||
});
|
||||
}
|
||||
|
||||
graphModel.on('editorImportComplete', () => {
|
||||
graphModel.on('nodeAdded.noodl.cloud.aggregate', function (node) {
|
||||
_managePortsForNode(node);
|
||||
});
|
||||
|
||||
for (const node of graphModel.getNodesWithType('noodl.cloud.aggregate')) {
|
||||
_managePortsForNode(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
10
packages/noodl-viewer-cloud/src/nodes/index.ts
Normal file
10
packages/noodl-viewer-cloud/src/nodes/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import NoodlRuntime from '@noodl/runtime';
|
||||
|
||||
export function registerNodes(runtime: NoodlRuntime) {
|
||||
[require('./cloud/request'),
|
||||
require('./cloud/response'),
|
||||
require('./data/aggregatenode')]
|
||||
.forEach(function (nodeDefinition) {
|
||||
runtime.registerNode(nodeDefinition);
|
||||
});
|
||||
}
|
||||
36
packages/noodl-viewer-cloud/src/noodl-js-api.js
Normal file
36
packages/noodl-viewer-cloud/src/noodl-js-api.js
Normal file
@@ -0,0 +1,36 @@
|
||||
'use strict';
|
||||
|
||||
const Model = require('@noodl/runtime/src/model');
|
||||
const NoodlRuntime = require('@noodl/runtime');
|
||||
const JavascriptNodeParser = require('@noodl/runtime/src/javascriptnodeparser');
|
||||
|
||||
//Cloud functions override some of the JavascriptNodeParser functions
|
||||
|
||||
//Override getComponentScopeForNode to just return an empty object. This basically disabled the 'Component' API in Function/Script nodes and removes a massive memory leak
|
||||
//Also improves performance.
|
||||
const componentScope = {};
|
||||
JavascriptNodeParser.getComponentScopeForNode = function () {
|
||||
return componentScope;
|
||||
};
|
||||
|
||||
//override the Noodl API so it uses a model scope
|
||||
JavascriptNodeParser.createNoodlAPI = function (modelScope) {
|
||||
return {
|
||||
getProjectSettings: NoodlRuntime.instance.getProjectSettings.bind(NoodlRuntime.instance),
|
||||
getMetaData: NoodlRuntime.instance.getMetaData.bind(NoodlRuntime.instance),
|
||||
Object: modelScope || Model,
|
||||
Variables: (modelScope || Model).get('--ndl--global-variables'),
|
||||
Records: require('@noodl/runtime/src/api/records')(modelScope),
|
||||
Users: require('./api/users')(modelScope),
|
||||
// CloudFunctions: require('./api/cloudfunctions'),
|
||||
Files: require('./api/files'),
|
||||
Objects: new Proxy(modelScope || Model, {
|
||||
get(target, prop, receiver) {
|
||||
return (modelScope || Model).get(prop);
|
||||
},
|
||||
set(obj, prop, value) {
|
||||
(modelScope || Model).get(prop).setAll(value);
|
||||
}
|
||||
})
|
||||
};
|
||||
};
|
||||
179
packages/noodl-viewer-cloud/src/sandbox.isolate.js
Normal file
179
packages/noodl-viewer-cloud/src/sandbox.isolate.js
Normal file
@@ -0,0 +1,179 @@
|
||||
import { CloudRunner } from '.';
|
||||
|
||||
let _runner
|
||||
|
||||
const _api_response_handlers = {}
|
||||
_noodl_api_response = (token,res) => {
|
||||
if(typeof _api_response_handlers[token] === 'function') {
|
||||
_api_response_handlers[token](res)
|
||||
delete _api_response_handlers[token]
|
||||
}
|
||||
}
|
||||
|
||||
const eventQueue = []
|
||||
_noodl_process_jobs = () => {
|
||||
while(eventQueue.length > 0) {
|
||||
const cb = eventQueue.shift()
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
const _defineAPI = () => {
|
||||
global.require = function(module) {
|
||||
console.log("Error, require not supported: " + module)
|
||||
}
|
||||
|
||||
global.console = {
|
||||
log:function() {
|
||||
let text = "";
|
||||
for(let i = 0; i < arguments.length; i++)
|
||||
text += arguments[i] + '\n'
|
||||
|
||||
_noodl_api_call('log',undefined,{level:'info',message:text})
|
||||
},
|
||||
info:function() {
|
||||
global.console.log.apply(undefined,arguments)
|
||||
},
|
||||
error:function() {
|
||||
let text = "";
|
||||
for(let i = 0; i < arguments.length; i++)
|
||||
text += arguments[i] + '\n'
|
||||
|
||||
_noodl_api_call('log',undefined,{level:'error',message:text})
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------- Fetch API --------------------------------
|
||||
class Headers {
|
||||
constructor(headers) {
|
||||
this._headers = headers;
|
||||
}
|
||||
|
||||
append(key,value) {
|
||||
const _key = key.toLowerCase();
|
||||
this._headers[_key] = this._headers[_key]?this._headers[_key].concat([value]):[value]
|
||||
}
|
||||
|
||||
set(key,value) {
|
||||
const _key = key.toLowerCase();
|
||||
this._headers[_key] = [value]
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const _key = key.toLowerCase();
|
||||
if(this._headers[_key] === undefined) return null
|
||||
return this._headers[_key].join(', ');
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
const _key = key.toLowerCase();
|
||||
delete this._headers[_key]
|
||||
}
|
||||
|
||||
has(key) {
|
||||
const _key = key.toLowerCase();
|
||||
return this._headers[key] !== undefined
|
||||
}
|
||||
|
||||
keys() {
|
||||
return Object.keys(this._headers)
|
||||
}
|
||||
|
||||
forEach(callback, thisArg = undefined) {
|
||||
for (const name of this.keys()) {
|
||||
Reflect.apply(callback, thisArg, [this.get(name), name, this]);
|
||||
}
|
||||
}
|
||||
|
||||
* values() {
|
||||
for (const name of this.keys()) {
|
||||
yield this.get(name);
|
||||
}
|
||||
}
|
||||
|
||||
* entries() {
|
||||
for (const name of this.keys()) {
|
||||
yield [name, this.get(name)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
global.fetch = async function(url,args) {
|
||||
return new Promise((resolve,reject) => {
|
||||
const token = Math.random().toString(26).slice(2)
|
||||
_api_response_handlers[token] = (res) => {
|
||||
if(res.error === undefined) {
|
||||
res.json = () => {
|
||||
try {
|
||||
return Promise.resolve(JSON.parse(res.body))
|
||||
}
|
||||
catch(e) {
|
||||
return Promise.reject('Failed to parse JSON response')
|
||||
}
|
||||
}
|
||||
res.text = () => {
|
||||
return Promise.resolve(res.body)
|
||||
}
|
||||
res.headers = new Headers(res.headers)
|
||||
resolve(res)
|
||||
}
|
||||
else reject(res.error)
|
||||
}
|
||||
_noodl_api_call("fetch",token,{url,...args})
|
||||
})
|
||||
}
|
||||
|
||||
global.setTimeout = function(cb,millis) {
|
||||
const token = Math.random().toString(26).slice(2)
|
||||
_api_response_handlers[token] = () => {
|
||||
cb()
|
||||
}
|
||||
_noodl_api_call("setTimeout",token,millis)
|
||||
}
|
||||
|
||||
global.setImmediate = function(cb) {
|
||||
eventQueue.push(cb)
|
||||
_noodl_request_process_jobs()
|
||||
}
|
||||
}
|
||||
|
||||
const _prepareCloudRunner = async () => {
|
||||
if(!_runner) {
|
||||
_runner = new CloudRunner({});
|
||||
if(typeof _exportedComponents === 'undefined') {
|
||||
throw Error("No cloud components present.")
|
||||
}
|
||||
await _runner.load(_exportedComponents)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRequest = (req) => {
|
||||
return new Promise((resolve,reject) => {
|
||||
_prepareCloudRunner().then(() => {
|
||||
_runner.run(req.function,{
|
||||
body:req.body,
|
||||
headers:req.headers,
|
||||
}).then(resolve).catch(reject)
|
||||
}).catch(reject)
|
||||
})
|
||||
}
|
||||
|
||||
_noodl_handleReq = (token,req) => {
|
||||
// req.function
|
||||
// req.headers
|
||||
// req.body
|
||||
|
||||
if(!global.log) {
|
||||
_defineAPI()
|
||||
}
|
||||
|
||||
console.info(`Cloud function ${req.function} called (requestId:${token})`)
|
||||
|
||||
handleRequest(req).then(r => {
|
||||
console.info(`Cloud function ${req.function} response [${r.statusCode}] (requestId:${token})`)
|
||||
_noodl_response(token,r)
|
||||
}).catch(e => {
|
||||
console.error(`Cloud function ${req.function} response [400] message: ${e.message} (requestId:${token})`)
|
||||
_noodl_response(token,{statusCode:400,body:JSON.stringify({error:e.message})})
|
||||
})
|
||||
}
|
||||
121
packages/noodl-viewer-cloud/src/sandbox.viewer.js
Normal file
121
packages/noodl-viewer-cloud/src/sandbox.viewer.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import { CloudRunner } from '.';
|
||||
|
||||
console.log('Noodl Editor Cloud Runtime!');
|
||||
console.log('Version: ' + _noodl_cloud_runtime_version);
|
||||
|
||||
const _runner = new CloudRunner({
|
||||
webSocketClass: WebSocket,
|
||||
connectToEditor: true,
|
||||
editorAddress: 'ws://localhost:8574',
|
||||
enableDebugInspectors: true
|
||||
});
|
||||
|
||||
_runner.runtime.graphModel.on('editorImportComplete', () => {
|
||||
ipcRenderer.send('noodl-cf-has-loaded-project');
|
||||
});
|
||||
|
||||
const handleRequest = async (functionName, req) => {
|
||||
return await _runner.run(functionName, req);
|
||||
};
|
||||
|
||||
const eventQueue = [];
|
||||
let hasScheduledProcessJobs = false;
|
||||
const _noodl_process_jobs = () => {
|
||||
hasScheduledProcessJobs = false;
|
||||
while (eventQueue.length > 0) {
|
||||
const cb = eventQueue.shift();
|
||||
cb();
|
||||
}
|
||||
};
|
||||
|
||||
const _setImmediate = window.setImmediate;
|
||||
window.setImmediate = (cb) => {
|
||||
eventQueue.push(cb);
|
||||
if (!hasScheduledProcessJobs) {
|
||||
hasScheduledProcessJobs = true;
|
||||
_setImmediate(_noodl_process_jobs);
|
||||
}
|
||||
};
|
||||
|
||||
const _fetch_response_handlers = {};
|
||||
const _fetch = window.fetch;
|
||||
window.fetch = function (url, args) {
|
||||
if (args && args.platform === 'node') {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Perform the fetch via the host node platform
|
||||
const token = Math.random().toString(26).slice(2);
|
||||
_fetch_response_handlers[token] = (args) => {
|
||||
if (args.error === undefined) {
|
||||
const res = {
|
||||
body: args.body,
|
||||
status: args.status,
|
||||
headers: args.headers,
|
||||
ok: args.status === 200 || args.status === 201
|
||||
};
|
||||
res.json = () => {
|
||||
try {
|
||||
return Promise.resolve(JSON.parse(res.body));
|
||||
} catch (e) {
|
||||
return Promise.reject('Failed to parse JSON response');
|
||||
}
|
||||
};
|
||||
res.text = () => {
|
||||
return Promise.resolve(res.body);
|
||||
};
|
||||
|
||||
resolve(res);
|
||||
} else reject(args.error);
|
||||
};
|
||||
|
||||
ipcRenderer.send('noodl-cf-fetch', {
|
||||
url,
|
||||
method: args.method,
|
||||
token,
|
||||
headers: JSON.parse(JSON.stringify(args.headers)),
|
||||
body: args.body
|
||||
});
|
||||
});
|
||||
} else return _fetch(url, args);
|
||||
};
|
||||
|
||||
ipcRenderer.on('noodl-cf-fetch-response', async function (event, args) {
|
||||
if (typeof _fetch_response_handlers[args.token] === 'function') {
|
||||
typeof _fetch_response_handlers[args.token](args);
|
||||
delete _fetch_response_handlers[args.token];
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on('noodl-cf-request', async function (event, args) {
|
||||
if (args.cloudService) {
|
||||
window._noodl_cloudservices = {
|
||||
endpoint: args.cloudService.endpoint,
|
||||
appId: args.cloudService.appId,
|
||||
masterKey: args.cloudService.masterKey
|
||||
};
|
||||
}
|
||||
|
||||
console.info(`Cloud function ${args.function} called`);
|
||||
|
||||
try {
|
||||
// args.function
|
||||
// args.headers
|
||||
// args.body
|
||||
|
||||
const res = await handleRequest(args.function, {
|
||||
body: args.body,
|
||||
headers: args.headers
|
||||
});
|
||||
|
||||
console.info(`Cloud function ${args.function} response [${res.statusCode}]`);
|
||||
|
||||
event.sender.send('noodl-cf-response', Object.assign({}, res, { token: args.token }));
|
||||
} catch (e) {
|
||||
console.error(`Cloud function ${args.function} response [400] message: ${e.message}`);
|
||||
|
||||
event.sender.send('noodl-cf-response', {
|
||||
token: args.token,
|
||||
statusCode: 400,
|
||||
body: JSON.stringify({ error: e.message })
|
||||
});
|
||||
}
|
||||
});
|
||||
193
packages/noodl-viewer-cloud/src/services/userservice.js
Normal file
193
packages/noodl-viewer-cloud/src/services/userservice.js
Normal file
@@ -0,0 +1,193 @@
|
||||
const NoodlRuntime = require('@noodl/runtime');
|
||||
const EventEmitter = require('@noodl/runtime/src/events');
|
||||
//const guid = require('../../../guid');
|
||||
const Model = require('@noodl/runtime/src/model');
|
||||
const CloudStore = require('@noodl/runtime/src/api/cloudstore');
|
||||
|
||||
class UserService {
|
||||
constructor(modelScope) {
|
||||
this.events = new EventEmitter();
|
||||
this.events.setMaxListeners(100000);
|
||||
|
||||
// User is fetched and validate when the request is initiated
|
||||
// see if there is a current user
|
||||
const request = (modelScope || Model).get('Request');
|
||||
if (request.UserId !== undefined) {
|
||||
const user = (modelScope || Model).get(request.UserId);
|
||||
|
||||
this.current = user;
|
||||
}
|
||||
|
||||
this.modelScope = modelScope;
|
||||
}
|
||||
|
||||
on() {
|
||||
this.events.on.apply(this.events, arguments);
|
||||
}
|
||||
|
||||
off() {
|
||||
this.events.off.apply(this.events, arguments);
|
||||
}
|
||||
|
||||
_makeRequest(path, options) {
|
||||
if (typeof _noodl_cloudservices === 'undefined') {
|
||||
options.error && options.error({ error: 'No active cloud service', status: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const cs = _noodl_cloudservices;
|
||||
|
||||
fetch(cs.endpoint + path, {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': cs.appId,
|
||||
'X-Parse-Master-Key': cs.masterKey,
|
||||
'content-type': 'application/json',
|
||||
'X-Parse-Session-Token': options.sessionToken
|
||||
},
|
||||
body: JSON.stringify(options.content)
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.ok) {
|
||||
res.json().then((json) => options.success(json));
|
||||
} else {
|
||||
res.json().then((json) => options.error({ error: json.error, status: res.status }));
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
options.error({ error: e.message });
|
||||
});
|
||||
}
|
||||
|
||||
setUserProperties(options) {
|
||||
if (this.current !== undefined) {
|
||||
//make a shallow copy to feed through CloudStore._serializeObject, which will modify the object
|
||||
const propsToSave = CloudStore._serializeObject({ ...options.properties }, '_User', this.modelScope || Model);
|
||||
|
||||
const _content = Object.assign({}, { email: options.email, username: options.username }, propsToSave);
|
||||
|
||||
delete _content.createdAt; // Remove props you cannot set
|
||||
delete _content.updatedAt;
|
||||
|
||||
this._makeRequest('/users/' + this.current.getId(), {
|
||||
method: 'PUT',
|
||||
content: _content,
|
||||
success: (response) => {
|
||||
// Store current user
|
||||
for (let key in _content) {
|
||||
this.current.set(key, _content[key]);
|
||||
}
|
||||
options.success(response);
|
||||
},
|
||||
error: (e) => {
|
||||
options.error(e.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logIn(options) {
|
||||
this._makeRequest('/login', {
|
||||
method: 'POST',
|
||||
content: {
|
||||
username: options.username,
|
||||
password: options.password,
|
||||
method: 'GET'
|
||||
},
|
||||
success: (user) => {
|
||||
delete user.ACL;
|
||||
delete user.className;
|
||||
delete user.__type;
|
||||
|
||||
const _user = CloudStore._fromJSON(user, '_User', this.modelScope || Model);
|
||||
|
||||
options.success(_user);
|
||||
},
|
||||
error: (e) => {
|
||||
options.error(e.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Just fetch the user don't set to current
|
||||
fetchUser(options) {
|
||||
this._makeRequest('/users/me', {
|
||||
method: 'GET',
|
||||
sessionToken: options.sessionToken,
|
||||
success: (user) => {
|
||||
// Store current user
|
||||
delete user.ACL;
|
||||
delete user.className;
|
||||
delete user.__type;
|
||||
|
||||
const _user = CloudStore._fromJSON(user, '_User', this.modelScope || Model);
|
||||
|
||||
options.success(_user);
|
||||
},
|
||||
error: (e) => {
|
||||
options.error(e.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fetchCurrentUser(options) {
|
||||
if (options.sessionToken) {
|
||||
// Fetch the current user with the session token
|
||||
this._makeRequest('/users/me', {
|
||||
method: 'GET',
|
||||
sessionToken: options.sessionToken,
|
||||
success: (user) => {
|
||||
// Store current user
|
||||
delete user.ACL;
|
||||
delete user.className;
|
||||
delete user.__type;
|
||||
|
||||
this.current = CloudStore._fromJSON(user, '_User', this.modelScope || Model);
|
||||
this.events.emit('sessionGained');
|
||||
options.success(this.current);
|
||||
},
|
||||
error: (e) => {
|
||||
options.error(e.error);
|
||||
}
|
||||
});
|
||||
} else if (this.current !== undefined) {
|
||||
// Fetch the current user, will use master key
|
||||
this._makeRequest('/users/' + this.current.getId(), {
|
||||
method: 'GET',
|
||||
success: (user) => {
|
||||
// Store current user
|
||||
delete user.ACL;
|
||||
delete user.className;
|
||||
delete user.__type;
|
||||
|
||||
this.current = CloudStore._fromJSON(user, '_User', this.modelScope || Model);
|
||||
|
||||
options.success(this.current);
|
||||
},
|
||||
error: (e) => {
|
||||
options.error(e.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UserService.forScope = (modelScope) => {
|
||||
if (modelScope === undefined) return UserService.instance;
|
||||
if (modelScope._userService) return modelScope._userService;
|
||||
|
||||
modelScope._userService = new UserService(modelScope);
|
||||
return modelScope._userService;
|
||||
};
|
||||
|
||||
var _instance;
|
||||
Object.defineProperty(UserService, 'instance', {
|
||||
get: function () {
|
||||
if (_instance === undefined) _instance = new UserService();
|
||||
return _instance;
|
||||
}
|
||||
});
|
||||
|
||||
NoodlRuntime.Services.UserService = UserService;
|
||||
|
||||
module.exports = UserService;
|
||||
507
packages/noodl-viewer-cloud/static/viewer/global.d.ts.keep
Normal file
507
packages/noodl-viewer-cloud/static/viewer/global.d.ts.keep
Normal file
@@ -0,0 +1,507 @@
|
||||
/* eslint-disable */
|
||||
|
||||
declare namespace Noodl {
|
||||
function getProjectSettings(): any;
|
||||
function getMetaData(): any;
|
||||
|
||||
interface VariablesApi {
|
||||
[K in VariableNames]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* You can access all variables in your application trough the Noodl.Variables object.
|
||||
* Changing a variable will trigger all connections to be updated for all Variable nodes
|
||||
* in your project with the corresponding variable name.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* // This will change the variable named MyVariable
|
||||
* // and trigger all variable nodes in your project
|
||||
* Noodl.Variables.MyVariable = "Hello";
|
||||
*
|
||||
* // Use this if you have spaces in your variable name
|
||||
* Noodl.Variables["My Variable"] = 10;
|
||||
*
|
||||
* Noodl.Variables.userName = "Mickeeeey";
|
||||
*
|
||||
* // Reading variables
|
||||
* console.log(Noodl.Variables.userName);
|
||||
* ```
|
||||
*
|
||||
* {@link https://docs.noodl.net/#/javascript/reference/variables}
|
||||
*/
|
||||
const Variables: VariablesApi;
|
||||
|
||||
/**
|
||||
* One step above Variables are Objects, this is a global data model of Noodl objects.
|
||||
* Each object is referenced with an Id and can contain a set of properties.
|
||||
* You can access all objects in your workspace through their Id and the Noodl.Objects prefix.
|
||||
* Change a property of an object will trigger all connections
|
||||
* from object nodes with the corresponding Id and property.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* // This will change the property MyProperty
|
||||
* // of object with id MyObjectId and trigger
|
||||
* // all object nodes (with that id) in your project
|
||||
* Noodl.Objects.MyObjectId.MyProperty = "Hello";
|
||||
*
|
||||
* // Use this notation of that object id contains spaces
|
||||
* Noodl.Objects["Form Values"].input_text = "Whoops";
|
||||
*
|
||||
* Noodl.Objects["Form Values"]["A property with spaces"] = 20;
|
||||
*
|
||||
* // Reading an object property
|
||||
* console.log(Noodl.Objects.CurrentUser.Name);
|
||||
*
|
||||
* // This will set all properties of the object you assign with
|
||||
* // to the object with id "SomeId"
|
||||
* // You cannot set the id property this way,
|
||||
* // that property will be ignored if part of the object you assign
|
||||
* Noodl.Objects.SomeId = {
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* {@link https://docs.noodl.net/#/javascript/reference/objects}
|
||||
*/
|
||||
const Objects: any;
|
||||
|
||||
/**
|
||||
* Allows access to Object from Javascript.
|
||||
*
|
||||
* {@link https://docs.noodl.net/#/javascript/reference/object}
|
||||
*/
|
||||
const Object: any;
|
||||
|
||||
interface RecordsApi {
|
||||
/**
|
||||
* This is an async function that will query the database using the query
|
||||
* that you provide and return the result or throw an exception if failed.
|
||||
*
|
||||
* The query parameter has the same format as the advanced query of the Query Records node.
|
||||
* {@link https://docs.noodl.net/#/nodes/data/cloud-data/query-records/#advanced-filters}
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* const results = await Noodl.Records.query("myClass", {
|
||||
* Completed: { equalTo: true },
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* The result is an array of Noodl.Object. The options can be used to specify sorting,
|
||||
* it also follows the same pattern as the advanced filters of the Query Records node.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* const results = await Noodl.Records.query("myClass", {
|
||||
* Completed: { equalTo: true },
|
||||
* }, {
|
||||
* sort:['createdAt']
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* You can also specify the limit for how many records to return as a maximum (defaults to 100)
|
||||
* with the limit option, and if you want the returned records to start from a given
|
||||
* index specify the skip option.
|
||||
*
|
||||
* ```ts
|
||||
* const results = await Noodl.Records.query("myClass", {
|
||||
* Completed: { equalTo: true },
|
||||
* }, {
|
||||
* sort: ['-createdAt'], // use - to sort descending
|
||||
* skip: 50,
|
||||
* limit: 200
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
query(
|
||||
className: RecordClassName,
|
||||
query?: any,
|
||||
options?: {
|
||||
limit?: number;
|
||||
skip?: number;
|
||||
sort?: string[];
|
||||
include?: any;
|
||||
select?: any;
|
||||
}
|
||||
): Promise<any>;
|
||||
|
||||
/**
|
||||
* This function returns the count of the number of records of a given class,
|
||||
* optionally matching a query filter.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* // The number of records of myClass in the database
|
||||
* const count = await Noodl.Records.count("myClass")
|
||||
*
|
||||
* // The number of myClass records in the database that match a query
|
||||
* const completedCount = await Noodl.Records.count("myClass", {
|
||||
* Completed: { equalTo: true },
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
count(className: RecordClassName, query?: any): Promise<number>;
|
||||
|
||||
/**
|
||||
* returns an array of unique values for a given propery or all records in
|
||||
* the database of a given class. Optionally you can suppoly a query filter.
|
||||
*/
|
||||
distinct(
|
||||
className: RecordClassName,
|
||||
property: string,
|
||||
query: any
|
||||
): Promise<any>;
|
||||
|
||||
/**
|
||||
* Use this function to fetch the latest properties of a specific record
|
||||
* from the cloud database. It will return the object / record.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* // If you use the a record ID you must also specify the class
|
||||
* const myRecord = await Noodl.Records.fetch(myRecordId, { className: "myClass" })
|
||||
*
|
||||
* // You can also fetch a record you have previously fetched or received from a
|
||||
* // query, to get the latest properties from the backend
|
||||
* await Noodl.Records.fetch(myRecord)
|
||||
* ```
|
||||
*/
|
||||
fetch(
|
||||
objectOrId: string | { getId(): string; },
|
||||
options?: {
|
||||
className?: RecordClassName;
|
||||
}
|
||||
): Promise<any>;
|
||||
|
||||
/**
|
||||
* Use this function to write an existing record to the cloud database.
|
||||
* It will attempt to save all properties of the record / object
|
||||
* if you don't specify the optional properties argument,
|
||||
* if so it will set and save those properties.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* Noodl.Objects[myRecordId].SomeProperty = "hello"
|
||||
*
|
||||
* // If you use the record id to save, you need to specify the classname explicitly
|
||||
* // by specfiying null or undefinded for properties it will save all proporties in
|
||||
* // the record
|
||||
* await Noodl.Records.save(myRecordId, null, { className: "myClass" })
|
||||
*
|
||||
* // Or use the object directly
|
||||
* await Noodl.Records.save(Noodl.Objects[myRecordId])
|
||||
*
|
||||
* // Set specified properties and save only those to the backned
|
||||
* await Noodl.Records.save(myRecord, {
|
||||
* SomeProperty:'hello'
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
save(
|
||||
objectOrId: string | { getId(): string; },
|
||||
properties: any,
|
||||
options?: {
|
||||
className?: RecordClassName;
|
||||
acl?: any;
|
||||
}
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* This function will increment (or decrease) propertis of a certain record
|
||||
* saving it to the cloud database in a race condition safe way.
|
||||
* That is, normally you would have to first read the current value,
|
||||
* modify it and save it to the database. Here you can do it with one operation.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* // Modify the specified numbers in the cloud
|
||||
* await Noodl.Records.increment(myRecord, {
|
||||
* Score: 10,
|
||||
* Life: -1
|
||||
* })
|
||||
*
|
||||
* // Like save, you can use a record Id and class
|
||||
* await Noodl.Records.save(myRecordId, { Likes: 1 }, { className: "myClass" })
|
||||
* ```
|
||||
*
|
||||
* Using the options you can also specify access control as described in this guide,
|
||||
* this let's you control which users can access a specific record.
|
||||
* The access control is specified as below:
|
||||
* ```ts
|
||||
* await Noodl.Records.save(myRecord, null, {
|
||||
* acl: {
|
||||
* // "*" means everyone, this rules gives everyone read access but not write
|
||||
* "*": { read: true, write: false },
|
||||
* // give a specific user write access
|
||||
* "a-user-id": { read: true, write: true },
|
||||
* // give a specific role write access
|
||||
* "role:a-role-name": { read: true, write: true },
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
increment(
|
||||
objectOrId: string | { getId(): string; },
|
||||
properties: any,
|
||||
options?: {
|
||||
className?: RecordClassName;
|
||||
}
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* This function will create a new record in the cloud database and return
|
||||
* the Noodl.Object of the newly created record.
|
||||
* If it's unsuccessful it will throw an exception.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* const myNewRecord = await Noodl.Records.create("myClass",{
|
||||
* SomeProperty:"Hello"
|
||||
* })
|
||||
*
|
||||
* console.log(myNewRecord.SomeProperty)
|
||||
* ```
|
||||
*
|
||||
* You can use the options agrument to specify access control rules
|
||||
* as detailed under Noodl.Records.save above.
|
||||
*/
|
||||
create(
|
||||
className: RecordClassName,
|
||||
properties: any,
|
||||
options?: {
|
||||
acl?: any
|
||||
}
|
||||
): Promise<any>;
|
||||
|
||||
/**
|
||||
* Use this function to delete an existing record from the cloud database.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* // If you specify the id of a record to be deleted, you must also provide the
|
||||
* // class name in the options
|
||||
* await Noodl.Records.delete(myRecordId,{className:"myClass"})
|
||||
*
|
||||
* // Or use the object directly (provided it was previously fetched or received via a query)
|
||||
* await Noodl.Records.delete(Noodl.Objects[myRecordId])
|
||||
* ```
|
||||
*/
|
||||
delete(
|
||||
objectOrId: string | { getId(): string; },
|
||||
options?: {
|
||||
className?: RecordClassName;
|
||||
}
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Use this function to add a relation between two records.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* // You can either specify the Ids and classes directly
|
||||
* await Noodl.Records.addRelation({
|
||||
* className: "myClass",
|
||||
* recordId: "owning-record-id",
|
||||
* key: "the-relation-key-on-the-owning-record",
|
||||
* targetRecordId: "the-id-of-the-record-to-add-a-relation-to",
|
||||
* targetClassName: "the-class-of-the-target-record"
|
||||
* })
|
||||
*
|
||||
* // Or if you already have two records that have been previously fetched or returned from a
|
||||
* // query
|
||||
* await Noodl.Records.addRelation({
|
||||
* record: myRecord,
|
||||
* key: 'relation-key',
|
||||
* targetRecord: theTargetRecord
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
addRelation(
|
||||
options: {
|
||||
recordId: string | { getId(): string; },
|
||||
className?: RecordClassName,
|
||||
key: string,
|
||||
targetRecordId: string | { getId(): string; },
|
||||
targetClassName?: RecordClassName
|
||||
}
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Use this function to remove a relation between two records.
|
||||
*
|
||||
* ```ts
|
||||
* // You can either specify the Ids and classes directly
|
||||
* await Noodl.Records.removeRelation({
|
||||
* className: "myClass",
|
||||
* recordId: "owning-record-id",
|
||||
* key: "the-relation-key-on-the-owning-record",
|
||||
* targetRecordId: "the-id-of-the-record-to-remove-a-relation-to",
|
||||
* targetClassName: "the-class-of-the-target-record"
|
||||
* })
|
||||
*
|
||||
* // Or if you already have two records that have been previously fetched or returned from a
|
||||
* // query
|
||||
* await Noodl.Records.removeRelation({
|
||||
* record: myRecord,
|
||||
* key: 'relation-key',
|
||||
* targetRecord: theTargetRecord
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
removeRelation(
|
||||
options: {
|
||||
recordId: string | { getId(): string; },
|
||||
className?: RecordClassName,
|
||||
key: string,
|
||||
targetRecordId: string | { getId(): string; },
|
||||
targetClassName?: RecordClassName
|
||||
}
|
||||
): Promise<void>;
|
||||
|
||||
|
||||
/**
|
||||
* compute a set of aggregates based on properties in the records.
|
||||
* It can be limited with a query.
|
||||
*
|
||||
* You can use the following aggregate functions:
|
||||
* - sum Compute the sum of a number property access matching records.
|
||||
* - min Compute the minimum value of a number property access matching records.
|
||||
* - max Compute the maximum value of a number property access matching records.
|
||||
* - avg Compute the average value of a number property access matching records.
|
||||
*/
|
||||
aggregate(
|
||||
className: RecordClassName,
|
||||
aggregates: any,
|
||||
query: any
|
||||
): Promise<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* With Records you can query, read and write records to the cloud database.
|
||||
* All functions are async and will throw an exception if they fail.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* try {
|
||||
* await Noodl.Records.delete(myRecord)
|
||||
* }
|
||||
* catch(e) {
|
||||
* console.log(e)
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* {@link https://docs.noodl.net/#/javascript/reference/records}
|
||||
*/
|
||||
const Records: RecordsApi;
|
||||
|
||||
interface CurrentUserObject {
|
||||
UserId: string;
|
||||
|
||||
// TODO: Fill in the User Record here.
|
||||
Properties: Record<string, any>;
|
||||
|
||||
/**
|
||||
* Attempt to save the properties of the current user.
|
||||
*
|
||||
* If you have made changes to the current() user object you will need
|
||||
* to call this function to write the changes to the backend.
|
||||
*/
|
||||
save(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Fetch that laters properties of the user object from the cloud database.
|
||||
* It will throw an exception if the user session has expired.
|
||||
*/
|
||||
fetch(): Promise<void>;
|
||||
}
|
||||
|
||||
interface UsersApi {
|
||||
/**
|
||||
* Attempt to login to create a user session.
|
||||
*
|
||||
* After a successful login you can access the user object with `Noodl.Users.Current`.
|
||||
*/
|
||||
logIn(options: { username: string; password: string }): Promise<any>;
|
||||
|
||||
/**
|
||||
* Get a session token for a user that you can later send to the client to log that user in.
|
||||
* This does not require a password and must be runon a cloud function
|
||||
* (since they all have full access to the database).
|
||||
*
|
||||
* You can provide a duration for the session,
|
||||
* or it will expire after 24 hours as default.
|
||||
*
|
||||
* If successful this call will return a user object that contains a session token
|
||||
* that you can return to the client and use with the become function.
|
||||
*
|
||||
* __installationId__ is an optional that is a unique id for the client if you don't want
|
||||
* to share sessions between different clients. Most common is to generate a random id
|
||||
* on the client and pass to the cloud function when you are logging in.
|
||||
*/
|
||||
impersonate(username: string, options?: { duration?: number, installationID?: string }): Promise<any>;
|
||||
|
||||
/**
|
||||
* Return the current user object and properties if one exists.
|
||||
*/
|
||||
get Current(): CurrentUserObject | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Noodl.Users object let's you access the current session user.
|
||||
*
|
||||
* {@link https://docs.noodl.net/#/javascript/reference/users}
|
||||
*/
|
||||
const Users: UsersApi;
|
||||
|
||||
interface FilesApi {
|
||||
/**
|
||||
* Delete a file that has been uploaded to the backend.
|
||||
* You need to provide the file name that was returned when the file was uploaded.
|
||||
* So not the full url but the hash+filename returned by the upload function.
|
||||
*/
|
||||
delete(filename: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Noodl.Files service lets you access the cloud services files.
|
||||
*
|
||||
* {@link https://docs.noodl.net/#/javascript/reference/files}
|
||||
*/
|
||||
const Files: FilesApi;
|
||||
}
|
||||
|
||||
interface ComponentApi {
|
||||
/**
|
||||
* `Component.Object` is the Component Object of the current component and
|
||||
* you can use it just like any other Noodl.Object.
|
||||
* Most commonly this means accessing the properties of the object.
|
||||
* When you set a property any Component Object node in this component
|
||||
* instance will update accordingly.
|
||||
*/
|
||||
Object: any;
|
||||
|
||||
/**
|
||||
* Object is the Parent Component Object,
|
||||
* that is the Component Object of the parent component in the visual heirarchy.
|
||||
* It is also used like any other Noodl.Object.
|
||||
*/
|
||||
ParentObject: any;
|
||||
|
||||
/**
|
||||
* If this component is the template of a repeater this will contain
|
||||
* the object of the items array corresponding to this specific component instance.
|
||||
* That is the same object as if you set an object Id Source to From Repeater, as shown below.
|
||||
*/
|
||||
RepeaterObject: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Component` object is ony available in Function and Script nodes and
|
||||
* it contains things related to the component scope where the
|
||||
* Function or Script node is executing.
|
||||
*
|
||||
* {@link https://docs.noodl.net/#/javascript/reference/component}
|
||||
*/
|
||||
declare const Component: ComponentApi;
|
||||
9
packages/noodl-viewer-cloud/static/viewer/index.html
Normal file
9
packages/noodl-viewer-cloud/static/viewer/index.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Noodl Editor Cloud Runtime</title>
|
||||
|
||||
<script src="./sandbox.viewer.bundle.js"></script>
|
||||
</head>
|
||||
</html>
|
||||
9
packages/noodl-viewer-cloud/tsconfig.json
Normal file
9
packages/noodl-viewer-cloud/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2019", "ES2020.String"],
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
9
packages/noodl-viewer-cloud/webpack-configs/constants.js
Normal file
9
packages/noodl-viewer-cloud/webpack-configs/constants.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
// Allows to define the output path of the files built by the viewer.
|
||||
//
|
||||
// For example in the CLI, we will also build this, just with a different output path.
|
||||
outPath: process.env.OUT_PATH || path.resolve(__dirname, '../../noodl-editor/src/external'),
|
||||
runtimeVersion: 'cloud-runtime-' + require('../package.json').version.replaceAll('.', '-')
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
//config shared for both regular viewer and deploy versions
|
||||
|
||||
module.exports = {
|
||||
externals: {},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.js']
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: 'ts-loader',
|
||||
exclude: /node_modules/
|
||||
}
|
||||
]
|
||||
},
|
||||
performance: {
|
||||
hints: false,
|
||||
maxEntrypointSize: 512000,
|
||||
maxAssetSize: 512000
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
const viewer = require('./webpack.viewer.dev');
|
||||
const isolate = require('./webpack.isolate.dev');
|
||||
|
||||
module.exports = [viewer, isolate];
|
||||
@@ -0,0 +1,34 @@
|
||||
const path = require('path');
|
||||
const { runtimeVersion } = require('./constants.js');
|
||||
const webpack = require('webpack');
|
||||
|
||||
const prefix = `const _noodl_cloud_runtime_version = "${runtimeVersion}";`;
|
||||
|
||||
module.exports = {
|
||||
//mode: 'production',
|
||||
mode: 'development',
|
||||
watch: true,
|
||||
entry: './src/sandbox.isolate.js',
|
||||
target: 'node',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: 'ts-loader',
|
||||
exclude: /node_modules/
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.js']
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, '../dist')
|
||||
},
|
||||
plugins: [
|
||||
new webpack.BannerPlugin({
|
||||
banner: prefix,
|
||||
raw: true
|
||||
})
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
const path = require('path');
|
||||
const { runtimeVersion } = require('./constants.js');
|
||||
const webpack = require('webpack');
|
||||
|
||||
const prefix = `const _noodl_cloud_runtime_version = "${runtimeVersion}";`;
|
||||
|
||||
module.exports = {
|
||||
mode: 'production',
|
||||
entry: './src/sandbox.isolate.js',
|
||||
target: 'node',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: 'ts-loader',
|
||||
exclude: /node_modules/
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.js']
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, '../dist')
|
||||
},
|
||||
plugins: [
|
||||
new webpack.BannerPlugin({
|
||||
banner: prefix,
|
||||
raw: true
|
||||
})
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
const viewer = require('./webpack.viewer.prod');
|
||||
const isolate = require('./webpack.isolate.prod');
|
||||
|
||||
module.exports = [viewer, isolate];
|
||||
@@ -0,0 +1,49 @@
|
||||
//shared config for regular (non-deploy) viewer
|
||||
|
||||
const path = require('path');
|
||||
const { merge } = require('webpack-merge');
|
||||
const { outPath, runtimeVersion } = require('./constants.js');
|
||||
const common = require('./webpack.common.js');
|
||||
const webpack = require('webpack');
|
||||
|
||||
const CleanWebpackPlugin = require('clean-webpack-plugin');
|
||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||
const GenerateJsonPlugin = require('generate-json-webpack-plugin');
|
||||
|
||||
const noodlEditorExternalViewerPath = path.join(outPath, 'cloudruntime');
|
||||
|
||||
function stripStartDirectories(targetPath, numDirs) {
|
||||
const p = targetPath.split('/');
|
||||
p.splice(0, numDirs);
|
||||
return p.join(path.sep);
|
||||
}
|
||||
|
||||
const prefix = `const { ipcRenderer } = require('electron'); const _noodl_cloud_runtime_version = "${runtimeVersion}";`;
|
||||
|
||||
module.exports = merge(common, {
|
||||
entry: {
|
||||
sandbox: './src/sandbox.viewer.js'
|
||||
},
|
||||
output: {
|
||||
filename: 'sandbox.viewer.bundle.js',
|
||||
path: noodlEditorExternalViewerPath
|
||||
},
|
||||
plugins: [
|
||||
new webpack.BannerPlugin({
|
||||
banner: prefix,
|
||||
raw: true
|
||||
}),
|
||||
new CleanWebpackPlugin(noodlEditorExternalViewerPath, {
|
||||
allowExternal: true
|
||||
}),
|
||||
new CopyWebpackPlugin([
|
||||
{
|
||||
from: 'static/viewer/**/*',
|
||||
transformPath: (targetPath) => stripStartDirectories(targetPath, 2)
|
||||
}
|
||||
]),
|
||||
new GenerateJsonPlugin('manifest.json', {
|
||||
version: runtimeVersion
|
||||
})
|
||||
]
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
const { merge } = require("webpack-merge");
|
||||
const common = require("./webpack.viewer.common.js");
|
||||
|
||||
module.exports = merge(common, {
|
||||
mode: "development",
|
||||
devtool: "inline-source-map",
|
||||
watch: true,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
const { merge } = require('webpack-merge');
|
||||
const common = require('./webpack.viewer.common.js');
|
||||
|
||||
module.exports = merge(common, {
|
||||
mode: 'production'
|
||||
});
|
||||
Reference in New Issue
Block a user