Skip to content

Commit 5477c16

Browse files
authored
Merge pull request #2373 from knadh/feat-visual-editor
This is a mega patch with a massive number of significant, complex changes integrating the visual, drag-and-drop block editor library, email-builder: https://github.com/usewaypoint/email-builder-js It adds a new template type (`visual`) to templates and integrates the editor UI in Admin -> Templates and Admin -> Campaigns UIs. There are changes to templates and campaign APIs, UI flows, and database table schemas. `email-builder` is written in TypeScript and React (eww) and is a massive lib, (2.3MB minified+gzipped), bigger than all of listmonk's own JS and other deps combined, but alas, there is no other viable editor and it has been a popular demand for a long time. So, `email-builder` is built separately, and the static assets are copied along with listmonk's assets. Since it's basically a separate "app" altogether, it's loaded as an iframe on the editor pages, outside of the Vue app. The visual editor integration is courtesy of @vividvilla who did all the R&D and trial and error and sent the original PR to make this possible. I really loathe the Javascript ecosystem. Sigh.
2 parents 5a0980e + ed700d7 commit 5477c16

127 files changed

Lines changed: 8747 additions & 744 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,16 @@ frontend/node_modules/
22
frontend/.cache/
33
frontend/yarn.lock
44
frontend/build/
5+
frontend/public/static/email-builder/
6+
frontend/dist/
7+
email-builder/node_modules/
8+
email-builder/.cache/
9+
email-builder/yarn.lock
10+
email-builder/dist/
511
.vscode/
612

