diff --git a/campaigns.go b/campaigns.go index be4f7dc..d1223e1 100644 --- a/campaigns.go +++ b/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}) } diff --git a/frontend/my/src/Campaigns.js b/frontend/my/src/Campaigns.js index 5703ff9..bfe27a5 100644 --- a/frontend/my/src/Campaigns.js +++ b/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 => ( + + ), + filterDropdown: ({ + setSelectedKeys, + selectedKeys, + confirm, + clearFilters + }) => ( +
+ { + this.searchInput = node + }} + placeholder={`Search`} + onChange={e => + setSelectedKeys(e.target.value ? [e.target.value] : []) + } + onPressEnter={() => confirm()} + style={{ width: 188, marginBottom: 8, display: "block" }} + /> + + +
+ ), 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 {
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 && ( diff --git a/models/models.go b/models/models.go index 53e1798..f008365 100644 --- a/models/models.go +++ b/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. diff --git a/queries.sql b/queries.sql index 9edfbe3..288152f 100644 --- a/queries.sql +++ b/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,