Browse Source

feat: send email notification on form submission

re #608

Signed-off-by: Pranav C <pranavxc@gmail.com>
pull/626/head
Pranav C 3 years ago
parent
commit
d09352888f
  1. 1
      packages/nc-gui/components/project/spreadsheet/rowsXcDataTable.vue
  2. 44
      packages/nc-gui/components/project/spreadsheet/views/formView.vue
  3. 12
      packages/nc-gui/plugins/ncApis/restApi.js
  4. 3
      packages/nocodb/src/lib/noco/NcProjectBuilder.ts
  5. 35
      packages/nocodb/src/lib/noco/common/BaseApiBuilder.ts
  6. 35
      packages/nocodb/src/lib/noco/common/BaseModel.ts
  7. 219
      packages/nocodb/src/lib/noco/common/formSubmissionEmailTemplate.ts
  8. 1
      packages/nocodb/src/lib/noco/gql/GqlApiBuilder.ts
  9. 1
      packages/nocodb/src/lib/noco/rest/RestApiBuilder.ts

1
packages/nc-gui/components/project/spreadsheet/rowsXcDataTable.vue

@ -296,6 +296,7 @@
:available-columns="availableColumns" :available-columns="availableColumns"
:meta="meta" :meta="meta"
:data="data" :data="data"
:id="selectedViewId"
:show-fields.sync="showFields" :show-fields.sync="showFields"
:all-columns="allColumns" :all-columns="allColumns"
:field-list="fieldList" :field-list="fieldList"

44
packages/nc-gui/components/project/spreadsheet/views/formView.vue