713
config.toml
814
node_modules
915
listmonk
10-
dist/*
16+
dist/*
17+
uploads/

Makefile

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,26 @@ GOPATH ?= $(HOME)/go
1111
STUFFBIN ?= $(GOPATH)/bin/stuffbin
1212
FRONTEND_YARN_MODULES = frontend/node_modules
1313
FRONTEND_DIST = frontend/dist
14+
FRONTEND_EMAIL_BUILDER_DIST_FINAL = frontend/public/static/email-builder
1415
FRONTEND_DEPS = \
1516
$(FRONTEND_YARN_MODULES) \
17+
$(FRONTEND_EMAIL_BUILDER_DIST_FINAL) \
1618
frontend/index.html \
1719
frontend/package.json \
1820
frontend/vite.config.js \
1921
frontend/.eslintrc.js \
2022
$(shell find frontend/fontello frontend/public frontend/src -type f)
2123

24+
FRONTEND_EMAIL_BUILDER = frontend/email-builder
25+
FRONTEND_EMAIL_BUILDER_YARN_MODULES = $(FRONTEND_EMAIL_BUILDER)/node_modules
26+
FRONTEND_EMAIL_BUILDER_DIST = $(FRONTEND_EMAIL_BUILDER)/dist
27+
FRONTEND_EMAIL_BUILDER_DEPS = \
28+
$(FRONTEND_EMAIL_BUILDER_YARN_MODULES) \
29+
$(FRONTEND_EMAIL_BUILDER)/package.json \
30+
$(FRONTEND_EMAIL_BUILDER)/tsconfig.json \
31+
$(FRONTEND_EMAIL_BUILDER)/vite.config.ts \
32+
$(shell find $(FRONTEND_EMAIL_BUILDER)/src -type f)
33+
2234
BIN := listmonk
2335
STATIC := config.toml.sample \
2436
schema.sql queries.sql permissions.json \
@@ -37,6 +49,10 @@ $(FRONTEND_YARN_MODULES): frontend/package.json frontend/yarn.lock
3749
cd frontend && $(YARN) install
3850
touch -c $(FRONTEND_YARN_MODULES)
3951

52+
$(FRONTEND_EMAIL_BUILDER_YARN_MODULES): frontend/package.json frontend/yarn.lock
53+
cd $(FRONTEND_EMAIL_BUILDER) && $(YARN) install
54+
touch -c $(FRONTEND_EMAIL_BUILDER_YARN_MODULES)
55+
4056
# Build the backend to ./listmonk.
4157
$(BIN): $(shell find . -type f -name "*.go") go.mod go.sum schema.sql queries.sql permissions.json
4258
CGO_ENABLED=0 go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}'" cmd/*.go
@@ -51,13 +67,26 @@ $(FRONTEND_DIST): $(FRONTEND_DEPS)
5167
export VUE_APP_VERSION="${VERSION}" && cd frontend && $(YARN) build
5268
touch -c $(FRONTEND_DIST)
5369

70+
# Build the JS email-builder dist.
71+
$(FRONTEND_EMAIL_BUILDER_DIST): $(FRONTEND_EMAIL_BUILDER_DEPS)
72+
export VUE_APP_VERSION="${VERSION}" && cd $(FRONTEND_EMAIL_BUILDER) && $(YARN) build
73+
touch -c $(FRONTEND_EMAIL_BUILDER_DIST)
74+
75+
# Copy the build assets to frontend.
76+
$(FRONTEND_EMAIL_BUILDER_DIST_FINAL): $(FRONTEND_EMAIL_BUILDER_DIST)
77+
mkdir -p $(FRONTEND_EMAIL_BUILDER_DIST_FINAL)
78+
cp -r $(FRONTEND_EMAIL_BUILDER_DIST)/* $(FRONTEND_EMAIL_BUILDER_DIST_FINAL)
79+
touch -c $(FRONTEND_EMAIL_BUILDER_DIST_FINAL)
5480

5581
.PHONY: build-frontend
56-
build-frontend: $(FRONTEND_DIST)
82+
build-frontend: $(FRONTEND_EMAIL_BUILDER_DIST_FINAL) $(FRONTEND_DIST)
83+
84+
.PHONY: build-email-builder
85+
build-email-builder: $(FRONTEND_EMAIL_BUILDER_DIST_FINAL)
5786

5887
# Run the JS frontend server in dev mode.
5988
.PHONY: run-frontend
60-
run-frontend:
89+
run-frontend: $(FRONTEND_EMAIL_BUILDER_DIST_FINAL)
6190
export VUE_APP_VERSION="${VERSION}" && cd frontend && $(YARN) dev
6291

6392
# Run Go tests.

cmd/campaigns.go

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ func (a *App) GetCampaigns(c echo.Context) error {
8888
if noBody {
8989
for i := range res {
9090
res[i].Body = ""
91+
res[i].BodySource.Valid = false
9192
}
9293
}
9394

@@ -142,17 +143,31 @@ func (a *App) PreviewCampaign(c echo.Context) error {
142143
return err
143144
}
144145

145-
// Fetch the campaign body from the DB.
146-
tplID, _ := strconv.Atoi(c.FormValue("template_id"))
146+
var (
147+
isPost = c.Request().Method == http.MethodPost
148+
contentType = c.FormValue("content_type")
149+
tplID, _ = strconv.Atoi(c.FormValue("template_id"))
150+
)
151+
// For visual content, template ID for previewing is irrelevant.
152+
if contentType == models.CampaignContentTypeVisual || tplID < 1 {
153+
tplID = 0
154+
}
155+
156+
// Get the campaign from the DB for previewing with the `template_body` field.
147157
camp, err := a.core.GetCampaignForPreview(id, tplID)
148158
if err != nil {
149159
return err
150160
}
151161

152162
// There's a body in the request to preview instead of the body in the DB.
153-
if c.Request().Method == http.MethodPost {
154-
camp.ContentType = c.FormValue("content_type")
163+
if isPost {
164+
camp.ContentType = contentType
155165
camp.Body = c.FormValue("body")
166+
167+
// For visual campaigns, template body from the DB shouldn't be used.
168+
if contentType == models.CampaignContentTypeVisual {
169+
camp.TemplateBody = ""
170+
}
156171
}
157172

158173
// Use a dummy campaign ID to prevent views and clicks from {{ TrackView }}
@@ -172,13 +187,52 @@ func (a *App) PreviewCampaign(c echo.Context) error {
172187
a.i18n.Ts("templates.errorRendering", "error", err.Error()))
173188
}
174189

190+
// Plaintext headers for plain body.
175191
if camp.ContentType == models.CampaignContentTypePlain {
176192
return c.String(http.StatusOK, string(msg.Body()))
177193
}
178194

179195
return c.HTML(http.StatusOK, string(msg.Body()))
180196
}
181197

198+
// PreviewCampaignArchive renders the public campaign archives page.
199+
func (a *App) PreviewCampaignArchive(c echo.Context) error {
200+
// Get the campaign ID.
201+
id := getID(c)
202+
203+
// Check if the user has access to the campaign.
204+
if err := a.checkCampaignPerm(auth.PermTypeGet, id, c); err != nil {
205+
return err
206+
}
207+
208+
// Fetch the campaign body from the DB.
209+
tplID, _ := strconv.Atoi(c.FormValue("template_id"))
210+
camp, err := a.core.GetCampaignForPreview(id, tplID)
211+
if err != nil {
212+
return err
213+
}
214+
215+
camp.ArchiveMeta = json.RawMessage([]byte(c.FormValue("archive_meta")))
216+
217+
// "Compile" the campaign template with appropriate data.
218+
res, err := a.compileArchiveCampaigns([]models.Campaign{camp})
219+
if err != nil {
220+
return c.Render(http.StatusInternalServerError, tplMessage,
221+
makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorFetchingCampaign")))
222+
}
223+
224+
// Render the campaign body.
225+
out := res[0].Campaign
226+
msg, err := a.manager.NewCampaignMessage(out, res[0].Subscriber)
227+
if err != nil {
228+
a.log.Printf("error rendering campaign: %v", err)
229+
return c.Render(http.StatusInternalServerError, tplMessage,
230+
makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorFetchingCampaign")))
231+
}
232+
233+
return c.HTML(http.StatusOK, string(msg.Body()))
234+
}
235+
182236
// CampaignContent handles campaign content (body) format conversions.
183237
func (a *App) CampaignContent(c echo.Context) error {
184238
var camp campContentReq
@@ -214,9 +268,6 @@ func (a *App) CreateCampaign(c echo.Context) error {
214268
o.Type = models.CampaignTypeRegular
215269
}
216270

217-
if o.ContentType == "" {
218-
o.ContentType = models.CampaignContentTypeRichtext
219-
}
220271
if o.Messenger == "" {
221272
o.Messenger = "email"
222273
}
@@ -228,7 +279,7 @@ func (a *App) CreateCampaign(c echo.Context) error {
228279
o = c
229280
}
230281

231-
if o.ArchiveTemplateID == 0 {
282+
if o.ArchiveTemplateID.Valid && o.ArchiveTemplateID.Int != 0 {
232283
o.ArchiveTemplateID = o.TemplateID
233284
}
234285

@@ -553,6 +604,16 @@ func (a *App) validateCampaignFields(c campReq) (campReq, error) {
553604
return c, errors.New(a.i18n.T("campaigns.fieldInvalidSubject"))
554605
}
555606

607+
// If no content-type is specified, default to richtext.
608+
if c.ContentType != models.CampaignContentTypeRichtext &&
609+
c.ContentType != models.CampaignContentTypeHTML &&
610+
c.ContentType != models.CampaignContentTypePlain &&
611+
c.ContentType != models.CampaignContentTypeVisual &&
612+
c.ContentType != models.CampaignContentTypeMarkdown {
613+
c.ContentType = models.CampaignContentTypeRichtext
614+
c.BodySource.Valid = false
615+
}
616+
556617
// If there's a "send_at" date, it should be in the future.
557618
if c.SendAt.Valid {
558619
if c.SendAt.Time.Before(time.Now()) {

cmd/handlers.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ func initHTTPHandlers(e *echo.Echo, a *App) {
150150
g.GET("/api/campaigns/:id", pm(hasID(a.GetCampaign), "campaigns:get_all", "campaigns:get"))
151151
g.GET("/api/campaigns/analytics/:type", pm(a.GetCampaignViewAnalytics, "campaigns:get_analytics"))
152152
g.GET("/api/campaigns/:id/preview", pm(hasID(a.PreviewCampaign), "campaigns:get_all", "campaigns:get"))
153+
g.POST("/api/campaigns/:id/preview/archive", pm(hasID(a.PreviewCampaignArchive), "campaigns:get_all", "campaigns:get"))
153154
g.POST("/api/campaigns/:id/preview", pm(hasID(a.PreviewCampaign), "campaigns:get_all", "campaigns:get"))
154155
g.POST("/api/campaigns/:id/content", pm(hasID(a.CampaignContent), "campaigns:manage_all", "campaigns:manage"))
155156
g.POST("/api/campaigns/:id/text", pm(hasID(a.PreviewCampaign), "campaigns:get"))

cmd/init.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ func initDB() *sqlx.DB {
316316
db.SetMaxIdleConns(c.MaxIdle)
317317
db.SetConnMaxLifetime(c.MaxLifetime)
318318

319-
return db
319+
return db.Unsafe()
320320
}
321321

322322
// readQueries reads named SQL queries from the SQL queries file into a query map.
@@ -358,7 +358,7 @@ func prepareQueries(qMap goyesql.Queries, db *sqlx.DB, ko *koanf.Koanf) *models.
358358

359359
// Scan and prepare all queries.
360360
var q models.Queries
361-
if err := goyesqlx.ScanToStruct(&q, qMap, db.Unsafe()); err != nil {
361+
if err := goyesqlx.ScanToStruct(&q, qMap, db); err != nil {
362362
lo.Fatalf("error preparing SQL queries: %v", err)
363363
}
364364

cmd/install.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ func installTemplates(q *models.Queries) (int, int) {
193193
}
194194

195195
var campTplID int
196-
if err := q.CreateTemplate.Get(&campTplID, "Default campaign template", models.TemplateTypeCampaign, "", campTpl.ReadBytes()); err != nil {
196+
if err := q.CreateTemplate.Get(&campTplID, "Default campaign template", models.TemplateTypeCampaign, "", campTpl.ReadBytes(), nil); err != nil {
197197
lo.Fatalf("error creating default campaign template: %v", err)
198198
}
199199
if _, err := q.SetDefaultTemplate.Exec(campTplID); err != nil {
@@ -207,7 +207,7 @@ func installTemplates(q *models.Queries) (int, int) {
207207
}
208208

209209
var archiveTplID int
210-
if err := q.CreateTemplate.Get(&archiveTplID, "Default archive template", models.TemplateTypeCampaign, "", archiveTpl.ReadBytes()); err != nil {
210+
if err := q.CreateTemplate.Get(&archiveTplID, "Default archive template", models.TemplateTypeCampaign, "", archiveTpl.ReadBytes(), nil); err != nil {
211211
lo.Fatalf("error creating default campaign template: %v", err)
212212
}
213213

@@ -217,10 +217,24 @@ func installTemplates(q *models.Queries) (int, int) {
217217
lo.Fatalf("error reading default e-mail template: %v", err)
218218
}
219219

220-
if _, err := q.CreateTemplate.Exec("Sample transactional template", models.TemplateTypeTx, "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil {
220+
if _, err := q.CreateTemplate.Exec("Sample transactional template", models.TemplateTypeTx, "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes(), nil); err != nil {
221221
lo.Fatalf("error creating sample transactional template: %v", err)
222222
}
223223

224+
// Sample visual campaign template.
225+
visualTpl, err := fs.Get("/static/email-templates/default-visual.tpl")
226+
if err != nil {
227+
lo.Fatalf("error reading default visual template: %v", err)
228+
}
229+
visualSrc, err := fs.Get("/static/email-templates/default-visual.json")
230+
if err != nil {
231+
lo.Fatalf("error reading default visual template json: %v", err)
232+
}
233+
234+
if _, err := q.CreateTemplate.Exec("Sample visual template", models.TemplateTypeCampaignVisual, "", visualTpl.ReadBytes(), visualSrc.ReadBytes()); err != nil {
235+
lo.Fatalf("error creating default campaign template: %v", err)
236+
}
237+
224238
return campTplID, archiveTplID
225239
}
226240

@@ -252,6 +266,7 @@ func installCampaign(campTplID, archiveTplID int, q *models.Queries) {
252266
archiveTplID,
253267
`{"name": "Subscriber"}`,
254268
nil,
269+
nil,
255270
); err != nil {
256271
lo.Fatalf("error creating sample campaign: %v", err)
257272
}

cmd/templates.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ func (a *App) CreateTemplate(c echo.Context) error {
117117
// Subject is only relevant for fixed tx templates. For campaigns,
118118
// the subject changes per campaign and is on models.Campaign.
119119
var funcs template.FuncMap
120-
if o.Type == models.TemplateTypeCampaign {
120+
if o.Type == models.TemplateTypeCampaign || o.Type == models.TemplateTypeCampaignVisual {
121121
o.Subject = ""
122122
funcs = a.manager.TemplateFuncs(nil)
123123
} else {
@@ -130,7 +130,7 @@ func (a *App) CreateTemplate(c echo.Context) error {
130130
}
131131

132132
// Create the template the in the DB.
133-
out, err := a.core.CreateTemplate(o.Name, o.Type, o.Subject, []byte(o.Body))
133+
out, err := a.core.CreateTemplate(o.Name, o.Type, o.Subject, []byte(o.Body), o.BodySource)
134134
if err != nil {
135135
return err
136136
}
@@ -171,7 +171,7 @@ func (a *App) UpdateTemplate(c echo.Context) error {
171171

172172
// Update the template in the DB.
173173
id := getID(c)
174-
out, err := a.core.UpdateTemplate(id, o.Name, o.Subject, []byte(o.Body))
174+
out, err := a.core.UpdateTemplate(id, o.Name, o.Subject, []byte(o.Body), o.BodySource)
175175
if err != nil {
176176
return err
177177
}
@@ -232,7 +232,7 @@ func (a *App) validateTemplate(o models.Template) error {
232232
// previewTemplate renders the HTML preview of a template.
233233
func (a *App) previewTemplate(tpl models.Template) ([]byte, error) {
234234
var out []byte
235-
if tpl.Type == models.TemplateTypeCampaign {
235+
if tpl.Type == models.TemplateTypeCampaign || tpl.Type == models.TemplateTypeCampaignVisual {
236236
camp := models.Campaign{
237237
UUID: dummyUUID,
238238
Name: a.i18n.T("templates.dummyName"),

cmd/upgrade.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ var migList = []migFunc{
4040
{"v3.0.0", migrations.V3_0_0},
4141
{"v4.0.0", migrations.V4_0_0},
4242
{"v4.1.0", migrations.V4_1_0},
43-
{"v5.0.0", migrations.V5_0_0},
43+
{"v5.0.0-rc.1", migrations.V5_0_0_rc1},
4444
}
4545

4646
// upgrade upgrades the database to the current version by running SQL migration files

frontend/.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,5 @@ module.exports = {
2828
comments: 200,
2929
}],
3030
},
31+
ignorePatterns: ['src/email-builder.js'],
3132
};

0 commit comments

Comments
 (0)