Add serverside sort to tables.

Lists, campaigns, and subscribers tables now support server-side
sorting from the UI. This significantly changes the internal
queries from prepared to string interpolated to support dynamic
sort params.
This commit is contained in:
Kailash Nadh 2020-10-24 20:00:29 +05:30
parent a0b36bb01b
commit 1aecd6f2e1
12 changed files with 174 additions and 48 deletions

View File

@ -63,6 +63,8 @@ type campsWrap struct {
var (
regexFromAddress = regexp.MustCompile(`(.+?)\s<(.+?)@(.+?)>`)
regexFullTextQuery = regexp.MustCompile(`\s+`)
campaignQuerySortFields = []string{"name", "status", "created_at", "updated_at"}
)
// handleGetCampaigns handles retrieval of campaigns.
@ -75,11 +77,13 @@ func handleGetCampaigns(c echo.Context) error {
id, _ = strconv.Atoi(c.Param("id"))
status = c.QueryParams()["status"]
query = strings.TrimSpace(c.FormValue("query"))
orderBy = c.FormValue("order_by")
order = c.FormValue("order")
noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
single = false
)
// Fetch one list.
single := false
if id > 0 {
single = true
}
@ -88,8 +92,18 @@ func handleGetCampaigns(c echo.Context) error {
string(regexFullTextQuery.ReplaceAll([]byte(query), []byte("&"))) + `%`
}
err := app.queries.QueryCampaigns.Select(&out.Results, id, pq.StringArray(status), query, pg.Offset, pg.Limit)
if err != nil {
// Sort params.
if !strSliceContains(orderBy, campaignQuerySortFields) {
orderBy = "created_at"
}
if order != sortAsc && order != sortDesc {
order = sortDesc
}
stmt := fmt.Sprintf(app.queries.QueryCampaigns, orderBy, order)
// Unsafe to ignore scanning fields not present in models.Campaigns.
if err := db.Select(&out.Results, stmt, id, pq.StringArray(status), query, pg.Offset, pg.Limit); err != nil {
app.log.Printf("error fetching campaigns: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching campaigns: %s", pqErrMsg(err)))

View File

@ -14,6 +14,9 @@ import (
const (
// stdInputMaxLen is the maximum allowed length for a standard input field.
stdInputMaxLen = 200
sortAsc = "asc"
sortDesc = "desc"
)
type okResp struct {

View File

@ -197,6 +197,7 @@ func initQueries(sqlFile string, db *sqlx.DB, fs stuffbin.FileSystem, prepareQue
if err := goyesqlx.ScanToStruct(&q, qMap, db.Unsafe()); err != nil {
lo.Fatalf("error preparing SQL queries: %v", err)
}
return qMap, &q
}

View File

@ -20,6 +20,10 @@ type listsWrap struct {
Page int `json:"page"`
}
var (
listQuerySortFields = []string{"name", "type", "subscriber_count", "created_at", "updated_at"}
)
// handleGetLists handles retrieval of lists.
func handleGetLists(c echo.Context) error {
var (
@ -27,6 +31,8 @@ func handleGetLists(c echo.Context) error {
out listsWrap
pg = getPagination(c.QueryParams(), 20, 50)
orderBy = c.FormValue("order_by")
order = c.FormValue("order")
listID, _ = strconv.Atoi(c.Param("id"))
single = false
)
@ -36,8 +42,15 @@ func handleGetLists(c echo.Context) error {
single = true
}
err := app.queries.GetLists.Select(&out.Results, listID, pg.Offset, pg.Limit)
if err != nil {
// Sort params.
if !strSliceContains(orderBy, listQuerySortFields) {
orderBy = "created_at"
}
if order != sortAsc && order != sortDesc {
order = sortAsc
}
if err := db.Select(&out.Results, fmt.Sprintf(app.queries.GetLists, orderBy, order), listID, pg.Offset, pg.Limit); err != nil {
app.log.Printf("error fetching lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching lists: %s", pqErrMsg(err)))

View File

@ -42,14 +42,14 @@ type Queries struct {
UnsubscribeSubscribersFromListsByQuery string `query:"unsubscribe-subscribers-from-lists-by-query"`
CreateList *sqlx.Stmt `query:"create-list"`
GetLists *sqlx.Stmt `query:"get-lists"`
GetLists string `query:"get-lists"`
GetListsByOptin *sqlx.Stmt `query:"get-lists-by-optin"`
UpdateList *sqlx.Stmt `query:"update-list"`
UpdateListsDate *sqlx.Stmt `query:"update-lists-date"`
DeleteLists *sqlx.Stmt `query:"delete-lists"`
CreateCampaign *sqlx.Stmt `query:"create-campaign"`
QueryCampaigns *sqlx.Stmt `query:"query-campaigns"`
QueryCampaigns string `query:"query-campaigns"`
GetCampaign *sqlx.Stmt `query:"get-campaign"`
GetCampaignForPreview *sqlx.Stmt `query:"get-campaign-for-preview"`
GetCampaignStats *sqlx.Stmt `query:"get-campaign-stats"`

View File

@ -58,11 +58,15 @@ type subOptin struct {
Lists []models.List
}
var dummySubscriber = models.Subscriber{
Email: "dummy@listmonk.app",
Name: "Dummy Subscriber",
UUID: dummyUUID,
}
var (
dummySubscriber = models.Subscriber{
Email: "dummy@listmonk.app",
Name: "Dummy Subscriber",
UUID: dummyUUID,
}
subQuerySortFields = []string{"email", "name", "created_at", "updated_at"}
)
// handleGetSubscriber handles the retrieval of a single subscriber by ID.
func handleGetSubscriber(c echo.Context) error {
@ -89,8 +93,10 @@ func handleQuerySubscribers(c echo.Context) error {
listID, _ = strconv.Atoi(c.FormValue("list_id"))
// The "WHERE ?" bit.
query = sanitizeSQLExp(c.FormValue("query"))
out subsWrap
query = sanitizeSQLExp(c.FormValue("query"))
orderBy = c.FormValue("order_by")
order = c.FormValue("order")
out subsWrap
)
listIDs := pq.Int64Array{}
@ -100,17 +106,21 @@ func handleQuerySubscribers(c echo.Context) error {
listIDs = append(listIDs, int64(listID))
}
// There's an arbitrary query condition from the frontend.
var (
cond = ""
ordBy = "updated_at"
ord = "DESC"
)
// There's an arbitrary query condition.
cond := ""
if query != "" {
cond = " AND " + query
}
stmt := fmt.Sprintf(app.queries.QuerySubscribers, cond, ordBy, ord)
// Sort params.
if !strSliceContains(orderBy, subQuerySortFields) {
orderBy = "updated_at"
}
if order != sortAsc && order != sortDesc {
order = sortAsc
}
stmt := fmt.Sprintf(app.queries.QuerySubscribers, cond, orderBy, order)
// Create a readonly transaction to prevent mutations.
tx, err := app.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})

View File

@ -132,3 +132,14 @@ func generateRandomString(n int) (string, error) {
func strHasLen(str string, min, max int) bool {
return len(str) >= min && len(str) <= max
}
// strSliceContains checks if a string is present in the string slice.
func strSliceContains(str string, sl []string) bool {
for _, s := range sl {
if s == str {
return true
}
}
return false
}

View File

@ -0,0 +1,37 @@
<template>
<section class="log-view">
<b-loading :active="loading" :is-full-page="false" />
<pre class="lines" ref="lines">
<template v-for="(l, i) in lines"><span v-html="formatLine(l)" :key="i" class="line"></span>
</template></pre>
</section>
</template>
<script>
const reFormatLine = new RegExp(/^(.*) (.+?)\.go:[0-9]+:\s/g);
export default {
name: 'LogView',
props: {
loading: Boolean,
lines: {
type: Array,
default: () => [],
},
},
methods: {
formatLine: (l) => l.replace(reFormatLine, '<span class="stamp">$1</span> '),
},
watch: {
lines() {
this.$nextTick(() => {
this.$refs.lines.scrollTop = this.$refs.lines.scrollHeight;
});
},
},
};
</script>

View File

@ -26,10 +26,10 @@
:row-class="highlightedRow"
paginated backend-pagination pagination-position="both" @page-change="onPageChange"
:current-page="queryParams.page" :per-page="campaigns.perPage" :total="campaigns.total"
hoverable>
hoverable backend-sorting @sort="onSort">
<template slot-scope="props">
<b-table-column class="status" field="status" label="Status"
width="10%" :id="props.row.id">
width="10%" :id="props.row.id" sortable>
<div>
<p>
<router-link :to="{ name: 'campaign', params: { 'id': props.row.id }}">
@ -74,7 +74,7 @@
</li>
</ul>
</b-table-column>
<b-table-column field="updatedAt" label="Timestamps" width="19%" sortable>
<b-table-column field="created_at" label="Timestamps" width="19%" sortable>
<div class="fields timestamps" :set="stats = getCampaignStats(props.row)">
<p>
<label>Created</label>
@ -96,7 +96,7 @@
</div>
</b-table-column>
<b-table-column :class="props.row.status" label="Stats" width="18%">
<b-table-column field="stats" :class="props.row.status" label="Stats" width="18%">
<div class="fields stats" :set="stats = getCampaignStats(props.row)">
<p>
<label>Views</label>
@ -215,6 +215,8 @@ export default Vue.extend({
queryParams: {
page: 1,
query: '',
orderBy: 'created_at',
order: 'desc',
},
pollID: null,
campaignStatsData: {},
@ -264,6 +266,12 @@ export default Vue.extend({
this.getCampaigns();
},
onSort(field, direction) {
this.queryParams.orderBy = field;
this.queryParams.order = direction;
this.getCampaigns();
},
// Campaign actions.
previewCampaign(c) {
this.previewItem = c;
@ -277,6 +285,8 @@ export default Vue.extend({
this.$api.getCampaigns({
page: this.queryParams.page,
query: this.queryParams.query,
order_by: this.queryParams.orderBy,
order: this.queryParams.order,
});
},

View File

@ -17,6 +17,7 @@
hoverable default-sort="createdAt"
paginated backend-pagination pagination-position="both" @page-change="onPageChange"
:current-page="queryParams.page" :per-page="lists.perPage" :total="lists.total"
backend-sorting @sort="onSort"
>
<template slot-scope="props">
<b-table-column field="name" label="Name" sortable width="25%"
@ -51,16 +52,16 @@
</div>
</b-table-column>
<b-table-column field="subscriberCount" label="Subscribers" numeric sortable centered>
<b-table-column field="subscriber_count" label="Subscribers" numeric sortable centered>
<router-link :to="`/subscribers/lists/${props.row.id}`">
{{ props.row.subscriberCount }}
</router-link>
</b-table-column>
<b-table-column field="createdAt" label="Created" sortable>
<b-table-column field="created_at" label="Created" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column field="updatedAt" label="Updated" sortable>
<b-table-column field="updated_at" label="Updated" sortable>
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
@ -115,7 +116,11 @@ export default Vue.extend({
curItem: null,
isEditing: false,
isFormVisible: false,
queryParams: { page: 1 },
queryParams: {
page: 1,
orderBy: 'created_at',
order: 'asc',
},
};
},
@ -125,6 +130,13 @@ export default Vue.extend({
this.getLists();
},
onSort(field, direction) {
this.queryParams.orderBy = field;
this.queryParams.order = direction;
this.getLists();
},
// Show the edit list form.
showEditForm(list) {
this.curItem = list;
@ -144,7 +156,11 @@ export default Vue.extend({
},
getLists() {
this.$api.getLists({ page: this.queryParams.page });
this.$api.getLists({
page: this.queryParams.page,
order_by: this.queryParams.orderBy,
order: this.queryParams.order,
});
},
deleteList(list) {

View File

@ -94,17 +94,16 @@
:checked-rows.sync="bulk.checked"
paginated backend-pagination pagination-position="both" @page-change="onPageChange"
:current-page="queryParams.page" :per-page="subscribers.perPage" :total="subscribers.total"
hoverable
checkable>
hoverable checkable backend-sorting @sort="onSort">
<template slot-scope="props">
<b-table-column field="status" label="Status">
<b-table-column field="status" label="Status" sortable>
<a :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)">
<b-tag :class="props.row.status">{{ props.row.status }}</b-tag>
</a>
</b-table-column>
<b-table-column field="email" label="E-mail">
<b-table-column field="email" label="E-mail" sortable>
<a :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)">
{{ props.row.email }}
@ -119,7 +118,7 @@
</b-taglist>
</b-table-column>
<b-table-column field="name" label="Name">
<b-table-column field="name" label="Name" sortable>
<a :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)">
{{ props.row.name }}
@ -130,11 +129,11 @@
{{ listCount(props.row.lists) }}
</b-table-column>
<b-table-column field="createdAt" label="Created">
<b-table-column field="created_at" label="Created" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column field="updatedAt" label="Updated">
<b-table-column field="updated_at" label="Updated" sortable>
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
@ -217,6 +216,8 @@ export default Vue.extend({
// ID of the list the current subscriber view is filtered by.
listID: null,
page: 1,
orderBy: 'updated_at',
order: 'desc',
},
};
},
@ -279,15 +280,17 @@ export default Vue.extend({
this.isBulkListFormVisible = true;
},
sortSubscribers(field, order, event) {
console.log(field, order, event);
},
onPageChange(p) {
this.queryParams.page = p;
this.querySubscribers();
},
onSort(field, direction) {
this.queryParams.orderBy = field;
this.queryParams.order = direction;
this.querySubscribers();
},
// Prepares an SQL expression for simple name search inputs and saves it
// in this.queryExp.
onSimpleQueryInput(v) {
@ -308,6 +311,8 @@ export default Vue.extend({
list_id: this.queryParams.listID,
query: this.queryParams.queryExp,
page: this.queryParams.page,
order_by: this.queryParams.orderBy,
order: this.queryParams.order,
}).then(() => {
this.bulk.checked = [];
});

View File

@ -1,3 +1,4 @@
-- subscribers
-- name: get-subscriber
-- Get a single subscriber by id or UUID.
@ -297,7 +298,7 @@ SELECT COUNT(*) OVER () AS total, lists.*, COUNT(subscriber_lists.subscriber_id)
FROM lists LEFT JOIN subscriber_lists
ON (subscriber_lists.list_id = lists.id AND subscriber_lists.status != 'unsubscribed')
WHERE ($1 = 0 OR id = $1)
GROUP BY lists.id ORDER BY lists.created_at OFFSET $2 LIMIT (CASE WHEN $3 = 0 THEN NULL ELSE $3 END);
GROUP BY lists.id ORDER BY %s %s OFFSET $2 LIMIT (CASE WHEN $3 = 0 THEN NULL ELSE $3 END);
-- name: get-lists-by-optin
-- Can have a list of IDs or a list of UUIDs.
@ -370,18 +371,23 @@ INSERT INTO campaign_lists (campaign_id, list_id, list_name)
-- there's a COUNT() OVER() that still returns the total result count
-- for pagination in the frontend, albeit being a field that'll repeat
-- with every resultant row.
SELECT COUNT(*) OVER () AS total, campaigns.*, (
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(l)), '[]') FROM (
SELECT COALESCE(campaign_lists.list_id, 0) AS id,
campaign_lists.list_name AS name
FROM campaign_lists WHERE campaign_lists.campaign_id = campaigns.id
SELECT campaigns.id, campaigns.uuid, campaigns.name, campaigns.subject, campaigns.from_email,
campaigns.messenger, campaigns.started_at, campaigns.to_send, campaigns.sent, campaigns.type,
campaigns.body, campaigns.send_at, campaigns.status, campaigns.content_type, campaigns.tags,
campaigns.template_id, campaigns.created_at, campaigns.updated_at,
COUNT(*) OVER () AS total,
(
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(l)), '[]') FROM (
SELECT COALESCE(campaign_lists.list_id, 0) AS id,
campaign_lists.list_name AS name
FROM campaign_lists WHERE campaign_lists.campaign_id = campaigns.id
) l
) AS lists
FROM campaigns
WHERE ($1 = 0 OR id = $1)
AND status=ANY(CASE WHEN ARRAY_LENGTH($2::campaign_status[], 1) != 0 THEN $2::campaign_status[] ELSE ARRAY[status] END)
AND ($3 = '' OR CONCAT(name, subject) ILIKE $3)
ORDER BY campaigns.updated_at DESC OFFSET $4 LIMIT $5;
ORDER BY %s %s OFFSET $4 LIMIT $5;
-- name: get-campaign
SELECT campaigns.*,