@ -376,13 +376,21 @@
<span class="font-weight-bold grey--text caption">Show a blank form after 5 seconds</span> <span class="font-weight-bold grey--text caption">Show a blank form after 5 seconds</span>
</template> </template>
</v-switch> </v-switch>
<!-- <v-switch v-model="localParams.submit.emailMe" dense inset hide-details> <v-switch
v-if="localParams.emailMe"
v-model="localParams.emailMe[$store.state.users.user.email]"
dense
inset
hide-details
class="nc-switch"
@change="checkSMTPStatus"
>
<template #label> <template #label>
<span class="caption font-weight-bold grey--text ">Email me at <span class="font-eright-bold">{{ <span class="caption font-weight-bold grey--text ">Email me at <span class="font-eright-bold">{{
$store.state.users.user.email $store.state.users.user.email
}}</span></span> }}</span></span>
</template> </template>
</v-switch>--> </v-switch>
</div> </div>
</div> </div>
</div> </div>
@ -405,11 +413,14 @@ import Editable from '../components/editable'
import EditColumn from '../components/editColumn' import EditColumn from '../components/editColumn'
import form from '../mixins/form' import form from '../mixins/form'
// todo: generate hideCols based on default values
const hiddenCols = ['created_at', 'updated_at']
export default { export default {
name: 'FormView', name: 'FormView',
components: { EditColumn, Editable, EditableCell, VirtualCell, HeaderCell, VirtualHeaderCell, draggable }, components: { EditColumn, Editable, EditableCell, VirtualCell, HeaderCell, VirtualHeaderCell, draggable },
mixins: [form, validationMixin], mixins: [form, validationMixin],
props: ['meta', 'availableColumns', 'nodes', 'sqlUi', 'formParams', 'showFields', 'fieldsOrder', 'allColumns', 'dbAlias', 'api'], props: ['meta', 'availableColumns', 'nodes', 'sqlUi', 'formParams', 'showFields', 'fieldsOrder', 'allColumns', 'dbAlias', 'api', 'id'],
data: () => ({ data: () => ({
localState: {}, localState: {},
moved: false, moved: false,
@ -427,6 +438,9 @@ export default {
validations() { validations() {
const obj = { localState: {}, virtual: {} } const obj = { localState: {}, virtual: {} }
for (const column of this.columns) { for (const column of this.columns) {
if (!this.localParams || !this.localParams.fields || !this.localParams.fields[column.alias]) {
continue
}
if (!column.virtual && ((column.rqd && !column.default) || this.localParams.fields[column.alias].required)) { if (!column.virtual && ((column.rqd && !column.default) || this.localParams.fields[column.alias].required)) {
obj.localState[column._cn] = { required } obj.localState[column._cn] = { required }
} else if (column.bt) { } else if (column.bt) {
@ -455,12 +469,12 @@ export default {
}, },
hiddenColumns: { hiddenColumns: {
get() { get() {
return this.allColumns.filter(c => !this.showFields[c.alias] && !(c.pk && c.ai) && !(this.meta.v || []).some(v => v.bt && v.bt.cn === c.cn)) return this.allColumns.filter(c => !this.showFields[c.alias] && !hiddenCols.includes(c.cn) && !(c.pk && c.ai) && !(this.meta.v || []).some(v => v.bt && v.bt.cn === c.cn))
} }
}, },
columns: { columns: {
get() { get() {
return this.allColumns.filter(c => this.showFields[c.alias]).sort((a, b) => ((this.fieldsOrder.indexOf(a.alias) + 1) || Infinity) - ((this.fieldsOrder.indexOf(b.alias) + 1) || Infinity)) return this.allColumns.filter(c => this.showFields[c.alias] && !hiddenCols.includes(c.cn)).sort((a, b) => ((this.fieldsOrder.indexOf(a.alias) + 1) || Infinity) - ((this.fieldsOrder.indexOf(b.alias) + 1) || Infinity))
}, },
set(val) { set(val) {
const showFields = val.reduce((o, v) => { const showFields = val.reduce((o, v) => {
@ -499,6 +513,7 @@ export default {
name: this.meta._tn, name: this.meta._tn,
description: 'Form view description', description: 'Form view description',
submit: {}, submit: {},
emailMe: {},
fields: {} fields: {}
}, this.localParams) }, this.localParams)
this.availableColumns.forEach((c) => { this.availableColumns.forEach((c) => {
@ -509,6 +524,15 @@ export default {
// this.hiddenColumns = this.meta.columns.filter(c => this.availableColumns.find(c1 => c.cn === c1.cn && c._cn === c1._cn)) // this.hiddenColumns = this.meta.columns.filter(c => this.availableColumns.find(c1 => c.cn === c1.cn && c._cn === c1._cn))
}, },
methods: { methods: {
async checkSMTPStatus() {
if (this.localParams.emailMe[this.$store.state.users.user.email]) {
const emailPlugin = await this.$store.dispatch('sqlMgr/ActSqlOp', [null, 'xcPluginRead', { title: 'SMTP' }])
if (!emailPlugin.active) {
this.$set(this.localParams.emailMe, this.$store.state.users.user.email, false)
this.$toast.info('Please activate SMTP plugin in App store for enabling email notification').goAway(5000)
}
}
},
updateCol(_, column, id) { updateCol(_, column, id) {
this.$set(this.localState, column, id) this.$set(this.localState, column, id)
}, },
@ -546,20 +570,22 @@ export default {
// }, {}) // }, {})
// if (this.isNew) { // if (this.isNew) {
// const data =
const data = await this.api.insert(this.localState) // todo: add params option in GraphQL
this.localState = { ...this.localState, ...data } let data = await this.api.insert(this.localState, { params: { form: this.id } })
data = { ...this.localState, ...data }
// save hasmany and manytomany relations from local state // save hasmany and manytomany relations from local state
if (this.$refs.virtual && Array.isArray(this.$refs.virtual)) { if (this.$refs.virtual && Array.isArray(this.$refs.virtual)) {
for (const vcell of this.$refs.virtual) { for (const vcell of this.$refs.virtual) {
if (vcell.save) { if (vcell.save) {
await vcell.save(this.localState) await vcell.save(data)
} }
} }
} }
this.virtual = {} this.virtual = {}
this.localState = {}
this.submitted = true this.submitted = true

12
packages/nc-gui/plugins/ncApis/restApi.js

@ -57,7 +57,10 @@ export default class RestApi {
/// api/v1/Film/m2mNotChildren/film_actor/44 /// api/v1/Film/m2mNotChildren/film_actor/44
// const list = await this.list(params); // const list = await this.list(params);
// const count = (await this.count({where: params.where || ''})).count; // const count = (await this.count({where: params.where || ''})).count;
const { list, info: { count } } = (await this.get(`/nc/${this.$ctx.projectId}/api/v1/${this.table}/m2mNotChildren/${assoc}/${pid}`, params)).data const {
list,
info: { count }
} = (await this.get(`/nc/${this.$ctx.projectId}/api/v1/${this.table}/m2mNotChildren/${assoc}/${pid}`, params)).data
return { list, count } return { list, count }
} }
@ -79,11 +82,14 @@ export default class RestApi {
return res.data return res.data
} }
async insert(data) { async insert(data, {
params = {}
} = {}) {
return (await this.$axios({ return (await this.$axios({
method: 'post', method: 'post',
url: `/nc/${this.$ctx.projectId}/api/v1/${this.table}`, url: `/nc/${this.$ctx.projectId}/api/v1/${this.table}`,
data data,
params
})).data })).data
} }

3
packages/nocodb/src/lib/noco/NcProjectBuilder.ts

@ -150,6 +150,9 @@ export default class NcProjectBuilder {
case 'xcVirtualTableUpdate': case 'xcVirtualTableUpdate':
await curBuilder.onVirtualTableUpdate(data.req.args); await curBuilder.onVirtualTableUpdate(data.req.args);
break; break;
case 'xcVirtualTableCreate':
await curBuilder.loadFormViews();
break;
case 'tableCreate': case 'tableCreate':

35
packages/nocodb/src/lib/noco/common/BaseApiBuilder.ts

@ -107,7 +107,7 @@ export default abstract class BaseApiBuilder<T extends Noco> implements XcDynami
public readonly app: T; public readonly app: T;
public hooks: { public hooks: {
[key: string]: { [tableName: string]: {
[key: string]: Array<{ [key: string]: Array<{
event: string; event: string;
url: string; url: string;
@ -115,6 +115,11 @@ export default abstract class BaseApiBuilder<T extends Noco> implements XcDynami
}> }>
} }
} }
public formViews: {
[tableName: string]: any
}
protected tablesCount = 0; protected tablesCount = 0;
protected relationsCount = 0; protected relationsCount = 0;
protected viewsCount = 0; protected viewsCount = 0;
@ -151,6 +156,7 @@ export default abstract class BaseApiBuilder<T extends Noco> implements XcDynami
this.acls = {}; this.acls = {};
this.procedureOrFunctionAcls = {}; this.procedureOrFunctionAcls = {};
this.hooks = {}; this.hooks = {};
this.formViews = {};
this.projectBuilder = projectBuilder; this.projectBuilder = projectBuilder;
} }
@ -305,6 +311,7 @@ export default abstract class BaseApiBuilder<T extends Noco> implements XcDynami
}, {'parent_model_title': oldTableName, type: 'vtable'}) }, {'parent_model_title': oldTableName, type: 'vtable'})
await this.loadHooks(); await this.loadHooks();
await this.loadFormViews();
await this.modifyTableNameInACL(oldTableName, newTableName); await this.modifyTableNameInACL(oldTableName, newTableName);
} }
@ -1221,6 +1228,29 @@ export default abstract class BaseApiBuilder<T extends Noco> implements XcDynami
} }
} }
// NOTE: xc-meta
public async loadFormViews(): Promise<void> {
this.baseLog(`loadFormViews :`);
this.formViews = {};
const formViewList = await this.xcMeta.metaList(this.projectId, this.dbAlias, 'nc_models', {
condition: {
show_as: 'form'
}
});
for (const formView of formViewList) {
if (!(formView.parent_model_title in this.formViews)) {
this.formViews[formView.parent_model_title] = {};
}
try {
formView.query_params = formView.query_params && JSON.parse(formView.query_params);
} catch (e) {
}
this.formViews[formView.parent_model_title][formView.id] = formView;
}
}
protected async generateAndSaveAcl(name: string, type = 'table'): Promise<void> { protected async generateAndSaveAcl(name: string, type = 'table'): Promise<void> {
this.baseLog(`generateAndSaveAcl : '%s' %s`, name, type); this.baseLog(`generateAndSaveAcl : '%s' %s`, name, type);
@ -1929,6 +1959,9 @@ export default abstract class BaseApiBuilder<T extends Noco> implements XcDynami
XcCache.del([this.projectId, this.dbAlias, 'table', args.tn].join('::')); XcCache.del([this.projectId, this.dbAlias, 'table', args.tn].join('::'));
// todo: update meta and model // todo: update meta and model
} }
if (args?.query_params?.extraViewParams?.formParams) {
this.formViews[args.tn][args.id].query_params = args.query_params
}
} }
} }

35
packages/nocodb/src/lib/noco/common/BaseModel.ts

@ -1,11 +1,12 @@
import Handlebars from "handlebars"; import Handlebars from "handlebars";
import {IWebhookNotificationAdapter} from "nc-plugin"; import {IWebhookNotificationAdapter} from "nc-plugin";
import ejs from "ejs";
import IEmailAdapter from "../../../interface/IEmailAdapter"; import IEmailAdapter from "../../../interface/IEmailAdapter";
import {BaseModelSql} from "../../dataMapper"; import {BaseModelSql} from "../../dataMapper";
// import axios from "axios"; // import axios from "axios";
import BaseApiBuilder from "./BaseApiBuilder"; import BaseApiBuilder from "./BaseApiBuilder";
import formSubmissionEmailTemplate from "./formSubmissionEmailTemplate";
Handlebars.registerHelper('json', function (context) { Handlebars.registerHelper('json', function (context) {
return JSON.stringify(context); return JSON.stringify(context);
@ -84,6 +85,38 @@ class BaseModel<T extends BaseApiBuilder<any>> extends BaseModelSql {
// const data = _data; // const data = _data;
// handle form view data submission
if (hookName === 'after.insert' && req?.query?.form && this.builder?.formViews?.[this.tn]?.[req.query.form]) {
const formView = this.builder?.formViews?.[this.tn]?.[req.query.form];
const emails = Object.entries(formView?.query_params?.extraViewParams?.formParams?.emailMe || {}).filter(a => a[1]).map(a => a[0])
if (emails?.length) {
const transformedData = data;
for (const col of this.columns) {
if (col.uidt === 'Attachment') {
if (typeof transformedData[col._cn] === 'string') {
transformedData[col._cn] = JSON.parse(transformedData[col._cn]);
}
transformedData[col._cn] = (transformedData[col._cn] || []).map((attachment) => {
if (['jpeg', 'gif', 'png', 'apng', 'svg', 'bmp', 'ico', 'jpg'].includes(attachment.title.split('.').pop())) {
return `<a href="${attachment.url}" target="_blank"><img height="50px" src="${attachment.url}"/></a>`
}
return `<a href="${attachment.url}" target="_blank">${attachment.title}</a>`
}).join('&nbsp;');
}
}
// todo: notification template
this.emailAdapter?.mailSend({
to: emails.join(','),
subject: this.parseBody('NocoDB Form', req, data, {}),
html: ejs.render(formSubmissionEmailTemplate, {
data: transformedData,
tn: this.tn,
_tn: this._tn
})
})
}
}
try { try {
if (this.tn in this.builder.hooks if (this.tn in this.builder.hooks
&& hookName in this.builder.hooks[this.tn] && hookName in this.builder.hooks[this.tn]

219
packages/nocodb/src/lib/noco/common/formSubmissionEmailTemplate.ts

@ -0,0 +1,219 @@
export default `<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>NocoDB forms: someone has responded to Form</title>
<style>
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
}
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
.btn-primary table td:hover {
background-color: #34495e !important;
}
.btn-primary a:hover {
background-color: #34495e !important;
border-color: #34495e !important;
}
}
</style>
</head>
<body class=""
style="background-color: #f6f6f6; font-family: sans-serif; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<span class="preheader"
style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">NocoDB forms: someone has responded to Form</span>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #f6f6f6; width: 100%;"
width="100%" bgcolor="#f6f6f6">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" valign="top">&nbsp;</td>
<td class="container"
style="font-family: sans-serif; font-size: 14px; vertical-align: top; display: block; max-width: 580px; padding: 10px; width: 580px; margin: 0 auto;"
width="580" valign="top">
<div class="content"
style="box-sizing: border-box; display: block; margin: 0 auto; max-width: 580px; padding: 10px;">
<!-- START CENTERED WHITE CONTAINER -->
<table role="presentation" class="main"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border-radius: 3px; width: 100%;"
width="100%">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper"
style="font-family: sans-serif; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 20px;"
valign="top">
<table role="presentation" border="0" cellpadding="0" cellspacing="0"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;"
width="100%">
<tr>
<td style="font-family: sans-serif; font-size: 14px;padding: 20px 5px 10px 5px; " colspan="2">
<a href="https://www.nocodb.com/" target="_blank">
<img src="https://www.nocodb.com/brand/x1.png" alt="NocoDB" width="70" style=" vertical-align: middle"> <h2 style="margin-left: 10px;display: inline-block">NocoDB</h2>
</a>
</td>
</tr>
<tr>
<td style="font-family: sans-serif; font-size: 14px;line-height: 20px; vertical-align: top;padding: 10px 5px 40px 5px; " colspan="2">
Someone has responded to Form. A record has been added to the subscriptions table in <span style="font-weight: bold"><%- _tn %></span>.
</td>
</tr>
<% Object.keys(data).forEach(function(prop) { %>
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;padding: 20px 5px 20px 5px;text-transform: capitalize "
valign="top">
<%- prop %>
</td>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;padding: 20px 5px 20px 5px;"
valign="top">
<%- data[prop] %>
</td>
</tr>
<% }); %>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- END CENTERED WHITE CONTAINER -->
<!-- START FOOTER -->
<div class="footer" style="clear: both; margin-top: 10px; text-align: center; width: 100%;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;"
width="100%">
<tr>
<td class="content-block"
style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; color: #999999; font-size: 12px; text-align: center;"
valign="top" align="center">
<span class="apple-link"
style="color: #999999; font-size: 12px; text-align: center;"></span>
<!-- <br> Don't like these emails? <a href="http://i.imgur.com/CScmqnj.gif">Unsubscribe</a>.-->
</td>
</tr>
<tr>
<td class="content-block powered-by"
style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; color: #999999; font-size: 12px; text-align: center;"
valign="top" align="center">
<a href="http://nocodb.com/">NocoDB</a>
<!-- Powered by <a href="http://htmlemail.io">HTMLemail</a>.-->
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
</div>
</td>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" valign="top">&nbsp;</td>
</tr>
</table>
</body>
</html>
`
/**
* @copyright Copyright (c) 2021, Xgene Cloud Ltd
*
* @author Naveen MR <oof1lab@gmail.com>
* @author Pranav C Balan <pranavxc@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

1
packages/nocodb/src/lib/noco/gql/GqlApiBuilder.ts

@ -213,6 +213,7 @@ export class GqlApiBuilder extends BaseApiBuilder<Noco> implements XcMetaMgr {
} }
await this.loadHooks(); await this.loadHooks();
await this.loadFormViews();
await this.initGraphqlRoute(); await this.initGraphqlRoute();
await super.loadCommon(); await super.loadCommon();

1
packages/nocodb/src/lib/noco/rest/RestApiBuilder.ts

@ -79,6 +79,7 @@ export class RestApiBuilder extends BaseApiBuilder<Noco> {
} }
await this.loadHooks(); await this.loadHooks();
await this.loadFormViews();
await super.loadCommon(); await super.loadCommon();
const t1 = process.hrtime(t); const t1 = process.hrtime(t);

Loading…
Cancel
Save