Refactor campaigns view

- Fix sorting issues
- Add status filter
- Add name + subject search
This commit is contained in:
Kailash Nadh 2019-03-28 17:17:51 +05:30
parent 9655ce6f14
commit 178604dbbf
4 changed files with 145 additions and 32 deletions

View File

@ -38,51 +38,73 @@ type campaignStats struct {
Rate float64 `json:"rate"` 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. // handleGetCampaigns handles retrieval of campaigns.
func handleGetCampaigns(c echo.Context) error { func handleGetCampaigns(c echo.Context) error {
var ( var (
app = c.Get("app").(*App) app = c.Get("app").(*App)
pg = getPagination(c.QueryParams()) pg = getPagination(c.QueryParams())
out models.Campaigns out campsWrap
id, _ = strconv.Atoi(c.Param("id")) id, _ = strconv.Atoi(c.Param("id"))
status = c.FormValue("status") status = c.QueryParams()["status"]
single = false query = strings.TrimSpace(c.FormValue("query"))
noBody, _ = strconv.ParseBool(c.QueryParam("no_body")) noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
single = false
) )
// Fetch one list. // Fetch one list.
if id > 0 { if id > 0 {
single = true 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 { if err != nil {
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)))
} else if single && len(out) == 0 { } else if single && len(out.Results) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.") return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
} else if len(out) == 0 { } else if len(out.Results) == 0 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}}) 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. // Replace null tags.
if out[i].Tags == nil { if out.Results[i].Tags == nil {
out[i].Tags = make(pq.StringArray, 0) out.Results[i].Tags = make(pq.StringArray, 0)
} }
if noBody { if noBody {
out[i].Body = "" out.Results[i].Body = ""
} }
} }
if single { 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}) return c.JSON(http.StatusOK, okResp{out})
} }

View File

@ -26,7 +26,7 @@ class Campaigns extends React.PureComponent {
state = { state = {
formType: null, formType: null,
pollID: -1, pollID: -1,
queryParams: "", queryParams: {},
stats: {}, stats: {},
record: null, record: null,
previewRecord: null, previewRecord: null,
@ -37,19 +37,13 @@ class Campaigns extends React.PureComponent {
// Pagination config. // Pagination config.
paginationOptions = { paginationOptions = {
hideOnSinglePage: true, hideOnSinglePage: false,
showSizeChanger: true, showSizeChanger: true,
showQuickJumper: true, showQuickJumper: true,
defaultPageSize: this.defaultPerPage, defaultPageSize: this.defaultPerPage,
pageSizeOptions: ["20", "50", "70", "100"], pageSizeOptions: ["20", "50", "70", "100"],
position: "both", position: "both",
showTotal: (total, range) => `${range[0]} to ${range[1]} of ${total}`, 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 })
}
} }
constructor(props) { constructor(props) {
@ -62,6 +56,50 @@ class Campaigns extends React.PureComponent {
sorter: true, sorter: true,
width: "20%", width: "20%",
vAlign: "top", 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) => { render: (text, record) => {
const out = [] const out = []
out.push( out.push(
@ -86,6 +124,14 @@ class Campaigns extends React.PureComponent {
dataIndex: "status", dataIndex: "status",
className: "status", className: "status",
width: "10%", 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) => { render: (status, record) => {
let color = cs.CampaignStatusColors.hasOwnProperty(status) let color = cs.CampaignStatusColors.hasOwnProperty(status)
? cs.CampaignStatusColors[status] ? cs.CampaignStatusColors[status]
@ -415,14 +461,17 @@ class Campaigns extends React.PureComponent {
} }
fetchRecords = params => { fetchRecords = params => {
if (!params) {
params = {}
}
let qParams = { let qParams = {
page: this.state.queryParams.page, page: this.state.queryParams.page,
per_page: this.state.queryParams.per_page per_page: this.state.queryParams.per_page
} }
// The records are for a specific list. // Avoid sending blank string where the enum check will fail.
if (this.state.queryParams.listID) { if (!params.status) {
qParams.listID = this.state.queryParams.listID delete params.status
} }
if (params) { if (params) {
@ -437,6 +486,17 @@ class Campaigns extends React.PureComponent {
qParams qParams
) )
.then(r => { .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() this.startStatsPoll()
}) })
} }
@ -447,7 +507,7 @@ class Campaigns extends React.PureComponent {
// If there's at least one running campaign, start polling. // If there's at least one running campaign, start polling.
let hasRunning = false let hasRunning = false
this.props.data[cs.ModelCampaigns].forEach(c => { this.props.data[cs.ModelCampaigns].results.forEach(c => {
if (c.status === cs.CampaignStatusRunning) { if (c.status === cs.CampaignStatusRunning) {
hasRunning = true hasRunning = true
return return
@ -605,12 +665,32 @@ class Campaigns extends React.PureComponent {
<br /> <br />
<Table <Table
className="subscribers" className="campaigns"
columns={this.columns} columns={this.columns}
rowKey={record => record.uuid} 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} loading={this.props.reqStates[cs.ModelCampaigns] !== cs.StateDone}
pagination={pagination} 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 && ( {this.state.previewRecord && (

View File

@ -141,6 +141,10 @@ type Campaign struct {
// TemplateBody is joined in from templates by the next-campaigns query. // TemplateBody is joined in from templates by the next-campaigns query.
TemplateBody string `db:"template_body" json:"-"` TemplateBody string `db:"template_body" json:"-"`
Tpl *template.Template `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. // CampaignMeta contains fields tracking a campaign's progress.

View File

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