Add support for searching lists + search UI. Closes #618.

This commit is contained in:
Kailash Nadh 2021-12-09 21:34:38 +05:30
parent e9709e54ee
commit ca128df49a
6 changed files with 56 additions and 27 deletions

View file

@ -102,13 +102,13 @@ func handleGetCampaigns(c echo.Context) error {
noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
)
// Fetch one list.
// Fetch one campaign.
single := false
if id > 0 {
single = true
}
queryStr, stmt := makeCampaignQuery(query, orderBy, order, app.queries.QueryCampaigns)
queryStr, stmt := makeSearchQuery(query, orderBy, order, app.queries.QueryCampaigns)
// Unsafe to ignore scanning fields not present in models.Campaigns.
if err := db.Select(&out.Results, stmt, id, pq.StringArray(status), queryStr, pg.Offset, pg.Limit); err != nil {
@ -791,9 +791,10 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
return o, nil
}
// makeCampaignQuery cleans an optional campaign search string and prepares the
// campaign SQL statement (string) and returns them.
func makeCampaignQuery(q, orderBy, order, query string) (string, string) {
// makeSearchQuery cleans an optional search string and prepares the
// query SQL statement (string interpolated) and returns the
// search query string along with the SQL expression.
func makeSearchQuery(q, orderBy, order, query string) (string, string) {
if q != "" {
q = `%` + string(regexFullTextQuery.ReplaceAll([]byte(q), []byte("&"))) + `%`
}

View file

@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gofrs/uuid"
"github.com/knadh/listmonk/models"
@ -31,20 +32,21 @@ func handleGetLists(c echo.Context) error {
out listsWrap
pg = getPagination(c.QueryParams(), 20)
query = strings.TrimSpace(c.FormValue("query"))
orderBy = c.FormValue("order_by")
order = c.FormValue("order")
minimal, _ = strconv.ParseBool(c.FormValue("minimal"))
listID, _ = strconv.Atoi(c.Param("id"))
single = false
)
// Fetch one list.
single := false
if listID > 0 {
single = true
}
// Minimal query simply returns the list of all lists without JOIN subscriber counts. This is fast.
if !single && minimal {
// Minimal query simply returns the list of all lists with no additional metadata. This is fast.
if err := app.queries.GetLists.Select(&out.Results, "", "id"); err != nil {
app.log.Printf("error fetching lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
@ -65,15 +67,14 @@ func handleGetLists(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{out})
}
// Sort params.
if !strSliceContains(orderBy, listQuerySortFields) {
orderBy = "created_at"
}
if order != sortAsc && order != sortDesc {
order = sortAsc
}
queryStr, stmt := makeSearchQuery(query, orderBy, order, app.queries.QueryLists)
if err := db.Select(&out.Results, fmt.Sprintf(app.queries.QueryLists, orderBy, order), listID, pg.Offset, pg.Limit); err != nil {
if err := db.Select(&out.Results,
stmt,
listID,
queryStr,
pg.Offset,
pg.Limit); err != nil {
app.log.Printf("error fetching lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",

View file

@ -50,8 +50,9 @@ describe('Lists', () => {
cy.get('input[name=name]').clear().type(`list-${n}`);
cy.get('select[name=type]').select('public');
cy.get('select[name=optin]').select('double');
cy.get('input[name=tags]').clear().type(`tag${n}`);
cy.get('button[type=submit]').click();
cy.get('input[name=tags]').clear().type(`tag${n}{enter}`);
cy.get('[data-cy=btn-save]').click();
cy.wait(100);
});
cy.wait(250);
@ -93,7 +94,7 @@ describe('Lists', () => {
cy.get('select[name=type]').select(t);
cy.get('select[name=optin]').select(o);
cy.get('input[name=tags]').type(`tag${n}{enter}${t}{enter}${o}{enter}`);
cy.get('button[type=submit]').click();
cy.get('[data-cy=btn-save]').click();
cy.wait(200);
// Confirm the addition by inspecting the newly created list row.
@ -101,17 +102,21 @@ describe('Lists', () => {
cy.get(`${tr} td[data-label=Name]`).contains(name);
cy.get(`${tr} td[data-label=Type] .tag[data-cy=type-${t}]`);
cy.get(`${tr} td[data-label=Type] .tag[data-cy=optin-${o}]`);
cy.get(`${tr} .tags`)
.should('contain', `tag${n}`)
.and('contain', t, { matchCase: false })
.and('contain', o, { matchCase: false });
n++;
});
});
});
it('Searches lists', () => {
cy.get('[data-cy=query]').clear().type('list-public-single-2{enter}');
cy.wait(200)
cy.get('tbody tr').its('length').should('eq', 1);
cy.get('tbody td[data-label="Name"]').first().contains('list-public-single-2');
cy.get('[data-cy=query]').clear().type('{enter}');
});
// Sort lists by clicking on various headers. At this point, there should be four
// lists with IDs = [3, 4, 5, 6]. Sort the items be columns and match them with
// the expected order of IDs.
@ -119,8 +124,8 @@ describe('Lists', () => {
cy.sortTable('thead th.cy-name', [4, 3, 6, 5]);
cy.sortTable('thead th.cy-name', [5, 6, 3, 4]);
cy.sortTable('thead th.cy-type', [5, 6, 4, 3]);
cy.sortTable('thead th.cy-type', [4, 3, 5, 6]);
cy.sortTable('thead th.cy-type', [3, 4, 5, 6]);
cy.sortTable('thead th.cy-type', [6, 5, 4, 3]);
cy.sortTable('thead th.cy-created_at', [3, 4, 5, 6]);
cy.sortTable('thead th.cy-created_at', [6, 5, 4, 3]);

View file

@ -42,7 +42,7 @@
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">{{ $t('globals.buttons.close') }}</b-button>
<b-button native-type="submit" type="is-primary"
:loading="loading.lists">{{ $t('globals.buttons.save') }}</b-button>
:loading="loading.lists" data-cy="btn-save">{{ $t('globals.buttons.save') }}</b-button>
</footer>
</div>
</form>

View file

@ -25,6 +25,25 @@
:current-page="queryParams.page" :per-page="lists.perPage" :total="lists.total"
backend-sorting @sort="onSort"
>
<template #top-left>
<div class="columns">
<div class="column is-6">
<form @submit.prevent="getLists">
<div>
<b-field>
<b-input v-model="queryParams.query" name="query" expanded
icon="magnify" ref="query" data-cy="query" />
<p class="controls">
<b-button native-type="submit" type="is-primary" icon-left="magnify"
data-cy="btn-query" />
</p>
</b-field>
</div>
</form>
</div>
</div>
</template>
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')"
header-class="cy-name" sortable width="25%"
paginated backend-pagination pagination-position="both"
@ -146,6 +165,7 @@ export default Vue.extend({
isFormVisible: false,
queryParams: {
page: 1,
query: '',
orderBy: 'id',
order: 'asc',
},
@ -192,6 +212,7 @@ export default Vue.extend({
getLists() {
this.$api.getLists({
page: this.queryParams.page,
query: this.queryParams.query,
order_by: this.queryParams.orderBy,
order: this.queryParams.order,
});

View file

@ -342,7 +342,8 @@ SELECT * FROM lists WHERE (CASE WHEN $1 = '' THEN 1=1 ELSE type=$1::list_type EN
-- name: query-lists
WITH ls AS (
SELECT COUNT(*) OVER () AS total, lists.* FROM lists
WHERE ($1 = 0 OR id = $1) OFFSET $2 LIMIT (CASE WHEN $3 = 0 THEN NULL ELSE $3 END)
WHERE ($1 = 0 OR id = $1) AND ($2 = '' OR name ILIKE $2)
OFFSET $3 LIMIT (CASE WHEN $4 = 0 THEN NULL ELSE $4 END)
),
counts AS (
SELECT COUNT(*) as subscriber_count, list_id FROM subscriber_lists