瀏覽代碼

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.
Kailash Nadh 4 年之前
父節點
當前提交
1aecd6f2e1
共有 12 個文件被更改,包括 174 次插入48 次删除
  1. 17 3
      cmd/campaigns.go
  2. 3 0
      cmd/handlers.go
  3. 1 0
      cmd/init.go
  4. 15 2
      cmd/lists.go
  5. 2 2
      cmd/queries.go
  6. 24 14
      cmd/subscribers.go
  7. 11 0
      cmd/utils.go
  8. 37 0
      frontend/src/components/LogView.vue
  9. 14 4
      frontend/src/views/Campaigns.vue
  10. 21 5
      frontend/src/views/Lists.vue
  11. 16 11
      frontend/src/views/Subscribers.vue
  12. 13 7
      queries.sql

+ 17 - 3
cmd/campaigns.go

@@ -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)))

+ 3 - 0
cmd/handlers.go

@@ -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 {

+ 1 - 0
cmd/init.go

@@ -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
 }
 

+ 15 - 2
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)))

+ 2 - 2
cmd/queries.go

@@ -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"`

+ 24 - 14
cmd/subscribers.go

@@ -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 - 0
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
+}

+ 37 - 0
frontend/src/components/LogView.vue

@@ -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>

+ 14 - 4
frontend/src/views/Campaigns.vue

@@ -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,
       });
     },
 

+ 21 - 5
frontend/src/views/Lists.vue

@@ -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) {

+ 16 - 11
frontend/src/views/Subscribers.vue

@@ -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 = [];
       });

+ 13 - 7
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.*,