Refactor campaigns view
- Fix sorting issues - Add status filter - Add name + subject search
This commit is contained in:
parent
9655ce6f14
commit
178604dbbf
48
campaigns.go
48
campaigns.go
|
@ -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})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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.
|
||||||
|
|
15
queries.sql
15
queries.sql
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue