Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions cmd/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,34 @@ func (a *App) GetTemplate(c echo.Context) error {

// GetTemplates handles retrieval of templates.
func (a *App) GetTemplates(c echo.Context) error {
// If no_body is true, blank out the body of the template from the response.
noBody, _ := strconv.ParseBool(c.QueryParam("no_body"))

// Fetch templates from the DB.
out, err := a.core.GetTemplates("", noBody)
var (
pg = a.pg.NewFromURL(c.Request().URL.Query())
query = strings.TrimSpace(c.FormValue("query"))
typ = c.FormValue("type")
orderBy = c.FormValue("order_by")
order = c.FormValue("order")
noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
)

// Query and retrieve templates from the DB.
res, total, err := a.core.QueryTemplates(query, typ, orderBy, order, noBody, pg.Offset, pg.Limit)
if err != nil {
return err
}

// Paginate the response.
if len(res) == 0 {
return c.JSON(http.StatusOK, okResp{models.PageResults{Results: []models.Template{}}})
}

out := models.PageResults{
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breaks backwards-compatibility by changing the current API response structure.

Query: query,
Results: res,
Total: total,
Page: pg.Page,
PerPage: pg.PerPage,
}

return c.JSON(http.StatusOK, okResp{out})
}

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -387,9 +387,9 @@ export const createTemplate = async (data) => http.post(
{ loading: models.templates },
);

export const getTemplates = async () => http.get(
export const getTemplates = async (params) => http.get(
'/api/templates',
{ loading: models.templates, store: models.templates },
{ params, loading: models.templates, store: models.templates },
);

export const getTemplate = async (id) => http.get(
Expand Down
72 changes: 61 additions & 11 deletions frontend/src/views/Templates.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<div class="column is-10">
<h1 class="title is-4">
{{ $t('globals.terms.templates') }}
<span v-if="templates.length > 0">({{ templates.length }})</span>
<span v-if="!isNaN(templates.total)">({{ templates.total }})</span>
</h1>
</div>
<div class="column has-text-right">
Expand All @@ -16,8 +16,29 @@
</div>
</header>

<b-table :data="templates" :hoverable="true" :loading="loading.templates" default-sort="createdAt">
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')" :td-attrs="$utils.tdID" sortable>
<b-table :data="templates.results" :hoverable="true" :loading="loading.templates" paginated backend-pagination
pagination-position="both" @page-change="onPageChange" :current-page="queryParams.page"
:per-page="templates.perPage" :total="templates.total" backend-sorting @sort="onSort">
<template #top-left>
<div class="columns">
<div class="column is-6">
<form @submit.prevent="getTemplates">
<div>
<b-field>
<b-input v-model="queryParams.query" name="query" expanded
:placeholder="$t('templates.queryPlaceholder')" icon="magnify" ref="query" />
<p class="controls">
<b-button native-type="submit" type="is-primary" icon-left="magnify" />
</p>
</b-field>
</div>
</form>
</div>
</div>
</template>

<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')" :td-attrs="$utils.tdID" sortable
header-class="cy-name">
<a href="#" @click.prevent="showEditForm(props.row)">
{{ props.row.name }}
</a>
Expand All @@ -30,7 +51,7 @@
</p>
</b-table-column>

<b-table-column v-slot="props" field="type" :label="$t('globals.fields.type')" sortable>
<b-table-column v-slot="props" field="type" :label="$t('globals.fields.type')" sortable header-class="cy-type">
<b-tag v-if="props.row.type === 'campaign'" :class="props.row.type" :data-cy="`type-${props.row.type}`">
{{ $tc('templates.typeCampaignHTML') }}
</b-tag>
Expand All @@ -47,11 +68,13 @@
{{ props.row.id }}
</b-table-column>

<b-table-column v-slot="props" field="createdAt" :label="$t('globals.fields.createdAt')" sortable>
<b-table-column v-slot="props" field="created_at" :label="$t('globals.fields.createdAt')" sortable
header-class="cy-created-at">
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>

<b-table-column v-slot="props" field="updatedAt" :label="$t('globals.fields.updatedAt')" sortable>
<b-table-column v-slot="props" field="updated_at" :label="$t('globals.fields.updatedAt')" sortable
header-class="cy-updated-at">
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>

Expand Down Expand Up @@ -135,6 +158,12 @@ export default Vue.extend({
isEditing: false,
isFormVisible: false,
previewItem: null,
queryParams: {
page: 1,
query: '',
orderBy: 'created_at',
order: 'desc',
},
};
},

Expand All @@ -154,7 +183,28 @@ export default Vue.extend({
},

formFinished() {
this.$api.getTemplates();
this.getTemplates();
},

onPageChange(p) {
this.queryParams.page = p;
this.getTemplates();
},

onSort(field, direction) {
this.queryParams.orderBy = field;
this.queryParams.order = direction;
this.getTemplates();
},

getTemplates() {
this.$api.getTemplates({
page: this.queryParams.page,
query: this.queryParams.query.replace(/[^\p{L}\p{N}\s]/gu, ' '),
order_by: this.queryParams.orderBy,
order: this.queryParams.order,
no_body: true,
});
},

previewTemplate(c) {
Expand All @@ -174,22 +224,22 @@ export default Vue.extend({
body_source: t.bodySource,
};
this.$api.createTemplate(data).then((d) => {
this.$api.getTemplates();
this.getTemplates();
this.$emit('finished');
this.$utils.toast(`'${d.name}' created`);
});
},

makeTemplateDefault(tpl) {
this.$api.makeTemplateDefault(tpl.id).then(() => {
this.$api.getTemplates();
this.getTemplates();
this.$utils.toast(this.$t('globals.messages.created', { name: tpl.name }));
});
},

deleteTemplate(tpl) {
this.$api.deleteTemplate(tpl.id).then(() => {
this.$api.getTemplates();
this.getTemplates();
this.$utils.toast(this.$t('globals.messages.deleted', { name: tpl.name }));
});
},
Expand All @@ -200,7 +250,7 @@ export default Vue.extend({
},

mounted() {
this.$api.getTemplates();
this.getTemplates();
},
});
</script>
1 change: 1 addition & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,7 @@
"templates.newTemplate": "New template",
"templates.placeholderHelp": "The placeholder {placeholder} should appear exactly once in the template.",
"templates.preview": "Preview",
"templates.queryPlaceholder": "Name",
"templates.rawHTML": "Raw HTML",
"templates.subject": "Subject",
"templates.typeCampaignHTML": "Campaign / HTML",
Expand Down
11 changes: 6 additions & 5 deletions internal/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,12 @@ var (
)

var (
regexFullTextQuery = regexp.MustCompile(`\s+`)
regexpSpaces = regexp.MustCompile(`[\s]+`)
campQuerySortFields = []string{"name", "status", "created_at", "updated_at"}
subQuerySortFields = []string{"email", "status", "name", "created_at", "updated_at"}
listQuerySortFields = []string{"name", "status", "created_at", "updated_at", "subscriber_count"}
regexFullTextQuery = regexp.MustCompile(`\s+`)
regexpSpaces = regexp.MustCompile(`[\s]+`)
campQuerySortFields = []string{"name", "status", "created_at", "updated_at"}
subQuerySortFields = []string{"email", "status", "name", "created_at", "updated_at"}
listQuerySortFields = []string{"name", "status", "created_at", "updated_at", "subscriber_count"}
templateQuerySortFields = []string{"name", "type", "created_at", "updated_at"}
)

// New returns a new instance of the core.
Expand Down
20 changes: 20 additions & 0 deletions internal/core/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,26 @@ func (c *Core) GetTemplates(status string, noBody bool) ([]models.Template, erro
return out, nil
}

// QueryTemplates retrieves paginated templates optionally filtering them by the given
// search string. It also returns the total number of records in the DB.
func (c *Core) QueryTemplates(searchStr, typ, orderBy, order string, noBody bool, offset, limit int) (models.Templates, int, error) {
queryStr, stmt := makeSearchQuery(searchStr, orderBy, order, c.q.QueryTemplates, templateQuerySortFields)

var out models.Templates
if err := c.db.Select(&out, stmt, noBody, typ, queryStr, offset, limit); err != nil {
c.log.Printf("error fetching templates: %v", err)
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.templates}", "error", pqErrMsg(err)))
}

total := 0
if len(out) > 0 {
total = out[0].Total
}

return out, total, nil
}

// GetTemplate retrieves a given template.
func (c *Core) GetTemplate(id int, noBody bool) (models.Template, error) {
var out []models.Template
Expand Down
1 change: 1 addition & 0 deletions models/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ type Queries struct {

CreateTemplate *sqlx.Stmt `query:"create-template"`
GetTemplates *sqlx.Stmt `query:"get-templates"`
QueryTemplates string `query:"query-templates"`
UpdateTemplate *sqlx.Stmt `query:"update-template"`
SetDefaultTemplate *sqlx.Stmt `query:"set-default-template"`
DeleteTemplate *sqlx.Stmt `query:"delete-template"`
Expand Down
6 changes: 6 additions & 0 deletions models/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ const (
TemplateTypeTx = "tx"
)

// Templates represents a slice of Template.
type Templates []Template

// Template represents a reusable e-mail template.
type Template struct {
Base
Expand All @@ -33,6 +36,9 @@ type Template struct {
// Only relevant to tx (transactional) templates.
SubjectTpl *txttpl.Template `json:"-"`
Tpl *template.Template `json:"-"`

// Pseudofield for getting the total number of templates in paginated queries.
Total int `db:"total" json:"-"`
}

// Compile compiles a template body and subject (only for tx templates) and
Expand Down
17 changes: 17 additions & 0 deletions queries/templates.sql
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ SELECT id, name, type, subject,
FROM templates WHERE ($1 = 0 OR id = $1) AND ($3 = '' OR type = $3::template_type)
ORDER BY created_at;

-- name: query-templates
-- Retrieves templates with pagination, search, and sorting.
-- $1: noBody - if true, blank out body and body_source
-- $2: type filter (empty string = all types)
-- $3: search query (for name matching)
-- $4: offset
-- $5: limit
SELECT COUNT(*) OVER () AS total, id, name, type, subject,
(CASE WHEN $1 = false THEN body ELSE '' END) as body,
(CASE WHEN $1 = false THEN body_source ELSE NULL END) as body_source,
is_default, created_at, updated_at
FROM templates
WHERE ($2 = '' OR type = $2::template_type)
AND ($3 = '' OR name ILIKE $3)
ORDER BY %order%
OFFSET $4 LIMIT (CASE WHEN $5 < 1 THEN NULL ELSE $5 END);

-- name: create-template
INSERT INTO templates (name, type, subject, body, body_source) VALUES($1, $2, $3, $4, $5) RETURNING id;

Expand Down