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

View file

@ -14,6 +14,9 @@ import (
const ( const (
// stdInputMaxLen is the maximum allowed length for a standard input field. // stdInputMaxLen is the maximum allowed length for a standard input field.
stdInputMaxLen = 200 stdInputMaxLen = 200
sortAsc = "asc"
sortDesc = "desc"
) )
type okResp struct { 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 { if err := goyesqlx.ScanToStruct(&q, qMap, db.Unsafe()); err != nil {
lo.Fatalf("error preparing SQL queries: %v", err) lo.Fatalf("error preparing SQL queries: %v", err)
} }
return qMap, &q return qMap, &q
} }

View file

@ -20,6 +20,10 @@ type listsWrap struct {
Page int `json:"page"` Page int `json:"page"`
} }
var (
listQuerySortFields = []string{"name", "type", "subscriber_count", "created_at", "updated_at"}
)
// handleGetLists handles retrieval of lists. // handleGetLists handles retrieval of lists.
func handleGetLists(c echo.Context) error { func handleGetLists(c echo.Context) error {
var ( var (
@ -27,6 +31,8 @@ func handleGetLists(c echo.Context) error {
out listsWrap out listsWrap
pg = getPagination(c.QueryParams(), 20, 50) pg = getPagination(c.QueryParams(), 20, 50)
orderBy = c.FormValue("order_by")
order = c.FormValue("order")
listID, _ = strconv.Atoi(c.Param("id")) listID, _ = strconv.Atoi(c.Param("id"))
single = false single = false
) )
@ -36,8 +42,15 @@ func handleGetLists(c echo.Context) error {
single = true single = true
} }
err := app.queries.GetLists.Select(&out.Results, listID, pg.Offset, pg.Limit) // Sort params.
if err != nil { 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) app.log.Printf("error fetching lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching lists: %s", pqErrMsg(err))) 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"` UnsubscribeSubscribersFromListsByQuery string `query:"unsubscribe-subscribers-from-lists-by-query"`
CreateList *sqlx.Stmt `query:"create-list"` 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"` GetListsByOptin *sqlx.Stmt `query:"get-lists-by-optin"`
UpdateList *sqlx.Stmt `query:"update-list"` UpdateList *sqlx.Stmt `query:"update-list"`
UpdateListsDate *sqlx.Stmt `query:"update-lists-date"` UpdateListsDate *sqlx.Stmt `query:"update-lists-date"`
DeleteLists *sqlx.Stmt `query:"delete-lists"` DeleteLists *sqlx.Stmt `query:"delete-lists"`
CreateCampaign *sqlx.Stmt `query:"create-campaign"` CreateCampaign *sqlx.Stmt `query:"create-campaign"`
QueryCampaigns *sqlx.Stmt `query:"query-campaigns"` QueryCampaigns string `query:"query-campaigns"`
GetCampaign *sqlx.Stmt `query:"get-campaign"` GetCampaign *sqlx.Stmt `query:"get-campaign"`
GetCampaignForPreview *sqlx.Stmt `query:"get-campaign-for-preview"` GetCampaignForPreview *sqlx.Stmt `query:"get-campaign-for-preview"`
GetCampaignStats *sqlx.Stmt `query:"get-campaign-stats"` GetCampaignStats *sqlx.Stmt `query:"get-campaign-stats"`

View file

@ -58,11 +58,15 @@ type subOptin struct {
Lists []models.List Lists []models.List
} }
var dummySubscriber = models.Subscriber{ var (
dummySubscriber = models.Subscriber{
Email: "dummy@listmonk.app", Email: "dummy@listmonk.app",
Name: "Dummy Subscriber", Name: "Dummy Subscriber",
UUID: dummyUUID, UUID: dummyUUID,
} }
subQuerySortFields = []string{"email", "name", "created_at", "updated_at"}
)
// handleGetSubscriber handles the retrieval of a single subscriber by ID. // handleGetSubscriber handles the retrieval of a single subscriber by ID.
func handleGetSubscriber(c echo.Context) error { func handleGetSubscriber(c echo.Context) error {
@ -90,6 +94,8 @@ func handleQuerySubscribers(c echo.Context) error {
// The "WHERE ?" bit. // The "WHERE ?" bit.
query = sanitizeSQLExp(c.FormValue("query")) query = sanitizeSQLExp(c.FormValue("query"))
orderBy = c.FormValue("order_by")
order = c.FormValue("order")
out subsWrap out subsWrap
) )
@ -100,17 +106,21 @@ func handleQuerySubscribers(c echo.Context) error {
listIDs = append(listIDs, int64(listID)) listIDs = append(listIDs, int64(listID))
} }
// There's an arbitrary query condition from the frontend. // There's an arbitrary query condition.
var ( cond := ""
cond = ""
ordBy = "updated_at"
ord = "DESC"
)
if query != "" { if query != "" {
cond = " AND " + 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. // Create a readonly transaction to prevent mutations.
tx, err := app.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true}) 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 { func strHasLen(str string, min, max int) bool {
return len(str) >= min && len(str) <= max 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" :row-class="highlightedRow"
paginated backend-pagination pagination-position="both" @page-change="onPageChange" paginated backend-pagination pagination-position="both" @page-change="onPageChange"
:current-page="queryParams.page" :per-page="campaigns.perPage" :total="campaigns.total" :current-page="queryParams.page" :per-page="campaigns.perPage" :total="campaigns.total"
hoverable> hoverable backend-sorting @sort="onSort">
<template slot-scope="props"> <template slot-scope="props">
<b-table-column class="status" field="status" label="Status" <b-table-column class="status" field="status" label="Status"
width="10%" :id="props.row.id"> width="10%" :id="props.row.id" sortable>
<div> <div>
<p> <p>
<router-link :to="{ name: 'campaign', params: { 'id': props.row.id }}"> <router-link :to="{ name: 'campaign', params: { 'id': props.row.id }}">
@ -74,7 +74,7 @@
</li> </li>
</ul> </ul>
</b-table-column> </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)"> <div class="fields timestamps" :set="stats = getCampaignStats(props.row)">
<p> <p>
<label>Created</label> <label>Created</label>
@ -96,7 +96,7 @@
</div> </div>
</b-table-column> </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)"> <div class="fields stats" :set="stats = getCampaignStats(props.row)">
<p> <p>
<label>Views</label> <label>Views</label>
@ -215,6 +215,8 @@ export default Vue.extend({
queryParams: { queryParams: {
page: 1, page: 1,
query: '', query: '',
orderBy: 'created_at',
order: 'desc',
}, },
pollID: null, pollID: null,
campaignStatsData: {}, campaignStatsData: {},
@ -264,6 +266,12 @@ export default Vue.extend({
this.getCampaigns(); this.getCampaigns();
}, },
onSort(field, direction) {
this.queryParams.orderBy = field;
this.queryParams.order = direction;
this.getCampaigns();
},
// Campaign actions. // Campaign actions.
previewCampaign(c) { previewCampaign(c) {
this.previewItem = c; this.previewItem = c;
@ -277,6 +285,8 @@ export default Vue.extend({
this.$api.getCampaigns({ this.$api.getCampaigns({
page: this.queryParams.page, page: this.queryParams.page,
query: this.queryParams.query, query: this.queryParams.query,
order_by: this.queryParams.orderBy,
order: this.queryParams.order,
}); });
}, },

View file

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

View file

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

View file

@ -1,3 +1,4 @@
-- subscribers -- subscribers
-- name: get-subscriber -- name: get-subscriber
-- Get a single subscriber by id or UUID. -- 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 FROM lists LEFT JOIN subscriber_lists
ON (subscriber_lists.list_id = lists.id AND subscriber_lists.status != 'unsubscribed') ON (subscriber_lists.list_id = lists.id AND subscriber_lists.status != 'unsubscribed')
WHERE ($1 = 0 OR id = $1) 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 -- name: get-lists-by-optin
-- Can have a list of IDs or a list of UUIDs. -- Can have a list of IDs or a list of UUIDs.
@ -370,7 +371,12 @@ INSERT INTO campaign_lists (campaign_id, list_id, list_name)
-- there's a COUNT() OVER() that still returns the total result count -- there's a COUNT() OVER() that still returns the total result count
-- for pagination in the frontend, albeit being a field that'll repeat -- for pagination in the frontend, albeit being a field that'll repeat
-- with every resultant row. -- with every resultant row.
SELECT COUNT(*) OVER () AS total, campaigns.*, ( 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(ARRAY_TO_JSON(ARRAY_AGG(l)), '[]') FROM (
SELECT COALESCE(campaign_lists.list_id, 0) AS id, SELECT COALESCE(campaign_lists.list_id, 0) AS id,
campaign_lists.list_name AS name campaign_lists.list_name AS name
@ -381,7 +387,7 @@ FROM campaigns
WHERE ($1 = 0 OR id = $1) 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 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) 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 -- name: get-campaign
SELECT campaigns.*, SELECT campaigns.*,