Refactor campaigns view
- Fix sorting issues - Add status filter - Add name + subject search
This commit is contained in:
parent
9655ce6f14
commit
178604dbbf
4 changed files with 145 additions and 32 deletions
48
campaigns.go
48
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})
|
||||
}
|
||||
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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.
|
||||
|
|
15
queries.sql
15
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,
|
||||
|
|
Loading…
Reference in a new issue