Enhancements to the process of developing a Marketplace plugin (#5777)

* in dev mode, start watching for changes in all packages

* plugin reload service

* typo

* fixes updates from fs

* checks if marketplace dev mode is on to decode the run code from plugin index file

* clean up

* removes console.log

* refactor: marketplace dashboard

* prep to merge

* dotenv

* fixes: install new upadates for one plugin at a time

* fixes app crash for new plugins(marketplace/datasource) with default schema

* avoid creating docs for marketplace to root docs

* Before starting watcher, build the marketplace once.

* fixes: installed plugin crashes if deleting the entire plugin from the dir, but the build still haves the plugin files
This commit is contained in:
Arpit 2023-03-24 17:05:08 +05:30 committed by GitHub
parent 70ffc1e27b
commit 7dea6c9ad1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 335 additions and 180 deletions

View file

@ -74,6 +74,21 @@ export default class Create extends Command {
let repoUrl;
const commonHygenArgs = [
'plugin',
'new',
'--name',
`${args.plugin_name}`,
'--type',
`${type}`,
'--display_name',
`${name}`,
'--plugins_path',
`${pluginsPath}`,
];
const hygenArgs = !marketplace ? [...commonHygenArgs, '--docs_path', `${docsPath}`] : commonHygenArgs;
if (marketplace) {
const buffer = fs.readFileSync(path.join('server', 'src', 'assets', 'marketplace', 'plugins.json'), 'utf8');
const pluginsJson = JSON.parse(buffer);
@ -89,21 +104,6 @@ export default class Create extends Command {
});
}
const hygenArgs = [
'plugin',
'new',
'--name',
`${args.plugin_name}`,
'--type',
`${type}`,
'--display_name',
`${name}`,
'--plugins_path',
`${pluginsPath}`,
'--docs_path',
`${docsPath}`,
];
CliUx.ux.action.start('creating plugin');
await runner(hygenArgs, {

View file

@ -28,6 +28,7 @@
"date-fns": "^2.29.3",
"deep-object-diff": "^1.1.9",
"dompurify": "^3.0.0",
"dotenv": "^16.0.3",
"draft-js": "^0.11.7",
"draft-js-export-html": "^1.4.1",
"driver.js": "^0.9.8",
@ -31098,6 +31099,14 @@
"tslib": "^2.0.3"
}
},
"node_modules/dotenv": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz",
"integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/draft-js": {
"version": "0.11.7",
"license": "MIT",
@ -68445,6 +68454,11 @@
"tslib": "^2.0.3"
}
},
"dotenv": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz",
"integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ=="
},
"draft-js": {
"version": "0.11.7",
"requires": {

View file

@ -23,6 +23,7 @@
"date-fns": "^2.29.3",
"deep-object-diff": "^1.1.9",
"dompurify": "^3.0.0",
"dotenv": "^16.0.3",
"draft-js": "^0.11.7",
"draft-js-export-html": "^1.4.1",
"driver.js": "^0.9.8",

View file

@ -1,41 +1,53 @@
import React from 'react';
import cx from 'classnames';
import { marketplaceService, pluginsService } from '@/_services';
import { pluginsService } from '@/_services';
import { toast } from 'react-hot-toast';
import Spinner from '@/_ui/Spinner';
import { capitalizeFirstLetter } from './utils';
export const InstalledPlugins = ({ isActive }) => {
const [plugins, setPlugins] = React.useState([]);
export const InstalledPlugins = ({
allPlugins = [],
installedPlugins,
fetching,
fetchPlugins,
ENABLE_MARKETPLACE_DEV_MODE,
}) => {
return (
<div className="col-9">
{fetching && (
<div className="m-auto text-center">
<Spinner />
</div>
)}
{!fetching && allPlugins.length > 0 && (
<div className="row row-cards">
{installedPlugins?.map((plugin) => {
const marketplacePlugin = allPlugins?.find((m) => m.id === plugin.pluginId);
const isUpdateAvailable = marketplacePlugin?.version !== plugin.version;
return (
<InstalledPlugins.Plugin
key={plugin.id}
plugin={plugin}
marketplacePlugin={marketplacePlugin}
fetchPlugins={fetchPlugins}
isDevMode={ENABLE_MARKETPLACE_DEV_MODE}
isUpdateAvailable={isUpdateAvailable}
/>
);
})}
{!fetching && installedPlugins?.length === 0 && (
<div className="empty">
<p className="empty-title">No results found</p>
</div>
)}
</div>
)}
</div>
);
};
const InstalledPluginCard = ({ plugin, marketplacePlugin, fetchPlugins, isDevMode, isUpdateAvailable }) => {
const [updating, setUpdating] = React.useState(false);
const [marketplacePlugins, setMarketplacePlugins] = React.useState([]);
const [fetching, setFetching] = React.useState(false);
React.useEffect(() => {
marketplaceService
.findAll()
.then(({ data = [] }) => setMarketplacePlugins(data))
.catch((error) => {
toast.error(error?.message || 'something went wrong');
});
}, [isActive]);
const fetchPlugins = async () => {
setFetching(true);
const { data, error } = await pluginsService.findAll();
setFetching(false);
if (error) {
toast.error(error?.message || 'something went wrong');
return;
}
setPlugins(data);
};
React.useEffect(() => {
fetchPlugins();
}, [isActive]);
const deletePlugin = async ({ id, name }) => {
var result = confirm('Are you sure you want to delete ' + name + '?');
@ -70,77 +82,97 @@ export const InstalledPlugins = ({ isActive }) => {
fetchPlugins();
};
const reloadPlugin = async ({ id, name }) => {
setUpdating(true);
const { error } = await pluginsService.reloadPlugin(id);
setUpdating(false);
if (error) {
toast.error(error?.message || `Unable to reload ${name}`);
return;
}
toast.success(`${name} reloaded`);
};
return (
<div className="col-9">
{fetching && (
<div className="m-auto text-center">
<Spinner />
</div>
)}
{!fetching && (
<div className="row row-cards">
{plugins?.map((plugin) => {
const marketplacePlugin = marketplacePlugins.find((m) => m.id === plugin.pluginId);
const isUpdateAvailable = marketplacePlugin?.version !== plugin.version;
return (
<div key={plugin.id} className="col-sm-6 col-lg-4">
<div className="card card-sm card-borderless">
<div className="card-body">
<div className="row align-items-center">
<div className="col-auto">
<span className="text-white avatar">
<img height="32" width="32" src={`data:image/svg+xml;base64,${plugin.iconFile.data}`} />
</span>
</div>
<div className="col">
<div className="font-weight-medium text-capitalize">{plugin.name}</div>
<div>{plugin.description}</div>
</div>
</div>
<div className="mt-4">
<div className="row">
<div className="col">
<sub>
v{plugin.version}{' '}
{isUpdateAvailable && (
<span
className={cx('link-span', { disabled: updating })}
onClick={() => updatePlugin(plugin, marketplacePlugin.version)}
>
<small className="font-weight-light">
(click to update to v{marketplacePlugin.version})
</small>
</span>
)}
</sub>
</div>
<div className="col-auto">
<div
className={cx('cursor-pointer link-primary', { disabled: updating })}
onClick={() => deletePlugin(plugin)}
>
Remove
</div>
</div>
</div>
</div>
</div>
{updating && (
<div className="progress progress-sm">
<div className="progress-bar progress-bar-indeterminate"></div>
</div>
<div key={plugin.id} className="col-sm-6 col-lg-4">
<div className="card card-sm card-borderless">
<div className="card-body">
<div className="row align-items-center">
<div className="col-auto">
<span className="text-white avatar">
<img height="32" width="32" src={`data:image/svg+xml;base64,${plugin.iconFile.data}`} />
</span>
</div>
<div className="col">
<div className="font-weight-medium text-capitalize">{plugin.name}</div>
<div>{plugin.description}</div>
</div>
<div className="col-2">
{isDevMode && (
<button
disabled={updating}
onClick={(e) => {
e.preventDefault();
reloadPlugin(plugin);
}}
className="btn btn-icon"
aria-label="Button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon icon-tabler icon-tabler-refresh"
width="24"
height="24"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4"></path>
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"></path>
</svg>
</button>
)}
</div>
</div>
<div className="mt-4">
<div className="row">
<div className="col">
<sub>
v{plugin.version}{' '}
{isUpdateAvailable && (
<span
className={cx('link-span', { disabled: updating })}
onClick={() => updatePlugin(plugin, marketplacePlugin?.version)}
>
<small className="font-weight-light">(click to update to v{marketplacePlugin?.version})</small>
</span>
)}
</sub>
</div>
<div className="col-auto">
<div
className={cx('cursor-pointer link-primary', { disabled: updating })}
onClick={() => deletePlugin(plugin)}
>
Remove
</div>
</div>
);
})}
{!fetching && plugins?.length === 0 && (
<div className="empty">
<p className="empty-title">No results found</p>
</div>
)}
</div>
</div>
)}
{updating && (
<div className="progress progress-sm">
<div className="progress-bar progress-bar-indeterminate"></div>
</div>
)}
</div>
</div>
);
};
InstalledPlugins.Plugin = InstalledPluginCard;

View file

@ -1,19 +1,10 @@
import React from 'react';
import { toast } from 'react-hot-toast';
import { MarketplaceCard } from './MarketplaceCard';
import { marketplaceService, pluginsService } from '@/_services';
import { pluginsService } from '@/_services';
export const MarketplacePlugins = ({ isActive }) => {
const [plugins, setPlugins] = React.useState([]);
export const MarketplacePlugins = ({ allPlugins = [] }) => {
const [installedPlugins, setInstalledPlugins] = React.useState({});
React.useEffect(() => {
marketplaceService
.findAll()
.then(({ data = [] }) => setPlugins(data))
.catch((error) => {
toast.error(error?.message || 'something went wrong');
});
}, [isActive]);
React.useEffect(() => {
pluginsService
@ -28,12 +19,16 @@ export const MarketplacePlugins = ({ isActive }) => {
.catch((error) => {
toast.error(error?.message || 'something went wrong');
});
return () => {
setInstalledPlugins({});
};
}, []);
return (
<div className="col-9">
<div className="row row-cards">
{plugins?.map(({ id, name, repo, version, description }) => {
{allPlugins?.map(({ id, name, repo, version, description }) => {
return (
<MarketplaceCard
key={id}

View file

@ -3,9 +3,46 @@ import Layout from '@/_ui/Layout';
import { ListGroupItem } from './ListGroupItem';
import { InstalledPlugins } from './InstalledPlugins';
import { MarketplacePlugins } from './MarketplacePlugins';
import { marketplaceService, pluginsService } from '@/_services';
import { toast } from 'react-hot-toast';
import config from 'config';
const MarketplacePage = ({ darkMode, switchDarkMode }) => {
const [active, setActive] = React.useState('installed');
const [marketplacePlugins, setMarketplacePlugins] = React.useState([]);
const [installedPlugins, setInstalledPlugins] = React.useState([]);
const [fetchingInstalledPlugins, setFetching] = React.useState(false);
const ENABLE_MARKETPLACE_DEV_MODE = config.ENABLE_MARKETPLACE_DEV_MODE === 'true';
React.useEffect(() => {
marketplaceService
.findAll()
.then(({ data = [] }) => setMarketplacePlugins(data))
.catch((error) => {
toast.error(error?.message || 'something went wrong');
});
fetchPlugins();
() => {
setMarketplacePlugins([]);
setInstalledPlugins([]);
};
}, [active]);
const fetchPlugins = async () => {
setFetching(true);
const { data, error } = await pluginsService.findAll();
setFetching(false);
if (error) {
toast.error(error?.message || 'something went wrong');
return;
}
setInstalledPlugins(data);
};
return (
<Layout switchDarkMode={switchDarkMode} darkMode={darkMode}>
@ -30,9 +67,15 @@ const MarketplacePage = ({ darkMode, switchDarkMode }) => {
</div>
</div>
{active === 'installed' ? (
<InstalledPlugins isActive={active === 'installed'} darkMode={darkMode} />
<InstalledPlugins
allPlugins={marketplacePlugins}
installedPlugins={installedPlugins}
fetching={fetchingInstalledPlugins}
fetchPlugins={fetchPlugins}
ENABLE_MARKETPLACE_DEV_MODE={ENABLE_MARKETPLACE_DEV_MODE}
/>
) : (
<MarketplacePlugins isActive={active === 'marketplace'} darkMode={darkMode} />
<MarketplacePlugins allPlugins={marketplacePlugins} />
)}
</div>
</div>

View file

@ -40,29 +40,30 @@ const DynamicForm = ({
React.useEffect(() => {
const { properties } = schema;
if (isEmpty(properties)) return null;
if (!isEmpty(properties)) {
let fields = {};
let encrpytedFieldsProps = {};
const flipComponentDropdown = find(properties, ['type', 'dropdown-component-flip']);
let fields = {};
let encrpytedFieldsProps = {};
const flipComponentDropdown = find(properties, ['type', 'dropdown-component-flip']);
if (flipComponentDropdown) {
const selector = options?.[flipComponentDropdown?.key]?.value;
fields = { ...flipComponentDropdown?.commonFields, ...properties[selector] };
} else {
fields = { ...properties };
}
if (flipComponentDropdown) {
const selector = options?.[flipComponentDropdown?.key]?.value;
fields = { ...flipComponentDropdown?.commonFields, ...properties[selector] };
} else {
fields = { ...properties };
Object.keys(fields).map((key) => {
const { type, encrypted } = fields[key];
if ((type === 'password' || encrypted) && !(key in computedProps)) {
//Editable encrypted fields only if datasource doesn't exists
encrpytedFieldsProps[key] = {
disabled: !!selectedDataSource?.id,
};
}
});
setComputedProps({ ...computedProps, ...encrpytedFieldsProps });
}
Object.keys(fields).map((key) => {
const { type, encrypted } = fields[key];
if ((type === 'password' || encrypted) && !(key in computedProps)) {
//Editable encrypted fields only if datasource doesn't exists
encrpytedFieldsProps[key] = {
disabled: !!selectedDataSource?.id,
};
}
});
setComputedProps({ ...computedProps, ...encrpytedFieldsProps });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options]);

View file

@ -18,9 +18,14 @@ function deletePlugin(id) {
return adapter.delete(`/plugins/${id}`);
}
function reloadPlugin(id) {
return adapter.post(`/plugins/${id}/reload`);
}
export const pluginsService = {
findAll,
installPlugin,
updatePlugin,
deletePlugin,
reloadPlugin,
};

View file

@ -3,6 +3,7 @@ const webpack = require('webpack');
const path = require('path');
const CompressionPlugin = require('compression-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
require('dotenv').config({ path: '../.env' });
const hash = require('string-hash');
const environment = process.env.NODE_ENV === 'production' ? 'production' : 'development';
@ -169,6 +170,7 @@ module.exports = {
COMMENT_FEATURE_ENABLE: process.env.COMMENT_FEATURE_ENABLE ?? true,
ENABLE_TOOLJET_DB: process.env.ENABLE_TOOLJET_DB ?? true,
ENABLE_MULTIPLAYER_EDITING: true,
ENABLE_MARKETPLACE_DEV_MODE: process.env.ENABLE_MARKETPLACE_DEV_MODE,
TOOLJET_MARKETPLACE_URL:
process.env.TOOLJET_MARKETPLACE_URL || 'https://tooljet-plugins-production.s3.us-east-2.amazonaws.com',
}),

View file

@ -1,16 +0,0 @@
---
to: <%= docs_path %>/docs/data-sources/<%= name %>.md
---
<%
Display_name = h.capitalize(display_name)
%>
# <%= name %>
ToolJet can connect to <%= Display_name %> datasource to read and write data.
- [Connection](#connection)
- [Getting Started](#querying-<%= name %>)
## Connection
## Querying <%= Display_name %> operations

View file

@ -15,7 +15,8 @@ to: <%= plugins_path %>/plugins/<%= name %>/package.json
],
"scripts": {
"test": "echo \"Error: run tests from root\" && exit 1",
"build": "ncc build lib/index.ts -o dist"
"build": "ncc build lib/index.ts -o dist",
"watch": "ncc build lib/index.ts -o dist --watch"
},
"homepage": "https://github.com/tooljet/tooljet#readme",
"dependencies": {

5
marketplace/lerna.json Normal file
View file

@ -0,0 +1,5 @@
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"useWorkspaces": true,
"version": "0.0.0"
}

View file

@ -7,7 +7,13 @@
"version": "1.0.0",
"devDependencies": {
"aws-sdk": "^2.1326.0",
"lerna": "^6.4.1",
"mime-types": "^2.1.35",
"recursive-readdir": "^2.2.3"
},
"scripts": {
"start:watch": "lerna run watch --stream --parallel",
"build": "npm run build --workspaces",
"start:dev": "npm run build && npm run start:watch"
}
}

View file

@ -8,15 +8,9 @@ export default class Github implements QueryService {
async run(sourceOptions: SourceOptions, queryOptions: QueryOptions, dataSourceId: string): Promise<QueryResult> {
const operation: Operation = queryOptions.operation;
console.log('---octakit operation', {
queryOptions
});
const octokit:Octokit = await this.getConnection(sourceOptions);
let result = {};
try {
switch (operation) {
case Operation.GetUserInfo:

View file

@ -12,7 +12,8 @@
],
"scripts": {
"test": "echo \"Error: run tests from root\" && exit 1",
"build": "ncc build lib/index.ts -o dist"
"build": "ncc build lib/index.ts -o dist",
"watch": "ncc build lib/index.ts -o dist --watch"
},
"homepage": "https://github.com/tooljet/tooljet#readme",
"dependencies": {

View file

@ -12,7 +12,8 @@
],
"scripts": {
"test": "echo \"Error: run tests from root\" && exit 1",
"build": "ncc build lib/index.ts -o dist"
"build": "ncc build lib/index.ts -o dist",
"watch": "ncc build lib/index.ts -o dist --watch"
},
"homepage": "https://github.com/tooljet/tooljet#readme",
"dependencies": {

View file

@ -12,7 +12,8 @@
],
"scripts": {
"test": "echo \"Error: run tests from root\" && exit 1",
"build": "ncc build lib/index.ts -o dist"
"build": "ncc build lib/index.ts -o dist",
"watch": "ncc build lib/index.ts -o dist --watch"
},
"homepage": "https://github.com/tooljet/tooljet#readme",
"dependencies": {

View file

@ -77,4 +77,10 @@ export class PluginsController {
return this.pluginsService.remove(id);
}
@Post(':id/reload')
@UseGuards(JwtAuthGuard)
async reload(@Param('id') id: string) {
return this.pluginsService.reload(id);
}
}

View file

@ -24,10 +24,13 @@ export class PluginsHelper {
}
async getService(pluginId: string, kind: string) {
const isMarketPlaceDev = process.env.ENABLE_MARKETPLACE_DEV_MODE === 'true';
try {
if (pluginId) {
let decoded: string;
if (this.plugins[pluginId]) {
if (!isMarketPlaceDev && this.plugins[pluginId]) {
decoded = this.plugins[pluginId];
} else {
const plugin = await this.pluginsRepository.findOne({ where: { id: pluginId }, relations: ['indexFile'] });

View file

@ -14,6 +14,7 @@ import { dbTransactionWrap } from 'src/helpers/utils.helper';
import { UpdateFileDto } from '@dto/update-file.dto';
const jszipInstance = new jszip();
const fs = require('fs');
@Injectable()
export class PluginsService {
@ -194,23 +195,39 @@ export class PluginsService {
return [indexFile, operationsFile, iconFile, manifestFile];
}
const fs = require('fs/promises');
async function readFile(filePath) {
return new Promise((resolve, reject) => {
const readStream = fs.createReadStream(filePath, { encoding: 'utf8' });
let fileContent = '';
readStream.on('data', (chunk) => {
fileContent += chunk;
});
readStream.on('error', (err) => {
reject(err);
});
readStream.on('end', () => {
resolve(fileContent);
});
});
}
// NOTE: big files are going to have a major impact on the memory consumption and speed of execution of the program
// as `fs.readFile` read the full content of the file in memory before returning the data.
// In this case, a better option is to read the file content using streams.
const [indexFile, operationsFile, iconFile, manifestFile] = await Promise.all([
fs.readFile(`../marketplace/plugins/${id}/dist/index.js`, 'utf8'),
fs.readFile(`../marketplace/plugins/${id}/lib/operations.json`, 'utf8'),
fs.readFile(`../marketplace/plugins/${id}/lib/icon.svg`, 'utf8'),
fs.readFile(`../marketplace/plugins/${id}/lib/manifest.json`, 'utf8'),
readFile(`../marketplace/plugins/${id}/dist/index.js`),
readFile(`../marketplace/plugins/${id}/lib/operations.json`),
readFile(`../marketplace/plugins/${id}/lib/icon.svg`),
readFile(`../marketplace/plugins/${id}/lib/manifest.json`),
]);
return [indexFile, operationsFile, iconFile, manifestFile];
}
fetchPluginFiles(id: string, repo: string) {
if (repo) return this.fetchPluginFilesFromRepo(repo);
if (repo && repo.length > 0) {
return this.fetchPluginFilesFromRepo(repo);
}
return this.fetchPluginFilesFromS3(id);
}
@ -230,4 +247,47 @@ export class PluginsService {
async remove(id: string) {
return await this.pluginsRepository.delete(id);
}
async reload(id: string) {
const queryRunner = this.connection.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const plugin = await this.findOne(id);
const { pluginId, repo, version } = plugin;
const [index, operations, icon, manifest] = await this.fetchPluginFiles(pluginId, repo);
const files = { index, operations, icon, manifest };
const uploadedFiles: { index?: File; operations?: File; icon?: File; manifest?: File } = {};
await Promise.all(
Object.keys(files).map(async (key) => {
return await dbTransactionWrap(async (manager: EntityManager) => {
const file = files[key];
const fileDto = new UpdateFileDto();
fileDto.data = encode(file);
fileDto.filename = key;
uploadedFiles[key] = await this.filesService.update(plugin[`${key}FileId`], fileDto, manager);
});
})
);
const updatedPlugin = new Plugin();
updatedPlugin.id = plugin.id;
updatedPlugin.repo = repo || '';
updatedPlugin.version = version;
return this.pluginsRepository.save(updatedPlugin);
} catch (error) {
await queryRunner.rollbackTransaction();
throw new InternalServerErrorException(error);
} finally {
await queryRunner.commitTransaction();
await queryRunner.release();
}
}
}