Explorar el Código

Refactor campaigns view

- Fix sorting issues
- Add status filter
- Add name + subject search
Kailash Nadh hace 6 años
padre
commit
178604dbbf
Se han modificado 4 ficheros con 145 adiciones y 32 borrados
  1. 35 13
      campaigns.go
  2. 95 15
      frontend/my/src/Campaigns.js
  3. 4 0
      models/models.go
  4. 11 4
      queries.sql

+ 35 - 13
campaigns.go

@@ -38,51 +38,73 @@ type campaignStats struct {
 	Rate      float64   `json:"rate"`
 }
 
-var regexFromAddress = regexp.MustCompile(`(.+?)\s<(.+?)@(.+?)>`)
+type campsWrap struct {
+	Results []models.Campaign `json:"results"`
+
+	Query   string `json:"query"`
+	Total   int    `json:"total"`
+	PerPage int    `json:"per_page"`
+	Page    int    `json:"page"`
+}
+
+var (
+	regexFromAddress   = regexp.MustCompile(`(.+?)\s<(.+?)@(.+?)>`)
+	regexFullTextQuery = regexp.MustCompile(`\s+`)
+)
 
 // handleGetCampaigns handles retrieval of campaigns.
 func handleGetCampaigns(c echo.Context) error {
 	var (
 		app = c.Get("app").(*App)
 		pg  = getPagination(c.QueryParams())
-		out models.Campaigns
+		out campsWrap
 
 		id, _     = strconv.Atoi(c.Param("id"))
-		status    = c.FormValue("status")
-		single    = false
+		status    = c.QueryParams()["status"]
+		query     = strings.TrimSpace(c.FormValue("query"))
 		noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
+		single    = false
 	)
 
 	// Fetch one list.
 	if id > 0 {
 		single = true
 	}
+	if query != "" {
+		query = string(regexFullTextQuery.ReplaceAll([]byte(query), []byte("&")))
+	}
 
-	err := app.Queries.GetCampaigns.Select(&out, id, status, pg.Offset, pg.Limit)
+	err := app.Queries.GetCampaigns.Select(&out.Results, id, pq.StringArray(status), query, pg.Offset, pg.Limit)
 	if err != nil {
 		return echo.NewHTTPError(http.StatusInternalServerError,
 			fmt.Sprintf("Error fetching campaigns: %s", pqErrMsg(err)))
-	} else if single && len(out) == 0 {
+	} else if single && len(out.Results) == 0 {
 		return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
-	} else if len(out) == 0 {
-		return c.JSON(http.StatusOK, okResp{[]struct{}{}})
+	} else if len(out.Results) == 0 {
+		out.Results = make([]models.Campaign, 0)
+		return c.JSON(http.StatusOK, out)
 	}
 
-	for i := 0; i < len(out); i++ {
+	for i := 0; i < len(out.Results); i++ {
 		// Replace null tags.
-		if out[i].Tags == nil {
-			out[i].Tags = make(pq.StringArray, 0)
+		if out.Results[i].Tags == nil {
+			out.Results[i].Tags = make(pq.StringArray, 0)
 		}
 
 		if noBody {
-			out[i].Body = ""
+			out.Results[i].Body = ""
 		}
 	}
 
 	if single {
-		return c.JSON(http.StatusOK, okResp{out[0]})
+		return c.JSON(http.StatusOK, okResp{out.Results[0]})
 	}
 
+	// Meta.
+	out.Total = out.Results[0].Total
+	out.Page = pg.Page
+	out.PerPage = pg.PerPage
+
 	return c.JSON(http.StatusOK, okResp{out})
 }
 

+ 95 - 15
frontend/my/src/Campaigns.js

@@ -26,7 +26,7 @@ class Campaigns extends React.PureComponent {
   state = {
     formType: null,
     pollID: -1,
-    queryParams: "",
+    queryParams: {},
     stats: {},
     record: null,
     previewRecord: null,
@@ -37,19 +37,13 @@ class Campaigns extends React.PureComponent {
 
   // Pagination config.
   paginationOptions = {
-    hideOnSinglePage: true,
+    hideOnSinglePage: false,
     showSizeChanger: true,
     showQuickJumper: true,
     defaultPageSize: this.defaultPerPage,
     pageSizeOptions: ["20", "50", "70", "100"],
     position: "both",
-    showTotal: (total, range) => `${range[0]} to ${range[1]} of ${total}`,
-    onChange: (page, perPage) => {
-      this.fetchRecords({ page: page, per_page: perPage })
-    },
-    onShowSizeChange: (page, perPage) => {
-      this.fetchRecords({ page: page, per_page: perPage })
-    }
+    showTotal: (total, range) => `${range[0]} to ${range[1]} of ${total}`
   }
 
   constructor(props) {
@@ -62,6 +56,50 @@ class Campaigns extends React.PureComponent {
         sorter: true,
         width: "20%",
         vAlign: "top",
+        filterIcon: filtered => (
+          <Icon
+            type="search"
+            style={{ color: filtered ? "#1890ff" : undefined }}
+          />
+        ),
+        filterDropdown: ({
+          setSelectedKeys,
+          selectedKeys,
+          confirm,
+          clearFilters
+        }) => (
+          <div style={{ padding: 8 }}>
+            <Input
+              ref={node => {
+                this.searchInput = node
+              }}
+              placeholder={`Search`}
+              onChange={e =>
+                setSelectedKeys(e.target.value ? [e.target.value] : [])
+              }
+              onPressEnter={() => confirm()}
+              style={{ width: 188, marginBottom: 8, display: "block" }}
+            />
+            <Button
+              type="primary"
+              onClick={() => confirm()}
+              icon="search"
+              size="small"
+              style={{ width: 90, marginRight: 8 }}
+            >
+              Search
+            </Button>
+            <Button
+              onClick={() => {
+                clearFilters()
+              }}
+              size="small"
+              style={{ width: 90 }}
+            >
+              Reset
+            </Button>
+          </div>
+        ),
         render: (text, record) => {
           const out = []
           out.push(
@@ -86,6 +124,14 @@ class Campaigns extends React.PureComponent {
         dataIndex: "status",
         className: "status",
         width: "10%",
+        filters: [
+          { text: "Draft", value: "draft" },
+          { text: "Running", value: "running" },
+          { text: "Scheduled", value: "scheduled" },
+          { text: "Paused", value: "paused" },
+          { text: "Cancelled", value: "cancelled" },
+          { text: "Finished", value: "finished" }
+        ],
         render: (status, record) => {
           let color = cs.CampaignStatusColors.hasOwnProperty(status)
             ? cs.CampaignStatusColors[status]
@@ -415,14 +461,17 @@ class Campaigns extends React.PureComponent {
   }
 
   fetchRecords = params => {
+    if (!params) {
+      params = {}
+    }
     let qParams = {
       page: this.state.queryParams.page,
       per_page: this.state.queryParams.per_page
     }
 
-    // The records are for a specific list.
-    if (this.state.queryParams.listID) {
-      qParams.listID = this.state.queryParams.listID
+    // Avoid sending blank string where the enum check will fail.
+    if (!params.status) {
+      delete params.status
     }
 
     if (params) {
@@ -437,6 +486,17 @@ class Campaigns extends React.PureComponent {
         qParams
       )
       .then(r => {
+        this.setState({
+          queryParams: {
+            ...this.state.queryParams,
+            total: this.props.data[cs.ModelCampaigns].total,
+            per_page: this.props.data[cs.ModelCampaigns].per_page,
+            page: this.props.data[cs.ModelCampaigns].page,
+            query: this.props.data[cs.ModelCampaigns].query,
+            status: params.status
+          }
+        })
+
         this.startStatsPoll()
       })
   }
@@ -447,7 +507,7 @@ class Campaigns extends React.PureComponent {
 
     // If there's at least one running campaign, start polling.
     let hasRunning = false
-    this.props.data[cs.ModelCampaigns].forEach(c => {
+    this.props.data[cs.ModelCampaigns].results.forEach(c => {
       if (c.status === cs.CampaignStatusRunning) {
         hasRunning = true
         return
@@ -605,12 +665,32 @@ class Campaigns extends React.PureComponent {
         <br />
 
         <Table
-          className="subscribers"
+          className="campaigns"
           columns={this.columns}
           rowKey={record => record.uuid}
-          dataSource={this.props.data[cs.ModelCampaigns]}
+          dataSource={(() => {
+            if (
+              !this.props.data[cs.ModelCampaigns] ||
+              !this.props.data[cs.ModelCampaigns].hasOwnProperty("results")
+            ) {
+              return []
+            }
+            return this.props.data[cs.ModelCampaigns].results
+          })()}
           loading={this.props.reqStates[cs.ModelCampaigns] !== cs.StateDone}
           pagination={pagination}
+          onChange={(pagination, filters, sorter, records) => {
+            this.fetchRecords({
+              per_page: pagination.pageSize,
+              page: pagination.current,
+              status:
+                filters.status && filters.status.length > 0
+                  ? filters.status
+                  : "",
+              query:
+                filters.name && filters.name.length > 0 ? filters.name[0] : ""
+            })
+          }}
         />
 
         {this.state.previewRecord && (

+ 4 - 0
models/models.go

@@ -141,6 +141,10 @@ type Campaign struct {
 	// TemplateBody is joined in from templates by the next-campaigns query.
 	TemplateBody string             `db:"template_body" json:"-"`
 	Tpl          *template.Template `json:"-"`
+
+	// Pseudofield for getting the total number of subscribers
+	// in searches and queries.
+	Total int `db:"total" json:"-"`
 }
 
 // CampaignMeta contains fields tracking a campaign's progress.

+ 11 - 4
queries.sql

@@ -255,8 +255,12 @@ INSERT INTO campaign_lists (campaign_id, list_id, list_name)
 -- name: get-campaigns
 -- Here, 'lists' is returned as an aggregated JSON array from campaign_lists because
 -- the list reference may have been deleted.
+-- While the results are sliced using offset+limit,
+-- 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.
 WITH camps AS (
-    SELECT campaigns.*, (
+    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
@@ -264,8 +268,10 @@ WITH camps AS (
         ) l
     ) AS lists
     FROM campaigns
-    WHERE ($1 = 0 OR id = $1) AND status=(CASE WHEN $2 != '' THEN $2::campaign_status ELSE status END)
-    ORDER BY created_at DESC OFFSET $3 LIMIT $4
+    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 (to_tsvector(name || subject) @@ to_tsquery($3)))
+    ORDER BY created_at DESC OFFSET $4 LIMIT $5
 ), views AS (
     SELECT campaign_id, COUNT(campaign_id) as num FROM campaign_views
     WHERE campaign_id = ANY(SELECT id FROM camps)
@@ -281,7 +287,8 @@ SELECT *,
     COALESCE(c.num, 0) AS clicks
 FROM camps
 LEFT JOIN views AS v ON (v.campaign_id = camps.id)
-LEFT JOIN clicks AS c ON (c.campaign_id = camps.id);
+LEFT JOIN clicks AS c ON (c.campaign_id = camps.id)
+ORDER BY camps.created_at DESC;
 
 -- name: get-campaign-for-preview
 SELECT campaigns.*, COALESCE(templates.body, (SELECT body FROM templates WHERE is_default = true LIMIT 1)) AS template_body,