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:
parent
a0b36bb01b
commit
1aecd6f2e1
|
@ -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)))
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
17
cmd/lists.go
17
cmd/lists.go
|
@ -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)))
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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})
|
||||
|
|
11
cmd/utils.go
11
cmd/utils.go
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 = [];
|
||||
});
|
||||
|
|
20
queries.sql
20
queries.sql
|
@ -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.*,
|
||||
|
|
Loading…
Reference in New Issue